xtasks geom_math edit, lib for elec and curr
+poly for mag
This commit is contained in:
parent
07fbaa4165
commit
4e6a655683
|
|
@ -0,0 +1,2 @@
|
||||||
|
[alias]
|
||||||
|
xtask = "run --release --package xtask --"
|
||||||
|
|
@ -3,4 +3,5 @@ build/ffi/
|
||||||
*.o
|
*.o
|
||||||
*.a
|
*.a
|
||||||
target/
|
target/
|
||||||
|
assets/old/
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["crates/*"]
|
members = ["crates/*", "xtask"]
|
||||||
|
default-members = ["crates/femm-app"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="278.932 265.471 31.349 31.349" xmlns:bx="https://boxy-svg.com">
|
||||||
|
<path style="stroke: rgb(125, 117, 117); vector-effect: non-scaling-stroke; stroke-width: 3px; fill: rgb(3, 3, 3);" transform="matrix(0.101437, 0, 0, 0.098416, 186.736202, 207.679973)" d="M 1011.929 635.017 L 1038.263 635.017 L 1044.534 706.885 A 48.651 48.651 0 0 1 1054 712.35 L 1119.376 681.847 L 1132.543 704.653 L 1073.439 746.018 A 48.651 48.651 0 0 1 1073.439 756.949 L 1132.543 798.314 L 1119.376 821.12 L 1054 790.617 A 48.651 48.651 0 0 1 1044.534 796.082 L 1038.263 867.951 L 1011.929 867.951 L 1005.658 796.082 A 48.651 48.651 0 0 1 996.191 790.617 L 930.816 821.12 L 917.649 798.314 L 976.753 756.949 A 48.651 48.651 0 0 1 976.753 746.018 L 917.649 704.653 L 930.816 681.847 L 996.191 712.35 A 48.651 48.651 0 0 1 1005.658 706.885 Z M 1025.096 730.844 A 20.64 20.64 0 0 0 1025.096 772.123 A 20.64 20.64 0 0 0 1025.096 730.844" bx:shape="cog 1025.096 751.484 20.64 48.651 116.467 0.57 6 1@6e4f8584"/>
|
||||||
|
<path style="vector-effect: non-scaling-stroke; stroke-width: 4px; stroke: rgb(107, 78, 0); fill: rgb(142, 142, 4);" transform="matrix(0.11202, 0, 0, 0.11202, 205.952327, 195.846981)" d="M 754.573 660.818 L 776.232 660.818 L 781.39 719.928 A 40.014 40.014 0 0 1 789.175 724.423 L 842.945 699.336 L 853.775 718.093 L 805.163 752.115 A 40.014 40.014 0 0 1 805.163 761.105 L 853.775 795.127 L 842.945 813.884 L 789.175 788.796 A 40.014 40.014 0 0 1 781.39 793.291 L 776.232 852.401 L 754.573 852.401 L 749.415 793.291 A 40.014 40.014 0 0 1 741.629 788.796 L 687.859 813.884 L 677.03 795.127 L 725.641 761.105 A 40.014 40.014 0 0 1 725.641 752.115 L 677.03 718.093 L 687.859 699.336 L 741.629 724.423 A 40.014 40.014 0 0 1 749.415 719.928 Z M 765.402 739.634 A 16.976 16.976 0 0 0 765.402 773.585 A 16.976 16.976 0 0 0 765.402 739.634" bx:shape="cog 765.402 756.61 16.976 40.014 95.791 0.57 6 1@b6f3ee13"/>
|
||||||
|
<path d="M 294.354 279.503 L 303.403 279.503 C 303.458 279.503 303.508 279.535 303.541 279.585 L 303.541 281.015 C 303.508 281.065 303.458 281.097 303.403 281.097 L 294.354 281.097 C 294.254 281.097 294.171 280.993 294.171 280.862 L 294.171 279.738 C 294.171 279.607 294.254 279.503 294.354 279.503 Z" style="fill: rgb(255, 255, 4); vector-effect: non-scaling-stroke; paint-order: stroke; stroke-width: 9px; stroke: rgb(0, 0, 0);"/>
|
||||||
|
<path d="M 309.392 270.753 L 309.392 271.338 C 309.392 272.2 308.848 272.9 308.179 272.9 L 306.774 272.9 C 306.538 272.9 306.318 272.812 306.129 272.661 L 306.129 270.373 C 306.129 270.095 306.05 269.841 305.917 269.646 C 306.137 269.365 306.441 269.191 306.774 269.191 L 308.179 269.191 C 308.848 269.191 309.392 269.89 309.392 270.753 Z" style="vector-effect: non-scaling-stroke; paint-order: stroke; stroke-width: 9px; stroke: rgb(0, 0, 0);"/>
|
||||||
|
<path d="M 303.585 279.738 L 303.585 280.862 C 303.585 280.92 303.569 280.974 303.541 281.015 L 303.541 282.749 C 303.541 283.362 303.928 283.859 304.403 283.859 L 305.268 283.859 C 305.742 283.859 306.129 283.362 306.129 282.749 L 306.129 272.661 C 305.787 272.385 305.561 271.895 305.561 271.338 L 305.561 270.753 C 305.561 270.32 305.698 269.928 305.917 269.646 C 305.76 269.411 305.527 269.262 305.268 269.262 L 304.403 269.262 C 303.928 269.262 303.541 269.76 303.541 270.373 L 303.541 279.585 C 303.569 279.626 303.585 279.68 303.585 279.738 Z" style="vector-effect: non-scaling-stroke; paint-order: stroke; stroke-width: 9px; stroke: rgb(0, 0, 0); fill: rgb(207, 23, 8);"/>
|
||||||
|
<path style="vector-effect: non-scaling-stroke; paint-order: stroke; stroke-width: 8px; stroke: rgb(89, 103, 61); fill: rgb(255, 255, 4);" transform="matrix(0.11202, 0, 0, 0.11202, 223.932612, 193.577602)" d="M 598.424 703.833 L 614.878 703.833 L 618.797 748.74 A 30.399 30.399 0 0 1 624.712 752.155 L 665.561 733.095 L 673.789 747.345 L 636.858 773.192 A 30.399 30.399 0 0 1 636.858 780.022 L 673.789 805.869 L 665.561 820.119 L 624.712 801.059 A 30.399 30.399 0 0 1 618.797 804.474 L 614.878 849.381 L 598.424 849.381 L 594.505 804.474 A 30.399 30.399 0 0 1 588.59 801.059 L 547.74 820.119 L 539.513 805.869 L 576.444 780.022 A 30.399 30.399 0 0 1 576.444 773.192 L 539.513 747.345 L 547.74 733.095 L 588.59 752.155 A 30.399 30.399 0 0 1 594.505 748.74 Z M 606.651 763.71 A 12.897 12.897 0 0 0 606.651 789.504 A 12.897 12.897 0 0 0 606.651 763.71" bx:shape="cog 606.651 776.607 12.897 30.399 72.774 0.57 6 1@453a207a"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.4 KiB |
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="187.197 187.197 125.607 125.607">
|
||||||
|
<rect x="187.197" y="187.197" width="125.607" height="125.607" style="fill: rgb(104, 130, 123); stroke-width: 2px; stroke: rgb(120, 243, 170);" rx="33.33" ry="33.33"/>
|
||||||
|
<path style="fill: none; stroke-width: 3px; stroke: rgb(76, 247, 163);" d="M 267.414 191.748 C 287.978 217.88 234.233 213.185 229.814 214.986 C 201.101 226.692 238.888 240.451 281.606 229.868 C 287.794 228.335 284.504 222.336 281.048 217.762 C 272.02 205.818 191.632 237.63 226.51 250.339 C 245.307 257.188 296.968 248.288 282.949 244.063 C 274.365 241.475 251.953 238.682 241.404 242.412 C 219.932 250.003 210.62 260.79 225.934 272.681 C 235.524 280.127 274.512 278.646 281.927 270.619 C 284.9 267.401 283.617 264.742 276.191 262.884 C 270.879 261.554 245.625 262.668 238.742 264.343 C 213.302 270.53 208.195 295.643 225.613 291.339 C 291.43 275.075 295.727 299.849 257.003 301.554"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 986 B |
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="242.113 136.367 31.774 31.775">
|
||||||
|
<g transform="matrix(1, 0, 0, 1, 241.354996, 141.399002)">
|
||||||
|
<path d="M 15.5 0 L 17.5 0 L 18.5 1 L 19.5 1 L 20.5 2 L 21.5 2 L 25.5 6 L 26.5 6 L 30 9.5 L 30 13.5 L 26.5 17 L 22.5 17 L 19.5 14 L 19 14.5 L 19 18.5 L 15.5 22 L 11.5 22 L 8 18.5 L 8 16.5 L 7 15.5 L 7 14.5 L 6 13.5 L 6 11.5 L 4 9.5 L 4 8.5 L 3 7.5 L 3 5.5 L 4.5 4 L 5.5 4 L 6 4.5 L 4 6.5 L 5 7.5 L 5 8.5 L 7 10.5 L 7 12.5 L 8.5 14 L 11.5 11 L 15.5 11 L 16.5 12 L 17.5 11 Q 18.75 11.25 19 10.5 L 19 9.5 L 23 5.5 L 20.5 3 L 19.5 3 L 18.5 2 L 17.5 2 L 16.5 1 L 14.5 3 L 13.5 3 L 13 2.5 L 15.5 0 Z M 23 7 L 20 10 L 20 14 L 23 16 L 27 16 L 29 14 L 29 10 L 27 7 L 23 7 Z M 12 12 L 9 15 L 9 19 L 12 21 L 16 21 L 18 19 L 18 15 L 16 12 L 12 12 Z M 19 13 Q 18 14 19 14 Q 20 14 19 13 Z " style="fill: #000; stroke: #000;"/>
|
||||||
|
<path d="M 23.5 7 L 25.5 7 L 29 10.5 L 29 12.5 L 25.5 16 L 23.5 16 L 20 12.5 L 20 10.5 L 23.5 7 Z " style="fill: #ff0; stroke: #ff0;"/>
|
||||||
|
<path d="M 12.5 12 L 14.5 12 L 18 15.5 L 18 17.5 L 14.5 21 L 12.5 21 L 9 17.5 L 9 15.5 L 12.5 12 Z " style="fill: #ff0; stroke: #ff0;"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="238.748 141.34 30.063 30.063">
|
||||||
|
<g transform="matrix(1, 0, 0, 1, 238.810587, 139.339917)">
|
||||||
|
<path style="fill: none; stroke: rgb(0, 53, 255); stroke-dashoffset: -3.02px; stroke-miterlimit: 4.56; stroke-linejoin: round; stroke-width: 5px; stroke-linecap: round;" d="M 27.431 24.348 C 26.218 14.419 15.699 -1.564 6.007 17.415"/>
|
||||||
|
<path d="M 0.5 2 L 5.5 2 L 6 2.5 L 6 25.5 L 6.5 26 L 29.5 26 L 30 26.5 L 30 31.5 L 29.5 32 Q 28.25 32.25 28 31.5 L 28 28.5 L 27.5 28 L 18.5 28 L 18 28.5 L 18 31.5 L 17.5 32 Q 16.25 32.25 16 31.5 L 16 28.5 L 15.5 28 L 6.5 28 L 6 28.5 L 6 31.5 L 5.5 32 Q 4.25 32.25 4 31.5 L 4 28.5 L 3.5 28 L 0.5 28 L 0 27.5 Q -0.25 26.25 0.5 26 L 3.5 26 L 4 25.5 L 4 16.5 L 3.5 16 L 0.5 16 L 0 15.5 Q -0.25 14.25 0.5 14 L 3.5 14 L 4 13.5 L 4 4.5 L 3.5 4 L 0.5 4 L 0 3.5 Q -0.25 2.25 0.5 2 Z " style="fill: #000; stroke: #000;"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 948 B |
|
|
@ -18,6 +18,8 @@ const BG: Color = Color::WHITE;
|
||||||
const GEOM: Color = Color::BLACK;
|
const GEOM: Color = Color::BLACK;
|
||||||
const LABEL_COLOR: Color = Color::from_rgb(0.25, 0.45, 0.85);
|
const LABEL_COLOR: Color = Color::from_rgb(0.25, 0.45, 0.85);
|
||||||
const PENDING_COLOR: Color = Color::from_rgb(0.85, 0.30, 0.30);
|
const PENDING_COLOR: Color = Color::from_rgb(0.85, 0.30, 0.30);
|
||||||
|
const SELECT_COLOR: Color = Color::from_rgb(0.95, 0.20, 0.20);
|
||||||
|
const SELECT_STROKE: f32 = 2.4;
|
||||||
|
|
||||||
/// active editing mode on the canvas.
|
/// active editing mode on the canvas.
|
||||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -36,6 +38,10 @@ pub enum CanvasMessage {
|
||||||
Click { world: (f64, f64), tool: Tool },
|
Click { world: (f64, f64), tool: Tool },
|
||||||
/// two-point segment request from the canvas.
|
/// two-point segment request from the canvas.
|
||||||
SegmentBetween { from: (f64, f64), to: (f64, f64) },
|
SegmentBetween { from: (f64, f64), to: (f64, f64) },
|
||||||
|
/// right-click on Select mode, toggling the closest entity to the given world point.
|
||||||
|
TogglePickAt { world: (f64, f64) },
|
||||||
|
/// Delete key in Select mode, removing every selected entity from the doc.
|
||||||
|
DeleteSelected,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// pan offset, zoom factor, and click-gesture bookkeeping for the canvas.
|
/// pan offset, zoom factor, and click-gesture bookkeeping for the canvas.
|
||||||
|
|
@ -84,6 +90,16 @@ impl<'a> canvas::Program<CanvasMessage> for DocCanvas<'a> {
|
||||||
return Some(Action::capture());
|
return Some(Action::capture());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right)) => {
|
||||||
|
if self.tool == Tool::Select {
|
||||||
|
if let Some(now) = cursor.position_in(bounds) {
|
||||||
|
let view = ViewTransform::fit(self.doc, bounds, state);
|
||||||
|
let world = view.inverse_map(now);
|
||||||
|
return Some(Action::publish(CanvasMessage::TogglePickAt { world })
|
||||||
|
.and_capture());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||||
let press = state.press_origin.take();
|
let press = state.press_origin.take();
|
||||||
state.drag_origin = None;
|
state.drag_origin = None;
|
||||||
|
|
@ -162,11 +178,23 @@ impl<'a> canvas::Program<CanvasMessage> for DocCanvas<'a> {
|
||||||
return Some(Action::request_redraw());
|
return Some(Action::request_redraw());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if matches!(key, iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape)) {
|
if matches!(
|
||||||
|
key,
|
||||||
|
iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||||
|
) {
|
||||||
if state.pending_segment_start.take().is_some() {
|
if state.pending_segment_start.take().is_some() {
|
||||||
return Some(Action::request_redraw());
|
return Some(Action::request_redraw());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if matches!(
|
||||||
|
key,
|
||||||
|
iced::keyboard::Key::Named(iced::keyboard::key::Named::Delete)
|
||||||
|
| iced::keyboard::Key::Named(iced::keyboard::key::Named::Backspace),
|
||||||
|
) {
|
||||||
|
if self.tool == Tool::Select {
|
||||||
|
return Some(Action::publish(CanvasMessage::DeleteSelected));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
@ -192,8 +220,13 @@ impl<'a> canvas::Program<CanvasMessage> for DocCanvas<'a> {
|
||||||
{
|
{
|
||||||
let a = view.map(p0.x, p0.y);
|
let a = view.map(p0.x, p0.y);
|
||||||
let b = view.map(p1.x, p1.y);
|
let b = view.map(p1.x, p1.y);
|
||||||
|
let (color, width) = if s.selected {
|
||||||
|
(SELECT_COLOR, SELECT_STROKE)
|
||||||
|
} else {
|
||||||
|
(GEOM, STROKE_WIDTH)
|
||||||
|
};
|
||||||
frame.stroke(&Path::line(a, b),
|
frame.stroke(&Path::line(a, b),
|
||||||
Stroke::default().with_width(STROKE_WIDTH).with_color(GEOM));
|
Stroke::default().with_width(width).with_color(color));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,6 +234,11 @@ impl<'a> canvas::Program<CanvasMessage> for DocCanvas<'a> {
|
||||||
if let (Some(p0), Some(p1)) =
|
if let (Some(p0), Some(p1)) =
|
||||||
(self.doc.nodes.get(a.n0 as usize), self.doc.nodes.get(a.n1 as usize))
|
(self.doc.nodes.get(a.n0 as usize), self.doc.nodes.get(a.n1 as usize))
|
||||||
{
|
{
|
||||||
|
let (color, width) = if a.selected {
|
||||||
|
(SELECT_COLOR, SELECT_STROKE)
|
||||||
|
} else {
|
||||||
|
(GEOM, STROKE_WIDTH)
|
||||||
|
};
|
||||||
if let Some((center, radius, start_angle, end_angle)) =
|
if let Some((center, radius, start_angle, end_angle)) =
|
||||||
arc_geometry(p0.x, p0.y, p1.x, p1.y, a.arc_length, a.normal_direction)
|
arc_geometry(p0.x, p0.y, p1.x, p1.y, a.arc_length, a.normal_direction)
|
||||||
{
|
{
|
||||||
|
|
@ -214,19 +252,24 @@ impl<'a> canvas::Program<CanvasMessage> for DocCanvas<'a> {
|
||||||
end_angle,
|
end_angle,
|
||||||
});
|
});
|
||||||
frame.stroke(&b.build(),
|
frame.stroke(&b.build(),
|
||||||
Stroke::default().with_width(STROKE_WIDTH).with_color(GEOM));
|
Stroke::default().with_width(width).with_color(color));
|
||||||
} else {
|
} else {
|
||||||
let a_px = view.map(p0.x, p0.y);
|
let a_px = view.map(p0.x, p0.y);
|
||||||
let b_px = view.map(p1.x, p1.y);
|
let b_px = view.map(p1.x, p1.y);
|
||||||
frame.stroke(&Path::line(a_px, b_px),
|
frame.stroke(&Path::line(a_px, b_px),
|
||||||
Stroke::default().with_width(STROKE_WIDTH).with_color(GEOM));
|
Stroke::default().with_width(width).with_color(color));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for n in &self.doc.nodes {
|
for n in &self.doc.nodes {
|
||||||
let p = view.map(n.x, n.y);
|
let p = view.map(n.x, n.y);
|
||||||
frame.fill(&Path::circle(p, NODE_RADIUS), GEOM);
|
let (color, r) = if n.selected {
|
||||||
|
(SELECT_COLOR, NODE_RADIUS + 2.0)
|
||||||
|
} else {
|
||||||
|
(GEOM, NODE_RADIUS)
|
||||||
|
};
|
||||||
|
frame.fill(&Path::circle(p, r), color);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(start_world) = state.pending_segment_start {
|
if let Some(start_world) = state.pending_segment_start {
|
||||||
|
|
@ -245,6 +288,11 @@ impl<'a> canvas::Program<CanvasMessage> for DocCanvas<'a> {
|
||||||
|
|
||||||
for label in &self.doc.block_labels {
|
for label in &self.doc.block_labels {
|
||||||
let p = view.map(label.x, label.y);
|
let p = view.map(label.x, label.y);
|
||||||
|
let (color, width) = if label.selected {
|
||||||
|
(SELECT_COLOR, SELECT_STROKE)
|
||||||
|
} else {
|
||||||
|
(LABEL_COLOR, STROKE_WIDTH)
|
||||||
|
};
|
||||||
let cross = Path::new(|b| {
|
let cross = Path::new(|b| {
|
||||||
b.move_to(Point::new(p.x - LABEL_TICK_PX, p.y));
|
b.move_to(Point::new(p.x - LABEL_TICK_PX, p.y));
|
||||||
b.line_to(Point::new(p.x + LABEL_TICK_PX, p.y));
|
b.line_to(Point::new(p.x + LABEL_TICK_PX, p.y));
|
||||||
|
|
@ -252,11 +300,11 @@ impl<'a> canvas::Program<CanvasMessage> for DocCanvas<'a> {
|
||||||
b.line_to(Point::new(p.x, p.y + LABEL_TICK_PX));
|
b.line_to(Point::new(p.x, p.y + LABEL_TICK_PX));
|
||||||
});
|
});
|
||||||
frame.stroke(&cross,
|
frame.stroke(&cross,
|
||||||
Stroke::default().with_width(STROKE_WIDTH).with_color(LABEL_COLOR));
|
Stroke::default().with_width(width).with_color(color));
|
||||||
frame.fill_text(Text {
|
frame.fill_text(Text {
|
||||||
content: label.block_type.clone(),
|
content: label.block_type.clone(),
|
||||||
position: Point::new(p.x + LABEL_TICK_PX + 4.0, p.y - 8.0),
|
position: Point::new(p.x + LABEL_TICK_PX + 4.0, p.y - 8.0),
|
||||||
color: LABEL_COLOR,
|
color,
|
||||||
size: 12.0.into(),
|
size: 12.0.into(),
|
||||||
..Text::default()
|
..Text::default()
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,22 @@ impl App {
|
||||||
self.status = format!("rejected segment {n0} -> {n1}");
|
self.status = format!("rejected segment {n0} -> {n1}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Message::Canvas(CanvasMessage::TogglePickAt { world }) => {
|
||||||
|
toggle_closest(&mut self.doc, world.0, world.1);
|
||||||
|
self.status = String::from("toggled selection at right-click");
|
||||||
|
}
|
||||||
|
Message::Canvas(CanvasMessage::DeleteSelected) => {
|
||||||
|
let n = self.doc.delete_selected_nodes();
|
||||||
|
let s = self.doc.delete_selected_segments();
|
||||||
|
let a = self.doc.delete_selected_arcs();
|
||||||
|
let b = self.doc.delete_selected_block_labels();
|
||||||
|
let total = n + s + a + b;
|
||||||
|
self.status = if total == 0 {
|
||||||
|
String::from("nothing selected")
|
||||||
|
} else {
|
||||||
|
format!("deleted: {n} nodes, {s} segments, {a} arcs, {b} labels")
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
@ -134,6 +150,54 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// flips the selected flag on whichever entity sits nearest to (x, y) in doc world coords.
|
||||||
|
fn toggle_closest(doc: &mut FemmDoc, x: f64, y: f64) {
|
||||||
|
use femm_doc_mag::geom_math::{
|
||||||
|
shortest_distance_from_arc, shortest_distance_from_segment,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Kind { Node, Segment, Arc, Label }
|
||||||
|
let mut best: Option<(Kind, usize, f64)> = None;
|
||||||
|
let mut consider = |kind: Kind, idx: usize, d: f64| {
|
||||||
|
match best {
|
||||||
|
None => best = Some((kind, idx, d)),
|
||||||
|
Some((_, _, bd)) if d < bd => best = Some((kind, idx, d)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (i, n) in doc.nodes.iter().enumerate() {
|
||||||
|
consider(Kind::Node, i, (n.x - x).hypot(n.y - y));
|
||||||
|
}
|
||||||
|
for (i, s) in doc.segments.iter().enumerate() {
|
||||||
|
if let (Some(p0), Some(p1)) =
|
||||||
|
(doc.nodes.get(s.n0 as usize), doc.nodes.get(s.n1 as usize))
|
||||||
|
{
|
||||||
|
consider(Kind::Segment, i, shortest_distance_from_segment((x, y), (p0.x, p0.y), (p1.x, p1.y)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (i, a) in doc.arcs.iter().enumerate() {
|
||||||
|
if let (Some(p0), Some(p1)) =
|
||||||
|
(doc.nodes.get(a.n0 as usize), doc.nodes.get(a.n1 as usize))
|
||||||
|
{
|
||||||
|
consider(Kind::Arc, i, shortest_distance_from_arc((x, y), (p0.x, p0.y), (p1.x, p1.y), a.arc_length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (i, b) in doc.block_labels.iter().enumerate() {
|
||||||
|
consider(Kind::Label, i, (b.x - x).hypot(b.y - y));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((kind, idx, _)) = best {
|
||||||
|
match kind {
|
||||||
|
Kind::Node => doc.nodes[idx].selected ^= true,
|
||||||
|
Kind::Segment => doc.segments[idx].selected ^= true,
|
||||||
|
Kind::Arc => doc.arcs[idx].selected ^= true,
|
||||||
|
Kind::Label => doc.block_labels[idx].selected ^= true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn tool_button(label: &str, this_tool: Tool, active: Tool) -> Element<'_, Message> {
|
fn tool_button(label: &str, this_tool: Tool, active: Tool) -> Element<'_, Message> {
|
||||||
let btn = button(text(label).size(13)).on_press(Message::SelectTool(this_tool));
|
let btn = button(text(label).size(13)).on_press(Message::SelectTool(this_tool));
|
||||||
if this_tool == active {
|
if this_tool == active {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,608 @@
|
||||||
|
//! geometry editing primitives on [`FemmDoc`]: add, delete, closest-point queries.
|
||||||
|
|
||||||
|
use crate::geom_math::{
|
||||||
|
arc_arc_intersection, circle_from_arc, line_arc_intersection, line_line_intersection,
|
||||||
|
shortest_distance_from_arc, shortest_distance_from_segment,
|
||||||
|
};
|
||||||
|
use crate::{ArcSegment, BlockLabel, FemmDoc, Node, Segment};
|
||||||
|
use num_complex::Complex64;
|
||||||
|
|
||||||
|
/// fraction of the node-bbox diagonal used as auto-tolerance for intersection-node coalescing.
|
||||||
|
const BBOX_TOLERANCE_FRAC: f64 = 1.0e-6;
|
||||||
|
/// fraction of a segment's length within which an off-endpoint node triggers a recursive split.
|
||||||
|
const ON_LINE_FRAC: f64 = 1.0e-5;
|
||||||
|
|
||||||
|
impl FemmDoc {
|
||||||
|
/// adds a node at (x, y), returning the index of an existing node within `tol` distance when present.
|
||||||
|
pub fn add_node(&mut self, x: f64, y: f64, tol: f64) -> usize {
|
||||||
|
if tol > 0.0 {
|
||||||
|
for (i, n) in self.nodes.iter().enumerate() {
|
||||||
|
let dx = n.x - x;
|
||||||
|
let dy = n.y - y;
|
||||||
|
if (dx * dx + dy * dy).sqrt() <= tol {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.nodes.push(Node {
|
||||||
|
x, y,
|
||||||
|
boundary_marker: String::new(),
|
||||||
|
in_group: 0,
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
self.nodes.len() - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// adds a block label at (x, y), returning the index of an existing label within `tol` when present.
|
||||||
|
pub fn add_block_label(&mut self, x: f64, y: f64, tol: f64) -> usize {
|
||||||
|
if tol > 0.0 {
|
||||||
|
for (i, b) in self.block_labels.iter().enumerate() {
|
||||||
|
let dx = b.x - x;
|
||||||
|
let dy = b.y - y;
|
||||||
|
if (dx * dx + dy * dy).sqrt() <= tol {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.block_labels.push(BlockLabel {
|
||||||
|
x, y,
|
||||||
|
max_area: 0.0,
|
||||||
|
block_type: String::from("<None>"),
|
||||||
|
in_conductor: String::from("<None>"),
|
||||||
|
in_group: 0,
|
||||||
|
is_external: false,
|
||||||
|
is_default: false,
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
self.block_labels.len() - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// adds a segment between two node indices, splitting at every crossing and through any on-line node.
|
||||||
|
pub fn add_segment(&mut self, n0: i32, n1: i32) -> bool {
|
||||||
|
self.add_segment_with_marker(n0, n1, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PSLG-aware variant propagating a boundary-marker name onto every resulting segment piece.
|
||||||
|
pub fn add_segment_with_marker(&mut self, n0: i32, n1: i32, marker: &str) -> bool {
|
||||||
|
if n0 == n1 { return false; }
|
||||||
|
let nn = self.nodes.len() as i32;
|
||||||
|
if n0 < 0 || n1 < 0 || n0 >= nn || n1 >= nn { return false; }
|
||||||
|
|
||||||
|
// duplicate either-orientation.
|
||||||
|
let (a, b) = if n0 < n1 { (n0, n1) } else { (n1, n0) };
|
||||||
|
for s in &self.segments {
|
||||||
|
let (sa, sb) = if s.n0 < s.n1 { (s.n0, s.n1) } else { (s.n1, s.n0) };
|
||||||
|
if sa == a && sb == b { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let n0p = (self.nodes[n0 as usize].x, self.nodes[n0 as usize].y);
|
||||||
|
let n1p = (self.nodes[n1 as usize].x, self.nodes[n1 as usize].y);
|
||||||
|
|
||||||
|
// crossings with existing segments and arcs.
|
||||||
|
let mut new_points: Vec<(f64, f64)> = Vec::new();
|
||||||
|
for s in &self.segments {
|
||||||
|
let sp0 = (self.nodes[s.n0 as usize].x, self.nodes[s.n0 as usize].y);
|
||||||
|
let sp1 = (self.nodes[s.n1 as usize].x, self.nodes[s.n1 as usize].y);
|
||||||
|
if let Some(hit) = line_line_intersection(n0p, n1p, sp0, sp1) {
|
||||||
|
new_points.push(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for arc in &self.arcs {
|
||||||
|
let ap0 = (self.nodes[arc.n0 as usize].x, self.nodes[arc.n0 as usize].y);
|
||||||
|
let ap1 = (self.nodes[arc.n1 as usize].x, self.nodes[arc.n1 as usize].y);
|
||||||
|
for hit in line_arc_intersection(n0p, n1p, ap0, ap1, arc.arc_length) {
|
||||||
|
new_points.push(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tol = self.bbox_tolerance();
|
||||||
|
for (x, y) in new_points {
|
||||||
|
self.add_node(x, y, tol);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.segments.push(Segment {
|
||||||
|
n0, n1,
|
||||||
|
max_side_length: -1.0,
|
||||||
|
boundary_marker: marker.to_string(),
|
||||||
|
hidden: false,
|
||||||
|
in_group: 0,
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// first non-endpoint node on the new segment's interior, if any.
|
||||||
|
let length = ((n1p.0 - n0p.0).powi(2) + (n1p.1 - n0p.1).powi(2)).sqrt();
|
||||||
|
let dmin = length * ON_LINE_FRAC;
|
||||||
|
|
||||||
|
let mut split_at: Option<i32> = None;
|
||||||
|
for (i, node) in self.nodes.iter().enumerate() {
|
||||||
|
let idx = i as i32;
|
||||||
|
if idx == n0 || idx == n1 { continue; }
|
||||||
|
let np = (node.x, node.y);
|
||||||
|
let de0 = ((np.0 - n0p.0).powi(2) + (np.1 - n0p.1).powi(2)).sqrt();
|
||||||
|
let de1 = ((np.0 - n1p.0).powi(2) + (np.1 - n1p.1).powi(2)).sqrt();
|
||||||
|
if de0 < dmin || de1 < dmin { continue; }
|
||||||
|
let d = shortest_distance_from_segment(np, n0p, n1p);
|
||||||
|
if d < dmin {
|
||||||
|
split_at = Some(idx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mid) = split_at {
|
||||||
|
self.segments.pop();
|
||||||
|
let a = self.add_segment_with_marker(n0, mid, marker);
|
||||||
|
let b = self.add_segment_with_marker(mid, n1, marker);
|
||||||
|
a || b
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// auto-tolerance for intersection-node coalescing, derived from the node bounding box.
|
||||||
|
fn bbox_tolerance(&self) -> f64 {
|
||||||
|
nodes_bbox_tolerance(&self.nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// rebuilds every list through the PSLG-aware add primitives, catching crossings missed by incremental edits.
|
||||||
|
pub fn enforce_pslg(&mut self) {
|
||||||
|
let old_nodes = std::mem::take(&mut self.nodes);
|
||||||
|
let old_segments = std::mem::take(&mut self.segments);
|
||||||
|
let old_arcs = std::mem::take(&mut self.arcs);
|
||||||
|
let old_block_labels = std::mem::take(&mut self.block_labels);
|
||||||
|
|
||||||
|
let tol = nodes_bbox_tolerance(&old_nodes);
|
||||||
|
|
||||||
|
// dedupes by position with metadata preserved.
|
||||||
|
for n in &old_nodes {
|
||||||
|
let mut duplicate = false;
|
||||||
|
for existing in &self.nodes {
|
||||||
|
if (existing.x - n.x).hypot(existing.y - n.y) < tol {
|
||||||
|
duplicate = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !duplicate {
|
||||||
|
self.nodes.push(n.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for s in old_segments {
|
||||||
|
let p0 = (old_nodes[s.n0 as usize].x, old_nodes[s.n0 as usize].y);
|
||||||
|
let p1 = (old_nodes[s.n1 as usize].x, old_nodes[s.n1 as usize].y);
|
||||||
|
let (Some(n0), Some(n1)) = (
|
||||||
|
self.closest_node(p0.0, p0.1),
|
||||||
|
self.closest_node(p1.0, p1.1),
|
||||||
|
) else { continue };
|
||||||
|
self.add_segment_with_marker(n0 as i32, n1 as i32, &s.boundary_marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
for a in old_arcs {
|
||||||
|
let p0 = (old_nodes[a.n0 as usize].x, old_nodes[a.n0 as usize].y);
|
||||||
|
let p1 = (old_nodes[a.n1 as usize].x, old_nodes[a.n1 as usize].y);
|
||||||
|
let (Some(n0), Some(n1)) = (
|
||||||
|
self.closest_node(p0.0, p0.1),
|
||||||
|
self.closest_node(p1.0, p1.1),
|
||||||
|
) else { continue };
|
||||||
|
self.add_arc_segment_with_template(n0 as i32, n1 as i32, a.arc_length, &a);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.block_labels = old_block_labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// adds an arc between two node indices, splitting at every crossing and through any on-arc node.
|
||||||
|
pub fn add_arc_segment(&mut self, n0: i32, n1: i32, arc_length_deg: f64) -> bool {
|
||||||
|
let template = ArcSegment {
|
||||||
|
n0, n1,
|
||||||
|
arc_length: arc_length_deg,
|
||||||
|
max_side_length: 10.0,
|
||||||
|
boundary_marker: String::new(),
|
||||||
|
hidden: false,
|
||||||
|
in_group: 0,
|
||||||
|
normal_direction: true,
|
||||||
|
selected: false,
|
||||||
|
};
|
||||||
|
self.add_arc_segment_with_template(n0, n1, arc_length_deg, &template)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PSLG-aware variant propagating boundary marker and side-length metadata onto every arc piece.
|
||||||
|
pub fn add_arc_segment_with_template(
|
||||||
|
&mut self,
|
||||||
|
n0: i32,
|
||||||
|
n1: i32,
|
||||||
|
arc_length_deg: f64,
|
||||||
|
template: &ArcSegment,
|
||||||
|
) -> bool {
|
||||||
|
if n0 == n1 { return false; }
|
||||||
|
let nn = self.nodes.len() as i32;
|
||||||
|
if n0 < 0 || n1 < 0 || n0 >= nn || n1 >= nn { return false; }
|
||||||
|
|
||||||
|
// same directed endpoints with similar sweep counts as duplicate.
|
||||||
|
for a in &self.arcs {
|
||||||
|
if a.n0 == n0 && a.n1 == n1 && (a.arc_length - arc_length_deg).abs() < 1.0e-2 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let n0p = (self.nodes[n0 as usize].x, self.nodes[n0 as usize].y);
|
||||||
|
let n1p = (self.nodes[n1 as usize].x, self.nodes[n1 as usize].y);
|
||||||
|
|
||||||
|
// crossings with existing segments and arcs.
|
||||||
|
let mut new_points: Vec<(f64, f64)> = Vec::new();
|
||||||
|
for s in &self.segments {
|
||||||
|
let sp0 = (self.nodes[s.n0 as usize].x, self.nodes[s.n0 as usize].y);
|
||||||
|
let sp1 = (self.nodes[s.n1 as usize].x, self.nodes[s.n1 as usize].y);
|
||||||
|
for hit in line_arc_intersection(sp0, sp1, n0p, n1p, arc_length_deg) {
|
||||||
|
new_points.push(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for arc in &self.arcs {
|
||||||
|
let ap0 = (self.nodes[arc.n0 as usize].x, self.nodes[arc.n0 as usize].y);
|
||||||
|
let ap1 = (self.nodes[arc.n1 as usize].x, self.nodes[arc.n1 as usize].y);
|
||||||
|
for hit in arc_arc_intersection(n0p, n1p, arc_length_deg, ap0, ap1, arc.arc_length) {
|
||||||
|
new_points.push(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tol = self.bbox_tolerance();
|
||||||
|
for (x, y) in new_points {
|
||||||
|
self.add_node(x, y, tol);
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_arc = ArcSegment {
|
||||||
|
n0, n1,
|
||||||
|
arc_length: arc_length_deg,
|
||||||
|
..template.clone()
|
||||||
|
};
|
||||||
|
self.arcs.push(new_arc);
|
||||||
|
|
||||||
|
// first non-endpoint node on the new arc's sweep range, if any.
|
||||||
|
let (cx, cy, radius) = circle_from_arc(n0p, n1p, arc_length_deg);
|
||||||
|
let sweep_rad = arc_length_deg.to_radians();
|
||||||
|
let arc_length_world = radius * sweep_rad.abs();
|
||||||
|
let dmin = arc_length_world * ON_LINE_FRAC;
|
||||||
|
|
||||||
|
let mut split_at: Option<i32> = None;
|
||||||
|
for (i, node) in self.nodes.iter().enumerate() {
|
||||||
|
let idx = i as i32;
|
||||||
|
if idx == n0 || idx == n1 { continue; }
|
||||||
|
let np = (node.x, node.y);
|
||||||
|
let de0 = ((np.0 - n0p.0).powi(2) + (np.1 - n0p.1).powi(2)).sqrt();
|
||||||
|
let de1 = ((np.0 - n1p.0).powi(2) + (np.1 - n1p.1).powi(2)).sqrt();
|
||||||
|
if de0 < dmin || de1 < dmin { continue; }
|
||||||
|
let d = shortest_distance_from_arc(np, n0p, n1p, arc_length_deg);
|
||||||
|
if d < dmin {
|
||||||
|
split_at = Some(idx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mid) = split_at {
|
||||||
|
self.arcs.pop();
|
||||||
|
let mid_pos = (self.nodes[mid as usize].x, self.nodes[mid as usize].y);
|
||||||
|
let c = Complex64::new(cx, cy);
|
||||||
|
let a0 = Complex64::new(n0p.0, n0p.1);
|
||||||
|
let a1 = Complex64::new(n1p.0, n1p.1);
|
||||||
|
let a2 = Complex64::new(mid_pos.0, mid_pos.1);
|
||||||
|
let sweep_to_mid = ((a2 - c) / (a0 - c)).arg().to_degrees();
|
||||||
|
let sweep_from_mid = ((a1 - c) / (a2 - c)).arg().to_degrees();
|
||||||
|
let a = self.add_arc_segment_with_template(n0, mid, sweep_to_mid, template);
|
||||||
|
let b = self.add_arc_segment_with_template(mid, n1, sweep_from_mid, template);
|
||||||
|
a || b
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// removes selected nodes and rewrites segment/arc endpoint indices to drop references.
|
||||||
|
pub fn delete_selected_nodes(&mut self) -> usize {
|
||||||
|
let keep: Vec<bool> = self.nodes.iter().map(|n| !n.selected).collect();
|
||||||
|
let mut remap: Vec<i32> = Vec::with_capacity(keep.len());
|
||||||
|
let mut next: i32 = 0;
|
||||||
|
for &k in &keep {
|
||||||
|
remap.push(if k { let r = next; next += 1; r } else { -1 });
|
||||||
|
}
|
||||||
|
let removed = self.nodes.len() - next as usize;
|
||||||
|
|
||||||
|
let mut new_nodes = Vec::with_capacity(next as usize);
|
||||||
|
for (i, n) in self.nodes.drain(..).enumerate() {
|
||||||
|
if keep[i] { new_nodes.push(n); }
|
||||||
|
}
|
||||||
|
self.nodes = new_nodes;
|
||||||
|
|
||||||
|
self.segments.retain(|s| {
|
||||||
|
let a = s.n0 as usize;
|
||||||
|
let b = s.n1 as usize;
|
||||||
|
a < keep.len() && b < keep.len() && keep[a] && keep[b]
|
||||||
|
});
|
||||||
|
for s in &mut self.segments {
|
||||||
|
s.n0 = remap[s.n0 as usize];
|
||||||
|
s.n1 = remap[s.n1 as usize];
|
||||||
|
}
|
||||||
|
|
||||||
|
self.arcs.retain(|a| {
|
||||||
|
let i = a.n0 as usize;
|
||||||
|
let j = a.n1 as usize;
|
||||||
|
i < keep.len() && j < keep.len() && keep[i] && keep[j]
|
||||||
|
});
|
||||||
|
for a in &mut self.arcs {
|
||||||
|
a.n0 = remap[a.n0 as usize];
|
||||||
|
a.n1 = remap[a.n1 as usize];
|
||||||
|
}
|
||||||
|
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// removes selected segments and returns the count.
|
||||||
|
pub fn delete_selected_segments(&mut self) -> usize {
|
||||||
|
let before = self.segments.len();
|
||||||
|
self.segments.retain(|s| !s.selected);
|
||||||
|
before - self.segments.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// removes selected arc segments and returns the count.
|
||||||
|
pub fn delete_selected_arcs(&mut self) -> usize {
|
||||||
|
let before = self.arcs.len();
|
||||||
|
self.arcs.retain(|a| !a.selected);
|
||||||
|
before - self.arcs.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// removes selected block labels and returns the count.
|
||||||
|
pub fn delete_selected_block_labels(&mut self) -> usize {
|
||||||
|
let before = self.block_labels.len();
|
||||||
|
self.block_labels.retain(|b| !b.selected);
|
||||||
|
before - self.block_labels.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// returns the index of the closest node to (x, y), or None when the node list is empty.
|
||||||
|
pub fn closest_node(&self, x: f64, y: f64) -> Option<usize> {
|
||||||
|
let mut best: Option<(usize, f64)> = None;
|
||||||
|
for (i, n) in self.nodes.iter().enumerate() {
|
||||||
|
let d = (n.x - x).hypot(n.y - y);
|
||||||
|
match best {
|
||||||
|
None => best = Some((i, d)),
|
||||||
|
Some((_, bd)) if d < bd => best = Some((i, d)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(i, _)| i)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// returns the index of the closest block label to (x, y), or None when none exist.
|
||||||
|
pub fn closest_block_label(&self, x: f64, y: f64) -> Option<usize> {
|
||||||
|
let mut best: Option<(usize, f64)> = None;
|
||||||
|
for (i, b) in self.block_labels.iter().enumerate() {
|
||||||
|
let d = (b.x - x).hypot(b.y - y);
|
||||||
|
match best {
|
||||||
|
None => best = Some((i, d)),
|
||||||
|
Some((_, bd)) if d < bd => best = Some((i, d)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(i, _)| i)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// returns the index of the segment whose nearest point is closest to (x, y).
|
||||||
|
pub fn closest_segment(&self, x: f64, y: f64) -> Option<usize> {
|
||||||
|
let mut best: Option<(usize, f64)> = None;
|
||||||
|
for (i, s) in self.segments.iter().enumerate() {
|
||||||
|
let (Some(p0), Some(p1)) = (self.nodes.get(s.n0 as usize), self.nodes.get(s.n1 as usize)) else { continue };
|
||||||
|
let d = point_to_segment_distance(x, y, p0.x, p0.y, p1.x, p1.y);
|
||||||
|
match best {
|
||||||
|
None => best = Some((i, d)),
|
||||||
|
Some((_, bd)) if d < bd => best = Some((i, d)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(i, _)| i)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// clears the selection flag on every geometric entity in the doc.
|
||||||
|
pub fn clear_selection(&mut self) {
|
||||||
|
for n in &mut self.nodes { n.selected = false; }
|
||||||
|
for s in &mut self.segments { s.selected = false; }
|
||||||
|
for a in &mut self.arcs { a.selected = false; }
|
||||||
|
for b in &mut self.block_labels { b.selected = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// auto-tolerance derived from the bounding box of a node slice.
|
||||||
|
fn nodes_bbox_tolerance(nodes: &[Node]) -> f64 {
|
||||||
|
if nodes.len() < 2 {
|
||||||
|
return 1.0e-8;
|
||||||
|
}
|
||||||
|
let mut xmin = f64::INFINITY;
|
||||||
|
let mut xmax = f64::NEG_INFINITY;
|
||||||
|
let mut ymin = f64::INFINITY;
|
||||||
|
let mut ymax = f64::NEG_INFINITY;
|
||||||
|
for n in nodes {
|
||||||
|
xmin = xmin.min(n.x); xmax = xmax.max(n.x);
|
||||||
|
ymin = ymin.min(n.y); ymax = ymax.max(n.y);
|
||||||
|
}
|
||||||
|
let dx = xmax - xmin;
|
||||||
|
let dy = ymax - ymin;
|
||||||
|
(dx * dx + dy * dy).sqrt() * BBOX_TOLERANCE_FRAC
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Euclidean distance from (px, py) to the segment between (ax, ay) and (bx, by).
|
||||||
|
fn point_to_segment_distance(px: f64, py: f64, ax: f64, ay: f64, bx: f64, by: f64) -> f64 {
|
||||||
|
let dx = bx - ax;
|
||||||
|
let dy = by - ay;
|
||||||
|
let len2 = dx * dx + dy * dy;
|
||||||
|
if len2 < 1e-18 {
|
||||||
|
return (px - ax).hypot(py - ay);
|
||||||
|
}
|
||||||
|
let t = (((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0);
|
||||||
|
let cx = ax + t * dx;
|
||||||
|
let cy = ay + t * dy;
|
||||||
|
(px - cx).hypot(py - cy)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn doc_with_corners() -> FemmDoc {
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(-1.0, 0.0, 0.0);
|
||||||
|
d.add_node( 1.0, 0.0, 0.0);
|
||||||
|
d.add_node( 0.0,-1.0, 0.0);
|
||||||
|
d.add_node( 0.0, 1.0, 0.0);
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_segment_rejected() {
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(0.0, 0.0, 0.0);
|
||||||
|
d.add_node(1.0, 0.0, 0.0);
|
||||||
|
assert!(d.add_segment(0, 1));
|
||||||
|
assert!(!d.add_segment(0, 1));
|
||||||
|
assert!(!d.add_segment(1, 0));
|
||||||
|
assert_eq!(d.segments.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn segment_passing_through_existing_node_splits() {
|
||||||
|
// three colinear nodes (-1,0), (1,0), (0,0). adding the outer-to-outer segment
|
||||||
|
// splits at the midpoint node, producing two pieces meeting there.
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(-1.0, 0.0, 0.0);
|
||||||
|
d.add_node( 1.0, 0.0, 0.0);
|
||||||
|
d.add_node( 0.0, 0.0, 0.0);
|
||||||
|
assert!(d.add_segment(0, 1));
|
||||||
|
assert_eq!(d.segments.len(), 2);
|
||||||
|
let touches_mid = d.segments.iter().filter(|s| s.n0 == 2 || s.n1 == 2).count();
|
||||||
|
assert_eq!(touches_mid, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enforce_pslg_splits_first_segment_at_late_intersection() {
|
||||||
|
// incremental edit splits only the second of two crossing segments. enforce_pslg
|
||||||
|
// back-splits the first segment through the intersection node.
|
||||||
|
let mut d = doc_with_corners();
|
||||||
|
assert!(d.add_segment(0, 1));
|
||||||
|
assert!(d.add_segment(2, 3));
|
||||||
|
assert_eq!(d.segments.len(), 3, "pre-enforce: one whole, one split");
|
||||||
|
|
||||||
|
d.enforce_pslg();
|
||||||
|
|
||||||
|
assert_eq!(d.nodes.len(), 5, "no node added or merged by enforce");
|
||||||
|
assert_eq!(d.segments.len(), 4, "both segments split at the origin");
|
||||||
|
let touches_origin = d.segments.iter().filter(|s| s.n0 == 4 || s.n1 == 4).count();
|
||||||
|
assert_eq!(touches_origin, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enforce_pslg_preserves_segment_boundary_marker() {
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(0.0, 0.0, 0.0);
|
||||||
|
d.add_node(1.0, 0.0, 0.0);
|
||||||
|
assert!(d.add_segment_with_marker(0, 1, "outer"));
|
||||||
|
|
||||||
|
d.enforce_pslg();
|
||||||
|
|
||||||
|
assert_eq!(d.segments.len(), 1);
|
||||||
|
assert_eq!(d.segments[0].boundary_marker, "outer");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enforce_pslg_preserves_arc_metadata() {
|
||||||
|
// arc carrying non-default marker, side length, group, and direction.
|
||||||
|
// enforce_pslg round-trips every field intact.
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(1.0, 0.0, 0.0);
|
||||||
|
d.add_node(0.0, 1.0, 0.0);
|
||||||
|
let template = ArcSegment {
|
||||||
|
n0: 0,
|
||||||
|
n1: 1,
|
||||||
|
arc_length: 90.0,
|
||||||
|
max_side_length: 5.0,
|
||||||
|
boundary_marker: String::from("outer"),
|
||||||
|
hidden: true,
|
||||||
|
in_group: 7,
|
||||||
|
normal_direction: false,
|
||||||
|
selected: false,
|
||||||
|
};
|
||||||
|
assert!(d.add_arc_segment_with_template(0, 1, 90.0, &template));
|
||||||
|
|
||||||
|
d.enforce_pslg();
|
||||||
|
|
||||||
|
assert_eq!(d.arcs.len(), 1);
|
||||||
|
let a = &d.arcs[0];
|
||||||
|
assert_eq!(a.boundary_marker, "outer");
|
||||||
|
assert!((a.max_side_length - 5.0).abs() < 1e-12);
|
||||||
|
assert!(a.hidden);
|
||||||
|
assert_eq!(a.in_group, 7);
|
||||||
|
assert!(!a.normal_direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_arc_rejected() {
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(1.0, 0.0, 0.0);
|
||||||
|
d.add_node(0.0, 1.0, 0.0);
|
||||||
|
assert!(d.add_arc_segment(0, 1, 90.0));
|
||||||
|
assert!(!d.add_arc_segment(0, 1, 90.0));
|
||||||
|
// reversed endpoints define a distinct arc.
|
||||||
|
assert!(d.add_arc_segment(1, 0, 90.0));
|
||||||
|
assert_eq!(d.arcs.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_segment_crossing_unit_quarter_arc_splits_segment() {
|
||||||
|
// unit quarter arc from (1,0) to (0,1); horizontal line at y=0.5 crosses the arc once.
|
||||||
|
// expect a fresh intersection node near (0.866, 0.5) and the segment split into two.
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node( 1.0, 0.0, 0.0);
|
||||||
|
d.add_node( 0.0, 1.0, 0.0);
|
||||||
|
d.add_node(-2.0, 0.5, 0.0);
|
||||||
|
d.add_node( 2.0, 0.5, 0.0);
|
||||||
|
assert!(d.add_arc_segment(0, 1, 90.0));
|
||||||
|
assert!(d.add_segment(2, 3));
|
||||||
|
|
||||||
|
assert_eq!(d.nodes.len(), 5);
|
||||||
|
let n4 = &d.nodes[4];
|
||||||
|
assert!((n4.x - 0.75_f64.sqrt()).abs() < 1e-6);
|
||||||
|
assert!((n4.y - 0.5).abs() < 1e-9);
|
||||||
|
assert_eq!(d.segments.len(), 2);
|
||||||
|
assert_eq!(d.arcs.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_arc_passing_through_existing_node_splits_into_two_arcs() {
|
||||||
|
// pre-existing node at (cos45, sin45) lies on the unit quarter arc from (1,0) to (0,1).
|
||||||
|
// adding the arc detects the on-arc node and emits two sub-arcs of 45 degrees each.
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(1.0, 0.0, 0.0);
|
||||||
|
d.add_node(0.0, 1.0, 0.0);
|
||||||
|
let mid_x = std::f64::consts::FRAC_1_SQRT_2;
|
||||||
|
d.add_node(mid_x, mid_x, 0.0);
|
||||||
|
assert!(d.add_arc_segment(0, 1, 90.0));
|
||||||
|
|
||||||
|
assert_eq!(d.arcs.len(), 2);
|
||||||
|
let touches_mid = d.arcs.iter().filter(|a| a.n0 == 2 || a.n1 == 2).count();
|
||||||
|
assert_eq!(touches_mid, 2);
|
||||||
|
// split sweeps sum to the parent 90 degrees, within floating slop.
|
||||||
|
let total: f64 = d.arcs.iter().map(|a| a.arc_length).sum();
|
||||||
|
assert!((total - 90.0).abs() < 1e-6, "sum of split sweeps = {total}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crossing_second_segment_splits_at_intersection() {
|
||||||
|
// two perpendicular segments through the origin. horizontal first, vertical second.
|
||||||
|
// the vertical add inserts an origin node and self-splits; the horizontal stays whole.
|
||||||
|
let mut d = doc_with_corners();
|
||||||
|
assert!(d.add_segment(0, 1));
|
||||||
|
assert!(d.add_segment(2, 3));
|
||||||
|
|
||||||
|
assert_eq!(d.nodes.len(), 5, "intersection node added");
|
||||||
|
let new_idx = 4;
|
||||||
|
assert!((d.nodes[new_idx].x).abs() < 1e-9);
|
||||||
|
assert!((d.nodes[new_idx].y).abs() < 1e-9);
|
||||||
|
|
||||||
|
// expected pieces: 0->1 (horizontal whole), 2->4 and 4->3 (vertical halves).
|
||||||
|
assert_eq!(d.segments.len(), 3);
|
||||||
|
let touches_new = d.segments.iter().filter(|s| s.n0 == new_idx as i32 || s.n1 == new_idx as i32).count();
|
||||||
|
assert_eq!(touches_new, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,322 @@
|
||||||
|
//! 2D intersection math and distance queries over (x, y) pairs and degree-sweep arcs.
|
||||||
|
|
||||||
|
use num_complex::Complex64;
|
||||||
|
|
||||||
|
/// closeness factor for common-endpoint and small-interval rejection.
|
||||||
|
const EPS_FRAC: f64 = 1.0e-8;
|
||||||
|
|
||||||
|
/// recovers the center and radius of the circle through an arc's endpoints with the given sweep.
|
||||||
|
pub fn circle_from_arc(p0: (f64, f64), p1: (f64, f64), sweep_deg: f64) -> (f64, f64, f64) {
|
||||||
|
let a0 = Complex64::new(p0.0, p0.1);
|
||||||
|
let a1 = Complex64::new(p1.0, p1.1);
|
||||||
|
let chord = (a1 - a0).norm();
|
||||||
|
let t = (a1 - a0) / chord;
|
||||||
|
let tta = sweep_deg.to_radians();
|
||||||
|
let r = chord / (2.0 * (tta / 2.0).sin());
|
||||||
|
let c = a0 + (Complex64::new(chord / 2.0, (r * r - chord * chord / 4.0).sqrt())) * t;
|
||||||
|
(c.re, c.im, r.abs())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// shortest distance from a point to a segment.
|
||||||
|
pub fn shortest_distance_from_segment(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {
|
||||||
|
let dx = b.0 - a.0;
|
||||||
|
let dy = b.1 - a.1;
|
||||||
|
let len2 = dx * dx + dy * dy;
|
||||||
|
if len2 < 1e-18 {
|
||||||
|
return ((p.0 - a.0).powi(2) + (p.1 - a.1).powi(2)).sqrt();
|
||||||
|
}
|
||||||
|
let t = (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0);
|
||||||
|
let cx = a.0 + t * dx;
|
||||||
|
let cy = a.1 + t * dy;
|
||||||
|
((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// shortest distance from a point to a sweep-defined arc.
|
||||||
|
pub fn shortest_distance_from_arc(
|
||||||
|
p: (f64, f64),
|
||||||
|
a0: (f64, f64),
|
||||||
|
a1: (f64, f64),
|
||||||
|
sweep_deg: f64,
|
||||||
|
) -> f64 {
|
||||||
|
let (cx, cy, r) = circle_from_arc(a0, a1, sweep_deg);
|
||||||
|
let pc = Complex64::new(p.0 - cx, p.1 - cy);
|
||||||
|
let d = pc.norm();
|
||||||
|
if d == 0.0 {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
let t = pc / d;
|
||||||
|
let foot = pc - Complex64::new(r * t.re, r * t.im);
|
||||||
|
let l = foot.norm();
|
||||||
|
let a0_dir = Complex64::new(a0.0 - cx, a0.1 - cy);
|
||||||
|
let z = ((t / a0_dir).arg() * 180.0 / std::f64::consts::PI + 360.0) % 360.0;
|
||||||
|
let sweep_abs = sweep_deg.abs();
|
||||||
|
if z > 0.0 && z < sweep_abs {
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
let e0 = ((p.0 - a0.0).powi(2) + (p.1 - a0.1).powi(2)).sqrt();
|
||||||
|
let e1 = ((p.0 - a1.0).powi(2) + (p.1 - a1.1).powi(2)).sqrt();
|
||||||
|
e0.min(e1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// intersection of the open segments p0->p1 and q0->q1, or None when none exists.
|
||||||
|
pub fn line_line_intersection(
|
||||||
|
p0: (f64, f64),
|
||||||
|
p1: (f64, f64),
|
||||||
|
q0: (f64, f64),
|
||||||
|
q1: (f64, f64),
|
||||||
|
) -> Option<(f64, f64)> {
|
||||||
|
let p0c = Complex64::new(p0.0, p0.1);
|
||||||
|
let p1c = Complex64::new(p1.0, p1.1);
|
||||||
|
let q0c = Complex64::new(q0.0, q0.1);
|
||||||
|
let q1c = Complex64::new(q1.0, q1.1);
|
||||||
|
|
||||||
|
// shared endpoint -> no other intersection
|
||||||
|
if (p0c - q0c).norm() < f64::EPSILON
|
||||||
|
|| (p0c - q1c).norm() < f64::EPSILON
|
||||||
|
|| (p1c - q0c).norm() < f64::EPSILON
|
||||||
|
|| (p1c - q1c).norm() < f64::EPSILON
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ee = ((p1c - p0c).norm()).min((q1c - q0c).norm()) * EPS_FRAC;
|
||||||
|
|
||||||
|
let denom = p1c - p0c;
|
||||||
|
if denom.norm() < f64::EPSILON {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let r0 = (q0c - p0c) / denom;
|
||||||
|
let r1 = (q1c - p0c) / denom;
|
||||||
|
|
||||||
|
if r0.re <= 0.0 && r1.re <= 0.0 { return None; }
|
||||||
|
if r0.re >= 1.0 && r1.re >= 1.0 { return None; }
|
||||||
|
if r0.im <= 0.0 && r1.im <= 0.0 { return None; }
|
||||||
|
if r0.im >= 0.0 && r1.im >= 0.0 { return None; }
|
||||||
|
|
||||||
|
let denom_im = r0.im - r1.im;
|
||||||
|
if denom_im.abs() < f64::EPSILON { return None; }
|
||||||
|
let z = r0.im / denom_im;
|
||||||
|
let x = ((1.0 - z) * r0 + z * r1).re;
|
||||||
|
if x < ee || x > 1.0 - ee {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hit = Complex64::new(p0.0, p0.1) * (1.0 - z) + Complex64::new(q0.0, q0.1) * 0.0;
|
||||||
|
let _ = hit;
|
||||||
|
let result = Complex64::new(q0.0, q0.1) * (1.0 - z) + Complex64::new(q1.0, q1.1) * z;
|
||||||
|
Some((result.re, result.im))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// intersection points of an open line segment with an open arc, returning 0, 1, or 2 hits.
|
||||||
|
pub fn line_arc_intersection(
|
||||||
|
p0: (f64, f64),
|
||||||
|
p1: (f64, f64),
|
||||||
|
a0: (f64, f64),
|
||||||
|
a1: (f64, f64),
|
||||||
|
sweep_deg: f64,
|
||||||
|
) -> Vec<(f64, f64)> {
|
||||||
|
let p0c = Complex64::new(p0.0, p0.1);
|
||||||
|
let p1c = Complex64::new(p1.0, p1.1);
|
||||||
|
let a0c = Complex64::new(a0.0, a0.1);
|
||||||
|
let (cx, cy, r) = circle_from_arc(a0, a1, sweep_deg);
|
||||||
|
let c = Complex64::new(cx, cy);
|
||||||
|
|
||||||
|
let d = (p1c - p0c).norm();
|
||||||
|
if d < f64::EPSILON {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let t = (p1c - p0c) / d;
|
||||||
|
let v = (c - p0c) / t;
|
||||||
|
|
||||||
|
if v.im.abs() > r {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let l = (r * r - v.im * v.im).sqrt();
|
||||||
|
let tta = sweep_deg.to_radians().abs();
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(2);
|
||||||
|
|
||||||
|
if (l / r) < 1.0e-5 {
|
||||||
|
let hit = p0c + Complex64::new(v.re, 0.0) * t;
|
||||||
|
let r_param = ((hit - p0c) / t).re;
|
||||||
|
let z = ((hit - c) / (a0c - c)).arg();
|
||||||
|
if r_param > 0.0 && r_param < d && z > 0.0 && z < tta {
|
||||||
|
out.push((hit.re, hit.im));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
for sign in [1.0, -1.0] {
|
||||||
|
let hit = p0c + Complex64::new(v.re + sign * l, 0.0) * t;
|
||||||
|
let r_param = ((hit - p0c) / t).re;
|
||||||
|
let z = ((hit - c) / (a0c - c)).arg();
|
||||||
|
if r_param > 0.0 && r_param < d && z > 0.0 && z < tta {
|
||||||
|
out.push((hit.re, hit.im));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// intersection points of two open arcs, returning 0, 1, or 2 hits.
|
||||||
|
pub fn arc_arc_intersection(
|
||||||
|
a0_p0: (f64, f64),
|
||||||
|
a0_p1: (f64, f64),
|
||||||
|
a0_sweep_deg: f64,
|
||||||
|
a1_p0: (f64, f64),
|
||||||
|
a1_p1: (f64, f64),
|
||||||
|
a1_sweep_deg: f64,
|
||||||
|
) -> Vec<(f64, f64)> {
|
||||||
|
let (c0x, c0y, r0) = circle_from_arc(a0_p0, a0_p1, a0_sweep_deg);
|
||||||
|
let (c1x, c1y, r1) = circle_from_arc(a1_p0, a1_p1, a1_sweep_deg);
|
||||||
|
let c0 = Complex64::new(c0x, c0y);
|
||||||
|
let c1 = Complex64::new(c1x, c1y);
|
||||||
|
let d = (c1 - c0).norm();
|
||||||
|
|
||||||
|
if d > r0 + r1 || d < 1.0e-8 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let l = ((r0 + r1 - d) * (d + r0 - r1) * (d - r0 + r1) * (d + r0 + r1)).sqrt() / (2.0 * d);
|
||||||
|
let c = 1.0 + (r0 / d) * (r0 / d) - (r1 / d) * (r1 / d);
|
||||||
|
let t = (c1 - c0) / d;
|
||||||
|
let tta0 = a0_sweep_deg.to_radians().abs();
|
||||||
|
let tta1 = a1_sweep_deg.to_radians().abs();
|
||||||
|
let a0c = Complex64::new(a0_p0.0, a0_p0.1);
|
||||||
|
let a1c = Complex64::new(a1_p0.0, a1_p0.1);
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(2);
|
||||||
|
|
||||||
|
let first = c0 + Complex64::new(c * d / 2.0, l) * t;
|
||||||
|
let z0 = ((first - c0) / (a0c - c0)).arg();
|
||||||
|
let z1 = ((first - c1) / (a1c - c1)).arg();
|
||||||
|
if z0 > 0.0 && z0 < tta0 && z1 > 0.0 && z1 < tta1 {
|
||||||
|
out.push((first.re, first.im));
|
||||||
|
}
|
||||||
|
|
||||||
|
// tangent-touch case: only one intersection
|
||||||
|
if (d - r0 + r1).abs() / (r0 + r1) < 1.0e-5 {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let second = c0 + Complex64::new(c * d / 2.0, -l) * t;
|
||||||
|
let z0 = ((second - c0) / (a0c - c0)).arg();
|
||||||
|
let z1 = ((second - c1) / (a1c - c1)).arg();
|
||||||
|
if z0 > 0.0 && z0 < tta0 && z1 > 0.0 && z1 < tta1 {
|
||||||
|
out.push((second.re, second.im));
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn close(a: (f64, f64), b: (f64, f64), tol: f64) -> bool {
|
||||||
|
((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() < tol
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_line_crosses_at_origin() {
|
||||||
|
// (-1,-1)->(1,1) crosses (-1,1)->(1,-1) at origin.
|
||||||
|
let hit = line_line_intersection((-1.0, -1.0), (1.0, 1.0), (-1.0, 1.0), (1.0, -1.0));
|
||||||
|
assert!(hit.is_some());
|
||||||
|
assert!(close(hit.unwrap(), (0.0, 0.0), 1e-9));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_line_parallel_no_intersection() {
|
||||||
|
let hit = line_line_intersection((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0));
|
||||||
|
assert!(hit.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_line_t_junction_at_endpoint_rejects() {
|
||||||
|
// the second segment's q0 sits ON the first segment's interior
|
||||||
|
// but the prospective endpoint check should reject (intersection at q0).
|
||||||
|
let hit = line_line_intersection((0.0, 0.0), (2.0, 0.0), (1.0, 0.0), (1.0, 1.0));
|
||||||
|
// shape: one endpoint sits on the other segment, no clean crossing.
|
||||||
|
// GetIntersection returns FALSE when an intersection lies within ee of an endpoint of the
|
||||||
|
// prospective line — here the meeting point IS the prospective q0, so no split is wanted.
|
||||||
|
assert!(hit.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_line_shared_endpoint_no_intersection() {
|
||||||
|
let hit = line_line_intersection((0.0, 0.0), (1.0, 0.0), (1.0, 0.0), (2.0, 1.0));
|
||||||
|
assert!(hit.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn circle_from_quarter_arc_unit() {
|
||||||
|
// a quarter circle (90 deg sweep) from (1, 0) to (0, 1) sits on the unit circle centered at origin.
|
||||||
|
let (cx, cy, r) = circle_from_arc((1.0, 0.0), (0.0, 1.0), 90.0);
|
||||||
|
assert!((cx - 0.0).abs() < 1e-9);
|
||||||
|
assert!((cy - 0.0).abs() < 1e-9);
|
||||||
|
assert!((r - 1.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_through_quarter_arc_crosses_once() {
|
||||||
|
// diagonal line from (-2, 0.5) to (2, 0.5) crosses the quarter circle (1,0)->(0,1) at one point.
|
||||||
|
let hits = line_arc_intersection((-2.0, 0.5), (2.0, 0.5), (1.0, 0.0), (0.0, 1.0), 90.0);
|
||||||
|
assert_eq!(hits.len(), 1);
|
||||||
|
let h = hits[0];
|
||||||
|
assert!((h.0 * h.0 + h.1 * h.1 - 1.0).abs() < 1e-9, "point should lie on unit circle: {h:?}");
|
||||||
|
assert!((h.1 - 0.5).abs() < 1e-9);
|
||||||
|
assert!(h.0 > 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_outside_arc_does_not_cross() {
|
||||||
|
// line clearly outside the arc swept region.
|
||||||
|
let hits = line_arc_intersection((-2.0, 2.5), (2.0, 2.5), (1.0, 0.0), (0.0, 1.0), 90.0);
|
||||||
|
assert!(hits.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arc_arc_two_intersections() {
|
||||||
|
// two unit circles offset by 1.0 along x, both swept 360 degrees... but we use partial arcs
|
||||||
|
// shaped to clip both crossings in their swept range.
|
||||||
|
// circle A: center (0,0), arcs covering full top half (180 deg from (1,0) to (-1,0)).
|
||||||
|
// circle B: center (1,0), arcs covering full top half (180 deg from (2,0) to (0,0)).
|
||||||
|
let hits = arc_arc_intersection(
|
||||||
|
(1.0, 0.0), (-1.0, 0.0), 180.0,
|
||||||
|
(2.0, 0.0), (0.0, 0.0), 180.0,
|
||||||
|
);
|
||||||
|
assert_eq!(hits.len(), 1, "two top-half arcs of overlapping unit circles meet at one upper crossing: {hits:?}");
|
||||||
|
let h = hits[0];
|
||||||
|
assert!((h.0 - 0.5).abs() < 1e-9, "x = 0.5 by symmetry, got {h:?}");
|
||||||
|
assert!(h.1 > 0.0, "intersection should be above the x-axis: {h:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arc_arc_disjoint() {
|
||||||
|
let hits = arc_arc_intersection(
|
||||||
|
(1.0, 0.0), (-1.0, 0.0), 180.0,
|
||||||
|
(11.0, 0.0), (9.0, 0.0), 180.0,
|
||||||
|
);
|
||||||
|
assert!(hits.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortest_distance_from_segment_perpendicular() {
|
||||||
|
// point (0, 1) to segment (-1, 0)->(1, 0) is distance 1.
|
||||||
|
let d = shortest_distance_from_segment((0.0, 1.0), (-1.0, 0.0), (1.0, 0.0));
|
||||||
|
assert!((d - 1.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortest_distance_from_segment_clamps_to_endpoint() {
|
||||||
|
// point (-2, 0) to segment (-1, 0)->(1, 0) is distance 1 (clamps to left endpoint).
|
||||||
|
let d = shortest_distance_from_segment((-2.0, 0.0), (-1.0, 0.0), (1.0, 0.0));
|
||||||
|
assert!((d - 1.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortest_distance_from_arc_radial() {
|
||||||
|
// point at origin distance to unit-circle quarter arc is 1.
|
||||||
|
let d = shortest_distance_from_arc((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), 90.0);
|
||||||
|
assert!((d - 1.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
//! current-flow pre-processor document model: geometry, properties, parser, writer.
|
//! current-flow pre-processor document model: geometry, properties, parser, writer.
|
||||||
|
|
||||||
pub mod geom;
|
pub mod geom;
|
||||||
|
pub mod geom_math;
|
||||||
pub mod props;
|
pub mod props;
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
pub mod writer;
|
pub mod writer;
|
||||||
|
pub mod edit;
|
||||||
|
|
||||||
use num_complex::Complex64;
|
use num_complex::Complex64;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,612 @@
|
||||||
|
//! geometry editing primitives on [`FemmDoc`]: add, delete, closest-point queries.
|
||||||
|
|
||||||
|
use crate::geom_math::{
|
||||||
|
arc_arc_intersection, circle_from_arc, line_arc_intersection, line_line_intersection,
|
||||||
|
shortest_distance_from_arc, shortest_distance_from_segment,
|
||||||
|
};
|
||||||
|
use crate::{ArcSegment, BlockLabel, FemmDoc, Node, Segment};
|
||||||
|
use num_complex::Complex64;
|
||||||
|
|
||||||
|
/// fraction of the node-bbox diagonal used as auto-tolerance for intersection-node coalescing.
|
||||||
|
const BBOX_TOLERANCE_FRAC: f64 = 1.0e-6;
|
||||||
|
/// fraction of a segment's length within which an off-endpoint node triggers a recursive split.
|
||||||
|
const ON_LINE_FRAC: f64 = 1.0e-5;
|
||||||
|
|
||||||
|
impl FemmDoc {
|
||||||
|
/// adds a node at (x, y), returning the index of an existing node within `tol` distance when present.
|
||||||
|
pub fn add_node(&mut self, x: f64, y: f64, tol: f64) -> usize {
|
||||||
|
if tol > 0.0 {
|
||||||
|
for (i, n) in self.nodes.iter().enumerate() {
|
||||||
|
let dx = n.x - x;
|
||||||
|
let dy = n.y - y;
|
||||||
|
if (dx * dx + dy * dy).sqrt() <= tol {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.nodes.push(Node {
|
||||||
|
x, y,
|
||||||
|
boundary_marker: String::new(),
|
||||||
|
in_conductor: String::new(),
|
||||||
|
in_group: 0,
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
self.nodes.len() - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// adds a block label at (x, y), returning the index of an existing label within `tol` when present.
|
||||||
|
pub fn add_block_label(&mut self, x: f64, y: f64, tol: f64) -> usize {
|
||||||
|
if tol > 0.0 {
|
||||||
|
for (i, b) in self.block_labels.iter().enumerate() {
|
||||||
|
let dx = b.x - x;
|
||||||
|
let dy = b.y - y;
|
||||||
|
if (dx * dx + dy * dy).sqrt() <= tol {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.block_labels.push(BlockLabel {
|
||||||
|
x, y,
|
||||||
|
max_area: 0.0,
|
||||||
|
block_type: String::from("<None>"),
|
||||||
|
in_conductor: String::from("<None>"),
|
||||||
|
in_group: 0,
|
||||||
|
is_external: false,
|
||||||
|
is_default: false,
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
self.block_labels.len() - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// adds a segment between two node indices, splitting at every crossing and through any on-line node.
|
||||||
|
pub fn add_segment(&mut self, n0: i32, n1: i32) -> bool {
|
||||||
|
self.add_segment_with_marker(n0, n1, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PSLG-aware variant propagating a boundary-marker name onto every resulting segment piece.
|
||||||
|
pub fn add_segment_with_marker(&mut self, n0: i32, n1: i32, marker: &str) -> bool {
|
||||||
|
if n0 == n1 { return false; }
|
||||||
|
let nn = self.nodes.len() as i32;
|
||||||
|
if n0 < 0 || n1 < 0 || n0 >= nn || n1 >= nn { return false; }
|
||||||
|
|
||||||
|
// duplicate either-orientation.
|
||||||
|
let (a, b) = if n0 < n1 { (n0, n1) } else { (n1, n0) };
|
||||||
|
for s in &self.segments {
|
||||||
|
let (sa, sb) = if s.n0 < s.n1 { (s.n0, s.n1) } else { (s.n1, s.n0) };
|
||||||
|
if sa == a && sb == b { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let n0p = (self.nodes[n0 as usize].x, self.nodes[n0 as usize].y);
|
||||||
|
let n1p = (self.nodes[n1 as usize].x, self.nodes[n1 as usize].y);
|
||||||
|
|
||||||
|
// crossings with existing segments and arcs.
|
||||||
|
let mut new_points: Vec<(f64, f64)> = Vec::new();
|
||||||
|
for s in &self.segments {
|
||||||
|
let sp0 = (self.nodes[s.n0 as usize].x, self.nodes[s.n0 as usize].y);
|
||||||
|
let sp1 = (self.nodes[s.n1 as usize].x, self.nodes[s.n1 as usize].y);
|
||||||
|
if let Some(hit) = line_line_intersection(n0p, n1p, sp0, sp1) {
|
||||||
|
new_points.push(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for arc in &self.arcs {
|
||||||
|
let ap0 = (self.nodes[arc.n0 as usize].x, self.nodes[arc.n0 as usize].y);
|
||||||
|
let ap1 = (self.nodes[arc.n1 as usize].x, self.nodes[arc.n1 as usize].y);
|
||||||
|
for hit in line_arc_intersection(n0p, n1p, ap0, ap1, arc.arc_length) {
|
||||||
|
new_points.push(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tol = self.bbox_tolerance();
|
||||||
|
for (x, y) in new_points {
|
||||||
|
self.add_node(x, y, tol);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.segments.push(Segment {
|
||||||
|
n0, n1,
|
||||||
|
max_side_length: -1.0,
|
||||||
|
boundary_marker: marker.to_string(),
|
||||||
|
in_conductor: String::new(),
|
||||||
|
hidden: false,
|
||||||
|
in_group: 0,
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// first non-endpoint node on the new segment's interior, if any.
|
||||||
|
let length = ((n1p.0 - n0p.0).powi(2) + (n1p.1 - n0p.1).powi(2)).sqrt();
|
||||||
|
let dmin = length * ON_LINE_FRAC;
|
||||||
|
|
||||||
|
let mut split_at: Option<i32> = None;
|
||||||
|
for (i, node) in self.nodes.iter().enumerate() {
|
||||||
|
let idx = i as i32;
|
||||||
|
if idx == n0 || idx == n1 { continue; }
|
||||||
|
let np = (node.x, node.y);
|
||||||
|
let de0 = ((np.0 - n0p.0).powi(2) + (np.1 - n0p.1).powi(2)).sqrt();
|
||||||
|
let de1 = ((np.0 - n1p.0).powi(2) + (np.1 - n1p.1).powi(2)).sqrt();
|
||||||
|
if de0 < dmin || de1 < dmin { continue; }
|
||||||
|
let d = shortest_distance_from_segment(np, n0p, n1p);
|
||||||
|
if d < dmin {
|
||||||
|
split_at = Some(idx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mid) = split_at {
|
||||||
|
self.segments.pop();
|
||||||
|
let a = self.add_segment_with_marker(n0, mid, marker);
|
||||||
|
let b = self.add_segment_with_marker(mid, n1, marker);
|
||||||
|
a || b
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// auto-tolerance for intersection-node coalescing, derived from the node bounding box.
|
||||||
|
fn bbox_tolerance(&self) -> f64 {
|
||||||
|
nodes_bbox_tolerance(&self.nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// rebuilds every list through the PSLG-aware add primitives, catching crossings missed by incremental edits.
|
||||||
|
pub fn enforce_pslg(&mut self) {
|
||||||
|
let old_nodes = std::mem::take(&mut self.nodes);
|
||||||
|
let old_segments = std::mem::take(&mut self.segments);
|
||||||
|
let old_arcs = std::mem::take(&mut self.arcs);
|
||||||
|
let old_block_labels = std::mem::take(&mut self.block_labels);
|
||||||
|
|
||||||
|
let tol = nodes_bbox_tolerance(&old_nodes);
|
||||||
|
|
||||||
|
// dedupes by position with metadata preserved.
|
||||||
|
for n in &old_nodes {
|
||||||
|
let mut duplicate = false;
|
||||||
|
for existing in &self.nodes {
|
||||||
|
if (existing.x - n.x).hypot(existing.y - n.y) < tol {
|
||||||
|
duplicate = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !duplicate {
|
||||||
|
self.nodes.push(n.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for s in old_segments {
|
||||||
|
let p0 = (old_nodes[s.n0 as usize].x, old_nodes[s.n0 as usize].y);
|
||||||
|
let p1 = (old_nodes[s.n1 as usize].x, old_nodes[s.n1 as usize].y);
|
||||||
|
let (Some(n0), Some(n1)) = (
|
||||||
|
self.closest_node(p0.0, p0.1),
|
||||||
|
self.closest_node(p1.0, p1.1),
|
||||||
|
) else { continue };
|
||||||
|
self.add_segment_with_marker(n0 as i32, n1 as i32, &s.boundary_marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
for a in old_arcs {
|
||||||
|
let p0 = (old_nodes[a.n0 as usize].x, old_nodes[a.n0 as usize].y);
|
||||||
|
let p1 = (old_nodes[a.n1 as usize].x, old_nodes[a.n1 as usize].y);
|
||||||
|
let (Some(n0), Some(n1)) = (
|
||||||
|
self.closest_node(p0.0, p0.1),
|
||||||
|
self.closest_node(p1.0, p1.1),
|
||||||
|
) else { continue };
|
||||||
|
self.add_arc_segment_with_template(n0 as i32, n1 as i32, a.arc_length, &a);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.block_labels = old_block_labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// adds an arc between two node indices, splitting at every crossing and through any on-arc node.
|
||||||
|
pub fn add_arc_segment(&mut self, n0: i32, n1: i32, arc_length_deg: f64) -> bool {
|
||||||
|
let template = ArcSegment {
|
||||||
|
n0, n1,
|
||||||
|
arc_length: arc_length_deg,
|
||||||
|
max_side_length: 10.0,
|
||||||
|
boundary_marker: String::new(),
|
||||||
|
in_conductor: String::new(),
|
||||||
|
hidden: false,
|
||||||
|
in_group: 0,
|
||||||
|
normal_direction: true,
|
||||||
|
selected: false,
|
||||||
|
};
|
||||||
|
self.add_arc_segment_with_template(n0, n1, arc_length_deg, &template)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PSLG-aware variant propagating boundary marker and side-length metadata onto every arc piece.
|
||||||
|
pub fn add_arc_segment_with_template(
|
||||||
|
&mut self,
|
||||||
|
n0: i32,
|
||||||
|
n1: i32,
|
||||||
|
arc_length_deg: f64,
|
||||||
|
template: &ArcSegment,
|
||||||
|
) -> bool {
|
||||||
|
if n0 == n1 { return false; }
|
||||||
|
let nn = self.nodes.len() as i32;
|
||||||
|
if n0 < 0 || n1 < 0 || n0 >= nn || n1 >= nn { return false; }
|
||||||
|
|
||||||
|
// same directed endpoints with similar sweep counts as duplicate.
|
||||||
|
for a in &self.arcs {
|
||||||
|
if a.n0 == n0 && a.n1 == n1 && (a.arc_length - arc_length_deg).abs() < 1.0e-2 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let n0p = (self.nodes[n0 as usize].x, self.nodes[n0 as usize].y);
|
||||||
|
let n1p = (self.nodes[n1 as usize].x, self.nodes[n1 as usize].y);
|
||||||
|
|
||||||
|
// crossings with existing segments and arcs.
|
||||||
|
let mut new_points: Vec<(f64, f64)> = Vec::new();
|
||||||
|
for s in &self.segments {
|
||||||
|
let sp0 = (self.nodes[s.n0 as usize].x, self.nodes[s.n0 as usize].y);
|
||||||
|
let sp1 = (self.nodes[s.n1 as usize].x, self.nodes[s.n1 as usize].y);
|
||||||
|
for hit in line_arc_intersection(sp0, sp1, n0p, n1p, arc_length_deg) {
|
||||||
|
new_points.push(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for arc in &self.arcs {
|
||||||
|
let ap0 = (self.nodes[arc.n0 as usize].x, self.nodes[arc.n0 as usize].y);
|
||||||
|
let ap1 = (self.nodes[arc.n1 as usize].x, self.nodes[arc.n1 as usize].y);
|
||||||
|
for hit in arc_arc_intersection(n0p, n1p, arc_length_deg, ap0, ap1, arc.arc_length) {
|
||||||
|
new_points.push(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tol = self.bbox_tolerance();
|
||||||
|
for (x, y) in new_points {
|
||||||
|
self.add_node(x, y, tol);
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_arc = ArcSegment {
|
||||||
|
n0, n1,
|
||||||
|
arc_length: arc_length_deg,
|
||||||
|
..template.clone()
|
||||||
|
};
|
||||||
|
self.arcs.push(new_arc);
|
||||||
|
|
||||||
|
// first non-endpoint node on the new arc's sweep range, if any.
|
||||||
|
let (cx, cy, radius) = circle_from_arc(n0p, n1p, arc_length_deg);
|
||||||
|
let sweep_rad = arc_length_deg.to_radians();
|
||||||
|
let arc_length_world = radius * sweep_rad.abs();
|
||||||
|
let dmin = arc_length_world * ON_LINE_FRAC;
|
||||||
|
|
||||||
|
let mut split_at: Option<i32> = None;
|
||||||
|
for (i, node) in self.nodes.iter().enumerate() {
|
||||||
|
let idx = i as i32;
|
||||||
|
if idx == n0 || idx == n1 { continue; }
|
||||||
|
let np = (node.x, node.y);
|
||||||
|
let de0 = ((np.0 - n0p.0).powi(2) + (np.1 - n0p.1).powi(2)).sqrt();
|
||||||
|
let de1 = ((np.0 - n1p.0).powi(2) + (np.1 - n1p.1).powi(2)).sqrt();
|
||||||
|
if de0 < dmin || de1 < dmin { continue; }
|
||||||
|
let d = shortest_distance_from_arc(np, n0p, n1p, arc_length_deg);
|
||||||
|
if d < dmin {
|
||||||
|
split_at = Some(idx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mid) = split_at {
|
||||||
|
self.arcs.pop();
|
||||||
|
let mid_pos = (self.nodes[mid as usize].x, self.nodes[mid as usize].y);
|
||||||
|
let c = Complex64::new(cx, cy);
|
||||||
|
let a0 = Complex64::new(n0p.0, n0p.1);
|
||||||
|
let a1 = Complex64::new(n1p.0, n1p.1);
|
||||||
|
let a2 = Complex64::new(mid_pos.0, mid_pos.1);
|
||||||
|
let sweep_to_mid = ((a2 - c) / (a0 - c)).arg().to_degrees();
|
||||||
|
let sweep_from_mid = ((a1 - c) / (a2 - c)).arg().to_degrees();
|
||||||
|
let a = self.add_arc_segment_with_template(n0, mid, sweep_to_mid, template);
|
||||||
|
let b = self.add_arc_segment_with_template(mid, n1, sweep_from_mid, template);
|
||||||
|
a || b
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// removes selected nodes and rewrites segment/arc endpoint indices to drop references.
|
||||||
|
pub fn delete_selected_nodes(&mut self) -> usize {
|
||||||
|
let keep: Vec<bool> = self.nodes.iter().map(|n| !n.selected).collect();
|
||||||
|
let mut remap: Vec<i32> = Vec::with_capacity(keep.len());
|
||||||
|
let mut next: i32 = 0;
|
||||||
|
for &k in &keep {
|
||||||
|
remap.push(if k { let r = next; next += 1; r } else { -1 });
|
||||||
|
}
|
||||||
|
let removed = self.nodes.len() - next as usize;
|
||||||
|
|
||||||
|
let mut new_nodes = Vec::with_capacity(next as usize);
|
||||||
|
for (i, n) in self.nodes.drain(..).enumerate() {
|
||||||
|
if keep[i] { new_nodes.push(n); }
|
||||||
|
}
|
||||||
|
self.nodes = new_nodes;
|
||||||
|
|
||||||
|
self.segments.retain(|s| {
|
||||||
|
let a = s.n0 as usize;
|
||||||
|
let b = s.n1 as usize;
|
||||||
|
a < keep.len() && b < keep.len() && keep[a] && keep[b]
|
||||||
|
});
|
||||||
|
for s in &mut self.segments {
|
||||||
|
s.n0 = remap[s.n0 as usize];
|
||||||
|
s.n1 = remap[s.n1 as usize];
|
||||||
|
}
|
||||||
|
|
||||||
|
self.arcs.retain(|a| {
|
||||||
|
let i = a.n0 as usize;
|
||||||
|
let j = a.n1 as usize;
|
||||||
|
i < keep.len() && j < keep.len() && keep[i] && keep[j]
|
||||||
|
});
|
||||||
|
for a in &mut self.arcs {
|
||||||
|
a.n0 = remap[a.n0 as usize];
|
||||||
|
a.n1 = remap[a.n1 as usize];
|
||||||
|
}
|
||||||
|
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// removes selected segments and returns the count.
|
||||||
|
pub fn delete_selected_segments(&mut self) -> usize {
|
||||||
|
let before = self.segments.len();
|
||||||
|
self.segments.retain(|s| !s.selected);
|
||||||
|
before - self.segments.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// removes selected arc segments and returns the count.
|
||||||
|
pub fn delete_selected_arcs(&mut self) -> usize {
|
||||||
|
let before = self.arcs.len();
|
||||||
|
self.arcs.retain(|a| !a.selected);
|
||||||
|
before - self.arcs.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// removes selected block labels and returns the count.
|
||||||
|
pub fn delete_selected_block_labels(&mut self) -> usize {
|
||||||
|
let before = self.block_labels.len();
|
||||||
|
self.block_labels.retain(|b| !b.selected);
|
||||||
|
before - self.block_labels.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// returns the index of the closest node to (x, y), or None when the node list is empty.
|
||||||
|
pub fn closest_node(&self, x: f64, y: f64) -> Option<usize> {
|
||||||
|
let mut best: Option<(usize, f64)> = None;
|
||||||
|
for (i, n) in self.nodes.iter().enumerate() {
|
||||||
|
let d = (n.x - x).hypot(n.y - y);
|
||||||
|
match best {
|
||||||
|
None => best = Some((i, d)),
|
||||||
|
Some((_, bd)) if d < bd => best = Some((i, d)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(i, _)| i)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// returns the index of the closest block label to (x, y), or None when none exist.
|
||||||
|
pub fn closest_block_label(&self, x: f64, y: f64) -> Option<usize> {
|
||||||
|
let mut best: Option<(usize, f64)> = None;
|
||||||
|
for (i, b) in self.block_labels.iter().enumerate() {
|
||||||
|
let d = (b.x - x).hypot(b.y - y);
|
||||||
|
match best {
|
||||||
|
None => best = Some((i, d)),
|
||||||
|
Some((_, bd)) if d < bd => best = Some((i, d)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(i, _)| i)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// returns the index of the segment whose nearest point is closest to (x, y).
|
||||||
|
pub fn closest_segment(&self, x: f64, y: f64) -> Option<usize> {
|
||||||
|
let mut best: Option<(usize, f64)> = None;
|
||||||
|
for (i, s) in self.segments.iter().enumerate() {
|
||||||
|
let (Some(p0), Some(p1)) = (self.nodes.get(s.n0 as usize), self.nodes.get(s.n1 as usize)) else { continue };
|
||||||
|
let d = point_to_segment_distance(x, y, p0.x, p0.y, p1.x, p1.y);
|
||||||
|
match best {
|
||||||
|
None => best = Some((i, d)),
|
||||||
|
Some((_, bd)) if d < bd => best = Some((i, d)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(i, _)| i)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// clears the selection flag on every geometric entity in the doc.
|
||||||
|
pub fn clear_selection(&mut self) {
|
||||||
|
for n in &mut self.nodes { n.selected = false; }
|
||||||
|
for s in &mut self.segments { s.selected = false; }
|
||||||
|
for a in &mut self.arcs { a.selected = false; }
|
||||||
|
for b in &mut self.block_labels { b.selected = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// auto-tolerance derived from the bounding box of a node slice.
|
||||||
|
fn nodes_bbox_tolerance(nodes: &[Node]) -> f64 {
|
||||||
|
if nodes.len() < 2 {
|
||||||
|
return 1.0e-8;
|
||||||
|
}
|
||||||
|
let mut xmin = f64::INFINITY;
|
||||||
|
let mut xmax = f64::NEG_INFINITY;
|
||||||
|
let mut ymin = f64::INFINITY;
|
||||||
|
let mut ymax = f64::NEG_INFINITY;
|
||||||
|
for n in nodes {
|
||||||
|
xmin = xmin.min(n.x); xmax = xmax.max(n.x);
|
||||||
|
ymin = ymin.min(n.y); ymax = ymax.max(n.y);
|
||||||
|
}
|
||||||
|
let dx = xmax - xmin;
|
||||||
|
let dy = ymax - ymin;
|
||||||
|
(dx * dx + dy * dy).sqrt() * BBOX_TOLERANCE_FRAC
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Euclidean distance from (px, py) to the segment between (ax, ay) and (bx, by).
|
||||||
|
fn point_to_segment_distance(px: f64, py: f64, ax: f64, ay: f64, bx: f64, by: f64) -> f64 {
|
||||||
|
let dx = bx - ax;
|
||||||
|
let dy = by - ay;
|
||||||
|
let len2 = dx * dx + dy * dy;
|
||||||
|
if len2 < 1e-18 {
|
||||||
|
return (px - ax).hypot(py - ay);
|
||||||
|
}
|
||||||
|
let t = (((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0);
|
||||||
|
let cx = ax + t * dx;
|
||||||
|
let cy = ay + t * dy;
|
||||||
|
(px - cx).hypot(py - cy)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn doc_with_corners() -> FemmDoc {
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(-1.0, 0.0, 0.0);
|
||||||
|
d.add_node( 1.0, 0.0, 0.0);
|
||||||
|
d.add_node( 0.0,-1.0, 0.0);
|
||||||
|
d.add_node( 0.0, 1.0, 0.0);
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_segment_rejected() {
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(0.0, 0.0, 0.0);
|
||||||
|
d.add_node(1.0, 0.0, 0.0);
|
||||||
|
assert!(d.add_segment(0, 1));
|
||||||
|
assert!(!d.add_segment(0, 1));
|
||||||
|
assert!(!d.add_segment(1, 0));
|
||||||
|
assert_eq!(d.segments.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn segment_passing_through_existing_node_splits() {
|
||||||
|
// three colinear nodes (-1,0), (1,0), (0,0). adding the outer-to-outer segment
|
||||||
|
// splits at the midpoint node, producing two pieces meeting there.
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(-1.0, 0.0, 0.0);
|
||||||
|
d.add_node( 1.0, 0.0, 0.0);
|
||||||
|
d.add_node( 0.0, 0.0, 0.0);
|
||||||
|
assert!(d.add_segment(0, 1));
|
||||||
|
assert_eq!(d.segments.len(), 2);
|
||||||
|
let touches_mid = d.segments.iter().filter(|s| s.n0 == 2 || s.n1 == 2).count();
|
||||||
|
assert_eq!(touches_mid, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enforce_pslg_splits_first_segment_at_late_intersection() {
|
||||||
|
// incremental edit splits only the second of two crossing segments. enforce_pslg
|
||||||
|
// back-splits the first segment through the intersection node.
|
||||||
|
let mut d = doc_with_corners();
|
||||||
|
assert!(d.add_segment(0, 1));
|
||||||
|
assert!(d.add_segment(2, 3));
|
||||||
|
assert_eq!(d.segments.len(), 3, "pre-enforce: one whole, one split");
|
||||||
|
|
||||||
|
d.enforce_pslg();
|
||||||
|
|
||||||
|
assert_eq!(d.nodes.len(), 5, "no node added or merged by enforce");
|
||||||
|
assert_eq!(d.segments.len(), 4, "both segments split at the origin");
|
||||||
|
let touches_origin = d.segments.iter().filter(|s| s.n0 == 4 || s.n1 == 4).count();
|
||||||
|
assert_eq!(touches_origin, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enforce_pslg_preserves_segment_boundary_marker() {
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(0.0, 0.0, 0.0);
|
||||||
|
d.add_node(1.0, 0.0, 0.0);
|
||||||
|
assert!(d.add_segment_with_marker(0, 1, "outer"));
|
||||||
|
|
||||||
|
d.enforce_pslg();
|
||||||
|
|
||||||
|
assert_eq!(d.segments.len(), 1);
|
||||||
|
assert_eq!(d.segments[0].boundary_marker, "outer");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enforce_pslg_preserves_arc_metadata() {
|
||||||
|
// arc carrying non-default marker, side length, group, and direction.
|
||||||
|
// enforce_pslg round-trips every field intact.
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(1.0, 0.0, 0.0);
|
||||||
|
d.add_node(0.0, 1.0, 0.0);
|
||||||
|
let template = ArcSegment {
|
||||||
|
n0: 0,
|
||||||
|
n1: 1,
|
||||||
|
arc_length: 90.0,
|
||||||
|
max_side_length: 5.0,
|
||||||
|
boundary_marker: String::from("outer"),
|
||||||
|
in_conductor: String::new(),
|
||||||
|
hidden: true,
|
||||||
|
in_group: 7,
|
||||||
|
normal_direction: false,
|
||||||
|
selected: false,
|
||||||
|
};
|
||||||
|
assert!(d.add_arc_segment_with_template(0, 1, 90.0, &template));
|
||||||
|
|
||||||
|
d.enforce_pslg();
|
||||||
|
|
||||||
|
assert_eq!(d.arcs.len(), 1);
|
||||||
|
let a = &d.arcs[0];
|
||||||
|
assert_eq!(a.boundary_marker, "outer");
|
||||||
|
assert!((a.max_side_length - 5.0).abs() < 1e-12);
|
||||||
|
assert!(a.hidden);
|
||||||
|
assert_eq!(a.in_group, 7);
|
||||||
|
assert!(!a.normal_direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_arc_rejected() {
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(1.0, 0.0, 0.0);
|
||||||
|
d.add_node(0.0, 1.0, 0.0);
|
||||||
|
assert!(d.add_arc_segment(0, 1, 90.0));
|
||||||
|
assert!(!d.add_arc_segment(0, 1, 90.0));
|
||||||
|
// reversed endpoints define a distinct arc.
|
||||||
|
assert!(d.add_arc_segment(1, 0, 90.0));
|
||||||
|
assert_eq!(d.arcs.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_segment_crossing_unit_quarter_arc_splits_segment() {
|
||||||
|
// unit quarter arc from (1,0) to (0,1); horizontal line at y=0.5 crosses the arc once.
|
||||||
|
// expect a fresh intersection node near (0.866, 0.5) and the segment split into two.
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node( 1.0, 0.0, 0.0);
|
||||||
|
d.add_node( 0.0, 1.0, 0.0);
|
||||||
|
d.add_node(-2.0, 0.5, 0.0);
|
||||||
|
d.add_node( 2.0, 0.5, 0.0);
|
||||||
|
assert!(d.add_arc_segment(0, 1, 90.0));
|
||||||
|
assert!(d.add_segment(2, 3));
|
||||||
|
|
||||||
|
assert_eq!(d.nodes.len(), 5);
|
||||||
|
let n4 = &d.nodes[4];
|
||||||
|
assert!((n4.x - 0.75_f64.sqrt()).abs() < 1e-6);
|
||||||
|
assert!((n4.y - 0.5).abs() < 1e-9);
|
||||||
|
assert_eq!(d.segments.len(), 2);
|
||||||
|
assert_eq!(d.arcs.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_arc_passing_through_existing_node_splits_into_two_arcs() {
|
||||||
|
// pre-existing node at (cos45, sin45) lies on the unit quarter arc from (1,0) to (0,1).
|
||||||
|
// adding the arc detects the on-arc node and emits two sub-arcs of 45 degrees each.
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(1.0, 0.0, 0.0);
|
||||||
|
d.add_node(0.0, 1.0, 0.0);
|
||||||
|
let mid_x = std::f64::consts::FRAC_1_SQRT_2;
|
||||||
|
d.add_node(mid_x, mid_x, 0.0);
|
||||||
|
assert!(d.add_arc_segment(0, 1, 90.0));
|
||||||
|
|
||||||
|
assert_eq!(d.arcs.len(), 2);
|
||||||
|
let touches_mid = d.arcs.iter().filter(|a| a.n0 == 2 || a.n1 == 2).count();
|
||||||
|
assert_eq!(touches_mid, 2);
|
||||||
|
// split sweeps sum to the parent 90 degrees, within floating slop.
|
||||||
|
let total: f64 = d.arcs.iter().map(|a| a.arc_length).sum();
|
||||||
|
assert!((total - 90.0).abs() < 1e-6, "sum of split sweeps = {total}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crossing_second_segment_splits_at_intersection() {
|
||||||
|
// two perpendicular segments through the origin. horizontal first, vertical second.
|
||||||
|
// the vertical add inserts an origin node and self-splits; the horizontal stays whole.
|
||||||
|
let mut d = doc_with_corners();
|
||||||
|
assert!(d.add_segment(0, 1));
|
||||||
|
assert!(d.add_segment(2, 3));
|
||||||
|
|
||||||
|
assert_eq!(d.nodes.len(), 5, "intersection node added");
|
||||||
|
let new_idx = 4;
|
||||||
|
assert!((d.nodes[new_idx].x).abs() < 1e-9);
|
||||||
|
assert!((d.nodes[new_idx].y).abs() < 1e-9);
|
||||||
|
|
||||||
|
// expected pieces: 0->1 (horizontal whole), 2->4 and 4->3 (vertical halves).
|
||||||
|
assert_eq!(d.segments.len(), 3);
|
||||||
|
let touches_new = d.segments.iter().filter(|s| s.n0 == new_idx as i32 || s.n1 == new_idx as i32).count();
|
||||||
|
assert_eq!(touches_new, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,322 @@
|
||||||
|
//! 2D intersection math and distance queries over (x, y) pairs and degree-sweep arcs.
|
||||||
|
|
||||||
|
use num_complex::Complex64;
|
||||||
|
|
||||||
|
/// closeness factor for common-endpoint and small-interval rejection.
|
||||||
|
const EPS_FRAC: f64 = 1.0e-8;
|
||||||
|
|
||||||
|
/// recovers the center and radius of the circle through an arc's endpoints with the given sweep.
|
||||||
|
pub fn circle_from_arc(p0: (f64, f64), p1: (f64, f64), sweep_deg: f64) -> (f64, f64, f64) {
|
||||||
|
let a0 = Complex64::new(p0.0, p0.1);
|
||||||
|
let a1 = Complex64::new(p1.0, p1.1);
|
||||||
|
let chord = (a1 - a0).norm();
|
||||||
|
let t = (a1 - a0) / chord;
|
||||||
|
let tta = sweep_deg.to_radians();
|
||||||
|
let r = chord / (2.0 * (tta / 2.0).sin());
|
||||||
|
let c = a0 + (Complex64::new(chord / 2.0, (r * r - chord * chord / 4.0).sqrt())) * t;
|
||||||
|
(c.re, c.im, r.abs())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// shortest distance from a point to a segment.
|
||||||
|
pub fn shortest_distance_from_segment(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {
|
||||||
|
let dx = b.0 - a.0;
|
||||||
|
let dy = b.1 - a.1;
|
||||||
|
let len2 = dx * dx + dy * dy;
|
||||||
|
if len2 < 1e-18 {
|
||||||
|
return ((p.0 - a.0).powi(2) + (p.1 - a.1).powi(2)).sqrt();
|
||||||
|
}
|
||||||
|
let t = (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0);
|
||||||
|
let cx = a.0 + t * dx;
|
||||||
|
let cy = a.1 + t * dy;
|
||||||
|
((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// shortest distance from a point to a sweep-defined arc.
|
||||||
|
pub fn shortest_distance_from_arc(
|
||||||
|
p: (f64, f64),
|
||||||
|
a0: (f64, f64),
|
||||||
|
a1: (f64, f64),
|
||||||
|
sweep_deg: f64,
|
||||||
|
) -> f64 {
|
||||||
|
let (cx, cy, r) = circle_from_arc(a0, a1, sweep_deg);
|
||||||
|
let pc = Complex64::new(p.0 - cx, p.1 - cy);
|
||||||
|
let d = pc.norm();
|
||||||
|
if d == 0.0 {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
let t = pc / d;
|
||||||
|
let foot = pc - Complex64::new(r * t.re, r * t.im);
|
||||||
|
let l = foot.norm();
|
||||||
|
let a0_dir = Complex64::new(a0.0 - cx, a0.1 - cy);
|
||||||
|
let z = ((t / a0_dir).arg() * 180.0 / std::f64::consts::PI + 360.0) % 360.0;
|
||||||
|
let sweep_abs = sweep_deg.abs();
|
||||||
|
if z > 0.0 && z < sweep_abs {
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
let e0 = ((p.0 - a0.0).powi(2) + (p.1 - a0.1).powi(2)).sqrt();
|
||||||
|
let e1 = ((p.0 - a1.0).powi(2) + (p.1 - a1.1).powi(2)).sqrt();
|
||||||
|
e0.min(e1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// intersection of the open segments p0->p1 and q0->q1, or None when none exists.
|
||||||
|
pub fn line_line_intersection(
|
||||||
|
p0: (f64, f64),
|
||||||
|
p1: (f64, f64),
|
||||||
|
q0: (f64, f64),
|
||||||
|
q1: (f64, f64),
|
||||||
|
) -> Option<(f64, f64)> {
|
||||||
|
let p0c = Complex64::new(p0.0, p0.1);
|
||||||
|
let p1c = Complex64::new(p1.0, p1.1);
|
||||||
|
let q0c = Complex64::new(q0.0, q0.1);
|
||||||
|
let q1c = Complex64::new(q1.0, q1.1);
|
||||||
|
|
||||||
|
// shared endpoint -> no other intersection
|
||||||
|
if (p0c - q0c).norm() < f64::EPSILON
|
||||||
|
|| (p0c - q1c).norm() < f64::EPSILON
|
||||||
|
|| (p1c - q0c).norm() < f64::EPSILON
|
||||||
|
|| (p1c - q1c).norm() < f64::EPSILON
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ee = ((p1c - p0c).norm()).min((q1c - q0c).norm()) * EPS_FRAC;
|
||||||
|
|
||||||
|
let denom = p1c - p0c;
|
||||||
|
if denom.norm() < f64::EPSILON {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let r0 = (q0c - p0c) / denom;
|
||||||
|
let r1 = (q1c - p0c) / denom;
|
||||||
|
|
||||||
|
if r0.re <= 0.0 && r1.re <= 0.0 { return None; }
|
||||||
|
if r0.re >= 1.0 && r1.re >= 1.0 { return None; }
|
||||||
|
if r0.im <= 0.0 && r1.im <= 0.0 { return None; }
|
||||||
|
if r0.im >= 0.0 && r1.im >= 0.0 { return None; }
|
||||||
|
|
||||||
|
let denom_im = r0.im - r1.im;
|
||||||
|
if denom_im.abs() < f64::EPSILON { return None; }
|
||||||
|
let z = r0.im / denom_im;
|
||||||
|
let x = ((1.0 - z) * r0 + z * r1).re;
|
||||||
|
if x < ee || x > 1.0 - ee {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hit = Complex64::new(p0.0, p0.1) * (1.0 - z) + Complex64::new(q0.0, q0.1) * 0.0;
|
||||||
|
let _ = hit;
|
||||||
|
let result = Complex64::new(q0.0, q0.1) * (1.0 - z) + Complex64::new(q1.0, q1.1) * z;
|
||||||
|
Some((result.re, result.im))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// intersection points of an open line segment with an open arc, returning 0, 1, or 2 hits.
|
||||||
|
pub fn line_arc_intersection(
|
||||||
|
p0: (f64, f64),
|
||||||
|
p1: (f64, f64),
|
||||||
|
a0: (f64, f64),
|
||||||
|
a1: (f64, f64),
|
||||||
|
sweep_deg: f64,
|
||||||
|
) -> Vec<(f64, f64)> {
|
||||||
|
let p0c = Complex64::new(p0.0, p0.1);
|
||||||
|
let p1c = Complex64::new(p1.0, p1.1);
|
||||||
|
let a0c = Complex64::new(a0.0, a0.1);
|
||||||
|
let (cx, cy, r) = circle_from_arc(a0, a1, sweep_deg);
|
||||||
|
let c = Complex64::new(cx, cy);
|
||||||
|
|
||||||
|
let d = (p1c - p0c).norm();
|
||||||
|
if d < f64::EPSILON {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let t = (p1c - p0c) / d;
|
||||||
|
let v = (c - p0c) / t;
|
||||||
|
|
||||||
|
if v.im.abs() > r {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let l = (r * r - v.im * v.im).sqrt();
|
||||||
|
let tta = sweep_deg.to_radians().abs();
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(2);
|
||||||
|
|
||||||
|
if (l / r) < 1.0e-5 {
|
||||||
|
let hit = p0c + Complex64::new(v.re, 0.0) * t;
|
||||||
|
let r_param = ((hit - p0c) / t).re;
|
||||||
|
let z = ((hit - c) / (a0c - c)).arg();
|
||||||
|
if r_param > 0.0 && r_param < d && z > 0.0 && z < tta {
|
||||||
|
out.push((hit.re, hit.im));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
for sign in [1.0, -1.0] {
|
||||||
|
let hit = p0c + Complex64::new(v.re + sign * l, 0.0) * t;
|
||||||
|
let r_param = ((hit - p0c) / t).re;
|
||||||
|
let z = ((hit - c) / (a0c - c)).arg();
|
||||||
|
if r_param > 0.0 && r_param < d && z > 0.0 && z < tta {
|
||||||
|
out.push((hit.re, hit.im));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// intersection points of two open arcs, returning 0, 1, or 2 hits.
|
||||||
|
pub fn arc_arc_intersection(
|
||||||
|
a0_p0: (f64, f64),
|
||||||
|
a0_p1: (f64, f64),
|
||||||
|
a0_sweep_deg: f64,
|
||||||
|
a1_p0: (f64, f64),
|
||||||
|
a1_p1: (f64, f64),
|
||||||
|
a1_sweep_deg: f64,
|
||||||
|
) -> Vec<(f64, f64)> {
|
||||||
|
let (c0x, c0y, r0) = circle_from_arc(a0_p0, a0_p1, a0_sweep_deg);
|
||||||
|
let (c1x, c1y, r1) = circle_from_arc(a1_p0, a1_p1, a1_sweep_deg);
|
||||||
|
let c0 = Complex64::new(c0x, c0y);
|
||||||
|
let c1 = Complex64::new(c1x, c1y);
|
||||||
|
let d = (c1 - c0).norm();
|
||||||
|
|
||||||
|
if d > r0 + r1 || d < 1.0e-8 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let l = ((r0 + r1 - d) * (d + r0 - r1) * (d - r0 + r1) * (d + r0 + r1)).sqrt() / (2.0 * d);
|
||||||
|
let c = 1.0 + (r0 / d) * (r0 / d) - (r1 / d) * (r1 / d);
|
||||||
|
let t = (c1 - c0) / d;
|
||||||
|
let tta0 = a0_sweep_deg.to_radians().abs();
|
||||||
|
let tta1 = a1_sweep_deg.to_radians().abs();
|
||||||
|
let a0c = Complex64::new(a0_p0.0, a0_p0.1);
|
||||||
|
let a1c = Complex64::new(a1_p0.0, a1_p0.1);
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(2);
|
||||||
|
|
||||||
|
let first = c0 + Complex64::new(c * d / 2.0, l) * t;
|
||||||
|
let z0 = ((first - c0) / (a0c - c0)).arg();
|
||||||
|
let z1 = ((first - c1) / (a1c - c1)).arg();
|
||||||
|
if z0 > 0.0 && z0 < tta0 && z1 > 0.0 && z1 < tta1 {
|
||||||
|
out.push((first.re, first.im));
|
||||||
|
}
|
||||||
|
|
||||||
|
// tangent-touch case: only one intersection
|
||||||
|
if (d - r0 + r1).abs() / (r0 + r1) < 1.0e-5 {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let second = c0 + Complex64::new(c * d / 2.0, -l) * t;
|
||||||
|
let z0 = ((second - c0) / (a0c - c0)).arg();
|
||||||
|
let z1 = ((second - c1) / (a1c - c1)).arg();
|
||||||
|
if z0 > 0.0 && z0 < tta0 && z1 > 0.0 && z1 < tta1 {
|
||||||
|
out.push((second.re, second.im));
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn close(a: (f64, f64), b: (f64, f64), tol: f64) -> bool {
|
||||||
|
((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() < tol
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_line_crosses_at_origin() {
|
||||||
|
// (-1,-1)->(1,1) crosses (-1,1)->(1,-1) at origin.
|
||||||
|
let hit = line_line_intersection((-1.0, -1.0), (1.0, 1.0), (-1.0, 1.0), (1.0, -1.0));
|
||||||
|
assert!(hit.is_some());
|
||||||
|
assert!(close(hit.unwrap(), (0.0, 0.0), 1e-9));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_line_parallel_no_intersection() {
|
||||||
|
let hit = line_line_intersection((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0));
|
||||||
|
assert!(hit.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_line_t_junction_at_endpoint_rejects() {
|
||||||
|
// the second segment's q0 sits ON the first segment's interior
|
||||||
|
// but the prospective endpoint check should reject (intersection at q0).
|
||||||
|
let hit = line_line_intersection((0.0, 0.0), (2.0, 0.0), (1.0, 0.0), (1.0, 1.0));
|
||||||
|
// shape: one endpoint sits on the other segment, no clean crossing.
|
||||||
|
// GetIntersection returns FALSE when an intersection lies within ee of an endpoint of the
|
||||||
|
// prospective line — here the meeting point IS the prospective q0, so no split is wanted.
|
||||||
|
assert!(hit.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_line_shared_endpoint_no_intersection() {
|
||||||
|
let hit = line_line_intersection((0.0, 0.0), (1.0, 0.0), (1.0, 0.0), (2.0, 1.0));
|
||||||
|
assert!(hit.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn circle_from_quarter_arc_unit() {
|
||||||
|
// a quarter circle (90 deg sweep) from (1, 0) to (0, 1) sits on the unit circle centered at origin.
|
||||||
|
let (cx, cy, r) = circle_from_arc((1.0, 0.0), (0.0, 1.0), 90.0);
|
||||||
|
assert!((cx - 0.0).abs() < 1e-9);
|
||||||
|
assert!((cy - 0.0).abs() < 1e-9);
|
||||||
|
assert!((r - 1.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_through_quarter_arc_crosses_once() {
|
||||||
|
// diagonal line from (-2, 0.5) to (2, 0.5) crosses the quarter circle (1,0)->(0,1) at one point.
|
||||||
|
let hits = line_arc_intersection((-2.0, 0.5), (2.0, 0.5), (1.0, 0.0), (0.0, 1.0), 90.0);
|
||||||
|
assert_eq!(hits.len(), 1);
|
||||||
|
let h = hits[0];
|
||||||
|
assert!((h.0 * h.0 + h.1 * h.1 - 1.0).abs() < 1e-9, "point should lie on unit circle: {h:?}");
|
||||||
|
assert!((h.1 - 0.5).abs() < 1e-9);
|
||||||
|
assert!(h.0 > 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_outside_arc_does_not_cross() {
|
||||||
|
// line clearly outside the arc swept region.
|
||||||
|
let hits = line_arc_intersection((-2.0, 2.5), (2.0, 2.5), (1.0, 0.0), (0.0, 1.0), 90.0);
|
||||||
|
assert!(hits.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arc_arc_two_intersections() {
|
||||||
|
// two unit circles offset by 1.0 along x, both swept 360 degrees... but we use partial arcs
|
||||||
|
// shaped to clip both crossings in their swept range.
|
||||||
|
// circle A: center (0,0), arcs covering full top half (180 deg from (1,0) to (-1,0)).
|
||||||
|
// circle B: center (1,0), arcs covering full top half (180 deg from (2,0) to (0,0)).
|
||||||
|
let hits = arc_arc_intersection(
|
||||||
|
(1.0, 0.0), (-1.0, 0.0), 180.0,
|
||||||
|
(2.0, 0.0), (0.0, 0.0), 180.0,
|
||||||
|
);
|
||||||
|
assert_eq!(hits.len(), 1, "two top-half arcs of overlapping unit circles meet at one upper crossing: {hits:?}");
|
||||||
|
let h = hits[0];
|
||||||
|
assert!((h.0 - 0.5).abs() < 1e-9, "x = 0.5 by symmetry, got {h:?}");
|
||||||
|
assert!(h.1 > 0.0, "intersection should be above the x-axis: {h:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arc_arc_disjoint() {
|
||||||
|
let hits = arc_arc_intersection(
|
||||||
|
(1.0, 0.0), (-1.0, 0.0), 180.0,
|
||||||
|
(11.0, 0.0), (9.0, 0.0), 180.0,
|
||||||
|
);
|
||||||
|
assert!(hits.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortest_distance_from_segment_perpendicular() {
|
||||||
|
// point (0, 1) to segment (-1, 0)->(1, 0) is distance 1.
|
||||||
|
let d = shortest_distance_from_segment((0.0, 1.0), (-1.0, 0.0), (1.0, 0.0));
|
||||||
|
assert!((d - 1.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortest_distance_from_segment_clamps_to_endpoint() {
|
||||||
|
// point (-2, 0) to segment (-1, 0)->(1, 0) is distance 1 (clamps to left endpoint).
|
||||||
|
let d = shortest_distance_from_segment((-2.0, 0.0), (-1.0, 0.0), (1.0, 0.0));
|
||||||
|
assert!((d - 1.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortest_distance_from_arc_radial() {
|
||||||
|
// point at origin distance to unit-circle quarter arc is 1.
|
||||||
|
let d = shortest_distance_from_arc((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), 90.0);
|
||||||
|
assert!((d - 1.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
//! electrostatic pre-processor document model: geometry, properties, parser, writer.
|
//! electrostatic pre-processor document model: geometry, properties, parser, writer.
|
||||||
|
|
||||||
pub mod geom;
|
pub mod geom;
|
||||||
|
pub mod geom_math;
|
||||||
pub mod props;
|
pub mod props;
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
pub mod writer;
|
pub mod writer;
|
||||||
|
pub mod edit;
|
||||||
|
|
||||||
pub use geom::{ArcSegment, BlockLabel, Node, Segment};
|
pub use geom::{ArcSegment, BlockLabel, Node, Segment};
|
||||||
pub use props::{BoundaryProp, ConductorProp, MaterialProp, PointProp};
|
pub use props::{BoundaryProp, ConductorProp, MaterialProp, PointProp};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,612 @@
|
||||||
|
//! geometry editing primitives on [`FemmDoc`]: add, delete, closest-point queries.
|
||||||
|
|
||||||
|
use crate::geom_math::{
|
||||||
|
arc_arc_intersection, circle_from_arc, line_arc_intersection, line_line_intersection,
|
||||||
|
shortest_distance_from_arc, shortest_distance_from_segment,
|
||||||
|
};
|
||||||
|
use crate::{ArcSegment, BlockLabel, FemmDoc, Node, Segment};
|
||||||
|
use num_complex::Complex64;
|
||||||
|
|
||||||
|
/// fraction of the node-bbox diagonal used as auto-tolerance for intersection-node coalescing.
|
||||||
|
const BBOX_TOLERANCE_FRAC: f64 = 1.0e-6;
|
||||||
|
/// fraction of a segment's length within which an off-endpoint node triggers a recursive split.
|
||||||
|
const ON_LINE_FRAC: f64 = 1.0e-5;
|
||||||
|
|
||||||
|
impl FemmDoc {
|
||||||
|
/// adds a node at (x, y), returning the index of an existing node within `tol` distance when present.
|
||||||
|
pub fn add_node(&mut self, x: f64, y: f64, tol: f64) -> usize {
|
||||||
|
if tol > 0.0 {
|
||||||
|
for (i, n) in self.nodes.iter().enumerate() {
|
||||||
|
let dx = n.x - x;
|
||||||
|
let dy = n.y - y;
|
||||||
|
if (dx * dx + dy * dy).sqrt() <= tol {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.nodes.push(Node {
|
||||||
|
x, y,
|
||||||
|
boundary_marker: String::new(),
|
||||||
|
in_conductor: String::new(),
|
||||||
|
in_group: 0,
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
self.nodes.len() - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// adds a block label at (x, y), returning the index of an existing label within `tol` when present.
|
||||||
|
pub fn add_block_label(&mut self, x: f64, y: f64, tol: f64) -> usize {
|
||||||
|
if tol > 0.0 {
|
||||||
|
for (i, b) in self.block_labels.iter().enumerate() {
|
||||||
|
let dx = b.x - x;
|
||||||
|
let dy = b.y - y;
|
||||||
|
if (dx * dx + dy * dy).sqrt() <= tol {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.block_labels.push(BlockLabel {
|
||||||
|
x, y,
|
||||||
|
max_area: 0.0,
|
||||||
|
block_type: String::from("<None>"),
|
||||||
|
in_conductor: String::from("<None>"),
|
||||||
|
in_group: 0,
|
||||||
|
is_external: false,
|
||||||
|
is_default: false,
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
self.block_labels.len() - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// adds a segment between two node indices, splitting at every crossing and through any on-line node.
|
||||||
|
pub fn add_segment(&mut self, n0: i32, n1: i32) -> bool {
|
||||||
|
self.add_segment_with_marker(n0, n1, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PSLG-aware variant propagating a boundary-marker name onto every resulting segment piece.
|
||||||
|
pub fn add_segment_with_marker(&mut self, n0: i32, n1: i32, marker: &str) -> bool {
|
||||||
|
if n0 == n1 { return false; }
|
||||||
|
let nn = self.nodes.len() as i32;
|
||||||
|
if n0 < 0 || n1 < 0 || n0 >= nn || n1 >= nn { return false; }
|
||||||
|
|
||||||
|
// duplicate either-orientation.
|
||||||
|
let (a, b) = if n0 < n1 { (n0, n1) } else { (n1, n0) };
|
||||||
|
for s in &self.segments {
|
||||||
|
let (sa, sb) = if s.n0 < s.n1 { (s.n0, s.n1) } else { (s.n1, s.n0) };
|
||||||
|
if sa == a && sb == b { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let n0p = (self.nodes[n0 as usize].x, self.nodes[n0 as usize].y);
|
||||||
|
let n1p = (self.nodes[n1 as usize].x, self.nodes[n1 as usize].y);
|
||||||
|
|
||||||
|
// crossings with existing segments and arcs.
|
||||||
|
let mut new_points: Vec<(f64, f64)> = Vec::new();
|
||||||
|
for s in &self.segments {
|
||||||
|
let sp0 = (self.nodes[s.n0 as usize].x, self.nodes[s.n0 as usize].y);
|
||||||
|
let sp1 = (self.nodes[s.n1 as usize].x, self.nodes[s.n1 as usize].y);
|
||||||
|
if let Some(hit) = line_line_intersection(n0p, n1p, sp0, sp1) {
|
||||||
|
new_points.push(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for arc in &self.arcs {
|
||||||
|
let ap0 = (self.nodes[arc.n0 as usize].x, self.nodes[arc.n0 as usize].y);
|
||||||
|
let ap1 = (self.nodes[arc.n1 as usize].x, self.nodes[arc.n1 as usize].y);
|
||||||
|
for hit in line_arc_intersection(n0p, n1p, ap0, ap1, arc.arc_length) {
|
||||||
|
new_points.push(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tol = self.bbox_tolerance();
|
||||||
|
for (x, y) in new_points {
|
||||||
|
self.add_node(x, y, tol);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.segments.push(Segment {
|
||||||
|
n0, n1,
|
||||||
|
max_side_length: -1.0,
|
||||||
|
boundary_marker: marker.to_string(),
|
||||||
|
in_conductor: String::new(),
|
||||||
|
hidden: false,
|
||||||
|
in_group: 0,
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// first non-endpoint node on the new segment's interior, if any.
|
||||||
|
let length = ((n1p.0 - n0p.0).powi(2) + (n1p.1 - n0p.1).powi(2)).sqrt();
|
||||||
|
let dmin = length * ON_LINE_FRAC;
|
||||||
|
|
||||||
|
let mut split_at: Option<i32> = None;
|
||||||
|
for (i, node) in self.nodes.iter().enumerate() {
|
||||||
|
let idx = i as i32;
|
||||||
|
if idx == n0 || idx == n1 { continue; }
|
||||||
|
let np = (node.x, node.y);
|
||||||
|
let de0 = ((np.0 - n0p.0).powi(2) + (np.1 - n0p.1).powi(2)).sqrt();
|
||||||
|
let de1 = ((np.0 - n1p.0).powi(2) + (np.1 - n1p.1).powi(2)).sqrt();
|
||||||
|
if de0 < dmin || de1 < dmin { continue; }
|
||||||
|
let d = shortest_distance_from_segment(np, n0p, n1p);
|
||||||
|
if d < dmin {
|
||||||
|
split_at = Some(idx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mid) = split_at {
|
||||||
|
self.segments.pop();
|
||||||
|
let a = self.add_segment_with_marker(n0, mid, marker);
|
||||||
|
let b = self.add_segment_with_marker(mid, n1, marker);
|
||||||
|
a || b
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// auto-tolerance for intersection-node coalescing, derived from the node bounding box.
|
||||||
|
fn bbox_tolerance(&self) -> f64 {
|
||||||
|
nodes_bbox_tolerance(&self.nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// rebuilds every list through the PSLG-aware add primitives, catching crossings missed by incremental edits.
|
||||||
|
pub fn enforce_pslg(&mut self) {
|
||||||
|
let old_nodes = std::mem::take(&mut self.nodes);
|
||||||
|
let old_segments = std::mem::take(&mut self.segments);
|
||||||
|
let old_arcs = std::mem::take(&mut self.arcs);
|
||||||
|
let old_block_labels = std::mem::take(&mut self.block_labels);
|
||||||
|
|
||||||
|
let tol = nodes_bbox_tolerance(&old_nodes);
|
||||||
|
|
||||||
|
// dedupes by position with metadata preserved.
|
||||||
|
for n in &old_nodes {
|
||||||
|
let mut duplicate = false;
|
||||||
|
for existing in &self.nodes {
|
||||||
|
if (existing.x - n.x).hypot(existing.y - n.y) < tol {
|
||||||
|
duplicate = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !duplicate {
|
||||||
|
self.nodes.push(n.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for s in old_segments {
|
||||||
|
let p0 = (old_nodes[s.n0 as usize].x, old_nodes[s.n0 as usize].y);
|
||||||
|
let p1 = (old_nodes[s.n1 as usize].x, old_nodes[s.n1 as usize].y);
|
||||||
|
let (Some(n0), Some(n1)) = (
|
||||||
|
self.closest_node(p0.0, p0.1),
|
||||||
|
self.closest_node(p1.0, p1.1),
|
||||||
|
) else { continue };
|
||||||
|
self.add_segment_with_marker(n0 as i32, n1 as i32, &s.boundary_marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
for a in old_arcs {
|
||||||
|
let p0 = (old_nodes[a.n0 as usize].x, old_nodes[a.n0 as usize].y);
|
||||||
|
let p1 = (old_nodes[a.n1 as usize].x, old_nodes[a.n1 as usize].y);
|
||||||
|
let (Some(n0), Some(n1)) = (
|
||||||
|
self.closest_node(p0.0, p0.1),
|
||||||
|
self.closest_node(p1.0, p1.1),
|
||||||
|
) else { continue };
|
||||||
|
self.add_arc_segment_with_template(n0 as i32, n1 as i32, a.arc_length, &a);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.block_labels = old_block_labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// adds an arc between two node indices, splitting at every crossing and through any on-arc node.
|
||||||
|
pub fn add_arc_segment(&mut self, n0: i32, n1: i32, arc_length_deg: f64) -> bool {
|
||||||
|
let template = ArcSegment {
|
||||||
|
n0, n1,
|
||||||
|
arc_length: arc_length_deg,
|
||||||
|
max_side_length: 10.0,
|
||||||
|
boundary_marker: String::new(),
|
||||||
|
in_conductor: String::new(),
|
||||||
|
hidden: false,
|
||||||
|
in_group: 0,
|
||||||
|
normal_direction: true,
|
||||||
|
selected: false,
|
||||||
|
};
|
||||||
|
self.add_arc_segment_with_template(n0, n1, arc_length_deg, &template)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PSLG-aware variant propagating boundary marker and side-length metadata onto every arc piece.
|
||||||
|
pub fn add_arc_segment_with_template(
|
||||||
|
&mut self,
|
||||||
|
n0: i32,
|
||||||
|
n1: i32,
|
||||||
|
arc_length_deg: f64,
|
||||||
|
template: &ArcSegment,
|
||||||
|
) -> bool {
|
||||||
|
if n0 == n1 { return false; }
|
||||||
|
let nn = self.nodes.len() as i32;
|
||||||
|
if n0 < 0 || n1 < 0 || n0 >= nn || n1 >= nn { return false; }
|
||||||
|
|
||||||
|
// same directed endpoints with similar sweep counts as duplicate.
|
||||||
|
for a in &self.arcs {
|
||||||
|
if a.n0 == n0 && a.n1 == n1 && (a.arc_length - arc_length_deg).abs() < 1.0e-2 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let n0p = (self.nodes[n0 as usize].x, self.nodes[n0 as usize].y);
|
||||||
|
let n1p = (self.nodes[n1 as usize].x, self.nodes[n1 as usize].y);
|
||||||
|
|
||||||
|
// crossings with existing segments and arcs.
|
||||||
|
let mut new_points: Vec<(f64, f64)> = Vec::new();
|
||||||
|
for s in &self.segments {
|
||||||
|
let sp0 = (self.nodes[s.n0 as usize].x, self.nodes[s.n0 as usize].y);
|
||||||
|
let sp1 = (self.nodes[s.n1 as usize].x, self.nodes[s.n1 as usize].y);
|
||||||
|
for hit in line_arc_intersection(sp0, sp1, n0p, n1p, arc_length_deg) {
|
||||||
|
new_points.push(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for arc in &self.arcs {
|
||||||
|
let ap0 = (self.nodes[arc.n0 as usize].x, self.nodes[arc.n0 as usize].y);
|
||||||
|
let ap1 = (self.nodes[arc.n1 as usize].x, self.nodes[arc.n1 as usize].y);
|
||||||
|
for hit in arc_arc_intersection(n0p, n1p, arc_length_deg, ap0, ap1, arc.arc_length) {
|
||||||
|
new_points.push(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tol = self.bbox_tolerance();
|
||||||
|
for (x, y) in new_points {
|
||||||
|
self.add_node(x, y, tol);
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_arc = ArcSegment {
|
||||||
|
n0, n1,
|
||||||
|
arc_length: arc_length_deg,
|
||||||
|
..template.clone()
|
||||||
|
};
|
||||||
|
self.arcs.push(new_arc);
|
||||||
|
|
||||||
|
// first non-endpoint node on the new arc's sweep range, if any.
|
||||||
|
let (cx, cy, radius) = circle_from_arc(n0p, n1p, arc_length_deg);
|
||||||
|
let sweep_rad = arc_length_deg.to_radians();
|
||||||
|
let arc_length_world = radius * sweep_rad.abs();
|
||||||
|
let dmin = arc_length_world * ON_LINE_FRAC;
|
||||||
|
|
||||||
|
let mut split_at: Option<i32> = None;
|
||||||
|
for (i, node) in self.nodes.iter().enumerate() {
|
||||||
|
let idx = i as i32;
|
||||||
|
if idx == n0 || idx == n1 { continue; }
|
||||||
|
let np = (node.x, node.y);
|
||||||
|
let de0 = ((np.0 - n0p.0).powi(2) + (np.1 - n0p.1).powi(2)).sqrt();
|
||||||
|
let de1 = ((np.0 - n1p.0).powi(2) + (np.1 - n1p.1).powi(2)).sqrt();
|
||||||
|
if de0 < dmin || de1 < dmin { continue; }
|
||||||
|
let d = shortest_distance_from_arc(np, n0p, n1p, arc_length_deg);
|
||||||
|
if d < dmin {
|
||||||
|
split_at = Some(idx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mid) = split_at {
|
||||||
|
self.arcs.pop();
|
||||||
|
let mid_pos = (self.nodes[mid as usize].x, self.nodes[mid as usize].y);
|
||||||
|
let c = Complex64::new(cx, cy);
|
||||||
|
let a0 = Complex64::new(n0p.0, n0p.1);
|
||||||
|
let a1 = Complex64::new(n1p.0, n1p.1);
|
||||||
|
let a2 = Complex64::new(mid_pos.0, mid_pos.1);
|
||||||
|
let sweep_to_mid = ((a2 - c) / (a0 - c)).arg().to_degrees();
|
||||||
|
let sweep_from_mid = ((a1 - c) / (a2 - c)).arg().to_degrees();
|
||||||
|
let a = self.add_arc_segment_with_template(n0, mid, sweep_to_mid, template);
|
||||||
|
let b = self.add_arc_segment_with_template(mid, n1, sweep_from_mid, template);
|
||||||
|
a || b
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// removes selected nodes and rewrites segment/arc endpoint indices to drop references.
|
||||||
|
pub fn delete_selected_nodes(&mut self) -> usize {
|
||||||
|
let keep: Vec<bool> = self.nodes.iter().map(|n| !n.selected).collect();
|
||||||
|
let mut remap: Vec<i32> = Vec::with_capacity(keep.len());
|
||||||
|
let mut next: i32 = 0;
|
||||||
|
for &k in &keep {
|
||||||
|
remap.push(if k { let r = next; next += 1; r } else { -1 });
|
||||||
|
}
|
||||||
|
let removed = self.nodes.len() - next as usize;
|
||||||
|
|
||||||
|
let mut new_nodes = Vec::with_capacity(next as usize);
|
||||||
|
for (i, n) in self.nodes.drain(..).enumerate() {
|
||||||
|
if keep[i] { new_nodes.push(n); }
|
||||||
|
}
|
||||||
|
self.nodes = new_nodes;
|
||||||
|
|
||||||
|
self.segments.retain(|s| {
|
||||||
|
let a = s.n0 as usize;
|
||||||
|
let b = s.n1 as usize;
|
||||||
|
a < keep.len() && b < keep.len() && keep[a] && keep[b]
|
||||||
|
});
|
||||||
|
for s in &mut self.segments {
|
||||||
|
s.n0 = remap[s.n0 as usize];
|
||||||
|
s.n1 = remap[s.n1 as usize];
|
||||||
|
}
|
||||||
|
|
||||||
|
self.arcs.retain(|a| {
|
||||||
|
let i = a.n0 as usize;
|
||||||
|
let j = a.n1 as usize;
|
||||||
|
i < keep.len() && j < keep.len() && keep[i] && keep[j]
|
||||||
|
});
|
||||||
|
for a in &mut self.arcs {
|
||||||
|
a.n0 = remap[a.n0 as usize];
|
||||||
|
a.n1 = remap[a.n1 as usize];
|
||||||
|
}
|
||||||
|
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// removes selected segments and returns the count.
|
||||||
|
pub fn delete_selected_segments(&mut self) -> usize {
|
||||||
|
let before = self.segments.len();
|
||||||
|
self.segments.retain(|s| !s.selected);
|
||||||
|
before - self.segments.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// removes selected arc segments and returns the count.
|
||||||
|
pub fn delete_selected_arcs(&mut self) -> usize {
|
||||||
|
let before = self.arcs.len();
|
||||||
|
self.arcs.retain(|a| !a.selected);
|
||||||
|
before - self.arcs.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// removes selected block labels and returns the count.
|
||||||
|
pub fn delete_selected_block_labels(&mut self) -> usize {
|
||||||
|
let before = self.block_labels.len();
|
||||||
|
self.block_labels.retain(|b| !b.selected);
|
||||||
|
before - self.block_labels.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// returns the index of the closest node to (x, y), or None when the node list is empty.
|
||||||
|
pub fn closest_node(&self, x: f64, y: f64) -> Option<usize> {
|
||||||
|
let mut best: Option<(usize, f64)> = None;
|
||||||
|
for (i, n) in self.nodes.iter().enumerate() {
|
||||||
|
let d = (n.x - x).hypot(n.y - y);
|
||||||
|
match best {
|
||||||
|
None => best = Some((i, d)),
|
||||||
|
Some((_, bd)) if d < bd => best = Some((i, d)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(i, _)| i)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// returns the index of the closest block label to (x, y), or None when none exist.
|
||||||
|
pub fn closest_block_label(&self, x: f64, y: f64) -> Option<usize> {
|
||||||
|
let mut best: Option<(usize, f64)> = None;
|
||||||
|
for (i, b) in self.block_labels.iter().enumerate() {
|
||||||
|
let d = (b.x - x).hypot(b.y - y);
|
||||||
|
match best {
|
||||||
|
None => best = Some((i, d)),
|
||||||
|
Some((_, bd)) if d < bd => best = Some((i, d)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(i, _)| i)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// returns the index of the segment whose nearest point is closest to (x, y).
|
||||||
|
pub fn closest_segment(&self, x: f64, y: f64) -> Option<usize> {
|
||||||
|
let mut best: Option<(usize, f64)> = None;
|
||||||
|
for (i, s) in self.segments.iter().enumerate() {
|
||||||
|
let (Some(p0), Some(p1)) = (self.nodes.get(s.n0 as usize), self.nodes.get(s.n1 as usize)) else { continue };
|
||||||
|
let d = point_to_segment_distance(x, y, p0.x, p0.y, p1.x, p1.y);
|
||||||
|
match best {
|
||||||
|
None => best = Some((i, d)),
|
||||||
|
Some((_, bd)) if d < bd => best = Some((i, d)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(i, _)| i)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// clears the selection flag on every geometric entity in the doc.
|
||||||
|
pub fn clear_selection(&mut self) {
|
||||||
|
for n in &mut self.nodes { n.selected = false; }
|
||||||
|
for s in &mut self.segments { s.selected = false; }
|
||||||
|
for a in &mut self.arcs { a.selected = false; }
|
||||||
|
for b in &mut self.block_labels { b.selected = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// auto-tolerance derived from the bounding box of a node slice.
|
||||||
|
fn nodes_bbox_tolerance(nodes: &[Node]) -> f64 {
|
||||||
|
if nodes.len() < 2 {
|
||||||
|
return 1.0e-8;
|
||||||
|
}
|
||||||
|
let mut xmin = f64::INFINITY;
|
||||||
|
let mut xmax = f64::NEG_INFINITY;
|
||||||
|
let mut ymin = f64::INFINITY;
|
||||||
|
let mut ymax = f64::NEG_INFINITY;
|
||||||
|
for n in nodes {
|
||||||
|
xmin = xmin.min(n.x); xmax = xmax.max(n.x);
|
||||||
|
ymin = ymin.min(n.y); ymax = ymax.max(n.y);
|
||||||
|
}
|
||||||
|
let dx = xmax - xmin;
|
||||||
|
let dy = ymax - ymin;
|
||||||
|
(dx * dx + dy * dy).sqrt() * BBOX_TOLERANCE_FRAC
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Euclidean distance from (px, py) to the segment between (ax, ay) and (bx, by).
|
||||||
|
fn point_to_segment_distance(px: f64, py: f64, ax: f64, ay: f64, bx: f64, by: f64) -> f64 {
|
||||||
|
let dx = bx - ax;
|
||||||
|
let dy = by - ay;
|
||||||
|
let len2 = dx * dx + dy * dy;
|
||||||
|
if len2 < 1e-18 {
|
||||||
|
return (px - ax).hypot(py - ay);
|
||||||
|
}
|
||||||
|
let t = (((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0);
|
||||||
|
let cx = ax + t * dx;
|
||||||
|
let cy = ay + t * dy;
|
||||||
|
(px - cx).hypot(py - cy)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn doc_with_corners() -> FemmDoc {
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(-1.0, 0.0, 0.0);
|
||||||
|
d.add_node( 1.0, 0.0, 0.0);
|
||||||
|
d.add_node( 0.0,-1.0, 0.0);
|
||||||
|
d.add_node( 0.0, 1.0, 0.0);
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_segment_rejected() {
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(0.0, 0.0, 0.0);
|
||||||
|
d.add_node(1.0, 0.0, 0.0);
|
||||||
|
assert!(d.add_segment(0, 1));
|
||||||
|
assert!(!d.add_segment(0, 1));
|
||||||
|
assert!(!d.add_segment(1, 0));
|
||||||
|
assert_eq!(d.segments.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn segment_passing_through_existing_node_splits() {
|
||||||
|
// three colinear nodes (-1,0), (1,0), (0,0). adding the outer-to-outer segment
|
||||||
|
// splits at the midpoint node, producing two pieces meeting there.
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(-1.0, 0.0, 0.0);
|
||||||
|
d.add_node( 1.0, 0.0, 0.0);
|
||||||
|
d.add_node( 0.0, 0.0, 0.0);
|
||||||
|
assert!(d.add_segment(0, 1));
|
||||||
|
assert_eq!(d.segments.len(), 2);
|
||||||
|
let touches_mid = d.segments.iter().filter(|s| s.n0 == 2 || s.n1 == 2).count();
|
||||||
|
assert_eq!(touches_mid, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enforce_pslg_splits_first_segment_at_late_intersection() {
|
||||||
|
// incremental edit splits only the second of two crossing segments. enforce_pslg
|
||||||
|
// back-splits the first segment through the intersection node.
|
||||||
|
let mut d = doc_with_corners();
|
||||||
|
assert!(d.add_segment(0, 1));
|
||||||
|
assert!(d.add_segment(2, 3));
|
||||||
|
assert_eq!(d.segments.len(), 3, "pre-enforce: one whole, one split");
|
||||||
|
|
||||||
|
d.enforce_pslg();
|
||||||
|
|
||||||
|
assert_eq!(d.nodes.len(), 5, "no node added or merged by enforce");
|
||||||
|
assert_eq!(d.segments.len(), 4, "both segments split at the origin");
|
||||||
|
let touches_origin = d.segments.iter().filter(|s| s.n0 == 4 || s.n1 == 4).count();
|
||||||
|
assert_eq!(touches_origin, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enforce_pslg_preserves_segment_boundary_marker() {
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(0.0, 0.0, 0.0);
|
||||||
|
d.add_node(1.0, 0.0, 0.0);
|
||||||
|
assert!(d.add_segment_with_marker(0, 1, "outer"));
|
||||||
|
|
||||||
|
d.enforce_pslg();
|
||||||
|
|
||||||
|
assert_eq!(d.segments.len(), 1);
|
||||||
|
assert_eq!(d.segments[0].boundary_marker, "outer");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enforce_pslg_preserves_arc_metadata() {
|
||||||
|
// arc carrying non-default marker, side length, group, and direction.
|
||||||
|
// enforce_pslg round-trips every field intact.
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(1.0, 0.0, 0.0);
|
||||||
|
d.add_node(0.0, 1.0, 0.0);
|
||||||
|
let template = ArcSegment {
|
||||||
|
n0: 0,
|
||||||
|
n1: 1,
|
||||||
|
arc_length: 90.0,
|
||||||
|
max_side_length: 5.0,
|
||||||
|
boundary_marker: String::from("outer"),
|
||||||
|
in_conductor: String::new(),
|
||||||
|
hidden: true,
|
||||||
|
in_group: 7,
|
||||||
|
normal_direction: false,
|
||||||
|
selected: false,
|
||||||
|
};
|
||||||
|
assert!(d.add_arc_segment_with_template(0, 1, 90.0, &template));
|
||||||
|
|
||||||
|
d.enforce_pslg();
|
||||||
|
|
||||||
|
assert_eq!(d.arcs.len(), 1);
|
||||||
|
let a = &d.arcs[0];
|
||||||
|
assert_eq!(a.boundary_marker, "outer");
|
||||||
|
assert!((a.max_side_length - 5.0).abs() < 1e-12);
|
||||||
|
assert!(a.hidden);
|
||||||
|
assert_eq!(a.in_group, 7);
|
||||||
|
assert!(!a.normal_direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_arc_rejected() {
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(1.0, 0.0, 0.0);
|
||||||
|
d.add_node(0.0, 1.0, 0.0);
|
||||||
|
assert!(d.add_arc_segment(0, 1, 90.0));
|
||||||
|
assert!(!d.add_arc_segment(0, 1, 90.0));
|
||||||
|
// reversed endpoints define a distinct arc.
|
||||||
|
assert!(d.add_arc_segment(1, 0, 90.0));
|
||||||
|
assert_eq!(d.arcs.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_segment_crossing_unit_quarter_arc_splits_segment() {
|
||||||
|
// unit quarter arc from (1,0) to (0,1); horizontal line at y=0.5 crosses the arc once.
|
||||||
|
// expect a fresh intersection node near (0.866, 0.5) and the segment split into two.
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node( 1.0, 0.0, 0.0);
|
||||||
|
d.add_node( 0.0, 1.0, 0.0);
|
||||||
|
d.add_node(-2.0, 0.5, 0.0);
|
||||||
|
d.add_node( 2.0, 0.5, 0.0);
|
||||||
|
assert!(d.add_arc_segment(0, 1, 90.0));
|
||||||
|
assert!(d.add_segment(2, 3));
|
||||||
|
|
||||||
|
assert_eq!(d.nodes.len(), 5);
|
||||||
|
let n4 = &d.nodes[4];
|
||||||
|
assert!((n4.x - 0.75_f64.sqrt()).abs() < 1e-6);
|
||||||
|
assert!((n4.y - 0.5).abs() < 1e-9);
|
||||||
|
assert_eq!(d.segments.len(), 2);
|
||||||
|
assert_eq!(d.arcs.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_arc_passing_through_existing_node_splits_into_two_arcs() {
|
||||||
|
// pre-existing node at (cos45, sin45) lies on the unit quarter arc from (1,0) to (0,1).
|
||||||
|
// adding the arc detects the on-arc node and emits two sub-arcs of 45 degrees each.
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(1.0, 0.0, 0.0);
|
||||||
|
d.add_node(0.0, 1.0, 0.0);
|
||||||
|
let mid_x = std::f64::consts::FRAC_1_SQRT_2;
|
||||||
|
d.add_node(mid_x, mid_x, 0.0);
|
||||||
|
assert!(d.add_arc_segment(0, 1, 90.0));
|
||||||
|
|
||||||
|
assert_eq!(d.arcs.len(), 2);
|
||||||
|
let touches_mid = d.arcs.iter().filter(|a| a.n0 == 2 || a.n1 == 2).count();
|
||||||
|
assert_eq!(touches_mid, 2);
|
||||||
|
// split sweeps sum to the parent 90 degrees, within floating slop.
|
||||||
|
let total: f64 = d.arcs.iter().map(|a| a.arc_length).sum();
|
||||||
|
assert!((total - 90.0).abs() < 1e-6, "sum of split sweeps = {total}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crossing_second_segment_splits_at_intersection() {
|
||||||
|
// two perpendicular segments through the origin. horizontal first, vertical second.
|
||||||
|
// the vertical add inserts an origin node and self-splits; the horizontal stays whole.
|
||||||
|
let mut d = doc_with_corners();
|
||||||
|
assert!(d.add_segment(0, 1));
|
||||||
|
assert!(d.add_segment(2, 3));
|
||||||
|
|
||||||
|
assert_eq!(d.nodes.len(), 5, "intersection node added");
|
||||||
|
let new_idx = 4;
|
||||||
|
assert!((d.nodes[new_idx].x).abs() < 1e-9);
|
||||||
|
assert!((d.nodes[new_idx].y).abs() < 1e-9);
|
||||||
|
|
||||||
|
// expected pieces: 0->1 (horizontal whole), 2->4 and 4->3 (vertical halves).
|
||||||
|
assert_eq!(d.segments.len(), 3);
|
||||||
|
let touches_new = d.segments.iter().filter(|s| s.n0 == new_idx as i32 || s.n1 == new_idx as i32).count();
|
||||||
|
assert_eq!(touches_new, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,322 @@
|
||||||
|
//! 2D intersection math and distance queries over (x, y) pairs and degree-sweep arcs.
|
||||||
|
|
||||||
|
use num_complex::Complex64;
|
||||||
|
|
||||||
|
/// closeness factor for common-endpoint and small-interval rejection.
|
||||||
|
const EPS_FRAC: f64 = 1.0e-8;
|
||||||
|
|
||||||
|
/// recovers the center and radius of the circle through an arc's endpoints with the given sweep.
|
||||||
|
pub fn circle_from_arc(p0: (f64, f64), p1: (f64, f64), sweep_deg: f64) -> (f64, f64, f64) {
|
||||||
|
let a0 = Complex64::new(p0.0, p0.1);
|
||||||
|
let a1 = Complex64::new(p1.0, p1.1);
|
||||||
|
let chord = (a1 - a0).norm();
|
||||||
|
let t = (a1 - a0) / chord;
|
||||||
|
let tta = sweep_deg.to_radians();
|
||||||
|
let r = chord / (2.0 * (tta / 2.0).sin());
|
||||||
|
let c = a0 + (Complex64::new(chord / 2.0, (r * r - chord * chord / 4.0).sqrt())) * t;
|
||||||
|
(c.re, c.im, r.abs())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// shortest distance from a point to a segment.
|
||||||
|
pub fn shortest_distance_from_segment(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {
|
||||||
|
let dx = b.0 - a.0;
|
||||||
|
let dy = b.1 - a.1;
|
||||||
|
let len2 = dx * dx + dy * dy;
|
||||||
|
if len2 < 1e-18 {
|
||||||
|
return ((p.0 - a.0).powi(2) + (p.1 - a.1).powi(2)).sqrt();
|
||||||
|
}
|
||||||
|
let t = (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0);
|
||||||
|
let cx = a.0 + t * dx;
|
||||||
|
let cy = a.1 + t * dy;
|
||||||
|
((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// shortest distance from a point to a sweep-defined arc.
|
||||||
|
pub fn shortest_distance_from_arc(
|
||||||
|
p: (f64, f64),
|
||||||
|
a0: (f64, f64),
|
||||||
|
a1: (f64, f64),
|
||||||
|
sweep_deg: f64,
|
||||||
|
) -> f64 {
|
||||||
|
let (cx, cy, r) = circle_from_arc(a0, a1, sweep_deg);
|
||||||
|
let pc = Complex64::new(p.0 - cx, p.1 - cy);
|
||||||
|
let d = pc.norm();
|
||||||
|
if d == 0.0 {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
let t = pc / d;
|
||||||
|
let foot = pc - Complex64::new(r * t.re, r * t.im);
|
||||||
|
let l = foot.norm();
|
||||||
|
let a0_dir = Complex64::new(a0.0 - cx, a0.1 - cy);
|
||||||
|
let z = ((t / a0_dir).arg() * 180.0 / std::f64::consts::PI + 360.0) % 360.0;
|
||||||
|
let sweep_abs = sweep_deg.abs();
|
||||||
|
if z > 0.0 && z < sweep_abs {
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
let e0 = ((p.0 - a0.0).powi(2) + (p.1 - a0.1).powi(2)).sqrt();
|
||||||
|
let e1 = ((p.0 - a1.0).powi(2) + (p.1 - a1.1).powi(2)).sqrt();
|
||||||
|
e0.min(e1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// intersection of the open segments p0->p1 and q0->q1, or None when none exists.
|
||||||
|
pub fn line_line_intersection(
|
||||||
|
p0: (f64, f64),
|
||||||
|
p1: (f64, f64),
|
||||||
|
q0: (f64, f64),
|
||||||
|
q1: (f64, f64),
|
||||||
|
) -> Option<(f64, f64)> {
|
||||||
|
let p0c = Complex64::new(p0.0, p0.1);
|
||||||
|
let p1c = Complex64::new(p1.0, p1.1);
|
||||||
|
let q0c = Complex64::new(q0.0, q0.1);
|
||||||
|
let q1c = Complex64::new(q1.0, q1.1);
|
||||||
|
|
||||||
|
// shared endpoint -> no other intersection
|
||||||
|
if (p0c - q0c).norm() < f64::EPSILON
|
||||||
|
|| (p0c - q1c).norm() < f64::EPSILON
|
||||||
|
|| (p1c - q0c).norm() < f64::EPSILON
|
||||||
|
|| (p1c - q1c).norm() < f64::EPSILON
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ee = ((p1c - p0c).norm()).min((q1c - q0c).norm()) * EPS_FRAC;
|
||||||
|
|
||||||
|
let denom = p1c - p0c;
|
||||||
|
if denom.norm() < f64::EPSILON {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let r0 = (q0c - p0c) / denom;
|
||||||
|
let r1 = (q1c - p0c) / denom;
|
||||||
|
|
||||||
|
if r0.re <= 0.0 && r1.re <= 0.0 { return None; }
|
||||||
|
if r0.re >= 1.0 && r1.re >= 1.0 { return None; }
|
||||||
|
if r0.im <= 0.0 && r1.im <= 0.0 { return None; }
|
||||||
|
if r0.im >= 0.0 && r1.im >= 0.0 { return None; }
|
||||||
|
|
||||||
|
let denom_im = r0.im - r1.im;
|
||||||
|
if denom_im.abs() < f64::EPSILON { return None; }
|
||||||
|
let z = r0.im / denom_im;
|
||||||
|
let x = ((1.0 - z) * r0 + z * r1).re;
|
||||||
|
if x < ee || x > 1.0 - ee {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hit = Complex64::new(p0.0, p0.1) * (1.0 - z) + Complex64::new(q0.0, q0.1) * 0.0;
|
||||||
|
let _ = hit;
|
||||||
|
let result = Complex64::new(q0.0, q0.1) * (1.0 - z) + Complex64::new(q1.0, q1.1) * z;
|
||||||
|
Some((result.re, result.im))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// intersection points of an open line segment with an open arc, returning 0, 1, or 2 hits.
|
||||||
|
pub fn line_arc_intersection(
|
||||||
|
p0: (f64, f64),
|
||||||
|
p1: (f64, f64),
|
||||||
|
a0: (f64, f64),
|
||||||
|
a1: (f64, f64),
|
||||||
|
sweep_deg: f64,
|
||||||
|
) -> Vec<(f64, f64)> {
|
||||||
|
let p0c = Complex64::new(p0.0, p0.1);
|
||||||
|
let p1c = Complex64::new(p1.0, p1.1);
|
||||||
|
let a0c = Complex64::new(a0.0, a0.1);
|
||||||
|
let (cx, cy, r) = circle_from_arc(a0, a1, sweep_deg);
|
||||||
|
let c = Complex64::new(cx, cy);
|
||||||
|
|
||||||
|
let d = (p1c - p0c).norm();
|
||||||
|
if d < f64::EPSILON {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let t = (p1c - p0c) / d;
|
||||||
|
let v = (c - p0c) / t;
|
||||||
|
|
||||||
|
if v.im.abs() > r {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let l = (r * r - v.im * v.im).sqrt();
|
||||||
|
let tta = sweep_deg.to_radians().abs();
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(2);
|
||||||
|
|
||||||
|
if (l / r) < 1.0e-5 {
|
||||||
|
let hit = p0c + Complex64::new(v.re, 0.0) * t;
|
||||||
|
let r_param = ((hit - p0c) / t).re;
|
||||||
|
let z = ((hit - c) / (a0c - c)).arg();
|
||||||
|
if r_param > 0.0 && r_param < d && z > 0.0 && z < tta {
|
||||||
|
out.push((hit.re, hit.im));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
for sign in [1.0, -1.0] {
|
||||||
|
let hit = p0c + Complex64::new(v.re + sign * l, 0.0) * t;
|
||||||
|
let r_param = ((hit - p0c) / t).re;
|
||||||
|
let z = ((hit - c) / (a0c - c)).arg();
|
||||||
|
if r_param > 0.0 && r_param < d && z > 0.0 && z < tta {
|
||||||
|
out.push((hit.re, hit.im));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// intersection points of two open arcs, returning 0, 1, or 2 hits.
|
||||||
|
pub fn arc_arc_intersection(
|
||||||
|
a0_p0: (f64, f64),
|
||||||
|
a0_p1: (f64, f64),
|
||||||
|
a0_sweep_deg: f64,
|
||||||
|
a1_p0: (f64, f64),
|
||||||
|
a1_p1: (f64, f64),
|
||||||
|
a1_sweep_deg: f64,
|
||||||
|
) -> Vec<(f64, f64)> {
|
||||||
|
let (c0x, c0y, r0) = circle_from_arc(a0_p0, a0_p1, a0_sweep_deg);
|
||||||
|
let (c1x, c1y, r1) = circle_from_arc(a1_p0, a1_p1, a1_sweep_deg);
|
||||||
|
let c0 = Complex64::new(c0x, c0y);
|
||||||
|
let c1 = Complex64::new(c1x, c1y);
|
||||||
|
let d = (c1 - c0).norm();
|
||||||
|
|
||||||
|
if d > r0 + r1 || d < 1.0e-8 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let l = ((r0 + r1 - d) * (d + r0 - r1) * (d - r0 + r1) * (d + r0 + r1)).sqrt() / (2.0 * d);
|
||||||
|
let c = 1.0 + (r0 / d) * (r0 / d) - (r1 / d) * (r1 / d);
|
||||||
|
let t = (c1 - c0) / d;
|
||||||
|
let tta0 = a0_sweep_deg.to_radians().abs();
|
||||||
|
let tta1 = a1_sweep_deg.to_radians().abs();
|
||||||
|
let a0c = Complex64::new(a0_p0.0, a0_p0.1);
|
||||||
|
let a1c = Complex64::new(a1_p0.0, a1_p0.1);
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(2);
|
||||||
|
|
||||||
|
let first = c0 + Complex64::new(c * d / 2.0, l) * t;
|
||||||
|
let z0 = ((first - c0) / (a0c - c0)).arg();
|
||||||
|
let z1 = ((first - c1) / (a1c - c1)).arg();
|
||||||
|
if z0 > 0.0 && z0 < tta0 && z1 > 0.0 && z1 < tta1 {
|
||||||
|
out.push((first.re, first.im));
|
||||||
|
}
|
||||||
|
|
||||||
|
// tangent-touch case: only one intersection
|
||||||
|
if (d - r0 + r1).abs() / (r0 + r1) < 1.0e-5 {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let second = c0 + Complex64::new(c * d / 2.0, -l) * t;
|
||||||
|
let z0 = ((second - c0) / (a0c - c0)).arg();
|
||||||
|
let z1 = ((second - c1) / (a1c - c1)).arg();
|
||||||
|
if z0 > 0.0 && z0 < tta0 && z1 > 0.0 && z1 < tta1 {
|
||||||
|
out.push((second.re, second.im));
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn close(a: (f64, f64), b: (f64, f64), tol: f64) -> bool {
|
||||||
|
((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() < tol
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_line_crosses_at_origin() {
|
||||||
|
// (-1,-1)->(1,1) crosses (-1,1)->(1,-1) at origin.
|
||||||
|
let hit = line_line_intersection((-1.0, -1.0), (1.0, 1.0), (-1.0, 1.0), (1.0, -1.0));
|
||||||
|
assert!(hit.is_some());
|
||||||
|
assert!(close(hit.unwrap(), (0.0, 0.0), 1e-9));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_line_parallel_no_intersection() {
|
||||||
|
let hit = line_line_intersection((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0));
|
||||||
|
assert!(hit.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_line_t_junction_at_endpoint_rejects() {
|
||||||
|
// the second segment's q0 sits ON the first segment's interior
|
||||||
|
// but the prospective endpoint check should reject (intersection at q0).
|
||||||
|
let hit = line_line_intersection((0.0, 0.0), (2.0, 0.0), (1.0, 0.0), (1.0, 1.0));
|
||||||
|
// shape: one endpoint sits on the other segment, no clean crossing.
|
||||||
|
// GetIntersection returns FALSE when an intersection lies within ee of an endpoint of the
|
||||||
|
// prospective line — here the meeting point IS the prospective q0, so no split is wanted.
|
||||||
|
assert!(hit.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_line_shared_endpoint_no_intersection() {
|
||||||
|
let hit = line_line_intersection((0.0, 0.0), (1.0, 0.0), (1.0, 0.0), (2.0, 1.0));
|
||||||
|
assert!(hit.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn circle_from_quarter_arc_unit() {
|
||||||
|
// a quarter circle (90 deg sweep) from (1, 0) to (0, 1) sits on the unit circle centered at origin.
|
||||||
|
let (cx, cy, r) = circle_from_arc((1.0, 0.0), (0.0, 1.0), 90.0);
|
||||||
|
assert!((cx - 0.0).abs() < 1e-9);
|
||||||
|
assert!((cy - 0.0).abs() < 1e-9);
|
||||||
|
assert!((r - 1.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_through_quarter_arc_crosses_once() {
|
||||||
|
// diagonal line from (-2, 0.5) to (2, 0.5) crosses the quarter circle (1,0)->(0,1) at one point.
|
||||||
|
let hits = line_arc_intersection((-2.0, 0.5), (2.0, 0.5), (1.0, 0.0), (0.0, 1.0), 90.0);
|
||||||
|
assert_eq!(hits.len(), 1);
|
||||||
|
let h = hits[0];
|
||||||
|
assert!((h.0 * h.0 + h.1 * h.1 - 1.0).abs() < 1e-9, "point should lie on unit circle: {h:?}");
|
||||||
|
assert!((h.1 - 0.5).abs() < 1e-9);
|
||||||
|
assert!(h.0 > 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_outside_arc_does_not_cross() {
|
||||||
|
// line clearly outside the arc swept region.
|
||||||
|
let hits = line_arc_intersection((-2.0, 2.5), (2.0, 2.5), (1.0, 0.0), (0.0, 1.0), 90.0);
|
||||||
|
assert!(hits.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arc_arc_two_intersections() {
|
||||||
|
// two unit circles offset by 1.0 along x, both swept 360 degrees... but we use partial arcs
|
||||||
|
// shaped to clip both crossings in their swept range.
|
||||||
|
// circle A: center (0,0), arcs covering full top half (180 deg from (1,0) to (-1,0)).
|
||||||
|
// circle B: center (1,0), arcs covering full top half (180 deg from (2,0) to (0,0)).
|
||||||
|
let hits = arc_arc_intersection(
|
||||||
|
(1.0, 0.0), (-1.0, 0.0), 180.0,
|
||||||
|
(2.0, 0.0), (0.0, 0.0), 180.0,
|
||||||
|
);
|
||||||
|
assert_eq!(hits.len(), 1, "two top-half arcs of overlapping unit circles meet at one upper crossing: {hits:?}");
|
||||||
|
let h = hits[0];
|
||||||
|
assert!((h.0 - 0.5).abs() < 1e-9, "x = 0.5 by symmetry, got {h:?}");
|
||||||
|
assert!(h.1 > 0.0, "intersection should be above the x-axis: {h:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arc_arc_disjoint() {
|
||||||
|
let hits = arc_arc_intersection(
|
||||||
|
(1.0, 0.0), (-1.0, 0.0), 180.0,
|
||||||
|
(11.0, 0.0), (9.0, 0.0), 180.0,
|
||||||
|
);
|
||||||
|
assert!(hits.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortest_distance_from_segment_perpendicular() {
|
||||||
|
// point (0, 1) to segment (-1, 0)->(1, 0) is distance 1.
|
||||||
|
let d = shortest_distance_from_segment((0.0, 1.0), (-1.0, 0.0), (1.0, 0.0));
|
||||||
|
assert!((d - 1.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortest_distance_from_segment_clamps_to_endpoint() {
|
||||||
|
// point (-2, 0) to segment (-1, 0)->(1, 0) is distance 1 (clamps to left endpoint).
|
||||||
|
let d = shortest_distance_from_segment((-2.0, 0.0), (-1.0, 0.0), (1.0, 0.0));
|
||||||
|
assert!((d - 1.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortest_distance_from_arc_radial() {
|
||||||
|
// point at origin distance to unit-circle quarter arc is 1.
|
||||||
|
let d = shortest_distance_from_arc((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), 90.0);
|
||||||
|
assert!((d - 1.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
//! heat-flow pre-processor document model: geometry, properties, parser, writer.
|
//! heat-flow pre-processor document model: geometry, properties, parser, writer.
|
||||||
|
|
||||||
pub mod geom;
|
pub mod geom;
|
||||||
|
pub mod geom_math;
|
||||||
pub mod props;
|
pub mod props;
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
pub mod writer;
|
pub mod writer;
|
||||||
|
pub mod edit;
|
||||||
|
|
||||||
use num_complex::Complex64;
|
use num_complex::Complex64;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -553,8 +553,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn add_segment_crossing_unit_quarter_arc_splits_segment() {
|
fn add_segment_crossing_unit_quarter_arc_splits_segment() {
|
||||||
// unit quarter arc from (1,0) to (0,1); horizontal line at y=0.5 crosses it once.
|
// unit quarter arc from (1,0) to (0,1); horizontal line at y=0.5 crosses the arc once.
|
||||||
// expect a new intersection node near (0.866, 0.5) and the segment split into two.
|
// expect a fresh intersection node near (0.866, 0.5) and the segment split into two.
|
||||||
let mut d = FemmDoc::default();
|
let mut d = FemmDoc::default();
|
||||||
d.add_node( 1.0, 0.0, 0.0);
|
d.add_node( 1.0, 0.0, 0.0);
|
||||||
d.add_node( 0.0, 1.0, 0.0);
|
d.add_node( 0.0, 1.0, 0.0);
|
||||||
|
|
@ -574,7 +574,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn add_arc_passing_through_existing_node_splits_into_two_arcs() {
|
fn add_arc_passing_through_existing_node_splits_into_two_arcs() {
|
||||||
// pre-existing node at (cos45, sin45) lies on the unit quarter arc from (1,0) to (0,1).
|
// pre-existing node at (cos45, sin45) lies on the unit quarter arc from (1,0) to (0,1).
|
||||||
// adding the arc should detect the on-arc node and emit two sub-arcs of 45 degrees each.
|
// adding the arc detects the on-arc node and emits two sub-arcs of 45 degrees each.
|
||||||
let mut d = FemmDoc::default();
|
let mut d = FemmDoc::default();
|
||||||
d.add_node(1.0, 0.0, 0.0);
|
d.add_node(1.0, 0.0, 0.0);
|
||||||
d.add_node(0.0, 1.0, 0.0);
|
d.add_node(0.0, 1.0, 0.0);
|
||||||
|
|
@ -592,9 +592,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn crossing_second_segment_splits_at_intersection() {
|
fn crossing_second_segment_splits_at_intersection() {
|
||||||
// horizontal (-1,0)->(1,0) added first stays whole.
|
// two perpendicular segments through the origin. horizontal first, vertical second.
|
||||||
// the vertical (0,-1)->(0,1) is added second; it intersects at origin,
|
// the vertical add inserts an origin node and self-splits; the horizontal stays whole.
|
||||||
// so a new node lands at (0,0) and the vertical splits into two pieces.
|
|
||||||
let mut d = doc_with_corners();
|
let mut d = doc_with_corners();
|
||||||
assert!(d.add_segment(0, 1));
|
assert!(d.add_segment(0, 1));
|
||||||
assert!(d.add_segment(2, 3));
|
assert!(d.add_segment(2, 3));
|
||||||
|
|
@ -604,8 +603,7 @@ mod tests {
|
||||||
assert!((d.nodes[new_idx].x).abs() < 1e-9);
|
assert!((d.nodes[new_idx].x).abs() < 1e-9);
|
||||||
assert!((d.nodes[new_idx].y).abs() < 1e-9);
|
assert!((d.nodes[new_idx].y).abs() < 1e-9);
|
||||||
|
|
||||||
// exactly one piece touches node 0 (-1,0) and node 4 (0,0): the original horizontal stays whole here.
|
// expected pieces: 0->1 (horizontal whole), 2->4 and 4->3 (vertical halves).
|
||||||
// expected pieces: 0->1 (horizontal whole), 2->4, 4->3 (vertical halves).
|
|
||||||
assert_eq!(d.segments.len(), 3);
|
assert_eq!(d.segments.len(), 3);
|
||||||
let touches_new = d.segments.iter().filter(|s| s.n0 == new_idx as i32 || s.n1 == new_idx as i32).count();
|
let touches_new = d.segments.iter().filter(|s| s.n0 == new_idx as i32 || s.n1 == new_idx as i32).count();
|
||||||
assert_eq!(touches_new, 2);
|
assert_eq!(touches_new, 2);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ pub mod props;
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
pub mod writer;
|
pub mod writer;
|
||||||
pub mod edit;
|
pub mod edit;
|
||||||
|
pub mod poly;
|
||||||
|
|
||||||
use num_complex::Complex64;
|
use num_complex::Complex64;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
//! .poly emitter consumed by the Triangle mesher.
|
||||||
|
|
||||||
|
use crate::FemmDoc;
|
||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
impl FemmDoc {
|
||||||
|
/// renders the doc geometry to the Triangle .poly text format, returning the file contents.
|
||||||
|
pub fn write_poly(&self) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let bbox_diag = self.bbox_diagonal();
|
||||||
|
let default_area = if bbox_diag > 0.0 { bbox_diag } else { -1.0 };
|
||||||
|
|
||||||
|
writeln!(out, "{}\t2\t0\t1", self.nodes.len()).unwrap();
|
||||||
|
for (i, n) in self.nodes.iter().enumerate() {
|
||||||
|
let marker = point_marker_index(self, &n.boundary_marker);
|
||||||
|
writeln!(out, "{i}\t{:.17e}\t{:.17e}\t{marker}", n.x, n.y).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
writeln!(out, "{}\t1", self.segments.len()).unwrap();
|
||||||
|
for (i, s) in self.segments.iter().enumerate() {
|
||||||
|
let marker = -boundary_marker_index(self, &s.boundary_marker);
|
||||||
|
writeln!(out, "{i}\t{}\t{}\t{marker}", s.n0, s.n1).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let holes: Vec<&_> = self.block_labels.iter().filter(|b| b.block_type == "<No Mesh>").collect();
|
||||||
|
writeln!(out, "{}", holes.len()).unwrap();
|
||||||
|
for (i, h) in holes.iter().enumerate() {
|
||||||
|
writeln!(out, "{i}\t{:.17e}\t{:.17e}", h.x, h.y).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let regions: Vec<&_> = self.block_labels.iter().filter(|b| b.block_type != "<No Mesh>").collect();
|
||||||
|
writeln!(out, "{}", regions.len()).unwrap();
|
||||||
|
for (i, r) in regions.iter().enumerate() {
|
||||||
|
let attr = (i as i32) + 1;
|
||||||
|
let max_area = if r.max_area > 0.0 && r.max_area < default_area {
|
||||||
|
r.max_area
|
||||||
|
} else {
|
||||||
|
default_area
|
||||||
|
};
|
||||||
|
writeln!(out, "{i}\t{:.17e}\t{:.17e}\t{attr}\t{:.17e}", r.x, r.y, max_area).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// writes the .poly text to disk at the given path.
|
||||||
|
pub fn save_poly(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
|
||||||
|
std::fs::write(path, self.write_poly())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bbox_diagonal(&self) -> f64 {
|
||||||
|
if self.nodes.len() < 2 { return 0.0; }
|
||||||
|
let mut xmin = f64::INFINITY;
|
||||||
|
let mut xmax = f64::NEG_INFINITY;
|
||||||
|
let mut ymin = f64::INFINITY;
|
||||||
|
let mut ymax = f64::NEG_INFINITY;
|
||||||
|
for n in &self.nodes {
|
||||||
|
xmin = xmin.min(n.x); xmax = xmax.max(n.x);
|
||||||
|
ymin = ymin.min(n.y); ymax = ymax.max(n.y);
|
||||||
|
}
|
||||||
|
let dx = xmax - xmin;
|
||||||
|
let dy = ymax - ymin;
|
||||||
|
(dx * dx + dy * dy).sqrt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// resolves a node's boundary-marker name to Triangle's marker integer (0 for unmarked).
|
||||||
|
fn point_marker_index(doc: &FemmDoc, name: &str) -> i32 {
|
||||||
|
if name.is_empty() { return 0; }
|
||||||
|
for (i, p) in doc.points.iter().enumerate() {
|
||||||
|
if p.name == name { return (i as i32) + 2; }
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// resolves a segment's boundary-marker name to Triangle's marker integer, returned positive.
|
||||||
|
/// the caller negates for Triangle's segment-marker convention.
|
||||||
|
fn boundary_marker_index(doc: &FemmDoc, name: &str) -> i32 {
|
||||||
|
if name.is_empty() { return 0; }
|
||||||
|
for (i, b) in doc.boundaries.iter().enumerate() {
|
||||||
|
if b.name == name { return (i as i32) + 2; }
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{BoundaryProp, PointProp, BlockLabel};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_poly_header_lines_count_correctly() {
|
||||||
|
// four-corner square with one block label inside; verify the section headers and counts.
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.points.push(PointProp { name: "A=0".into(), ..PointProp::default() });
|
||||||
|
d.boundaries.push(BoundaryProp { name: "outer".into(), ..BoundaryProp::default() });
|
||||||
|
d.add_node(0.0, 0.0, 0.0);
|
||||||
|
d.add_node(1.0, 0.0, 0.0);
|
||||||
|
d.add_node(1.0, 1.0, 0.0);
|
||||||
|
d.add_node(0.0, 1.0, 0.0);
|
||||||
|
d.nodes[0].boundary_marker = "A=0".into();
|
||||||
|
d.add_segment_with_marker(0, 1, "outer");
|
||||||
|
d.add_segment_with_marker(1, 2, "outer");
|
||||||
|
d.add_segment_with_marker(2, 3, "outer");
|
||||||
|
d.add_segment_with_marker(3, 0, "outer");
|
||||||
|
d.block_labels.push(BlockLabel {
|
||||||
|
x: 0.5, y: 0.5,
|
||||||
|
max_area: 0.0,
|
||||||
|
block_type: "Air".into(),
|
||||||
|
..BlockLabel::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let poly = d.write_poly();
|
||||||
|
let lines: Vec<&str> = poly.lines().collect();
|
||||||
|
|
||||||
|
assert!(lines[0].starts_with("4\t2\t0\t1"), "node header: {}", lines[0]);
|
||||||
|
assert!(lines[5].starts_with("4\t1"), "segment header: {}", lines[5]);
|
||||||
|
assert_eq!(lines[10], "0", "no holes section: {}", lines[10]);
|
||||||
|
assert_eq!(lines[11], "1", "one region: {}", lines[11]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_poly_marks_nodes_and_segments_by_property_index() {
|
||||||
|
// first point property listed -> marker 2; first boundary property listed -> -2 on segment.
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.points.push(PointProp { name: "A=0".into(), ..PointProp::default() });
|
||||||
|
d.boundaries.push(BoundaryProp { name: "outer".into(), ..BoundaryProp::default() });
|
||||||
|
d.add_node(0.0, 0.0, 0.0);
|
||||||
|
d.add_node(1.0, 0.0, 0.0);
|
||||||
|
d.nodes[0].boundary_marker = "A=0".into();
|
||||||
|
d.add_segment_with_marker(0, 1, "outer");
|
||||||
|
|
||||||
|
let poly = d.write_poly();
|
||||||
|
let lines: Vec<&str> = poly.lines().collect();
|
||||||
|
|
||||||
|
assert!(lines[1].ends_with("\t2"), "node 0 marker = 2: {}", lines[1]);
|
||||||
|
assert!(lines[2].ends_with("\t0"), "node 1 marker = 0: {}", lines[2]);
|
||||||
|
assert!(lines[4].ends_with("\t-2"), "segment 0 marker = -2: {}", lines[4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_poly_holes_partitioned_from_regions() {
|
||||||
|
// one "<No Mesh>" label counts as a hole; everything else is a region.
|
||||||
|
let mut d = FemmDoc::default();
|
||||||
|
d.add_node(0.0, 0.0, 0.0);
|
||||||
|
d.add_node(1.0, 0.0, 0.0);
|
||||||
|
d.block_labels.push(BlockLabel {
|
||||||
|
x: 0.5, y: 0.5,
|
||||||
|
block_type: "<No Mesh>".into(),
|
||||||
|
..BlockLabel::default()
|
||||||
|
});
|
||||||
|
d.block_labels.push(BlockLabel {
|
||||||
|
x: 0.2, y: 0.2,
|
||||||
|
block_type: "Air".into(),
|
||||||
|
..BlockLabel::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let poly = d.write_poly();
|
||||||
|
let lines: Vec<&str> = poly.lines().collect();
|
||||||
|
|
||||||
|
// node header (0), 2 node rows (1,2), segment header "0\t1" (3),
|
||||||
|
// hole count (4), 1 hole row (5), region count (6), 1 region row (7).
|
||||||
|
assert_eq!(lines[4], "1", "one hole: {}", lines[4]);
|
||||||
|
assert_eq!(lines[6], "1", "one region: {}", lines[6]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin) ;;
|
||||||
|
*) echo "wrong platform: $(uname -s) - use cargo xtask build" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
export MACOSX_DEPLOYMENT_TARGET=11.0
|
||||||
|
|
||||||
|
BUILD="$ROOT/build"
|
||||||
|
APP="$BUILD/bin/femm.app"
|
||||||
|
CONTENTS="$APP/Contents"
|
||||||
|
MACOS="$CONTENTS/MacOS"
|
||||||
|
RESOURCES="$CONTENTS/Resources"
|
||||||
|
ICONSET="$BUILD/AppIcon.iconset"
|
||||||
|
ICNS="$RESOURCES/AppIcon.icns"
|
||||||
|
SVG="$ROOT/assets/femm.svg"
|
||||||
|
|
||||||
|
echo "Building Rust workspace (release)..."
|
||||||
|
cargo build --release -p femm-app
|
||||||
|
|
||||||
|
BIN="$ROOT/target/release/femm"
|
||||||
|
if [ ! -f "$BIN" ]; then
|
||||||
|
echo "ERROR: femm binary not found at $BIN" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$APP"
|
||||||
|
mkdir -p "$MACOS" "$RESOURCES"
|
||||||
|
cp "$BIN" "$MACOS/femm"
|
||||||
|
|
||||||
|
if [ -f "$SVG" ]; then
|
||||||
|
if ! command -v rsvg-convert >/dev/null; then
|
||||||
|
echo "ERROR: rsvg-convert missing - brew install librsvg" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! command -v iconutil >/dev/null; then
|
||||||
|
echo "ERROR: iconutil missing - install Xcode command line tools" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf "$ICONSET"
|
||||||
|
mkdir -p "$ICONSET"
|
||||||
|
for size in 16 32 64 128 256 512 1024; do
|
||||||
|
rsvg-convert --width="$size" --height="$size" "$SVG" -o "$ICONSET/icon_${size}.png"
|
||||||
|
done
|
||||||
|
cp "$ICONSET/icon_16.png" "$ICONSET/icon_16x16.png"
|
||||||
|
cp "$ICONSET/icon_32.png" "$ICONSET/icon_16x16@2x.png"
|
||||||
|
cp "$ICONSET/icon_32.png" "$ICONSET/icon_32x32.png"
|
||||||
|
cp "$ICONSET/icon_64.png" "$ICONSET/icon_32x32@2x.png"
|
||||||
|
cp "$ICONSET/icon_128.png" "$ICONSET/icon_128x128.png"
|
||||||
|
cp "$ICONSET/icon_256.png" "$ICONSET/icon_128x128@2x.png"
|
||||||
|
cp "$ICONSET/icon_256.png" "$ICONSET/icon_256x256.png"
|
||||||
|
cp "$ICONSET/icon_512.png" "$ICONSET/icon_256x256@2x.png"
|
||||||
|
cp "$ICONSET/icon_512.png" "$ICONSET/icon_512x512.png"
|
||||||
|
cp "$ICONSET/icon_1024.png" "$ICONSET/icon_512x512@2x.png"
|
||||||
|
iconutil -c icns "$ICONSET" -o "$ICNS"
|
||||||
|
else
|
||||||
|
echo "WARNING: $SVG not found - app bundle will lack an icon"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "$CONTENTS/Info.plist" <<'PLIST'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key> <string>en</string>
|
||||||
|
<key>CFBundleExecutable</key> <string>femm</string>
|
||||||
|
<key>CFBundleIconFile</key> <string>AppIcon</string>
|
||||||
|
<key>CFBundleIdentifier</key> <string>org.else-if.femm</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key> <string>6.0</string>
|
||||||
|
<key>CFBundleName</key> <string>femm</string>
|
||||||
|
<key>CFBundleDisplayName</key> <string>FEMM</string>
|
||||||
|
<key>CFBundlePackageType</key> <string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key> <string>0.0.1</string>
|
||||||
|
<key>CFBundleVersion</key> <string>0.0.1</string>
|
||||||
|
<key>LSMinimumSystemVersion</key> <string>11.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key> <true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
|
||||||
|
echo "Built: $APP"
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin) ;;
|
||||||
|
*) echo "wrong platform: $(uname -s) - use cargo xtask debug" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
export RUST_BACKTRACE=1
|
||||||
|
exec cargo run -p femm-app -- "$@"
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
exec bash "$ROOT/scripts/macos/build_ffi.sh" "$@"
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin) ;;
|
||||||
|
*) echo "wrong platform: $(uname -s) - use cargo xtask install" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
DEST="/Applications/femm.app"
|
||||||
|
|
||||||
|
bash "$ROOT/scripts/macos/build.sh"
|
||||||
|
|
||||||
|
pkill -f "femm.app/Contents/MacOS/femm" 2>/dev/null || true
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
|
echo "Installing to $DEST..."
|
||||||
|
rm -rf "$DEST"
|
||||||
|
cp -R "$ROOT/build/bin/femm.app" "$DEST"
|
||||||
|
|
||||||
|
echo "Installed: $DEST"
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
[package]
|
||||||
|
name = "xtask"
|
||||||
|
version = "0.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "xtask"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Command, ExitCode};
|
||||||
|
|
||||||
|
const KNOWN_PLATFORMS: &[&str] = &["macos", "windows", "linux"];
|
||||||
|
|
||||||
|
/// dispatches a cargo xtask sub-command to the matching platform script under scripts/.
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let args: Vec<String> = env::args().skip(1).collect();
|
||||||
|
let cmd = args.first().map(String::as_str).unwrap_or("");
|
||||||
|
|
||||||
|
if cmd.is_empty() || cmd == "help" || cmd == "--help" || cmd == "-h" {
|
||||||
|
print_help();
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let extra_args: Vec<&String> = args.iter().skip(1).collect();
|
||||||
|
let (action, platform) = parse(cmd);
|
||||||
|
|
||||||
|
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.parent()
|
||||||
|
.expect("xtask manifest must have a parent")
|
||||||
|
.to_path_buf();
|
||||||
|
|
||||||
|
let (script, runner) = match platform.as_str() {
|
||||||
|
"windows" => (
|
||||||
|
repo_root.join(format!("scripts/windows/{action}.ps1")),
|
||||||
|
vec![
|
||||||
|
"powershell",
|
||||||
|
"-NoProfile",
|
||||||
|
"-ExecutionPolicy",
|
||||||
|
"Bypass",
|
||||||
|
"-File",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"linux" | "macos" => (
|
||||||
|
repo_root.join(format!("scripts/{platform}/{action}.sh")),
|
||||||
|
vec!["bash"],
|
||||||
|
),
|
||||||
|
other => {
|
||||||
|
eprintln!("unknown platform: {other}");
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !script.exists() {
|
||||||
|
eprintln!("script not found: {}", script.display());
|
||||||
|
return ExitCode::from(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let extra_display = if extra_args.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
" {}",
|
||||||
|
extra_args.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(" "),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
eprintln!("-> {} {}{}", runner.join(" "), script.display(), extra_display);
|
||||||
|
|
||||||
|
let mut command = Command::new(runner[0]);
|
||||||
|
for arg in &runner[1..] {
|
||||||
|
command.arg(arg);
|
||||||
|
}
|
||||||
|
command.arg(&script);
|
||||||
|
for a in &extra_args {
|
||||||
|
command.arg(a.as_str());
|
||||||
|
}
|
||||||
|
command.current_dir(&repo_root);
|
||||||
|
|
||||||
|
match command.status() {
|
||||||
|
Ok(status) if status.success() => ExitCode::SUCCESS,
|
||||||
|
Ok(status) => ExitCode::from(status.code().unwrap_or(1) as u8),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("failed to run {}: {e}", script.display());
|
||||||
|
ExitCode::from(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// splits an action-platform command into (action, platform), defaulting to the host OS.
|
||||||
|
fn parse(cmd: &str) -> (String, String) {
|
||||||
|
if let Some(idx) = cmd.rfind('-') {
|
||||||
|
let suffix = &cmd[idx + 1..];
|
||||||
|
if KNOWN_PLATFORMS.contains(&suffix) {
|
||||||
|
return (cmd[..idx].to_string(), suffix.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(cmd.to_string(), current_platform().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// resolves the host OS to a script directory name, exiting if the OS lacks scripts.
|
||||||
|
fn current_platform() -> &'static str {
|
||||||
|
match env::consts::OS {
|
||||||
|
"linux" => "linux",
|
||||||
|
"macos" => "macos",
|
||||||
|
"windows" => "windows",
|
||||||
|
other => {
|
||||||
|
eprintln!("unsupported OS: {other}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// prints command list, platform suffix syntax, and packaging hints.
|
||||||
|
fn print_help() {
|
||||||
|
eprintln!("usage: cargo xtask <command>");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("commands:");
|
||||||
|
eprintln!(" build release build for the current platform");
|
||||||
|
eprintln!(" install release build + install (macOS: /Applications)");
|
||||||
|
eprintln!(" debug debug build + foreground launch");
|
||||||
|
eprintln!(" ffi rebuild the C/C++ engine archives under build/ffi/");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("append -macos / -windows / -linux to force a platform.");
|
||||||
|
eprintln!(" e.g. cargo xtask build-macos");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("trailing args pass through to the script.");
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue