Sketch profiles: grid-accelerated splitting and node merging, bounding-box nesting (large sketches several times faster); randomized sketch-editing test (thousands of mixed create/modify/constraint/projection/undo steps: no panics, designs round-trip)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
97abc687ae
commit
4455402eab
3 files changed
+243
-20
No files matched your search
@@ -514,3 +514,7 @@ fn snap(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "sketch_constraints_tests.rs"]
|
#[path = "sketch_constraints_tests.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "sketch_fuzz_tests.rs"]
|
||||||
|
mod fuzz;
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
//! Random sequences of sketch commands: nothing panics, every sketch stays evaluable, and the
|
||||||
|
//! design survives a save/load round trip.
|
||||||
|
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::{EngineError, Session};
|
||||||
|
|
||||||
|
/// Small deterministic generator (xorshift).
|
||||||
|
struct Rng(u64);
|
||||||
|
|
||||||
|
impl Rng {
|
||||||
|
fn next(&mut self) -> u64 {
|
||||||
|
let mut x = self.0;
|
||||||
|
x ^= x << 13;
|
||||||
|
x ^= x >> 7;
|
||||||
|
x ^= x << 17;
|
||||||
|
self.0 = x;
|
||||||
|
x
|
||||||
|
}
|
||||||
|
fn f(&mut self, lo: f64, hi: f64) -> f64 {
|
||||||
|
lo + (self.next() % 1_000_000) as f64 / 1_000_000.0 * (hi - lo)
|
||||||
|
}
|
||||||
|
fn pick<'a, T>(&mut self, v: &'a [T]) -> Option<&'a T> {
|
||||||
|
if v.is_empty() { None } else { v.get((self.next() as usize) % v.len()) }
|
||||||
|
}
|
||||||
|
fn pt(&mut self) -> Value {
|
||||||
|
json!([(self.f(-50.0, 50.0) * 10.0).round() / 10.0, (self.f(-50.0, 50.0) * 10.0).round() / 10.0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entities(s: &Session) -> (Vec<String>, Vec<String>, Vec<String>) {
|
||||||
|
let Some(id) = s.active_sketch else { return Default::default() };
|
||||||
|
let st = s.model.state();
|
||||||
|
let Some(ss) = st.sketch(id) else { return Default::default() };
|
||||||
|
let sk = &ss.sketch;
|
||||||
|
let curves = sk.curves.iter().map(|c| c.id.clone()).collect();
|
||||||
|
let points = sk.points.iter().map(|p| p.id.clone()).collect();
|
||||||
|
let cons = sk.constraints.iter().map(|c| c.id.clone()).collect();
|
||||||
|
(curves, points, cons)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn random_sketch_editing_never_breaks() {
|
||||||
|
for seed in 1..=4u64 {
|
||||||
|
let mut r = Rng(0x9E37_79B9_7F4A_7C15 ^ seed);
|
||||||
|
let mut s = Session::default();
|
||||||
|
let _ = s.execute("PrimitiveBox", &json!({"length": 30, "width": 20, "height": 10}));
|
||||||
|
let _ = s.execute("SketchCreate", &json!({"plane": {"face": [15, 10, 10]}}));
|
||||||
|
for step in 0..150 {
|
||||||
|
let (curves, points, cons) = entities(&s);
|
||||||
|
let c = |r: &mut Rng| r.pick(&curves).cloned().unwrap_or_default();
|
||||||
|
let p = |r: &mut Rng| r.pick(&points).cloned().unwrap_or_default();
|
||||||
|
let (cmd, params) = match r.next() % 34 {
|
||||||
|
0 => ("DrawPolyline", json!({"points": [r.pt(), r.pt(), r.pt()], "infer": true})),
|
||||||
|
1 => ("CircleCenterRadius", json!({"center": r.pt(), "radius": r.f(0.5, 20.0)})),
|
||||||
|
2 => ("ArcCenterTwoPoint", json!({"center": r.pt(), "start": r.pt(), "end": r.pt()})),
|
||||||
|
3 => ("ShapeRectangleTwoPoint", json!({"p0": r.pt(), "p1": r.pt()})),
|
||||||
|
4 => ("DrawSpline", json!({"points": [r.pt(), r.pt(), r.pt(), r.pt()]})),
|
||||||
|
5 => ("CircleElipse", json!({"center": r.pt(), "major": r.pt(), "minor_radius": r.f(0.5, 10.0)})),
|
||||||
|
6 => ("ConicCurveCmd", json!({"start": r.pt(), "end": r.pt(), "apex": r.pt(), "rho": r.f(0.1, 0.9)})),
|
||||||
|
7 => ("TrimSketchCmd", json!({"curve": c(&mut r), "at": r.pt()})),
|
||||||
|
8 => ("ExtendSketchCmd", json!({"curve": c(&mut r), "at": r.pt()})),
|
||||||
|
9 => ("BreakSketchCmd", json!({"curve": c(&mut r), "at": r.pt()})),
|
||||||
|
10 => ("FilletSketchCmd", json!({"point": p(&mut r), "radius": r.f(0.1, 5.0)})),
|
||||||
|
11 => ("ChamferSketchEqualDistance", json!({"point": p(&mut r), "distance": r.f(0.1, 5.0)})),
|
||||||
|
12 => ("Offset", json!({"curves": [c(&mut r)], "distance": r.f(-5.0, 5.0), "side": r.pt()})),
|
||||||
|
13 => ("MirrorSketchCommand", json!({"entities": [c(&mut r)], "line": c(&mut r)})),
|
||||||
|
14 => ("CircularSketchPatternCommand", json!({"entities": [c(&mut r)], "center": r.pt(), "count": 3})),
|
||||||
|
15 => ("ConstraintCoincident", json!({"a": p(&mut r), "b": p(&mut r)})),
|
||||||
|
16 => ("ConstraintTangent", json!({"a": c(&mut r), "b": c(&mut r)})),
|
||||||
|
17 => ("ConstraintParallel", json!({"a": c(&mut r), "b": c(&mut r)})),
|
||||||
|
18 => ("ConstraintSmooth", json!({"a": c(&mut r), "b": c(&mut r)})),
|
||||||
|
19 => ("SketchDimension", json!({"entities": [c(&mut r)]})),
|
||||||
|
20 => ("SketchDimension", json!({"entities": [p(&mut r), p(&mut r)], "driven": r.next().is_multiple_of(2)})),
|
||||||
|
21 => ("sketch.delete", json!({"entities": [r.pick(&cons).cloned().unwrap_or_else(|| c(&mut r))]})),
|
||||||
|
22 => ("sketch.move_point", json!({"point": p(&mut r), "to": r.pt()})),
|
||||||
|
23 => ("ProjectNewCmd", json!({"refs": [{"edge": [r.f(0.0, 30.0), 0.0, 10.0]}]})),
|
||||||
|
24 => ("sketch.break_link", json!({"entities": [c(&mut r)]})),
|
||||||
|
25 => ("SketchConstrainer", json!({"entities": [c(&mut r), c(&mut r)]})),
|
||||||
|
26 => ("SketchAutoConstraintAndDimCmd", json!({})),
|
||||||
|
27 => ("MTextCmd", json!({"text": "Ab", "at": r.pt(), "height": r.f(1.0, 8.0)})),
|
||||||
|
28 => ("sketch.toggle_driven", json!({"constraint": r.pick(&cons).cloned().unwrap_or_default()})),
|
||||||
|
29 => ("ArcTangent", json!({"start": format!("{}.end", c(&mut r)), "end": r.pt()})),
|
||||||
|
30 => ("SketchScaleCmd", json!({"entities": [c(&mut r)], "base": r.pt(), "factor": r.f(0.5, 2.0)})),
|
||||||
|
31 => ("UndoCommand", json!({})),
|
||||||
|
32 => ("ShapeArcSlotThreePoint", json!({"start": r.pt(), "through": r.pt(), "end": r.pt(), "width": r.f(0.5, 4.0)})),
|
||||||
|
_ => ("RedoCommand", json!({})),
|
||||||
|
};
|
||||||
|
if let Err(EngineError::Internal(id, msg)) = s.execute(cmd, ¶ms) {
|
||||||
|
panic!("seed {seed} step {step}: {id} {params}: {msg}");
|
||||||
|
}
|
||||||
|
if s.active_sketch.is_none() {
|
||||||
|
let _ = s.execute("SketchCreate", &json!({"plane": "XY"}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The design round-trips.
|
||||||
|
let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||||
|
assert_eq!(back, *s.doc, "seed {seed}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+139
-20
@@ -68,6 +68,45 @@ fn end_param(sh: &Shape, p: Vec2, tol: f64) -> Option<f64> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A uniform grid over a set of boxes (about one box per cell), for neighbour queries.
|
||||||
|
struct Grid {
|
||||||
|
lo: Vec2,
|
||||||
|
size: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Grid {
|
||||||
|
fn new(boxes: &[(Vec2, Vec2)]) -> Grid {
|
||||||
|
let (mut lo, mut hi) = (Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY));
|
||||||
|
for (a, b) in boxes {
|
||||||
|
lo = Vec2::new(lo.x.min(a.x), lo.y.min(a.y));
|
||||||
|
hi = Vec2::new(hi.x.max(b.x), hi.y.max(b.y));
|
||||||
|
}
|
||||||
|
if !(lo.is_finite() && hi.is_finite()) {
|
||||||
|
return Grid { lo: Vec2::ZERO, size: 1.0 };
|
||||||
|
}
|
||||||
|
let ext = (hi.x - lo.x).max(hi.y - lo.y).max(1e-9);
|
||||||
|
let per_side = (boxes.len() as f64).sqrt().ceil().clamp(1.0, 512.0);
|
||||||
|
Grid { lo, size: ext / per_side }
|
||||||
|
}
|
||||||
|
fn cell(&self, p: Vec2) -> (i64, i64) {
|
||||||
|
(((p.x - self.lo.x) / self.size).floor() as i64, ((p.y - self.lo.y) / self.size).floor() as i64)
|
||||||
|
}
|
||||||
|
/// Cells a box covers; None for boxes spanning too many (they are checked against all).
|
||||||
|
fn cells(&self, b: &(Vec2, Vec2)) -> Option<Vec<(i64, i64)>> {
|
||||||
|
let (c0, c1) = (self.cell(b.0), self.cell(b.1));
|
||||||
|
if (c1.0 - c0.0 + 1).saturating_mul(c1.1 - c0.1 + 1) > 2048 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for x in c0.0..=c1.0 {
|
||||||
|
for y in c0.1..=c1.1 {
|
||||||
|
out.push((x, y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn find(p: &mut [usize], mut i: usize) -> usize {
|
fn find(p: &mut [usize], mut i: usize) -> usize {
|
||||||
while let Some(&q) = p.get(i) {
|
while let Some(&q) = p.get(i) {
|
||||||
if q == i {
|
if q == i {
|
||||||
@@ -150,17 +189,7 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
|
|||||||
Shape::Round { .. } => {}
|
Shape::Round { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut cuts: Vec<Vec<f64>> = vec![Vec::new(); shapes.len()];
|
// Bounding boxes, and a uniform grid over them: only shapes sharing a cell can meet.
|
||||||
for (i, (_, sh)) in shapes.iter().enumerate() {
|
|
||||||
for p in &ends {
|
|
||||||
if let Some(t) = sh.param_on(*p, tol)
|
|
||||||
&& let Some(c) = cuts.get_mut(i)
|
|
||||||
{
|
|
||||||
c.push(t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Bounding boxes: only shapes whose boxes overlap can cross.
|
|
||||||
let boxes: Vec<(Vec2, Vec2)> = shapes
|
let boxes: Vec<(Vec2, Vec2)> = shapes
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(_, sh)| match *sh {
|
.map(|(_, sh)| match *sh {
|
||||||
@@ -172,9 +201,69 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
|
|||||||
(Some((a0, a1)), Some((b0, b1))) => a0.x <= b1.x && b0.x <= a1.x && a0.y <= b1.y && b0.y <= a1.y,
|
(Some((a0, a1)), Some((b0, b1))) => a0.x <= b1.x && b0.x <= a1.x && a0.y <= b1.y && b0.y <= a1.y,
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
if shapes.len() <= 20_000 {
|
let grid = Grid::new(&boxes);
|
||||||
|
let mut cuts: Vec<Vec<f64>> = vec![Vec::new(); shapes.len()];
|
||||||
|
// End points of other curves lying on each shape.
|
||||||
|
let mut end_cells: std::collections::HashMap<(i64, i64), Vec<usize>> = std::collections::HashMap::new();
|
||||||
|
for (k, p) in ends.iter().enumerate() {
|
||||||
|
end_cells.entry(grid.cell(*p)).or_default().push(k);
|
||||||
|
}
|
||||||
|
let all_ends: Vec<usize> = (0..ends.len()).collect();
|
||||||
|
for (i, (_, sh)) in shapes.iter().enumerate() {
|
||||||
|
let Some(b) = boxes.get(i) else { continue };
|
||||||
|
let cand: Vec<usize> = match grid.cells(b) {
|
||||||
|
Some(cs) => cs.iter().flat_map(|c| end_cells.get(c).map(Vec::as_slice).unwrap_or(&[])).copied().collect(),
|
||||||
|
None => all_ends.clone(),
|
||||||
|
};
|
||||||
|
for k in cand {
|
||||||
|
if let Some(p) = ends.get(k)
|
||||||
|
&& let Some(t) = sh.param_on(*p, tol)
|
||||||
|
&& let Some(cu) = cuts.get_mut(i)
|
||||||
|
{
|
||||||
|
cu.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut shape_cells: std::collections::HashMap<(i64, i64), Vec<usize>> = std::collections::HashMap::new();
|
||||||
|
let mut global: Vec<usize> = Vec::new();
|
||||||
|
for (i, b) in boxes.iter().enumerate() {
|
||||||
|
match grid.cells(b) {
|
||||||
|
Some(cs) => {
|
||||||
|
for c in cs {
|
||||||
|
shape_cells.entry(c).or_default().push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => global.push(i),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut partners: Vec<Vec<usize>> = vec![Vec::new(); shapes.len()];
|
||||||
|
for v in shape_cells.values() {
|
||||||
|
for (k, &i) in v.iter().enumerate() {
|
||||||
|
for &j in v.iter().skip(k + 1) {
|
||||||
|
let (lo, hi) = if i < j { (i, j) } else { (j, i) };
|
||||||
|
if let Some(p) = partners.get_mut(lo) {
|
||||||
|
p.push(hi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for &g in &global {
|
||||||
|
for j in 0..shapes.len() {
|
||||||
|
if j != g {
|
||||||
|
let (lo, hi) = if g < j { (g, j) } else { (j, g) };
|
||||||
|
if let Some(p) = partners.get_mut(lo) {
|
||||||
|
p.push(hi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for p in &mut partners {
|
||||||
|
p.sort_unstable();
|
||||||
|
p.dedup();
|
||||||
|
}
|
||||||
|
if shapes.len() <= 50_000 {
|
||||||
for i in 0..shapes.len() {
|
for i in 0..shapes.len() {
|
||||||
for j in (i + 1)..shapes.len() {
|
for &j in partners.get(i).map(Vec::as_slice).unwrap_or(&[]) {
|
||||||
if !overlap(i, j) {
|
if !overlap(i, j) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -211,12 +300,24 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
|
|||||||
}
|
}
|
||||||
// Pieces → edges between merged nodes.
|
// Pieces → edges between merged nodes.
|
||||||
let mut nodes: Vec<Vec2> = Vec::new();
|
let mut nodes: Vec<Vec2> = Vec::new();
|
||||||
|
// Grid buckets of `tol`-sized cells: merging looks only at neighbouring cells.
|
||||||
|
let mut grid: std::collections::HashMap<(i64, i64), Vec<usize>> = std::collections::HashMap::new();
|
||||||
|
let cell = |p: Vec2| ((p.x / tol).floor() as i64, (p.y / tol).floor() as i64);
|
||||||
let mut node = |p: Vec2| -> usize {
|
let mut node = |p: Vec2| -> usize {
|
||||||
if let Some(i) = nodes.iter().position(|q| q.dist(p) <= tol) {
|
let (cx, cy) = cell(p);
|
||||||
return i;
|
for dx in -1..=1 {
|
||||||
|
for dy in -1..=1 {
|
||||||
|
if let Some(v) = grid.get(&(cx + dx, cy + dy))
|
||||||
|
&& let Some(i) = v.iter().copied().find(|i| nodes.get(*i).is_some_and(|q| q.dist(p) <= tol))
|
||||||
|
{
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
nodes.push(p);
|
nodes.push(p);
|
||||||
nodes.len() - 1
|
let i = nodes.len() - 1;
|
||||||
|
grid.entry((cx, cy)).or_default().push(i);
|
||||||
|
i
|
||||||
};
|
};
|
||||||
let mut loops: Vec<(Loop2, Vec<String>, usize)> = Vec::new(); // (ccw loop, curve ids, component)
|
let mut loops: Vec<(Loop2, Vec<String>, usize)> = Vec::new(); // (ccw loop, curve ids, component)
|
||||||
let mut edges: Vec<Edge> = Vec::new();
|
let mut edges: Vec<Edge> = Vec::new();
|
||||||
@@ -402,8 +503,18 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nesting: parent = smallest containing loop from another component.
|
// Nesting: parent = smallest containing loop from another component. Bounding boxes rule
|
||||||
|
// out most pairs before the point-in-loop test.
|
||||||
let areas: Vec<f64> = loops.iter().map(|(l, _, _)| l.signed_area().abs()).collect();
|
let areas: Vec<f64> = loops.iter().map(|(l, _, _)| l.signed_area().abs()).collect();
|
||||||
|
let boxes: Vec<(Vec2, Vec2)> = loops
|
||||||
|
.iter()
|
||||||
|
.map(|(l, _, _)| {
|
||||||
|
l.polyline(1e-3).iter().fold((Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)), |(lo, hi), p| {
|
||||||
|
(Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y)))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let inside_box = |j: usize, p: Vec2| boxes.get(j).is_some_and(|(lo, hi)| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y);
|
||||||
let mut parent_of: Vec<Option<usize>> = vec![None; loops.len()];
|
let mut parent_of: Vec<Option<usize>> = vec![None; loops.len()];
|
||||||
for (i, (li, _, ci)) in loops.iter().enumerate() {
|
for (i, (li, _, ci)) in loops.iter().enumerate() {
|
||||||
let probe = li.segs.first().map(Seg2::mid).unwrap_or_default();
|
let probe = li.segs.first().map(Seg2::mid).unwrap_or_default();
|
||||||
@@ -411,10 +522,10 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
|
|||||||
let mut best: Option<(f64, usize)> = None;
|
let mut best: Option<(f64, usize)> = None;
|
||||||
for (j, (lj, _, cj)) in loops.iter().enumerate() {
|
for (j, (lj, _, cj)) in loops.iter().enumerate() {
|
||||||
let aj = areas.get(j).copied().unwrap_or(0.0);
|
let aj = areas.get(j).copied().unwrap_or(0.0);
|
||||||
if i == j || ci == cj || aj <= ai {
|
if i == j || ci == cj || aj <= ai || !inside_box(j, probe) || best.is_some_and(|(ba, _)| aj >= ba) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if lj.contains(probe) && best.is_none_or(|(ba, _)| aj < ba) {
|
if lj.contains(probe) {
|
||||||
best = Some((aj, j));
|
best = Some((aj, j));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -422,11 +533,19 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
|
|||||||
*p = best.map(|(_, j)| j);
|
*p = best.map(|(_, j)| j);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let mut kids_of: Vec<Vec<usize>> = vec![Vec::new(); loops.len()];
|
||||||
|
for (k, p) in parent_of.iter().enumerate() {
|
||||||
|
if let Some(p) = p
|
||||||
|
&& let Some(v) = kids_of.get_mut(*p)
|
||||||
|
{
|
||||||
|
v.push(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut out: Vec<Profile> = loops
|
let mut out: Vec<Profile> = loops
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, (l, ids, _))| {
|
.map(|(i, (l, ids, _))| {
|
||||||
let kids: Vec<usize> = (0..loops.len()).filter(|k| parent_of.get(*k).copied().flatten() == Some(i)).collect();
|
let kids: Vec<usize> = kids_of.get(i).cloned().unwrap_or_default();
|
||||||
let holes: Vec<Loop2> = kids.iter().filter_map(|k| loops.get(*k).map(|(h, _, _)| h.ccw().reversed())).collect();
|
let holes: Vec<Loop2> = kids.iter().filter_map(|k| loops.get(*k).map(|(h, _, _)| h.ccw().reversed())).collect();
|
||||||
let hole_curves: Vec<Vec<String>> = kids.iter().filter_map(|k| loops.get(*k).map(|(_, ids, _)| ids.clone())).collect();
|
let hole_curves: Vec<Vec<String>> = kids.iter().filter_map(|k| loops.get(*k).map(|(_, ids, _)| ids.clone())).collect();
|
||||||
let region = Region2 { outer: l.ccw(), holes };
|
let region = Region2 { outer: l.ccw(), holes };
|
||||||
|
|||||||
Reference in new issue
Block a user