From 95336e72dd4dd576097a90b519b70e359d66206c Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Fri, 3 Jul 2026 22:50:51 -0500 Subject: [PATCH 1/7] =?UTF-8?q?feat(kernel):=20hole-aware=20sketch=20profi?= =?UTF-8?q?les=20=E2=80=94=20multi-loop=20extrude=20without=20booleans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sketch2D gains interior hole loops (IR: optional holes field, serde/ts back-compat via default); vcad-kernel-sketch extrude_with_holes builds the multi-loop BRep directly (outer + hole laterals, holed caps) so a polygon-with-holes extrudes in one operation with no boolean pipeline. vcad-kernel-booleans grows a no_crossing fast path: when two solids' boundaries provably do not cross (AABB + no face-pair intersection), union/difference/intersection resolve by containment ray tests instead of the full SSI pipeline. Consumers wired per the IR checklist: eval (extrude/revolve reject holes where unsupported with a typed error), vcode syntax + docs, to_loon, app materializer/document_api/migrate, kernel-wasm, engine evaluate.ts, ir:gen regenerated. vcad-gdsii bridge emits holed islands as single multi-loop extrudes (the Difference path is gone); vcad-process prisms use native holes too. New holed_islands example + tests across sketch (volume, watertightness), booleans (containment), gdsii (picture-frame ring now = 1 extrude, 0 Differences), eval. Unblocks: fast rendering of merged GDS die layers (power grids with hundreds of holes previously built giant 3D Difference trees). Co-Authored-By: Claude Fable 5 --- Cargo.lock | 2 + crates/vcad-app/src/document_api.rs | 1 + crates/vcad-app/src/materializer.rs | 4 + crates/vcad-app/src/migrate.rs | 1 + crates/vcad-cli/src/tui/sketch_mode.rs | 1 + crates/vcad-eval/src/convert.rs | 8 + crates/vcad-eval/src/evaluate.rs | 70 +- crates/vcad-eval/src/lib.rs | 145 ++++ crates/vcad-gdsii/examples/flat_import.rs | 1 + crates/vcad-gdsii/examples/holed_islands.rs | 203 +++++ crates/vcad-gdsii/src/bridge.rs | 99 +-- crates/vcad-ir/src/lib.rs | 9 + crates/vcad-ir/src/to_loon.rs | 9 + crates/vcad-ir/src/vcode.rs | 102 ++- crates/vcad-kernel-booleans/src/bbox.rs | 71 ++ crates/vcad-kernel-booleans/src/lib.rs | 7 +- .../vcad-kernel-booleans/src/no_crossing.rs | 748 ++++++++++++++++++ crates/vcad-kernel-booleans/src/pipeline.rs | 61 +- crates/vcad-kernel-sketch/Cargo.toml | 1 + crates/vcad-kernel-sketch/src/extrude.rs | 451 ++++++++++- crates/vcad-kernel-sketch/src/lib.rs | 6 +- crates/vcad-kernel-tessellate/Cargo.toml | 1 + crates/vcad-kernel-tessellate/src/lib.rs | 95 +++ crates/vcad-kernel-wasm/src/lib.rs | 173 ++-- crates/vcad-kernel/src/lib.rs | 18 + crates/vcad-loon/src/convert.rs | 1 + crates/vcad-process/src/bridge.rs | 91 ++- docs/features/vcode.md | 1 + packages/engine/src/evaluate.ts | 5 + packages/ir/src/generated.ts | 10 +- 30 files changed, 2186 insertions(+), 209 deletions(-) create mode 100644 crates/vcad-gdsii/examples/holed_islands.rs create mode 100644 crates/vcad-kernel-booleans/src/no_crossing.rs diff --git a/Cargo.lock b/Cargo.lock index 99c1c2f49..7b9a365ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7260,6 +7260,7 @@ name = "vcad-kernel-sketch" version = "0.9.4" dependencies = [ "thiserror 2.0.18", + "vcad-kernel-booleans", "vcad-kernel-geom", "vcad-kernel-math", "vcad-kernel-primitives", @@ -7310,6 +7311,7 @@ dependencies = [ name = "vcad-kernel-tessellate" version = "0.9.4" dependencies = [ + "earcutr", "tang", "vcad-kernel-geom", "vcad-kernel-math", diff --git a/crates/vcad-app/src/document_api.rs b/crates/vcad-app/src/document_api.rs index 17f48a822..8f947af34 100644 --- a/crates/vcad-app/src/document_api.rs +++ b/crates/vcad-app/src/document_api.rs @@ -591,6 +591,7 @@ mod tests { // both near-identical shells render and z-fight ("the pork-chop // sawtooth"). let sketch_json = serde_json::to_string(&vcad_ir::CsgOp::Sketch2D { + holes: None, origin: vcad_ir::Vec3::new(0.0, 0.0, 0.0), x_dir: vcad_ir::Vec3::new(1.0, 0.0, 0.0), y_dir: vcad_ir::Vec3::new(0.0, 1.0, 0.0), diff --git a/crates/vcad-app/src/materializer.rs b/crates/vcad-app/src/materializer.rs index 8617d42f3..00f7ab499 100644 --- a/crates/vcad-app/src/materializer.rs +++ b/crates/vcad-app/src/materializer.rs @@ -1390,6 +1390,7 @@ fn default_sketch() -> CsgOp { x_dir: Vec3::new(1.0, 0.0, 0.0), y_dir: Vec3::new(0.0, 1.0, 0.0), segments: Vec::new(), + holes: None, } } @@ -1722,6 +1723,7 @@ mod tests { // Sketch JSON the AI would pass (a closed kidney-ish profile). let sketch_json = serde_json::to_string(&vcad_ir::CsgOp::Sketch2D { + holes: None, origin: Vec3::new(0.0, 0.0, 0.0), x_dir: Vec3::new(1.0, 0.0, 0.0), y_dir: Vec3::new(0.0, 1.0, 0.0), @@ -1885,6 +1887,7 @@ mod tests { // sketch validator ever sees the degenerate profile. let mut crdt = CrdtDocument::new(ReplicaId(1)); let empty_sketch_json = serde_json::to_string(&vcad_ir::CsgOp::Sketch2D { + holes: None, origin: Vec3::new(0.0, 0.0, 0.0), x_dir: Vec3::new(1.0, 0.0, 0.0), y_dir: Vec3::new(0.0, 1.0, 0.0), @@ -1938,6 +1941,7 @@ mod tests { fn test_materialize_revolve() { let mut crdt = CrdtDocument::new(ReplicaId(1)); let sketch_json = serde_json::to_string(&vcad_ir::CsgOp::Sketch2D { + holes: None, origin: Vec3::new(0.0, 0.0, 0.0), x_dir: Vec3::new(1.0, 0.0, 0.0), y_dir: Vec3::new(0.0, 1.0, 0.0), diff --git a/crates/vcad-app/src/migrate.rs b/crates/vcad-app/src/migrate.rs index d015f6b1d..5de040e9c 100644 --- a/crates/vcad-app/src/migrate.rs +++ b/crates/vcad-app/src/migrate.rs @@ -852,6 +852,7 @@ mod tests { id: sketch_id, name: None, op: CsgOp::Sketch2D { + holes: None, origin: Vec3::new(0.0, 0.0, 0.0), x_dir: Vec3::new(1.0, 0.0, 0.0), y_dir: Vec3::new(0.0, 1.0, 0.0), diff --git a/crates/vcad-cli/src/tui/sketch_mode.rs b/crates/vcad-cli/src/tui/sketch_mode.rs index e00c0905d..dfead72f4 100644 --- a/crates/vcad-cli/src/tui/sketch_mode.rs +++ b/crates/vcad-cli/src/tui/sketch_mode.rs @@ -82,6 +82,7 @@ impl SketchModeState { x_dir: Vec3::new(x[0], x[1], x[2]), y_dir: Vec3::new(y[0], y[1], y[2]), segments, + holes: None, }) } } diff --git a/crates/vcad-eval/src/convert.rs b/crates/vcad-eval/src/convert.rs index 4a97da736..7451a9c96 100644 --- a/crates/vcad-eval/src/convert.rs +++ b/crates/vcad-eval/src/convert.rs @@ -45,3 +45,11 @@ pub fn ir_sketch_to_profile( let segments: Vec = segments.iter().map(convert_segment).collect(); SketchProfile::new(to_point3(origin), to_vec3(x_dir), to_vec3(y_dir), segments) } + +/// Convert IR hole loops into kernel segment loops. +pub fn ir_holes_to_segments(holes: &[Vec]) -> Vec> { + holes + .iter() + .map(|hole| hole.iter().map(convert_segment).collect()) + .collect() +} diff --git a/crates/vcad-eval/src/evaluate.rs b/crates/vcad-eval/src/evaluate.rs index 09ac657b6..fd0364929 100644 --- a/crates/vcad-eval/src/evaluate.rs +++ b/crates/vcad-eval/src/evaluate.rs @@ -612,14 +612,24 @@ fn evaluate_op_timed( } // Handle Sketch2D - let (s_origin, s_x_dir, s_y_dir, segments) = extract_sketch(&sketch_node.op)?; + let (s_origin, s_x_dir, s_y_dir, segments, holes) = extract_sketch(&sketch_node.op)?; let profile = ir_sketch_to_profile(s_origin, s_x_dir, s_y_dir, segments) .map_err(EvalError::Sketch)?; let has_twist = twist_angle.is_some_and(|t| t.abs() > 1e-12); let has_scale = scale_end.is_some_and(|s| (s - 1.0).abs() > 1e-12); - let solid = if has_twist || has_scale { + let solid = if !holes.is_empty() { + if has_twist || has_scale { + return Err(EvalError::Sketch( + vcad_kernel_sketch::SketchError::HolesUnsupported( + "extrude with twist or taper", + ), + )); + } + let hole_loops = crate::convert::ir_holes_to_segments(holes); + Solid::extrude_with_holes(profile, &hole_loops, dir).map_err(EvalError::Sketch)? + } else if has_twist || has_scale { Solid::extrude_with_options( profile, dir, @@ -642,7 +652,8 @@ fn evaluate_op_timed( } => { let sketch_node = nodes.get(sketch).ok_or(EvalError::MissingNode(*sketch))?; - let (s_origin, s_x_dir, s_y_dir, segments) = extract_sketch(&sketch_node.op)?; + let (s_origin, s_x_dir, s_y_dir, segments, holes) = extract_sketch(&sketch_node.op)?; + reject_holes(holes, "revolve")?; let profile = ir_sketch_to_profile(s_origin, s_x_dir, s_y_dir, segments) .map_err(EvalError::Sketch)?; @@ -669,7 +680,8 @@ fn evaluate_op_timed( } => { let sketch_node = nodes.get(sketch).ok_or(EvalError::MissingNode(*sketch))?; - let (s_origin, s_x_dir, s_y_dir, segments) = extract_sketch(&sketch_node.op)?; + let (s_origin, s_x_dir, s_y_dir, segments, holes) = extract_sketch(&sketch_node.op)?; + reject_holes(holes, "sweep")?; let profile = ir_sketch_to_profile(s_origin, s_x_dir, s_y_dir, segments) .map_err(EvalError::Sketch)?; @@ -707,7 +719,9 @@ fn evaluate_op_timed( let sketch_node = nodes .get(sketch_id) .ok_or(EvalError::MissingNode(*sketch_id))?; - let (s_origin, s_x_dir, s_y_dir, segments) = extract_sketch(&sketch_node.op)?; + let (s_origin, s_x_dir, s_y_dir, segments, holes) = + extract_sketch(&sketch_node.op)?; + reject_holes(holes, "loft")?; let profile = ir_sketch_to_profile(s_origin, s_x_dir, s_y_dir, segments) .map_err(EvalError::Sketch)?; profiles.push(profile); @@ -1067,25 +1081,47 @@ fn is_transform_op(op: &CsgOp) -> bool { ) } +/// Error out when a sketch with interior holes reaches an operation that +/// doesn't support them (only extrude does today). +fn reject_holes( + holes: &[Vec], + op_name: &'static str, +) -> Result<(), EvalError> { + if holes.is_empty() { + Ok(()) + } else { + Err(EvalError::Sketch( + vcad_kernel_sketch::SketchError::HolesUnsupported(op_name), + )) + } +} + +/// Borrowed fields of a `Sketch2D`: origin, x-dir, y-dir, outer segments, +/// and interior hole loops (empty when absent). +type SketchFields<'a> = ( + &'a vcad_ir::Vec3, + &'a vcad_ir::Vec3, + &'a vcad_ir::Vec3, + &'a [vcad_ir::SketchSegment2D], + &'a [Vec], +); + /// Extract sketch fields from a CsgOp, returning error if not a Sketch2D. -fn extract_sketch( - op: &CsgOp, -) -> Result< - ( - &vcad_ir::Vec3, - &vcad_ir::Vec3, - &vcad_ir::Vec3, - &[vcad_ir::SketchSegment2D], - ), - EvalError, -> { +fn extract_sketch(op: &CsgOp) -> Result, EvalError> { match op { CsgOp::Sketch2D { origin, x_dir, y_dir, segments, - } => Ok((origin, x_dir, y_dir, segments)), + holes, + } => Ok(( + origin, + x_dir, + y_dir, + segments, + holes.as_deref().unwrap_or(&[]), + )), _ => Err(EvalError::InvalidSketchRef), } } diff --git a/crates/vcad-eval/src/lib.rs b/crates/vcad-eval/src/lib.rs index 710676e59..d3a76906c 100644 --- a/crates/vcad-eval/src/lib.rs +++ b/crates/vcad-eval/src/lib.rs @@ -366,6 +366,7 @@ mod tests { origin: Vec3::new(0.0, 0.0, 0.0), x_dir: Vec3::new(1.0, 0.0, 0.0), y_dir: Vec3::new(0.0, 1.0, 0.0), + holes: None, segments: vec![ SketchSegment2D::Line { start: Vec2::new(0.0, 0.0), @@ -411,6 +412,149 @@ mod tests { assert!(!scene.parts[0].mesh.positions.is_empty()); } + fn mesh_volume(mesh: &EvaluatedMesh) -> f64 { + let v = &mesh.positions; + let mut vol = 0.0; + for tri in mesh.indices.chunks(3) { + let i = [ + tri[0] as usize * 3, + tri[1] as usize * 3, + tri[2] as usize * 3, + ]; + let p: Vec<[f64; 3]> = i + .iter() + .map(|&k| [v[k] as f64, v[k + 1] as f64, v[k + 2] as f64]) + .collect(); + vol += p[0][0] * (p[1][1] * p[2][2] - p[2][1] * p[1][2]) + - p[1][0] * (p[0][1] * p[2][2] - p[2][1] * p[0][2]) + + p[2][0] * (p[0][1] * p[1][2] - p[1][1] * p[0][2]); + } + (vol / 6.0).abs() + } + + /// Closed CCW rectangle loop as IR line segments. + fn rect_segments(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec { + let p = [ + Vec2::new(x0, y0), + Vec2::new(x1, y0), + Vec2::new(x1, y1), + Vec2::new(x0, y1), + ]; + (0..4) + .map(|i| SketchSegment2D::Line { + start: p[i], + end: p[(i + 1) % 4], + }) + .collect() + } + + fn holed_sketch_doc(holes: Option>>) -> Document { + let mut doc = Document::new(); + doc.nodes.insert( + 1, + Node { + id: 1, + name: None, + op: CsgOp::Sketch2D { + origin: Vec3::new(0.0, 0.0, 0.0), + x_dir: Vec3::new(1.0, 0.0, 0.0), + y_dir: Vec3::new(0.0, 1.0, 0.0), + segments: rect_segments(0.0, 0.0, 20.0, 10.0), + holes, + }, + }, + ); + doc.nodes.insert( + 2, + Node { + id: 2, + name: None, + op: CsgOp::Extrude { + sketch: 1, + direction: Vec3::new(0.0, 0.0, 5.0), + twist_angle: None, + scale_end: None, + }, + }, + ); + doc.roots.push(SceneEntry { + root: 2, + material: "default".to_string(), + visible: None, + }); + doc + } + + #[test] + fn evaluate_sketch_extrude_with_holes() { + let holes = vec![ + rect_segments(2.0, 2.0, 6.0, 8.0), + rect_segments(12.0, 3.0, 17.0, 7.0), + ]; + let scene = + evaluate_document(&holed_sketch_doc(Some(holes)), &EvalOptions::default()).unwrap(); + assert_eq!(scene.parts.len(), 1); + assert!(scene.failures.is_empty(), "failures: {:?}", scene.failures); + + // Volume = (20·10 − 4·6 − 5·4) · 5 + let expected = (200.0 - 24.0 - 20.0) * 5.0; + let vol = mesh_volume(&scene.parts[0].mesh); + assert!( + (vol - expected).abs() < 1e-3 * expected, + "expected {expected}, got {vol}" + ); + } + + #[test] + fn sketch2d_json_without_holes_still_parses() { + // Serde back-compat: documents written before the `holes` field + // existed must load unchanged. + let json = r#"{ + "type": "Sketch2D", + "origin": {"x": 0.0, "y": 0.0, "z": 0.0}, + "x_dir": {"x": 1.0, "y": 0.0, "z": 0.0}, + "y_dir": {"x": 0.0, "y": 1.0, "z": 0.0}, + "segments": [ + {"type": "Line", "start": {"x": 0.0, "y": 0.0}, "end": {"x": 1.0, "y": 0.0}}, + {"type": "Line", "start": {"x": 1.0, "y": 0.0}, "end": {"x": 1.0, "y": 1.0}}, + {"type": "Line", "start": {"x": 1.0, "y": 1.0}, "end": {"x": 0.0, "y": 0.0}} + ] + }"#; + let op: CsgOp = serde_json::from_str(json).unwrap(); + match &op { + CsgOp::Sketch2D { + holes, segments, .. + } => { + assert!(holes.is_none()); + assert_eq!(segments.len(), 3); + } + other => panic!("expected Sketch2D, got {other:?}"), + } + // And a hole-free sketch round-trips without emitting the field. + let out = serde_json::to_string(&op).unwrap(); + assert!( + !out.contains("holes"), + "hole-free sketch serialized holes: {out}" + ); + } + + #[test] + fn revolve_rejects_holed_sketch() { + let mut doc = holed_sketch_doc(Some(vec![rect_segments(2.0, 2.0, 6.0, 8.0)])); + doc.nodes.get_mut(&2).unwrap().op = CsgOp::Revolve { + sketch: 1, + axis_origin: Vec3::new(-1.0, 0.0, 0.0), + axis_dir: Vec3::new(0.0, 1.0, 0.0), + angle_deg: 360.0, + }; + let scene = evaluate_document(&doc, &EvalOptions::default()).unwrap(); + // The failure is recorded per-root rather than aborting evaluation. + assert!( + !scene.failures.is_empty(), + "revolve of a holed sketch should fail" + ); + } + #[test] fn evaluate_pcb_board_is_centered_solid() { use vcad_ir::ecad::{BoardOutline, DesignRules, LayerStackup, NetClassRules, Pcb}; @@ -509,6 +653,7 @@ mod tests { origin: Vec3::new(0.0, 0.0, 0.0), x_dir: Vec3::new(1.0, 0.0, 0.0), y_dir: Vec3::new(0.0, 1.0, 0.0), + holes: None, segments: vec![ SketchSegment2D::Line { start: Vec2::new(5.0, 0.0), diff --git a/crates/vcad-gdsii/examples/flat_import.rs b/crates/vcad-gdsii/examples/flat_import.rs index 726146fbc..a4337479e 100644 --- a/crates/vcad-gdsii/examples/flat_import.rs +++ b/crates/vcad-gdsii/examples/flat_import.rs @@ -95,6 +95,7 @@ fn main() { x_dir: Vec3::new(1.0, 0.0, 0.0), y_dir: Vec3::new(0.0, 1.0, 0.0), segments, + holes: None, }, }, ); diff --git a/crates/vcad-gdsii/examples/holed_islands.rs b/crates/vcad-gdsii/examples/holed_islands.rs new file mode 100644 index 000000000..4882bed3a --- /dev/null +++ b/crates/vcad-gdsii/examples/holed_islands.rs @@ -0,0 +1,203 @@ +//! Throwaway benchmark generator for extrude-with-holes. +//! +//! Mimics what the 2D-union GDSII bridge emits for a power-grid layer: +//! per-island `Sketch2D` + `Extrude`, where each island has a large outer +//! profile and hundreds of interior holes. Writes two equivalent documents: +//! +//! - `_difference.vcad` — holes as `Difference { outer, Union(holes) }` +//! (the representation the bridge uses while the IR lacks native holes) +//! - `_holes.vcad` — holes carried natively on `Sketch2D::holes` +//! +//! ```sh +//! cargo run --release -p vcad-gdsii --example holed_islands -- /tmp/holed +//! ``` +//! +//! Render both with vcad-render to compare evaluation cost. + +use vcad_ir::{ + CsgOp, Document, MaterialDef, Node, NodeId, SceneEntry, SketchSegment2D, Vec2, Vec3, +}; + +/// Islands per document. +const ISLANDS: usize = 4; +/// Hole grid per island (HOLES_X × HOLES_Y holes). +const HOLES_X: usize = 20; +const HOLES_Y: usize = 12; +/// Sawtooth teeth on the outer profile's top edge — drives the outer +/// vertex count to ~10k like a real li1/met1 island boundary. +const TEETH: usize = 2500; + +fn line(a: (f64, f64), b: (f64, f64)) -> SketchSegment2D { + SketchSegment2D::Line { + start: Vec2::new(a.0, a.1), + end: Vec2::new(b.0, b.1), + } +} + +/// Closed CCW loop through `pts`. +fn closed_loop(pts: &[(f64, f64)]) -> Vec { + (0..pts.len()) + .map(|i| line(pts[i], pts[(i + 1) % pts.len()])) + .collect() +} + +/// Island outer profile: a 100×50 plate whose top edge is a fine sawtooth +/// (2 vertices per tooth → ~2·TEETH+4 outer vertices). +fn island_outline(ox: f64, oy: f64) -> Vec<(f64, f64)> { + let w = 100.0; + let h = 50.0; + let mut pts = vec![(ox, oy), (ox + w, oy), (ox + w, oy + h)]; + let tooth_w = w / TEETH as f64; + for i in 0..TEETH { + let x1 = ox + w - (i as f64 + 0.5) * tooth_w; + let x2 = ox + w - (i as f64 + 1.0) * tooth_w; + pts.push((x1, oy + h - 0.5)); + pts.push((x2, oy + h)); + } + pts +} + +/// Hole loops for one island: a HOLES_X × HOLES_Y grid of small squares. +fn island_holes(ox: f64, oy: f64) -> Vec> { + let mut holes = Vec::new(); + for i in 0..HOLES_X { + for j in 0..HOLES_Y { + let cx = ox + 5.0 + i as f64 * 4.5; + let cy = oy + 4.0 + j as f64 * 3.4; + holes.push(vec![ + (cx, cy), + (cx + 2.0, cy), + (cx + 2.0, cy + 1.5), + (cx, cy + 1.5), + ]); + } + } + holes +} + +struct Builder { + doc: Document, + next: NodeId, +} + +impl Builder { + fn alloc(&mut self, op: CsgOp) -> NodeId { + let id = self.next; + self.next += 1; + self.doc.nodes.insert(id, Node { id, name: None, op }); + id + } + + fn sketch(&mut self, outline: &[(f64, f64)], holes: Option>>) -> NodeId { + self.alloc(CsgOp::Sketch2D { + origin: Vec3::new(0.0, 0.0, 0.0), + x_dir: Vec3::new(1.0, 0.0, 0.0), + y_dir: Vec3::new(0.0, 1.0, 0.0), + segments: closed_loop(outline), + holes: holes.map(|hs| hs.iter().map(|h| closed_loop(h)).collect()), + }) + } + + fn extrude(&mut self, sketch: NodeId) -> NodeId { + self.alloc(CsgOp::Extrude { + sketch, + direction: Vec3::new(0.0, 0.0, 2.0), + twist_angle: None, + scale_end: None, + }) + } + + /// Balanced union tree over `ids` (mirrors the bridge's emission). + fn union_tree(&mut self, ids: &[NodeId]) -> NodeId { + match ids.len() { + 1 => ids[0], + n => { + let (l, r) = ids.split_at(n / 2); + let (l, r) = (self.union_tree(l), self.union_tree(r)); + self.alloc(CsgOp::Union { left: l, right: r }) + } + } + } + + fn finish(mut self, root: NodeId) -> Document { + self.doc.materials.insert( + "metal".into(), + MaterialDef { + name: "metal".into(), + color: [0.8, 0.7, 0.25], + metallic: 0.3, + roughness: 0.6, + ..Default::default() + }, + ); + self.doc.roots.push(SceneEntry { + root, + material: "metal".into(), + visible: None, + }); + self.doc + } +} + +fn new_builder() -> Builder { + Builder { + doc: Document::new(), + next: 1, + } +} + +/// Difference-based document: per island, Extrude(outer) − Union(hole extrudes). +fn difference_doc() -> Document { + let mut b = new_builder(); + let mut islands = Vec::new(); + for k in 0..ISLANDS { + let (ox, oy) = ((k % 2) as f64 * 110.0, (k / 2) as f64 * 60.0); + let outer_sketch = b.sketch(&island_outline(ox, oy), None); + let outer = b.extrude(outer_sketch); + let hole_ids: Vec = island_holes(ox, oy) + .iter() + .map(|h| { + let s = b.sketch(h, None); + b.extrude(s) + }) + .collect(); + let holes_union = b.union_tree(&hole_ids); + islands.push(b.alloc(CsgOp::Difference { + left: outer, + right: holes_union, + })); + } + let root = b.union_tree(&islands.clone()); + b.finish(root) +} + +/// Native-holes document: per island, Extrude(Sketch2D with holes). +fn holes_doc() -> Document { + let mut b = new_builder(); + let mut islands = Vec::new(); + for k in 0..ISLANDS { + let (ox, oy) = ((k % 2) as f64 * 110.0, (k / 2) as f64 * 60.0); + let sketch = b.sketch(&island_outline(ox, oy), Some(island_holes(ox, oy))); + islands.push(b.extrude(sketch)); + } + let root = b.union_tree(&islands.clone()); + b.finish(root) +} + +fn main() { + let out = std::env::args().nth(1).unwrap_or_else(|| "holed".into()); + + let diff = difference_doc(); + let holes = holes_doc(); + let n_holes = HOLES_X * HOLES_Y; + eprintln!( + "{} islands, {} holes each, ~{} outer vertices each", + ISLANDS, + n_holes, + 2 * TEETH + 4 + ); + + std::fs::write(format!("{out}_difference.vcad"), diff.to_json().unwrap()).unwrap(); + std::fs::write(format!("{out}_holes.vcad"), holes.to_json().unwrap()).unwrap(); + eprintln!("wrote {out}_difference.vcad and {out}_holes.vcad"); +} diff --git a/crates/vcad-gdsii/src/bridge.rs b/crates/vcad-gdsii/src/bridge.rs index ff6eb0bc4..135173802 100644 --- a/crates/vcad-gdsii/src/bridge.rs +++ b/crates/vcad-gdsii/src/bridge.rs @@ -85,42 +85,22 @@ pub fn to_vcad_document( // layers arrive as tens of thousands of abutting/overlapping rects; // extruding them individually forces the 3D boolean pipeline to sew // touching prisms one by one. After a 2D union each connected island - // is a single profile, islands are pairwise disjoint by construction - // (the cheap non-overlapping union path), and only interior holes - // ever reach a real 3D boolean (as a Difference per island). + // is a single multi-loop profile (outer ring + interior hole rings), + // islands are pairwise disjoint by construction (the cheap + // non-overlapping union path), and holes are carved at the profile + // level by the hole-aware extrude — no 3D boolean is ever needed. let merged = union_polygons(&layer_polys.polygons); let mut islands: Vec = Vec::with_capacity(merged.0.len()); for island in &merged.0 { - let outer = ring_solid( + islands.push(island_solid( &mut doc, &mut next_id, - island.exterior(), + island, db_to_mm, z_mm, thickness_mm, - ); - let node = if island.interiors().is_empty() { - outer - } else { - let holes: Vec = island - .interiors() - .iter() - .map(|ring| { - ring_solid(&mut doc, &mut next_id, ring, db_to_mm, z_mm, thickness_mm) - }) - .collect(); - let holes_root = balanced_union(&mut doc, &mut next_id, holes); - alloc( - &mut doc, - &mut next_id, - CsgOp::Difference { - left: outer, - right: holes_root, - }, - ) - }; - islands.push(node); + )); } if islands.is_empty() { continue; // every polygon degenerated away in the 2D union @@ -188,24 +168,23 @@ fn union_polygons(polygons: &[Vec<[f64; 2]>]) -> MultiPolygon { level.pop().unwrap_or_else(|| MultiPolygon::new(Vec::new())) } -/// Extrude one ring (already closed; geo duplicates the first point last) -/// into a sketch + extrude pair, returning the extrude node. -fn ring_solid( +/// Emit one island (outer ring plus interior hole rings) as a single +/// hole-aware `Sketch2D` + `Extrude` pair, returning the extrude node. +/// Holes ride on the sketch as native inner loops carved at the profile +/// level, so no 3D `Difference` (or any boolean) is emitted for them. +fn island_solid( doc: &mut Document, next_id: &mut NodeId, - ring: &LineString, + island: &Polygon, db_to_mm: f64, z_mm: f64, thickness_mm: f64, ) -> NodeId { - let pts = &ring.0[..ring.0.len().saturating_sub(1)]; - let segments: Vec = pts + let holes: Vec> = island + .interiors() .iter() - .zip(pts.iter().cycle().skip(1)) - .map(|(a, b)| SketchSegment2D::Line { - start: Vec2::new(a.x * db_to_mm, a.y * db_to_mm), - end: Vec2::new(b.x * db_to_mm, b.y * db_to_mm), - }) + .map(|ring| ring_segments(ring, db_to_mm)) + .filter(|segs| segs.len() >= 3) // drop degenerate slivers .collect(); let sketch = alloc( doc, @@ -214,7 +193,8 @@ fn ring_solid( origin: Vec3::new(0.0, 0.0, z_mm), x_dir: Vec3::new(1.0, 0.0, 0.0), y_dir: Vec3::new(0.0, 1.0, 0.0), - segments, + segments: ring_segments(island.exterior(), db_to_mm), + holes: if holes.is_empty() { None } else { Some(holes) }, }, ); alloc( @@ -229,6 +209,19 @@ fn ring_solid( ) } +/// Segment list of one ring (already closed; geo duplicates the first point +/// last), scaled from DB units to document millimeters. +fn ring_segments(ring: &LineString, db_to_mm: f64) -> Vec { + let pts = &ring.0[..ring.0.len().saturating_sub(1)]; + pts.iter() + .zip(pts.iter().cycle().skip(1)) + .map(|(a, b)| SketchSegment2D::Line { + start: Vec2::new(a.x * db_to_mm, a.y * db_to_mm), + end: Vec2::new(b.x * db_to_mm, b.y * db_to_mm), + }) + .collect() +} + /// Union `nodes` into a balanced tree (depth ~log₂ n), not a left chain — /// deep chains overflow the stack of recursive document consumers. /// `nodes` must be non-empty. @@ -475,9 +468,10 @@ mod tests { } #[test] - fn abutting_ring_produces_hole_via_difference() { + fn abutting_ring_produces_holed_sketch() { // Four rects forming a closed picture frame: the 2D union yields one - // island with one interior ring -> outer extrude minus hole extrude. + // island with one interior ring -> a single hole-aware sketch + + // extrude, no boolean ops at all. let mut cell = Cell::new("top"); let rects: [[(i32, i32); 5]; 4] = [ [(0, 0), (3000, 0), (3000, 1000), (0, 1000), (0, 0)], // bottom @@ -498,14 +492,27 @@ mod tests { let doc = to_vcad_document(&lib, "top", &[(1, 0.0, 0.2, "l1")], DEFAULT_VIEW_SCALE).unwrap(); let count = |pred: fn(&CsgOp) -> bool| doc.nodes.values().filter(|n| pred(&n.op)).count(); - // outer + hole - assert_eq!(count(|op| matches!(op, CsgOp::Extrude { .. })), 2); - assert_eq!(count(|op| matches!(op, CsgOp::Difference { .. })), 1); + // One multi-loop sketch, one extrude, zero booleans. + assert_eq!(count(|op| matches!(op, CsgOp::Sketch2D { .. })), 1); + assert_eq!(count(|op| matches!(op, CsgOp::Extrude { .. })), 1); + assert_eq!(count(|op| matches!(op, CsgOp::Difference { .. })), 0); assert_eq!(count(|op| matches!(op, CsgOp::Union { .. })), 0); - // The scene root is the Difference (single island). + // The scene root is the Extrude (single island)... assert!(matches!( doc.nodes[&doc.roots[0].root].op, - CsgOp::Difference { .. } + CsgOp::Extrude { .. } )); + // ...whose sketch carries the frame opening as one interior loop. + let holes = doc + .nodes + .values() + .find_map(|n| match &n.op { + CsgOp::Sketch2D { holes, .. } => Some(holes), + _ => None, + }) + .unwrap(); + let holes = holes.as_ref().expect("island sketch should carry holes"); + assert_eq!(holes.len(), 1); + assert_eq!(holes[0].len(), 4); } } diff --git a/crates/vcad-ir/src/lib.rs b/crates/vcad-ir/src/lib.rs index 82994ae44..21466d772 100644 --- a/crates/vcad-ir/src/lib.rs +++ b/crates/vcad-ir/src/lib.rs @@ -752,6 +752,14 @@ pub enum CsgOp { /// The segments forming the closed profile. #[tool(expand)] segments: Vec, + /// Optional interior hole loops. Each entry is a closed loop of + /// segments in the same sketch coordinate system, lying strictly + /// inside the outer profile and disjoint from the other holes. + /// Extrude turns each loop into an interior wall directly — no + /// boolean Difference pass. Loop winding may be CW or CCW. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + holes: Option>>, }, #[tool( category = "sketch_op", @@ -1872,6 +1880,7 @@ mod tests { origin: Vec3::new(0.0, 0.0, 0.0), x_dir: Vec3::new(1.0, 0.0, 0.0), y_dir: Vec3::new(0.0, 1.0, 0.0), + holes: None, segments: vec![ SketchSegment2D::Line { start: Vec2::new(0.0, 0.0), diff --git a/crates/vcad-ir/src/to_loon.rs b/crates/vcad-ir/src/to_loon.rs index e3b4e527e..b3593e60e 100644 --- a/crates/vcad-ir/src/to_loon.rs +++ b/crates/vcad-ir/src/to_loon.rs @@ -356,7 +356,16 @@ fn op_to_loon(op: &CsgOp, doc: &Document) -> OpResult { x_dir, y_dir, segments, + holes, } => { + // The loon CAD dialect has no hole-loop syntax yet; holed + // sketches emit a comment placeholder like other unsupported ops. + if holes.as_ref().is_some_and(|h| !h.is_empty()) { + return OpResult::Unsupported( + "Sketch2D (with holes)".to_string(), + "[cube 1.0 1.0 1.0] ; TODO: Sketch2D with interior holes not yet supported in loon".to_string(), + ); + } let mut buf = String::new(); let _ = writeln!(buf, "[sketch"); let _ = writeln!( diff --git a/crates/vcad-ir/src/vcode.rs b/crates/vcad-ir/src/vcode.rs index 42f0de1a2..9b4b17dcc 100644 --- a/crates/vcad-ir/src/vcode.rs +++ b/crates/vcad-ir/src/vcode.rs @@ -2066,6 +2066,9 @@ where ); let mut segments = Vec::new(); + // Hole loops introduced by "H" lines; segments after an "H" + // belong to the most recent hole loop. + let mut holes: Vec> = Vec::new(); // Parse sketch segments until END loop { @@ -2085,7 +2088,11 @@ where continue; // Skip empty lines in sketch } - match seg_parts[0] { + let seg = match seg_parts[0] { + "H" => { + holes.push(Vec::new()); + continue; + } "L" => { if seg_parts.len() != 5 { return Err(VCodeParseError { @@ -2093,7 +2100,7 @@ where message: format!("L requires 4 args, got {}", seg_parts.len() - 1), }); } - segments.push(SketchSegment2D::Line { + SketchSegment2D::Line { start: Vec2::new( parse_f64(seg_parts[1], *current_line)?, parse_f64(seg_parts[2], *current_line)?, @@ -2102,7 +2109,7 @@ where parse_f64(seg_parts[3], *current_line)?, parse_f64(seg_parts[4], *current_line)?, ), - }); + } } "A" => { if seg_parts.len() != 8 { @@ -2111,7 +2118,7 @@ where message: format!("A requires 7 args, got {}", seg_parts.len() - 1), }); } - segments.push(SketchSegment2D::Arc { + SketchSegment2D::Arc { start: Vec2::new( parse_f64(seg_parts[1], *current_line)?, parse_f64(seg_parts[2], *current_line)?, @@ -2125,7 +2132,7 @@ where parse_f64(seg_parts[6], *current_line)?, ), ccw: parse_u32(seg_parts[7], *current_line)? != 0, - }); + } } _ => { return Err(VCodeParseError { @@ -2133,6 +2140,10 @@ where message: format!("unknown sketch segment opcode: {}", seg_parts[0]), }); } + }; + match holes.last_mut() { + Some(hole) => hole.push(seg), + None => segments.push(seg), } } @@ -2141,6 +2152,7 @@ where x_dir, y_dir, segments, + holes: if holes.is_empty() { None } else { Some(holes) }, }) } @@ -2478,6 +2490,7 @@ fn format_op( x_dir, y_dir, segments, + holes, } => { let mut lines = vec![format!( "SK {} {} {} {} {} {} {} {} {}{}", @@ -2493,28 +2506,37 @@ fn format_op( name_suffix )]; + let push_seg = |lines: &mut Vec, seg: &SketchSegment2D| match seg { + SketchSegment2D::Line { start, end } => { + lines.push(format!("L {} {} {} {}", start.x, start.y, end.x, end.y)); + } + SketchSegment2D::Arc { + start, + end, + center, + ccw, + } => { + lines.push(format!( + "A {} {} {} {} {} {} {}", + start.x, + start.y, + end.x, + end.y, + center.x, + center.y, + if *ccw { 1 } else { 0 } + )); + } + }; + for seg in segments { - match seg { - SketchSegment2D::Line { start, end } => { - lines.push(format!("L {} {} {} {}", start.x, start.y, end.x, end.y)); - } - SketchSegment2D::Arc { - start, - end, - center, - ccw, - } => { - lines.push(format!( - "A {} {} {} {} {} {} {}", - start.x, - start.y, - end.x, - end.y, - center.x, - center.y, - if *ccw { 1 } else { 0 } - )); - } + push_seg(&mut lines, seg); + } + // Each hole loop starts with an "H" marker line. + for hole in holes.iter().flatten() { + lines.push("H".to_string()); + for seg in hole { + push_seg(&mut lines, seg); } } @@ -2993,7 +3015,9 @@ mod tests { x_dir, y_dir, segments, + holes, } => { + assert_eq!(*holes, None); assert_eq!(*origin, Vec3::new(0.0, 0.0, 0.0)); assert_eq!(*x_dir, Vec3::new(1.0, 0.0, 0.0)); assert_eq!(*y_dir, Vec3::new(0.0, 1.0, 0.0)); @@ -3014,6 +3038,32 @@ mod tests { } } + #[test] + fn test_sketch_with_holes_roundtrip() { + // "H" starts an interior hole loop; segments after it belong to the + // most recent hole. Round-trips through to_vcode/from_vcode. + let compact = "SK 0 0 0 1 0 0 0 1 0\nL 0 0 10 0\nL 10 0 10 10\nL 10 10 0 10\nL 0 10 0 0\nH\nL 2 2 4 2\nL 4 2 4 4\nL 4 4 2 4\nL 2 4 2 2\nEND\nE 0 0 0 5"; + let doc = from_vcode(compact).unwrap(); + + match &doc.nodes[&0].op { + CsgOp::Sketch2D { + segments, holes, .. + } => { + assert_eq!(segments.len(), 4); + let holes = holes.as_ref().expect("holes should parse"); + assert_eq!(holes.len(), 1); + assert_eq!(holes[0].len(), 4); + } + _ => panic!("expected Sketch2D"), + } + + // Round-trip: formatting re-emits the H marker and reparses equal. + let emitted = to_vcode(&doc).unwrap(); + assert!(emitted.contains("\nH\n"), "expected H marker in: {emitted}"); + let restored = from_vcode(&emitted).unwrap(); + assert_eq!(doc.nodes[&0].op, restored.nodes[&0].op); + } + #[test] fn test_sketch_revolve() { let compact = diff --git a/crates/vcad-kernel-booleans/src/bbox.rs b/crates/vcad-kernel-booleans/src/bbox.rs index bddfbbfa7..c4db1c77f 100644 --- a/crates/vcad-kernel-booleans/src/bbox.rs +++ b/crates/vcad-kernel-booleans/src/bbox.rs @@ -362,6 +362,20 @@ pub fn find_candidate_face_pairs(a: &BRepSolid, b: &BRepSolid) -> Vec<(FaceId, F .map(|(fid, _)| (fid, face_aabb(b, fid))) .collect(); + // Large × large operands (unioning chip-layer islands accumulates + // 100k-face solids) make the quadratic scan below intractable; use a + // sweep over x instead. + let n_a = a.topology.faces.len(); + if n_a.saturating_mul(b_faces.len()) > 65_536 { + let a_faces: Vec<(FaceId, Aabb3)> = a + .topology + .faces + .iter() + .map(|(fid, _)| (fid, face_aabb(a, fid))) + .collect(); + return sweep_candidate_face_pairs(&a_faces, &b_faces); + } + let mut pairs = Vec::new(); for (fa_id, _) in &a.topology.faces { @@ -377,6 +391,63 @@ pub fn find_candidate_face_pairs(a: &BRepSolid, b: &BRepSolid) -> Vec<(FaceId, F pairs } +/// Sweep-and-prune broadphase over the x axis: process faces of both solids +/// in ascending `min.x`, keeping per-side active lists pruned by `max.x`, +/// and emit pairs whose y/z extents also overlap. O(n log n + k) for +/// spatially spread inputs instead of the quadratic all-pairs scan. +fn sweep_candidate_face_pairs( + a_faces: &[(FaceId, Aabb3)], + b_faces: &[(FaceId, Aabb3)], +) -> Vec<(FaceId, FaceId)> { + // Event list: (min_x, side, index). Sorted ascending. + let mut events: Vec<(f64, bool, usize)> = Vec::with_capacity(a_faces.len() + b_faces.len()); + events.extend( + a_faces + .iter() + .enumerate() + .map(|(i, (_, bb))| (bb.min.x, false, i)), + ); + events.extend( + b_faces + .iter() + .enumerate() + .map(|(i, (_, bb))| (bb.min.x, true, i)), + ); + events.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap_or(std::cmp::Ordering::Equal)); + + let mut active_a: Vec = Vec::new(); + let mut active_b: Vec = Vec::new(); + let mut pairs = Vec::new(); + + let yz_overlap = |p: &Aabb3, q: &Aabb3| -> bool { + p.min.y <= q.max.y && p.max.y >= q.min.y && p.min.z <= q.max.z && p.max.z >= q.min.z + }; + + for (min_x, is_b, idx) in events { + if is_b { + let bb = &b_faces[idx].1; + active_a.retain(|&ia| a_faces[ia].1.max.x >= min_x); + for &ia in &active_a { + if yz_overlap(&a_faces[ia].1, bb) { + pairs.push((a_faces[ia].0, b_faces[idx].0)); + } + } + active_b.push(idx); + } else { + let bb = &a_faces[idx].1; + active_b.retain(|&ib| b_faces[ib].1.max.x >= min_x); + for &ib in &active_b { + if yz_overlap(bb, &b_faces[ib].1) { + pairs.push((a_faces[idx].0, b_faces[ib].0)); + } + } + active_a.push(idx); + } + } + + pairs +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/vcad-kernel-booleans/src/lib.rs b/crates/vcad-kernel-booleans/src/lib.rs index 1f8122b4c..8eed9e9c9 100644 --- a/crates/vcad-kernel-booleans/src/lib.rs +++ b/crates/vcad-kernel-booleans/src/lib.rs @@ -19,6 +19,7 @@ pub mod bbox; pub mod classify; pub mod cyl_cyl; pub mod mesh; +mod no_crossing; mod pipeline; mod repair; pub mod sew; @@ -722,7 +723,7 @@ mod tests { // Compute winding normal from first 3 verts let e1 = loop_verts[1] - loop_verts[0]; let e2 = loop_verts[2] - loop_verts[0]; - let winding_normal = e1.cross(&e2); + let winding_normal = e1.cross(e2); let wn = if winding_normal.norm() > 1e-12 { winding_normal.normalize() } else { @@ -900,7 +901,7 @@ mod tests { .fold(f64::NEG_INFINITY, f64::max); let e1 = loop_verts[1] - loop_verts[0]; let e2 = loop_verts[2] - loop_verts[0]; - let winding_n = e1.cross(&e2); + let winding_n = e1.cross(e2); let wn = if winding_n.norm() > 1e-12 { winding_n.normalize() } else { @@ -932,7 +933,7 @@ mod tests { // Compute cross products for first few triangles let e1 = loop_verts[1] - loop_verts[0]; let e2 = loop_verts[2] - loop_verts[0]; - let n1 = e1.cross(&e2); + let n1 = e1.cross(e2); eprintln!( " First triangle (v0,v1,v2) cross: ({:.4}, {:.4}, {:.4})", n1.x, n1.y, n1.z diff --git a/crates/vcad-kernel-booleans/src/no_crossing.rs b/crates/vcad-kernel-booleans/src/no_crossing.rs new file mode 100644 index 000000000..f116132d2 --- /dev/null +++ b/crates/vcad-kernel-booleans/src/no_crossing.rs @@ -0,0 +1,748 @@ +//! Fast path for booleans whose operand boundaries do not cross. +//! +//! When surface-surface intersection produces no face splits, the two +//! solids' boundaries never cross: the operands are either fully disjoint +//! or one is nested inside the other. The general pipeline still tessellates +//! both operands and ray-classifies *every* face — O(|A|·|B|) work that +//! dominates document evaluation when thousands of disjoint solids are +//! unioned (e.g. chip-layer islands from the GDSII bridge). This module +//! resolves those cases with two point-in-solid queries instead. +//! +//! Contact without crossing (two boxes sharing a wall) must still use the +//! general pipeline so coincident faces are deduplicated; [`boundaries_touch`] +//! detects coincident-surface candidate pairs whose 2D regions actually +//! touch and vetoes the fast path. + +use vcad_kernel_geom::Plane; +use vcad_kernel_math::{Point3, Vec3}; +use vcad_kernel_primitives::BRepSolid; +use vcad_kernel_tessellate::tessellate_brep; +use vcad_kernel_topo::FaceId; + +use crate::api::{BooleanOp, BooleanResult}; +use crate::mesh::{empty_brep, point_in_mesh}; +use crate::sew; + +/// Geometric tolerance for contact detection, matched to the sew tolerance. +const TOL: f64 = 1e-6; + +/// How the two non-crossing operands relate spatially. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Containment { + /// Every point of A lies inside B. + pub a_in_b: bool, + /// Every point of B lies inside A. + pub b_in_a: bool, +} + +/// Detect whether any candidate face pair has coincident surfaces whose +/// bounded regions actually touch or overlap. Such contact (shared walls, +/// stacked boxes) needs the general pipeline's coincident-face handling. +/// +/// Conservative: any coincident pair that cannot be cheaply proven disjoint +/// reports contact. +pub(crate) fn boundaries_touch(a: &BRepSolid, b: &BRepSolid, pairs: &[(FaceId, FaceId)]) -> bool { + for &(fa, fb) in pairs { + let surf_a = &a.geometry.surfaces[a.topology.faces[fa].surface_index]; + let surf_b = &b.geometry.surfaces[b.topology.faces[fb].surface_index]; + + let (Some(plane_a), Some(plane_b)) = ( + surf_a.as_any().downcast_ref::(), + surf_b.as_any().downcast_ref::(), + ) else { + // Non-planar candidate pair. SSI found no crossing, but two + // coincident curved surfaces (coaxial equal-radius cylinders, + // concentric spheres) can share area without an intersection + // curve. Be conservative when the surfaces look coincident. + if curved_surfaces_possibly_coincident(surf_a.as_ref(), surf_b.as_ref()) { + return true; + } + continue; + }; + + let n_a = plane_a.normal_dir.into_inner(); + let n_b = plane_b.normal_dir.into_inner(); + if n_a.cross(n_b).norm() > 1e-9 { + continue; // Not parallel → SSI already proved no crossing. + } + if (plane_b.origin - plane_a.origin).dot(n_a).abs() > TOL { + continue; // Parallel but offset → disjoint planes. + } + + // Coincident planes: do the bounded regions touch? + if planar_regions_touch(a, fa, b, fb, &n_a) { + return true; + } + } + false +} + +/// Conservative coincidence test for curved surface pairs: reports `true` +/// (possible contact) when both surfaces are curved and of the same kind. +/// Planar-vs-curved pairs cannot share area without an intersection curve. +fn curved_surfaces_possibly_coincident( + surf_a: &dyn vcad_kernel_geom::Surface, + surf_b: &dyn vcad_kernel_geom::Surface, +) -> bool { + use vcad_kernel_geom::SurfaceKind; + let ka = surf_a.surface_type(); + let kb = surf_b.surface_type(); + if ka == SurfaceKind::Plane || kb == SurfaceKind::Plane { + return false; + } + ka == kb +} + +/// Outer-loop vertices of a face. +fn face_outer_verts(brep: &BRepSolid, face: FaceId) -> Vec { + let topo = &brep.topology; + topo.loop_half_edges(topo.faces[face].outer_loop) + .map(|he| topo.vertices[topo.half_edges[he].origin].point) + .collect() +} + +/// Do two coplanar face regions touch or overlap? Projects both outer loops +/// into the shared plane and tests vertex containment (boundary-inclusive) +/// plus edge-edge intersection. Inner loops are ignored, which errs on the +/// side of reporting contact (safe: the general pipeline takes over). +fn planar_regions_touch( + a: &BRepSolid, + fa: FaceId, + b: &BRepSolid, + fb: FaceId, + normal: &Vec3, +) -> bool { + let verts_a = face_outer_verts(a, fa); + let verts_b = face_outer_verts(b, fb); + if verts_a.len() < 3 || verts_b.len() < 3 { + // Degenerate loops (analytic disk caps) — be conservative. + return true; + } + + // Build a shared 2D frame on the plane. + let u = pick_tangent(normal); + let v = normal.cross(u); + let origin = verts_a[0]; + let proj = |p: &Point3| -> (f64, f64) { + let d = *p - origin; + (d.dot(u), d.dot(v)) + }; + let poly_a: Vec<(f64, f64)> = verts_a.iter().map(&proj).collect(); + let poly_b: Vec<(f64, f64)> = verts_b.iter().map(&proj).collect(); + + // Any vertex of one polygon inside (or on the boundary of) the other? + if poly_b.iter().any(|p| point_in_poly_inclusive(*p, &poly_a)) + || poly_a.iter().any(|p| point_in_poly_inclusive(*p, &poly_b)) + { + return true; + } + + // Any edge crossing (including touching within tolerance)? + let na = poly_a.len(); + let nb = poly_b.len(); + for i in 0..na { + let a1 = poly_a[i]; + let a2 = poly_a[(i + 1) % na]; + for j in 0..nb { + let b1 = poly_b[j]; + let b2 = poly_b[(j + 1) % nb]; + if segments_touch(a1, a2, b1, b2) { + return true; + } + } + } + false +} + +/// A unit vector orthogonal to `n`. +fn pick_tangent(n: &Vec3) -> Vec3 { + let candidate = if n.x.abs() < 0.9 { + Vec3::x() + } else { + Vec3::y() + }; + let t = candidate - candidate.dot(n) * *n; + t.normalize() +} + +/// Boundary-inclusive point-in-polygon: true when `p` is inside the polygon +/// or within [`TOL`] of its boundary. +fn point_in_poly_inclusive(p: (f64, f64), poly: &[(f64, f64)]) -> bool { + let n = poly.len(); + // On-boundary check. + for i in 0..n { + if point_segment_dist_sq(p, poly[i], poly[(i + 1) % n]) < TOL * TOL { + return true; + } + } + // Ray-cast parity. + let mut inside = false; + let mut j = n - 1; + for i in 0..n { + let (xi, yi) = poly[i]; + let (xj, yj) = poly[j]; + if ((yi > p.1) != (yj > p.1)) && (p.0 < (xj - xi) * (p.1 - yi) / (yj - yi) + xi) { + inside = !inside; + } + j = i; + } + inside +} + +/// Squared distance from point to segment in 2D. +fn point_segment_dist_sq(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len_sq = dx * dx + dy * dy; + let t = if len_sq < 1e-30 { + 0.0 + } else { + (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len_sq).clamp(0.0, 1.0) + }; + let (cx, cy) = (a.0 + t * dx, a.1 + t * dy); + let (ex, ey) = (p.0 - cx, p.1 - cy); + ex * ex + ey * ey +} + +/// Do two 2D segments intersect or come within [`TOL`] of each other? +fn segments_touch(a1: (f64, f64), a2: (f64, f64), b1: (f64, f64), b2: (f64, f64)) -> bool { + let d1 = (a2.0 - a1.0, a2.1 - a1.1); + let d2 = (b2.0 - b1.0, b2.1 - b1.1); + let denom = d1.0 * d2.1 - d1.1 * d2.0; + if denom.abs() > 1e-12 { + let d = (b1.0 - a1.0, b1.1 - a1.1); + let t = (d.0 * d2.1 - d.1 * d2.0) / denom; + let u = (d.0 * d1.1 - d.1 * d1.0) / denom; + if (-1e-9..=1.0 + 1e-9).contains(&t) && (-1e-9..=1.0 + 1e-9).contains(&u) { + return true; + } + } + // Parallel / near-parallel: endpoint-to-segment proximity. + point_segment_dist_sq(a1, b1, b2) < TOL * TOL + || point_segment_dist_sq(a2, b1, b2) < TOL * TOL + || point_segment_dist_sq(b1, a1, a2) < TOL * TOL + || point_segment_dist_sq(b2, a1, a2) < TOL * TOL +} + +/// Resolve mutual containment of two solids whose boundaries provably do +/// not cross or touch. Returns `None` when a robust answer couldn't be +/// established (caller falls back to the general pipeline). +pub(crate) fn resolve_containment( + a: &BRepSolid, + b: &BRepSolid, + segments: u32, +) -> Option { + let a_in_b = solid_sample_in_solid(a, b, segments)?; + let b_in_a = solid_sample_in_solid(b, a, segments)?; + Some(Containment { a_in_b, b_in_a }) +} + +/// Is (a sample point of) `inner` inside `outer`? Because the boundaries do +/// not cross, one sample decides the whole solid. Tries several boundary +/// vertices of `inner`, skipping ones that produce degenerate ray casts. +fn solid_sample_in_solid(inner: &BRepSolid, outer: &BRepSolid, segments: u32) -> Option { + const MAX_ATTEMPTS: usize = 8; + for (_, v) in inner.topology.vertices.iter().take(MAX_ATTEMPTS) { + match point_in_brep(&v.point, outer) { + RayParity::Inside => return Some(true), + RayParity::Outside => return Some(false), + RayParity::Degenerate => continue, + RayParity::NeedsMesh => { + // Curved faces along the ray — fall back to the mesh test. + let mesh = tessellate_brep(outer, segments); + return Some(point_in_mesh(&v.point, &mesh)); + } + } + } + None +} + +enum RayParity { + Inside, + Outside, + /// The ray grazed an edge/vertex or the query point sits on a face. + Degenerate, + /// A curved face intersects the ray column; exact planar parity + /// counting doesn't apply. + NeedsMesh, +} + +/// Exact +Z ray-parity test against a planar-faced B-rep. Faces are +/// prefiltered by AABB in the XY column above `p`. +fn point_in_brep(p: &Point3, solid: &BRepSolid) -> RayParity { + let topo = &solid.topology; + let mut crossings = 0u32; + + for (_fid, face) in topo.faces.iter() { + // Cheap XY-column prefilter from loop vertices. + let mut min_x = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut min_y = f64::INFINITY; + let mut max_y = f64::NEG_INFINITY; + let mut max_z = f64::NEG_INFINITY; + let loops = std::iter::once(face.outer_loop).chain(face.inner_loops.iter().copied()); + for loop_id in loops { + for he in topo.loop_half_edges(loop_id) { + let q = topo.vertices[topo.half_edges[he].origin].point; + min_x = min_x.min(q.x); + max_x = max_x.max(q.x); + min_y = min_y.min(q.y); + max_y = max_y.max(q.y); + max_z = max_z.max(q.z); + } + } + if p.x < min_x - TOL + || p.x > max_x + TOL + || p.y < min_y - TOL + || p.y > max_y + TOL + || p.z > max_z + TOL + { + continue; + } + + let surface = &solid.geometry.surfaces[face.surface_index]; + let Some(plane) = surface.as_any().downcast_ref::() else { + return RayParity::NeedsMesh; + }; + // Degenerate loops (analytic disk caps) have vertex AABBs that + // don't bound the face; punt to the mesh path. + if topo.loop_len(face.outer_loop) < 3 { + return RayParity::NeedsMesh; + } + + let n = plane.normal_dir.into_inner(); + if n.z.abs() < 1e-12 { + // Ray parallel to the face plane. If the query point lies in + // the plane it may run along the face — degenerate. + if (p - plane.origin).dot(n).abs() < TOL { + return RayParity::Degenerate; + } + continue; + } + + // Intersection of the vertical ray with the face plane. + let t = (plane.origin - p).dot(n) / n.z; + if t < -TOL { + continue; // Below the query point. + } + + // Containment of (p.x, p.y) in the face polygon, projected to XY + // (valid because the plane is not vertical). + let hit = (p.x, p.y); + let outer_xy: Vec<(f64, f64)> = topo + .loop_half_edges(face.outer_loop) + .map(|he| { + let q = topo.vertices[topo.half_edges[he].origin].point; + (q.x, q.y) + }) + .collect(); + match xy_containment(hit, &outer_xy) { + XyContainment::Boundary => return RayParity::Degenerate, + XyContainment::Outside => continue, + XyContainment::Inside => {} + } + let mut in_hole = false; + for &inner in &face.inner_loops { + let hole_xy: Vec<(f64, f64)> = topo + .loop_half_edges(inner) + .map(|he| { + let q = topo.vertices[topo.half_edges[he].origin].point; + (q.x, q.y) + }) + .collect(); + match xy_containment(hit, &hole_xy) { + XyContainment::Boundary => return RayParity::Degenerate, + XyContainment::Inside => { + in_hole = true; + break; + } + XyContainment::Outside => {} + } + } + if in_hole { + continue; + } + + if t < TOL { + // The query point lies on this face — on the boundary. + return RayParity::Degenerate; + } + crossings += 1; + } + + if crossings % 2 == 1 { + RayParity::Inside + } else { + RayParity::Outside + } +} + +enum XyContainment { + Inside, + Outside, + Boundary, +} + +/// Classify a 2D point against a polygon: near-boundary (within [`TOL`]), +/// strictly inside, or outside. +fn xy_containment(p: (f64, f64), poly: &[(f64, f64)]) -> XyContainment { + let n = poly.len(); + if n < 3 { + return XyContainment::Outside; + } + for i in 0..n { + if point_segment_dist_sq(p, poly[i], poly[(i + 1) % n]) < TOL * TOL { + return XyContainment::Boundary; + } + } + let mut inside = false; + let mut j = n - 1; + for i in 0..n { + let (xi, yi) = poly[i]; + let (xj, yj) = poly[j]; + if ((yi > p.1) != (yj > p.1)) && (p.0 < (xj - xi) * (p.1 - yi) / (yj - yi) + xi) { + inside = !inside; + } + j = i; + } + if inside { + XyContainment::Inside + } else { + XyContainment::Outside + } +} + +/// Build the boolean result for two non-crossing, non-touching solids from +/// their containment relation. +pub(crate) fn no_crossing_result( + a: BRepSolid, + b: BRepSolid, + op: BooleanOp, + rel: Containment, +) -> BooleanResult { + let all_faces = |s: &BRepSolid| -> Vec { s.topology.faces.keys().collect() }; + match op { + BooleanOp::Union => { + if rel.a_in_b { + BooleanResult::BRep(Box::new(b)) + } else if rel.b_in_a { + BooleanResult::BRep(Box::new(a)) + } else { + // Disjoint (or B nested in a cavity of A): keep both shells. + let fa = all_faces(&a); + let fb = all_faces(&b); + BooleanResult::BRep(Box::new(sew::sew_faces(&a, &fa, &b, &fb, false, TOL))) + } + } + BooleanOp::Difference => { + if rel.a_in_b { + BooleanResult::BRep(Box::new(empty_brep())) + } else if rel.b_in_a { + // B carves a cavity inside A: keep A plus B's faces reversed. + let fa = all_faces(&a); + let fb = all_faces(&b); + BooleanResult::BRep(Box::new(sew::sew_faces(&a, &fa, &b, &fb, true, TOL))) + } else { + BooleanResult::BRep(Box::new(a)) + } + } + BooleanOp::Intersection => { + if rel.a_in_b { + BooleanResult::BRep(Box::new(a)) + } else if rel.b_in_a { + BooleanResult::BRep(Box::new(b)) + } else { + BooleanResult::BRep(Box::new(empty_brep())) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::boolean_op; + use vcad_kernel_primitives::make_cube; + + fn translated(mut s: BRepSolid, d: Vec3) -> BRepSolid { + let t = vcad_kernel_math::Transform::translation(d.x, d.y, d.z); + for (_, v) in s.topology.vertices.iter_mut() { + v.point = t.apply_point(&v.point); + } + s.geometry.surfaces = s + .geometry + .surfaces + .drain(..) + .map(|surf| surf.transform(&t)) + .collect(); + s + } + + fn volume(s: &BRepSolid) -> f64 { + let mesh = tessellate_brep(s, 32); + let verts = &mesh.vertices; + let mut vol = 0.0; + for tri in mesh.indices.chunks(3) { + let i = [ + tri[0] as usize * 3, + tri[1] as usize * 3, + tri[2] as usize * 3, + ]; + let v: Vec<[f64; 3]> = i + .iter() + .map(|&k| [verts[k] as f64, verts[k + 1] as f64, verts[k + 2] as f64]) + .collect(); + vol += v[0][0] * (v[1][1] * v[2][2] - v[2][1] * v[1][2]) + - v[1][0] * (v[0][1] * v[2][2] - v[2][1] * v[0][2]) + + v[2][0] * (v[0][1] * v[1][2] - v[1][1] * v[0][2]); + } + (vol / 6.0).abs() + } + + #[test] + fn disjoint_union_keeps_both_shells() { + // AABBs overlap (diagonal offset in x only), geometry disjoint. + let a = make_cube(10.0, 10.0, 10.0); + let b = translated(make_cube(10.0, 10.0, 10.0), Vec3::new(20.0, 0.5, 0.5)); + let result = boolean_op(&a, &b, BooleanOp::Union, 16) + .into_brep() + .unwrap(); + assert!((volume(&result) - 2000.0).abs() < 1.0); + assert_eq!(result.topology.faces.len(), 12); + } + + #[test] + fn nested_union_returns_outer() { + let a = make_cube(10.0, 10.0, 10.0); + let b = translated(make_cube(2.0, 2.0, 2.0), Vec3::new(4.0, 4.0, 4.0)); + let result = boolean_op(&a, &b, BooleanOp::Union, 16) + .into_brep() + .unwrap(); + assert!((volume(&result) - 1000.0).abs() < 1.0); + } + + #[test] + fn nested_difference_carves_cavity() { + let a = make_cube(10.0, 10.0, 10.0); + let b = translated(make_cube(2.0, 2.0, 2.0), Vec3::new(4.0, 4.0, 4.0)); + let result = boolean_op(&a, &b, BooleanOp::Difference, 16) + .into_brep() + .unwrap(); + assert!((volume(&result) - (1000.0 - 8.0)).abs() < 1.0); + } + + #[test] + fn disjoint_intersection_is_empty() { + let a = make_cube(10.0, 10.0, 10.0); + let b = translated(make_cube(10.0, 10.0, 10.0), Vec3::new(20.0, 0.5, 0.5)); + let result = boolean_op(&a, &b, BooleanOp::Intersection, 16) + .into_brep() + .unwrap(); + assert_eq!(result.topology.faces.len(), 0); + } + + #[test] + fn touching_boxes_still_use_general_pipeline() { + // Boxes sharing a full wall: contact must be detected so the + // general pipeline can deduplicate the coincident faces. + let a = make_cube(10.0, 10.0, 10.0); + let b = translated(make_cube(10.0, 10.0, 10.0), Vec3::new(10.0, 0.0, 0.0)); + let pairs = crate::bbox::find_candidate_face_pairs(&a, &b); + assert!(boundaries_touch(&a, &b, &pairs)); + + let result = boolean_op(&a, &b, BooleanOp::Union, 16) + .into_brep() + .unwrap(); + assert!((volume(&result) - 2000.0).abs() < 1.0); + } + + #[test] + fn point_in_brep_basic() { + let cube = make_cube(10.0, 10.0, 10.0); + assert!(matches!( + point_in_brep(&Point3::new(5.0, 5.0, 5.0), &cube), + RayParity::Inside + )); + assert!(matches!( + point_in_brep(&Point3::new(15.0, 5.0, 5.0), &cube), + RayParity::Outside + )); + } +} + +#[cfg(test)] +mod probe_tests { + use super::*; + use crate::api::boolean_op; + use vcad_kernel_sketch_probe::*; + + /// Two interdigitated comb prisms, geometrically disjoint but with + /// heavily overlapping AABBs and coplanar caps — the GDS island shape. + #[test] + fn interdigitated_combs_take_fast_path() { + let comb = |ox: f64, flip: bool| -> BRepSolid { + // Comb: base bar plus 3 teeth. + let mut pts: Vec<(f64, f64)> = Vec::new(); + if !flip { + pts.push((0.0, 0.0)); + pts.push((1.0, 0.0)); + for i in 0..3 { + let y0 = 1.0 + i as f64 * 2.0; + pts.push((1.0, y0)); + pts.push((5.0, y0)); + pts.push((5.0, y0 + 1.0)); + pts.push((1.0, y0 + 1.0)); + } + pts.push((1.0, 8.0)); + pts.push((0.0, 8.0)); + } else { + pts.push((6.0, 0.0)); + pts.push((7.0, 0.0)); + pts.push((7.0, 8.0)); + pts.push((6.0, 8.0)); + for i in (0..3).rev() { + // Teeth sit inside A's notches (y gaps (2,3), (4,5), + // (6,7)) with 0.25 clearance on every side. + let y0 = 2.25 + i as f64 * 2.0; + pts.push((6.0, y0 + 0.5)); + pts.push((2.0, y0 + 0.5)); + pts.push((2.0, y0)); + pts.push((6.0, y0)); + } + } + let pts: Vec<(f64, f64)> = pts.iter().map(|&(x, y)| (x + ox, y)).collect(); + prism(&pts, 2.0) + }; + let a = comb(0.0, false); + let b = comb(0.0, true); + eprintln!("vol a = {}, vol b = {}", volume(&a), volume(&b)); + let result = boolean_op(&a, &b, BooleanOp::Union, 16) + .into_brep() + .unwrap(); + eprintln!( + "vol result = {}, faces = {}", + volume(&result), + result.topology.faces.len() + ); + let expected = volume(&a) + volume(&b); + assert!((volume(&result) - expected).abs() < 1e-3 * expected); + // Fast path keeps faces unfragmented: exact sum of input faces. + assert_eq!( + result.topology.faces.len(), + a.topology.faces.len() + b.topology.faces.len(), + "expected no-crossing fast path (no phantom splits)" + ); + } + + fn volume(s: &BRepSolid) -> f64 { + let mesh = tessellate_brep(s, 16); + let verts = &mesh.vertices; + let mut vol = 0.0; + for tri in mesh.indices.chunks(3) { + let i = [ + tri[0] as usize * 3, + tri[1] as usize * 3, + tri[2] as usize * 3, + ]; + let v: Vec<[f64; 3]> = i + .iter() + .map(|&k| [verts[k] as f64, verts[k + 1] as f64, verts[k + 2] as f64]) + .collect(); + vol += v[0][0] * (v[1][1] * v[2][2] - v[2][1] * v[1][2]) + - v[1][0] * (v[0][1] * v[2][2] - v[2][1] * v[0][2]) + + v[2][0] * (v[0][1] * v[1][2] - v[1][1] * v[0][2]); + } + (vol / 6.0).abs() + } +} + +/// Test-only prism builder from a 2D polygon (CCW), extruded along +Z. +#[cfg(test)] +mod vcad_kernel_sketch_probe { + use vcad_kernel_geom::{GeometryStore, Plane}; + use vcad_kernel_topo::{Orientation, ShellType, Topology}; + + pub use vcad_kernel_primitives::BRepSolid; + + pub fn prism(pts: &[(f64, f64)], h: f64) -> BRepSolid { + let mut topo = Topology::new(); + let mut geom = GeometryStore::new(); + let n = pts.len(); + let bot: Vec<_> = pts + .iter() + .map(|&(x, y)| topo.add_vertex(vcad_kernel_math::Point3::new(x, y, 0.0))) + .collect(); + let top: Vec<_> = pts + .iter() + .map(|&(x, y)| topo.add_vertex(vcad_kernel_math::Point3::new(x, y, h))) + .collect(); + let mut faces = Vec::new(); + let mut he_pairs: std::collections::HashMap<(u64, u64), vcad_kernel_topo::HalfEdgeId> = + Default::default(); + let key = |v: vcad_kernel_topo::VertexId| -> u64 { + // slotmap keys are unique; hash via Debug format + let s = format!("{v:?}"); + let mut acc = 0u64; + for b in s.bytes() { + acc = acc.wrapping_mul(131).wrapping_add(b as u64); + } + acc + }; + let mut mk_face = |topo: &mut Topology, + geom: &mut GeometryStore, + ring: &[vcad_kernel_topo::VertexId], + normal_hint: Option| + -> vcad_kernel_topo::FaceId { + let p0 = topo.vertices[ring[0]].point; + let p1 = topo.vertices[ring[1]].point; + let p2 = topo.vertices[ring[2]].point; + let plane = match normal_hint { + Some(nrm) => Plane::from_normal(p0, nrm), + None => Plane::new(p0, p1 - p0, p2 - p1), + }; + let sidx = geom.add_surface(Box::new(plane)); + let hes: Vec<_> = ring.iter().map(|&v| topo.add_half_edge(v)).collect(); + for (i, &he) in hes.iter().enumerate() { + let a = ring[i]; + let b = ring[(i + 1) % ring.len()]; + if let Some(&other) = he_pairs.get(&(key(b), key(a))) { + topo.add_edge(he, other); + } else { + he_pairs.insert((key(a), key(b)), he); + } + } + let l = topo.add_loop(&hes); + let f = topo.add_face(l, sidx, Orientation::Forward); + faces.push(f); + f + }; + for i in 0..n { + let j = (i + 1) % n; + mk_face( + &mut topo, + &mut geom, + &[bot[i], bot[j], top[j], top[i]], + None, + ); + } + let bot_rev: Vec<_> = bot.iter().rev().copied().collect(); + mk_face( + &mut topo, + &mut geom, + &bot_rev, + Some(-vcad_kernel_math::Vec3::z()), + ); + mk_face( + &mut topo, + &mut geom, + &top, + Some(vcad_kernel_math::Vec3::z()), + ); + let shell = topo.add_shell(faces, ShellType::Outer); + let solid_id = topo.add_solid(shell); + BRepSolid { + topology: topo, + geometry: geom, + solid_id, + } + } +} diff --git a/crates/vcad-kernel-booleans/src/pipeline.rs b/crates/vcad-kernel-booleans/src/pipeline.rs index fd3383609..29707466a 100644 --- a/crates/vcad-kernel-booleans/src/pipeline.rs +++ b/crates/vcad-kernel-booleans/src/pipeline.rs @@ -15,8 +15,13 @@ use crate::{bbox, classify, sew, split, ssi, trim}; /// Per-face split data: intersection curve with entry/exit points. type FaceSplits = Vec<(ssi::IntersectionCurve, Point3, Point3)>; -/// Result of computing SSI for one face pair: splits for face A and face B. -type SsiPairResult = (FaceId, FaceSplits, FaceId, FaceSplits); +/// Result of computing SSI for one face pair: splits for face A and face B, +/// plus whether the pair *really* crosses — i.e. the trimmed curve intervals +/// of both faces overlap on the same intersection curve. Splits are also +/// recorded when the carrier curve crosses only one face of the pair +/// (phantom splits from nearby-but-disjoint faces); those must not block +/// the no-crossing fast path. +type SsiPairResult = (FaceId, FaceSplits, FaceId, FaceSplits, bool); /// Debug logging macro - only prints when debug-boolean feature is enabled #[allow(unused_macros)] @@ -551,7 +556,10 @@ pub(crate) fn brep_boolean( if a_anchors_circle && split::is_conical_face(&b, face_b) { results_b.push((curve.clone(), circle.center, circle.center)); } - return Some((face_a, results_a, face_b, results_b)); + // Circle splits are not interval-trimmed; treat any hit as a + // real crossing (conservative). + let real = !results_a.is_empty() || !results_b.is_empty(); + return Some((face_a, results_a, face_b, results_b, real)); } // TwoSampled is the analytic cylinder × cylinder result (the @@ -573,7 +581,8 @@ pub(crate) fn brep_boolean( if split::is_cylindrical_face(&b, face_b) { results_b.push((curve.clone(), Point3::origin(), Point3::origin())); } - return Some((face_a, results_a, face_b, results_b)); + let real = !results_a.is_empty() || !results_b.is_empty(); + return Some((face_a, results_a, face_b, results_b, real)); } // Expand TwoLines into individual Line curves for processing @@ -612,6 +621,7 @@ pub(crate) fn brep_boolean( _ => vec![curve.clone()], }; + let mut real_crossing = false; for single_curve in &curves_to_process { // Trim curve to A's face boundary (for non-circle curves) let segs_a = trim::trim_curve_to_face(single_curve, face_a, &a, 64); @@ -664,9 +674,30 @@ pub(crate) fn brep_boolean( results_b.push((single_curve.clone(), entry, exit)); } } + + // The pair genuinely crosses only when a positive-length stretch + // of the curve lies on BOTH faces: their trimmed t-intervals + // must overlap. One-sided segments are phantom splits from the + // infinite carrier curve crossing a nearby (but disjoint) face. + if !real_crossing { + 'overlap: for sa in &segs_a { + for sb in &segs_b { + let lo = sa.t_start.max(sb.t_start); + let hi = sa.t_end.min(sb.t_end); + if hi > lo { + let p0 = evaluate_curve(single_curve, lo); + let p1 = evaluate_curve(single_curve, hi); + if (p1 - p0).norm() > 1e-6 { + real_crossing = true; + break 'overlap; + } + } + } + } + } } - Some((face_a, results_a, face_b, results_b)) + Some((face_a, results_a, face_b, results_b, real_crossing)) }; // Use Rayon parallelism only when there are enough pairs to amortize thread overhead @@ -679,8 +710,10 @@ pub(crate) fn brep_boolean( // Merge results into HashMaps let mut splits_a: HashMap = HashMap::new(); let mut splits_b: HashMap = HashMap::new(); + let mut any_real_crossing = false; - for (face_a, results_a, face_b, results_b) in split_results { + for (face_a, results_a, face_b, results_b, real_crossing) in split_results { + any_real_crossing |= real_crossing; if !results_a.is_empty() { splits_a.entry(face_a).or_default().extend(results_a); } @@ -693,6 +726,22 @@ pub(crate) fn brep_boolean( debug_bool!("Faces of A to split: {}", splits_a.len()); debug_bool!("Faces of B to split: {}", splits_b.len()); + // No real crossings: the boundaries never cross, so the operands are + // disjoint or nested (or merely touching, which `boundaries_touch` + // detects and routes to the general pipeline for coincident-face + // dedup). Resolving containment with two point queries avoids the + // O(|A|·|B|) classify-everything stage below — the difference between + // hours and seconds when unioning thousands of disjoint solids. + // One-sided (phantom) splits are dropped in this case: they come from + // infinite carrier curves crossing a single face and would only + // fragment the result. + if !any_real_crossing && !crate::no_crossing::boundaries_touch(&a, &b, &pairs) { + if let Some(rel) = crate::no_crossing::resolve_containment(&a, &b, segments) { + debug_bool!("No-crossing fast path: {:?}", rel); + return crate::no_crossing::no_crossing_result(a, b, op, rel); + } + } + // Apply splits to both solids apply_splits_to_solid(&mut a, splits_a, segments, "A"); debug_bool!("\n--- Stage 2.5: After splits applied to A ---"); diff --git a/crates/vcad-kernel-sketch/Cargo.toml b/crates/vcad-kernel-sketch/Cargo.toml index 7d3d32ba6..4645b930f 100644 --- a/crates/vcad-kernel-sketch/Cargo.toml +++ b/crates/vcad-kernel-sketch/Cargo.toml @@ -15,3 +15,4 @@ thiserror.workspace = true [dev-dependencies] vcad-kernel-tessellate = { workspace = true } +vcad-kernel-booleans = { workspace = true } diff --git a/crates/vcad-kernel-sketch/src/extrude.rs b/crates/vcad-kernel-sketch/src/extrude.rs index 599e9cfb1..a7cba7bd1 100644 --- a/crates/vcad-kernel-sketch/src/extrude.rs +++ b/crates/vcad-kernel-sketch/src/extrude.rs @@ -615,6 +615,249 @@ fn extrude_with_arcs(profile: &SketchProfile, direction: Vec3, arc_segs: usize) } } +/// Extrude a closed profile with interior holes along a direction. +/// +/// The holes are given as closed loops of segments expressed in the *outer +/// profile's* 2D sketch coordinate system. Each hole loop becomes a ring of +/// interior lateral wall faces, and the two cap faces carry one inner loop +/// per hole, so the result is a single multiply-connected solid — no boolean +/// `Difference` pass is needed. +/// +/// Winding is normalized internally: the outer loop is made counter- +/// clockwise and hole loops clockwise (viewed from the +normal direction), +/// so callers may pass loops in either orientation. +/// +/// Arc segments (in the outer profile or holes) are polygonized with the +/// default [`ExtrudeOptions::arc_segments`] subdivision; the analytic +/// cylinder fast paths only apply to hole-free profiles. +/// +/// # Preconditions +/// +/// Hole loops must lie strictly inside the outer profile and must not touch +/// or overlap each other. This is not validated (it would cost more than the +/// extrusion itself); violating it yields self-intersecting geometry. +/// +/// # Errors +/// +/// Returns an error if the direction is zero, or if any hole loop is empty, +/// degenerate, or not closed. +pub fn extrude_with_holes( + profile: &SketchProfile, + holes: &[Vec], + direction: Vec3, +) -> Result { + if holes.is_empty() { + return extrude(profile, direction); + } + let dir_len = direction.norm(); + if dir_len < 1e-12 { + return Err(SketchError::ZeroExtrusion); + } + + let arc_segs = ExtrudeOptions::default().arc_segments as usize; + + // Validate each hole loop (closure, degeneracy) by round-tripping it + // through the SketchProfile constructor on the outer profile's plane, + // then polygonize arcs so the build below is line-only. + let mut hole_profiles: Vec = Vec::with_capacity(holes.len()); + for hole in holes { + let hp = SketchProfile::new( + profile.origin, + *profile.x_dir.as_ref(), + *profile.y_dir.as_ref(), + hole.clone(), + )?; + hole_profiles.push(if hp.is_line_only() { + hp + } else { + hp.tessellate(arc_segs) + }); + } + let outer = if profile.is_line_only() { + profile.clone() + } else { + profile.tessellate(arc_segs) + }; + + // Normalize winding: outer CCW, holes CW (viewed from +normal). The + // lateral-face winding below then produces outward normals on the outer + // walls and hole-facing normals on the interior walls. + let outer_pts = oriented_loop_points(&outer, true); + let hole_pts: Vec> = hole_profiles + .iter() + .map(|hp| oriented_loop_points(hp, false)) + .collect(); + + let mut topo = Topology::new(); + let mut geom = GeometryStore::new(); + + let quantize_pt = |p: Point3| -> [i64; 3] { + [ + (p.x * 1e9).round() as i64, + (p.y * 1e9).round() as i64, + (p.z * 1e9).round() as i64, + ] + }; + let mut vertex_cache: HashMap<[i64; 3], VertexId> = HashMap::new(); + let mut get_or_create = |topo: &mut Topology, pos: Point3| -> VertexId { + let key = quantize_pt(pos); + *vertex_cache + .entry(key) + .or_insert_with(|| topo.add_vertex(pos)) + }; + + // Bottom/top vertex rings for the outer loop and each hole loop. + let make_rings = |topo: &mut Topology, + cache: &mut dyn FnMut(&mut Topology, Point3) -> VertexId, + pts: &[Point2]| + -> (Vec, Vec) { + let mut bot = Vec::with_capacity(pts.len()); + let mut top = Vec::with_capacity(pts.len()); + for p2 in pts { + let p3 = profile.to_3d(*p2); + bot.push(cache(topo, p3)); + top.push(cache(topo, p3 + direction)); + } + (bot, top) + }; + + let (outer_bot, outer_top) = make_rings(&mut topo, &mut get_or_create, &outer_pts); + let hole_rings: Vec<(Vec, Vec)> = hole_pts + .iter() + .map(|pts| make_rings(&mut topo, &mut get_or_create, pts)) + .collect(); + + let mut all_faces = Vec::new(); + let mut he_map: HashMap<([i64; 3], [i64; 3]), HalfEdgeId> = HashMap::new(); + + // Lateral faces: one planar quad per loop edge, winding + // bot_i → bot_next → top_next → top_i. For the CCW outer loop this + // points normals out of the solid; for CW hole loops it points them + // into the hole cavity (also out of the material). + let build_lateral_ring = + |topo: &mut Topology, + geom: &mut GeometryStore, + bot: &[VertexId], + top: &[VertexId], + faces: &mut Vec, + he_map: &mut HashMap<([i64; 3], [i64; 3]), HalfEdgeId>| { + let n = bot.len(); + for i in 0..n { + let j = (i + 1) % n; + let p0 = topo.vertices[bot[i]].point; + let p1 = topo.vertices[bot[j]].point; + let p2 = topo.vertices[top[j]].point; + let p3 = topo.vertices[top[i]].point; + let (face_id, face_hes) = build_planar_lateral_face( + topo, geom, bot[i], bot[j], top[j], top[i], p0, p1, p2, p3, + ); + faces.push(face_id); + for he_id in face_hes { + let he = &topo.half_edges[he_id]; + let origin = topo.vertices[he.origin].point; + let next = he.next.unwrap(); + let dest = topo.vertices[topo.half_edges[next].origin].point; + he_map.insert((quantize_pt(origin), quantize_pt(dest)), he_id); + } + } + }; + + build_lateral_ring( + &mut topo, + &mut geom, + &outer_bot, + &outer_top, + &mut all_faces, + &mut he_map, + ); + for (bot, top) in &hole_rings { + build_lateral_ring(&mut topo, &mut geom, bot, top, &mut all_faces, &mut he_map); + } + + // Caps. Bottom cap: outward normal is -profile.normal, so both the outer + // loop and the hole loops are reversed relative to their stored order; + // top cap keeps the stored order. Reversal keeps every cap half-edge + // anti-parallel to its lateral twin so `pair_twin_half_edges` closes the + // manifold. + for (is_bottom, cap_normal) in [ + (true, -*profile.normal.as_ref()), + (false, *profile.normal.as_ref()), + ] { + let ring = |bot: &Vec, top: &Vec| -> Vec { + if is_bottom { + bot.clone() + } else { + top.clone() + } + }; + let outer_ring = ring(&outer_bot, &outer_top); + let origin_pos = topo.vertices[outer_ring[0]].point; + let surf_idx = geom.add_surface(Box::new(Plane::from_normal(origin_pos, cap_normal))); + + let outer_loop_id = + add_cap_loop(&mut topo, &outer_ring, is_bottom, &mut he_map, quantize_pt); + let face_id = topo.add_face(outer_loop_id, surf_idx, Orientation::Forward); + for (bot, top) in &hole_rings { + let hole_ring = ring(bot, top); + let loop_id = add_cap_loop(&mut topo, &hole_ring, is_bottom, &mut he_map, quantize_pt); + topo.add_inner_loop(face_id, loop_id); + } + all_faces.push(face_id); + } + + pair_twin_half_edges(&mut topo, &he_map); + + let shell = topo.add_shell(all_faces, ShellType::Outer); + let solid_id = topo.add_solid(shell); + + Ok(BRepSolid { + topology: topo, + geometry: geom, + solid_id, + }) +} + +/// Segment start points of a (line-only) profile, oriented CCW when `ccw` +/// is true and CW otherwise, as measured in the profile's 2D plane. +fn oriented_loop_points(profile: &SketchProfile, ccw: bool) -> Vec { + let mut pts = profile.vertices_2d(); + let is_ccw = profile.signed_area() > 0.0; + if is_ccw != ccw { + pts.reverse(); + } + pts +} + +/// Create a cap loop over `ring` (reversed when `reversed`), registering its +/// half-edges in `he_map` for twin pairing. Returns the loop id; the caller +/// attaches it to a face as the outer or an inner loop. +fn add_cap_loop( + topo: &mut Topology, + ring: &[VertexId], + reversed: bool, + he_map: &mut HashMap<([i64; 3], [i64; 3]), HalfEdgeId>, + quantize_pt: F, +) -> vcad_kernel_topo::LoopId +where + F: Fn(Point3) -> [i64; 3], +{ + let ordered: Vec = if reversed { + ring.iter().rev().copied().collect() + } else { + ring.to_vec() + }; + let hes: Vec = ordered.iter().map(|&v| topo.add_half_edge(v)).collect(); + let loop_id = topo.add_loop(&hes); + for &he_id in &hes { + let he = &topo.half_edges[he_id]; + let origin = topo.vertices[he.origin].point; + let next = he.next.unwrap(); + let dest = topo.vertices[topo.half_edges[next].origin].point; + he_map.insert((quantize_pt(origin), quantize_pt(dest)), he_id); + } + loop_id +} + /// Sample an arc from `start` to `end` with center `center`. Returns /// `arc_segs + 1` points in 2D sketch space including both endpoints. fn sample_arc_2d( @@ -1314,6 +1557,213 @@ mod tests { (vol / 6.0).abs() } + // ========================================================================= + // Tests for extrude_with_holes + // ========================================================================= + + /// Closed rectangle loop (CCW) as line segments. + fn rect_loop(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec { + let p = [ + Point2::new(x0, y0), + Point2::new(x1, y0), + Point2::new(x1, y1), + Point2::new(x0, y1), + ]; + (0..4) + .map(|i| SketchSegment::Line { + start: p[i], + end: p[(i + 1) % 4], + }) + .collect() + } + + /// Closed polygonal circle loop (CCW) as `n` line segments. + fn circle_loop(cx: f64, cy: f64, r: f64, n: usize) -> Vec { + let pt = |i: usize| { + let a = 2.0 * PI * (i % n) as f64 / n as f64; + Point2::new(cx + r * a.cos(), cy + r * a.sin()) + }; + (0..n) + .map(|i| SketchSegment::Line { + start: pt(i), + end: pt(i + 1), + }) + .collect() + } + + fn mesh_volume_of(solid: &BRepSolid) -> f64 { + let mesh = vcad_kernel_tessellate::tessellate_brep(solid, 32); + compute_mesh_volume(&mesh) + } + + #[test] + fn test_extrude_with_holes_donut_volume_matches_boolean() { + // Polygonal donut: 64-gon outer, 64-gon hole. The same loops drive + // both the native holed extrude and the boolean Difference path, so + // the volumes must agree to float noise. + let h = 4.0; + let outer = SketchProfile::new( + Point3::origin(), + Vec3::x(), + Vec3::y(), + circle_loop(0.0, 0.0, 10.0, 64), + ) + .unwrap(); + let hole = circle_loop(0.0, 0.0, 4.0, 64); + + let holed = extrude_with_holes(&outer, std::slice::from_ref(&hole), Vec3::new(0.0, 0.0, h)) + .unwrap(); + + let outer_solid = extrude(&outer, Vec3::new(0.0, 0.0, h)).unwrap(); + let hole_profile = + SketchProfile::new(Point3::origin(), Vec3::x(), Vec3::y(), hole).unwrap(); + let hole_solid = extrude(&hole_profile, Vec3::new(0.0, 0.0, h)).unwrap(); + + let diff = vcad_kernel_booleans::boolean_op( + &outer_solid, + &hole_solid, + vcad_kernel_booleans::BooleanOp::Difference, + 32, + ) + .into_brep() + .unwrap(); + let boolean = mesh_volume_of(&diff); + let v_holed = mesh_volume_of(&holed); + + // Independent analytic check: prismatic volume = (A_outer − A_hole)·h. + let a_outer = SketchProfile::new( + Point3::origin(), + Vec3::x(), + Vec3::y(), + circle_loop(0.0, 0.0, 10.0, 64), + ) + .unwrap() + .signed_area(); + let a_hole = SketchProfile::new( + Point3::origin(), + Vec3::x(), + Vec3::y(), + circle_loop(0.0, 0.0, 4.0, 64), + ) + .unwrap() + .signed_area(); + let expected = (a_outer - a_hole) * h; + + // TriangleMesh stores f32 vertices, so mesh-derived volumes carry + // ~1e-7 relative noise; 1e-6 relative is the tightest meaningful + // tolerance here (observed error ≈ 7e-9 relative). + assert!( + (v_holed - expected).abs() < 1e-6 * expected.max(1.0), + "holed extrude volume {v_holed} != analytic {expected}" + ); + assert!( + (v_holed - boolean).abs() < 1e-6 * expected.max(1.0), + "holed extrude volume {v_holed} != boolean path {boolean}" + ); + } + + #[test] + fn test_extrude_with_holes_two_hole_plate() { + let outer = SketchProfile::new( + Point3::origin(), + Vec3::x(), + Vec3::y(), + rect_loop(0.0, 0.0, 30.0, 10.0), + ) + .unwrap(); + let h1 = rect_loop(5.0, 3.0, 10.0, 7.0); + let h2 = rect_loop(20.0, 2.0, 26.0, 8.0); + let h = 2.5; + + let solid = extrude_with_holes(&outer, &[h1, h2], Vec3::new(0.0, 0.0, h)).unwrap(); + + // Volume = (30·10 − 5·4 − 6·6) · 2.5 + let expected = (300.0 - 20.0 - 36.0) * h; + let vol = mesh_volume_of(&solid); + assert!( + (vol - expected).abs() < 1e-6 * expected, + "expected {expected}, got {vol}" + ); + + // Watertight: every half-edge twinned. + let unpaired = solid + .topology + .half_edges + .values() + .filter(|he| he.twin.is_none()) + .count(); + assert_eq!(unpaired, 0, "found {unpaired} unpaired half-edges"); + + // 4 outer + 2×4 hole lateral faces + 2 caps. + assert_eq!(solid.topology.faces.len(), 14); + // Caps carry 2 inner loops each. + let caps_with_holes = solid + .topology + .faces + .values() + .filter(|f| f.inner_loops.len() == 2) + .count(); + assert_eq!(caps_with_holes, 2); + } + + #[test] + fn test_extrude_with_holes_winding_invariance() { + // Hole loops may be passed CW or CCW; result must be identical. + let outer = SketchProfile::new( + Point3::origin(), + Vec3::x(), + Vec3::y(), + rect_loop(0.0, 0.0, 10.0, 10.0), + ) + .unwrap(); + let hole_ccw = rect_loop(4.0, 4.0, 6.0, 6.0); + let hole_cw: Vec = hole_ccw + .iter() + .rev() + .map(|s| match s { + SketchSegment::Line { start, end } => SketchSegment::Line { + start: *end, + end: *start, + }, + _ => unreachable!(), + }) + .collect(); + + let v1 = mesh_volume_of( + &extrude_with_holes(&outer, &[hole_ccw], Vec3::new(0.0, 0.0, 3.0)).unwrap(), + ); + let v2 = mesh_volume_of( + &extrude_with_holes(&outer, &[hole_cw], Vec3::new(0.0, 0.0, 3.0)).unwrap(), + ); + let expected = (100.0 - 4.0) * 3.0; + assert!((v1 - expected).abs() < 1e-6 * expected, "ccw hole: {v1}"); + assert!((v2 - expected).abs() < 1e-6 * expected, "cw hole: {v2}"); + } + + #[test] + fn test_extrude_with_holes_empty_holes_is_plain_extrude() { + let profile = SketchProfile::rectangle(Point3::origin(), Vec3::x(), Vec3::y(), 10.0, 5.0); + let solid = extrude_with_holes(&profile, &[], Vec3::new(0.0, 0.0, 20.0)).unwrap(); + assert_eq!(solid.topology.faces.len(), 6); + } + + #[test] + fn test_extrude_with_holes_open_hole_error() { + let outer = SketchProfile::new( + Point3::origin(), + Vec3::x(), + Vec3::y(), + rect_loop(0.0, 0.0, 10.0, 10.0), + ) + .unwrap(); + let open_hole = vec![SketchSegment::Line { + start: Point2::new(2.0, 2.0), + end: Point2::new(4.0, 2.0), + }]; + let result = extrude_with_holes(&outer, &[open_hole], Vec3::new(0.0, 0.0, 3.0)); + assert!(matches!(result, Err(SketchError::NotClosed(_)))); + } + // ========================================================================= // Tests for extrude_with_options // ========================================================================= @@ -1439,7 +1889,6 @@ mod tests { twist_angle: PI, // 180 degrees scale_end: 0.8, arc_segments: 4, - ..Default::default() }; let solid = extrude_with_options(&profile, Vec3::new(0.0, 0.0, 20.0), options).unwrap(); diff --git a/crates/vcad-kernel-sketch/src/lib.rs b/crates/vcad-kernel-sketch/src/lib.rs index f3b9d3192..edf486cde 100644 --- a/crates/vcad-kernel-sketch/src/lib.rs +++ b/crates/vcad-kernel-sketch/src/lib.rs @@ -29,7 +29,7 @@ mod extrude; mod profile; mod revolve; -pub use extrude::{extrude, extrude_with_options, ExtrudeOptions}; +pub use extrude::{extrude, extrude_with_holes, extrude_with_options, ExtrudeOptions}; pub use profile::{SketchProfile, SketchSegment}; pub use revolve::revolve; @@ -69,4 +69,8 @@ pub enum SketchError { /// Profile has no segments. #[error("profile has no segments")] EmptyProfile, + + /// Interior hole loops are not supported by this operation. + #[error("interior hole loops are not supported for {0}")] + HolesUnsupported(&'static str), } diff --git a/crates/vcad-kernel-tessellate/Cargo.toml b/crates/vcad-kernel-tessellate/Cargo.toml index 7c3e9b753..a8271c939 100644 --- a/crates/vcad-kernel-tessellate/Cargo.toml +++ b/crates/vcad-kernel-tessellate/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true repository.workspace = true [dependencies] +earcutr = "0.4" tang = { workspace = true } vcad-kernel-math = { workspace = true } vcad-kernel-topo = { workspace = true } diff --git a/crates/vcad-kernel-tessellate/src/lib.rs b/crates/vcad-kernel-tessellate/src/lib.rs index e54c2ea20..eb7dd1b57 100644 --- a/crates/vcad-kernel-tessellate/src/lib.rs +++ b/crates/vcad-kernel-tessellate/src/lib.rs @@ -1086,6 +1086,12 @@ fn tessellate_concave_polygon(verts: &[Point3], reversed: bool) -> TriangleMesh }) .collect(); + // Large concave polygons (e.g. 1000+-vertex chip-layer island caps) + // make the O(n³) ear-clip loop below impractical; earcut is near-linear. + if n > EARCUT_VERTEX_THRESHOLD { + return earcut_polygon_with_holes(&verts_2d, &[], verts, &[], reversed); + } + // Build mesh with all 3D vertices let mut mesh = TriangleMesh::new(); for v in verts { @@ -1345,12 +1351,101 @@ fn tessellate_planar_face_with_holes( // that together form a full circle at the same position). merge_overlapping_holes(&mut inner_2d, &mut inner_loops); + // Large multiply-connected faces (chip-layer islands with hundreds of + // holes and thousands of boundary vertices) blow up the O(n²)-ish + // bridge + ear-clip path below; route them through earcut, which is + // near-linear and handles holes natively without Steiner points. + let total_verts = outer_2d.len() + inner_2d.iter().map(Vec::len).sum::(); + if total_verts > EARCUT_VERTEX_THRESHOLD { + return earcut_polygon_with_holes( + &outer_2d, + &inner_2d, + &outer_verts, + &inner_loops, + reversed, + ); + } + // After merging overlapping arcs, use bridge+ear-clip directly. // The merged holes are well-shaped (no more overlapping semicircles), // so bridge construction works reliably. triangulate_polygon_with_holes(&outer_2d, &inner_2d, &outer_verts, &inner_loops, reversed) } +/// Boundary-vertex count above which planar-face triangulation switches +/// from the bridge/ear-clip path to earcut. Small faces keep the existing +/// path (its Steiner-ring refinement yields nicer triangles); large faces +/// need earcut's near-linear running time. +const EARCUT_VERTEX_THRESHOLD: usize = 64; + +/// Triangulate a projected planar polygon (with optional holes) via earcut. +/// +/// `outer_2d`/`inner_2d` are the projected loops; `outer_3d`/`inner_3d` the +/// corresponding 3D vertices in identical order. No vertices are added or +/// removed, so the cap stays watertight against adjacent lateral faces. +fn earcut_polygon_with_holes( + outer_2d: &[(f64, f64)], + inner_2d: &[Vec<(f64, f64)>], + outer_3d: &[Point3], + inner_3d: &[Vec], + reversed: bool, +) -> TriangleMesh { + let mut mesh = TriangleMesh::new(); + + let mut data: Vec = Vec::with_capacity(2 * (outer_2d.len() + inner_2d.len() * 4)); + let mut hole_starts: Vec = Vec::with_capacity(inner_2d.len()); + for &(x, y) in outer_2d { + data.push(x); + data.push(y); + } + for hole in inner_2d { + hole_starts.push(data.len() / 2); + for &(x, y) in hole { + data.push(x); + data.push(y); + } + } + + for v in outer_3d.iter().chain(inner_3d.iter().flatten()) { + mesh.vertices.push(v.x as f32); + mesh.vertices.push(v.y as f32); + mesh.vertices.push(v.z as f32); + } + + let Ok(tris) = earcutr::earcut(&data, &hole_starts, 2) else { + return mesh; + }; + + // earcut's output winding follows the input outer ring; normalize to the + // same convention as `ear_clip_triangulate` (CCW in the projected frame + // unless `reversed`). Probe the first non-degenerate triangle. + let mut flip = false; + for t in tris.chunks(3) { + let (ax, ay) = (data[2 * t[0]], data[2 * t[0] + 1]); + let (bx, by) = (data[2 * t[1]], data[2 * t[1] + 1]); + let (cx, cy) = (data[2 * t[2]], data[2 * t[2] + 1]); + let area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); + if area.abs() > 1e-12 { + flip = (area > 0.0) == reversed; + break; + } + } + + for t in tris.chunks(3) { + if flip { + mesh.indices.push(t[0] as u32); + mesh.indices.push(t[2] as u32); + mesh.indices.push(t[1] as u32); + } else { + mesh.indices.push(t[0] as u32); + mesh.indices.push(t[1] as u32); + mesh.indices.push(t[2] as u32); + } + } + + mesh +} + /// Merge inner loops that overlap (e.g., two semicircular arcs forming a full circle). /// Loops are merged when their centroids are closer than the sum of their average radii. fn merge_overlapping_holes(inner_2d: &mut Vec>, inner_3d: &mut Vec>) { diff --git a/crates/vcad-kernel-wasm/src/lib.rs b/crates/vcad-kernel-wasm/src/lib.rs index 41567f1f1..4eb9acc84 100644 --- a/crates/vcad-kernel-wasm/src/lib.rs +++ b/crates/vcad-kernel-wasm/src/lib.rs @@ -330,31 +330,59 @@ pub struct WasmSketchProfile { pub y_dir: [f64; 3], /// Segments forming the closed profile. pub segments: Vec, + /// Optional interior hole loops, each a closed loop of segments in the + /// same sketch coordinate system, strictly inside the outer profile. + /// Only `extrude` honors holes; other profile consumers reject them. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub holes: Option>>, +} + +/// Convert one JS sketch segment to its kernel equivalent. +fn to_kernel_segment(s: &WasmSketchSegment) -> SketchSegment { + match s { + WasmSketchSegment::Line { start, end } => SketchSegment::Line { + start: Point2::new(start[0], start[1]), + end: Point2::new(end[0], end[1]), + }, + WasmSketchSegment::Arc { + start, + end, + center, + ccw, + } => SketchSegment::Arc { + start: Point2::new(start[0], start[1]), + end: Point2::new(end[0], end[1]), + center: Point2::new(center[0], center[1]), + ccw: *ccw, + }, + } } impl WasmSketchProfile { - fn to_kernel_profile(&self) -> Result { - let segments: Vec = self - .segments + /// Interior hole loops converted to kernel segments (empty when absent). + fn kernel_holes(&self) -> Vec> { + self.holes + .as_deref() + .unwrap_or(&[]) .iter() - .map(|s| match s { - WasmSketchSegment::Line { start, end } => SketchSegment::Line { - start: Point2::new(start[0], start[1]), - end: Point2::new(end[0], end[1]), - }, - WasmSketchSegment::Arc { - start, - end, - center, - ccw, - } => SketchSegment::Arc { - start: Point2::new(start[0], start[1]), - end: Point2::new(end[0], end[1]), - center: Point2::new(center[0], center[1]), - ccw: *ccw, - }, - }) - .collect(); + .map(|hole| hole.iter().map(to_kernel_segment).collect()) + .collect() + } + + /// Error when the profile carries interior holes, which `op_name` + /// doesn't support. + fn reject_holes(&self, op_name: &str) -> Result<(), JsError> { + if self.holes.as_ref().is_some_and(|h| !h.is_empty()) { + return Err(JsError::new(&format!( + "interior hole loops are not supported for {op_name}" + ))); + } + Ok(()) + } + + fn to_kernel_profile(&self) -> Result { + let segments: Vec = self.segments.iter().map(to_kernel_segment).collect(); SketchProfile::new( Point3::new(self.origin[0], self.origin[1], self.origin[2]), @@ -578,9 +606,14 @@ impl Solid { let dir = Vec3::new(direction[0], direction[1], direction[2]); - vcad_kernel::Solid::extrude(kernel_profile, dir) - .map(|inner| Solid { inner }) - .map_err(|e| JsError::new(&e.to_string())) + let holes = profile.kernel_holes(); + if holes.is_empty() { + vcad_kernel::Solid::extrude(kernel_profile, dir) + } else { + vcad_kernel::Solid::extrude_with_holes(kernel_profile, &holes, dir) + } + .map(|inner| Solid { inner }) + .map_err(|e| JsError::new(&e.to_string())) } /// Create a solid by extruding a 2D sketch profile with twist and/or scale. @@ -600,6 +633,7 @@ impl Solid { if direction.len() != 3 { return Err(JsError::new("Direction must have 3 components")); } + profile.reject_holes("extrude with twist or taper")?; let kernel_profile = profile.to_kernel_profile().map_err(|e| JsError::new(&e))?; @@ -628,6 +662,7 @@ impl Solid { "Axis origin and direction must have 3 components", )); } + profile.reject_holes("revolve")?; let kernel_profile = profile.to_kernel_profile().map_err(|e| JsError::new(&e))?; @@ -661,6 +696,7 @@ impl Solid { if start.len() != 3 || end.len() != 3 { return Err(JsError::new("Start and end must have 3 components")); } + profile.reject_holes("sweep")?; // Use centered profile so it wraps around the path properly let kernel_profile = profile @@ -707,6 +743,7 @@ impl Solid { let profile: WasmSketchProfile = serde_json::from_str(&profile_json) .map_err(|e| JsError::new(&format!("Invalid profile: {}", e)))?; + profile.reject_holes("sweep")?; // Use centered profile so it wraps around the helix path properly let kernel_profile = profile @@ -742,6 +779,9 @@ impl Solid { if profiles.len() < 2 { return Err(JsError::new("Loft requires at least 2 profiles")); } + for p in &profiles { + p.reject_holes("loft")?; + } let kernel_profiles: Result, _> = profiles.iter().map(|p| p.to_kernel_profile()).collect(); @@ -3466,6 +3506,27 @@ pub fn is_physics_available() -> bool { // Internal evaluation helpers // ========================================================================= +/// Convert one IR sketch segment to the WASM profile representation. +fn ir_segment_to_wasm(seg: &vcad_ir::SketchSegment2D) -> WasmSketchSegment { + match seg { + vcad_ir::SketchSegment2D::Line { start, end } => WasmSketchSegment::Line { + start: [start.x, start.y], + end: [end.x, end.y], + }, + vcad_ir::SketchSegment2D::Arc { + start, + end, + center, + ccw, + } => WasmSketchSegment::Arc { + start: [start.x, start.y], + end: [end.x, end.y], + center: [center.x, center.y], + ccw: *ccw, + }, + } +} + /// Recursively evaluate a node in the IR DAG. fn evaluate_node(doc: &vcad_ir::Document, node_id: vcad_ir::NodeId) -> Result { let node = doc @@ -3657,35 +3718,23 @@ fn evaluate_node(doc: &vcad_ir::Document, node_id: vcad_ir::NodeId) -> Result { - let wasm_segments: Vec = segments - .iter() - .map(|seg| match seg { - vcad_ir::SketchSegment2D::Line { start, end } => { - WasmSketchSegment::Line { - start: [start.x, start.y], - end: [end.x, end.y], - } - } - vcad_ir::SketchSegment2D::Arc { - start, - end, - center, - ccw, - } => WasmSketchSegment::Arc { - start: [start.x, start.y], - end: [end.x, end.y], - center: [center.x, center.y], - ccw: *ccw, - }, - }) - .collect(); + let wasm_segments: Vec = + segments.iter().map(ir_segment_to_wasm).collect(); + let wasm_holes: Option>> = + holes.as_ref().map(|hs| { + hs.iter() + .map(|hole| hole.iter().map(ir_segment_to_wasm).collect()) + .collect() + }); let profile = WasmSketchProfile { origin: [origin.x, origin.y, origin.z], x_dir: [x_dir.x, x_dir.y, x_dir.z], y_dir: [y_dir.x, y_dir.y, y_dir.z], segments: wasm_segments, + holes: wasm_holes, }; let profile_json = serde_json::to_string(&profile).map_err(|e| { @@ -3727,35 +3776,22 @@ fn evaluate_node(doc: &vcad_ir::Document, node_id: vcad_ir::NodeId) -> Result { - let wasm_segments: Vec = segments - .iter() - .map(|seg| match seg { - vcad_ir::SketchSegment2D::Line { start, end } => { - WasmSketchSegment::Line { - start: [start.x, start.y], - end: [end.x, end.y], - } - } - vcad_ir::SketchSegment2D::Arc { - start, - end, - center, - ccw, - } => WasmSketchSegment::Arc { - start: [start.x, start.y], - end: [end.x, end.y], - center: [center.x, center.y], - ccw: *ccw, - }, - }) - .collect(); + if holes.as_ref().is_some_and(|h| !h.is_empty()) { + return Err(JsError::new( + "interior hole loops are not supported for revolve", + )); + } + let wasm_segments: Vec = + segments.iter().map(ir_segment_to_wasm).collect(); let profile = WasmSketchProfile { origin: [origin.x, origin.y, origin.z], x_dir: [x_dir.x, x_dir.y, x_dir.z], y_dir: [y_dir.x, y_dir.y, y_dir.z], segments: wasm_segments, + holes: None, }; let profile_json = serde_json::to_string(&profile).map_err(|e| { @@ -3835,6 +3871,7 @@ fn evaluate_node(doc: &vcad_ir::Document, node_id: vcad_ir::NodeId) -> Result], + direction: Vec3, + ) -> Result { + let brep = vcad_kernel_sketch::extrude_with_holes(&profile, holes, direction)?; + Ok(Solid { + repr: SolidRepr::BRep(Box::new(brep)), + segments: 32, + }) + } + /// Create a solid by extruding a sketch profile with twist and/or scale. /// /// # Arguments diff --git a/crates/vcad-loon/src/convert.rs b/crates/vcad-loon/src/convert.rs index cf175f93c..2aa9e4219 100644 --- a/crates/vcad-loon/src/convert.rs +++ b/crates/vcad-loon/src/convert.rs @@ -873,6 +873,7 @@ impl ConvertCtx { x_dir, y_dir, segments, + holes: None, })) } _ => Err(format!("expected Sketch, got {tag}")), diff --git a/crates/vcad-process/src/bridge.rs b/crates/vcad-process/src/bridge.rs index 72323a2f7..7de793fbb 100644 --- a/crates/vcad-process/src/bridge.rs +++ b/crates/vcad-process/src/bridge.rs @@ -87,64 +87,37 @@ impl Builder { id } - /// Sketch a closed ring at `z` (mm) and extrude it up by `height` mm. - fn ring_extrude(&mut self, ring: &geo::LineString, z_mm: f64, height_mm: f64) -> NodeId { - let segments: Vec = ring - .lines() - .filter(|l| l.start != l.end) - .map(|l| SketchSegment2D::Line { - start: Vec2::new(l.start.x * UM_TO_MM, l.start.y * UM_TO_MM), - end: Vec2::new(l.end.x * UM_TO_MM, l.end.y * UM_TO_MM), - }) + /// One polygon (with holes) as a prism from `z0` to `z1` (µm): a single + /// hole-aware sketch + extrude. Interior rings ride on the sketch as + /// native inner loops, so no boolean is emitted for them. + fn prism(&mut self, polygon: &Polygon, z0_um: f64, z1_um: f64) -> NodeId { + let holes: Vec> = polygon + .interiors() + .iter() + .map(ring_segments) + .filter(|segs| segs.len() >= 3) // drop degenerate slivers .collect(); let sketch = self.alloc( None, CsgOp::Sketch2D { - origin: Vec3::new(0.0, 0.0, z_mm), + origin: Vec3::new(0.0, 0.0, z0_um * UM_TO_MM), x_dir: Vec3::new(1.0, 0.0, 0.0), y_dir: Vec3::new(0.0, 1.0, 0.0), - segments, + segments: ring_segments(polygon.exterior()), + holes: if holes.is_empty() { None } else { Some(holes) }, }, ); self.alloc( None, CsgOp::Extrude { sketch, - direction: Vec3::new(0.0, 0.0, height_mm), + direction: Vec3::new(0.0, 0.0, (z1_um - z0_um) * UM_TO_MM), twist_angle: None, scale_end: None, }, ) } - /// One polygon (with holes) as a prism from `z0` to `z1` (µm). - fn prism(&mut self, polygon: &Polygon, z0_um: f64, z1_um: f64) -> NodeId { - let z_mm = z0_um * UM_TO_MM; - let h_mm = (z1_um - z0_um) * UM_TO_MM; - let body = self.ring_extrude(polygon.exterior(), z_mm, h_mm); - if polygon.interiors().is_empty() { - return body; - } - // Subtract holes; overshoot them vertically so the boolean never - // has to resolve coplanar top/bottom faces. - let overshoot = h_mm * 0.05; - let holes: Vec = polygon - .interiors() - .iter() - .map(|ring| self.ring_extrude(ring, z_mm - overshoot, h_mm + 2.0 * overshoot)) - .collect(); - let holes = self - .union_tree(holes) - .expect("non-empty interiors yield a node"); - self.alloc( - None, - CsgOp::Difference { - left: body, - right: holes, - }, - ) - } - /// Balanced union so deep chains never overflow recursive consumers. fn union_tree(&mut self, mut level: Vec) -> Option { while level.len() > 1 { @@ -181,6 +154,17 @@ impl Builder { } } +/// Segment list of one closed ring, scaled to view millimeters. +fn ring_segments(ring: &geo::LineString) -> Vec { + ring.lines() + .filter(|l| l.start != l.end) + .map(|l| SketchSegment2D::Line { + start: Vec2::new(l.start.x * UM_TO_MM, l.start.y * UM_TO_MM), + end: Vec2::new(l.end.x * UM_TO_MM, l.end.y * UM_TO_MM), + }) + .collect() +} + fn window_rect(window: [f64; 4]) -> Result> { let [x0, y0, x1, y1] = window; if !(window.iter().all(|v| v.is_finite()) && x1 > x0 && y1 > y0) { @@ -507,6 +491,33 @@ mod tests { assert!(section.materials.contains_key("resist")); } + #[test] + fn prism_with_holes_is_one_sketch_extrude_pair() { + use geo::LineString; + let outer = LineString::from(vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]); + let hole = LineString::from(vec![(4.0, 4.0), (6.0, 4.0), (6.0, 6.0), (4.0, 6.0)]); + let polygon = Polygon::new(outer, vec![hole]); + + let mut b = Builder::new(); + let root = b.prism(&polygon, 0.0, 1.0); + + // Native hole loops: no boolean anywhere in the emitted graph. + assert!(!b + .doc + .nodes + .values() + .any(|n| matches!(n.op, CsgOp::Difference { .. } | CsgOp::Union { .. }))); + let CsgOp::Extrude { sketch, .. } = b.doc.nodes[&root].op else { + panic!("prism root must be an extrude"); + }; + let CsgOp::Sketch2D { holes, .. } = &b.doc.nodes[&sketch].op else { + panic!("extrude profile must be a sketch"); + }; + let holes = holes.as_ref().expect("interior ring becomes a hole loop"); + assert_eq!(holes.len(), 1); + assert_eq!(holes[0].len(), 4); + } + #[test] fn rejects_bad_window_and_cut() { let lib = sample_library(); diff --git a/docs/features/vcode.md b/docs/features/vcode.md index 5581db923..f8d903dd9 100644 --- a/docs/features/vcode.md +++ b/docs/features/vcode.md @@ -114,6 +114,7 @@ OPCODE [args...] [# comment] | `SK` | Sketch start | `ox oy oz xx xy xz yx yy yz` (origin, x_dir, y_dir) | | `L` | Line (in sketch) | `x1 y1 x2 y2` | | `A` | Arc (in sketch) | `x1 y1 x2 y2 cx cy ccw` | +| `H` | Hole loop start (in sketch) | segments that follow form an interior hole loop | | `END` | Sketch end | (closes sketch block) | | `E` | Extrude | `sketch_node dx dy dz` | | `V` | Revolve | `sketch_node ox oy oz ax ay az angle_deg` | diff --git a/packages/engine/src/evaluate.ts b/packages/engine/src/evaluate.ts index 3b79369b3..c3fe6c969 100644 --- a/packages/engine/src/evaluate.ts +++ b/packages/engine/src/evaluate.ts @@ -230,6 +230,11 @@ function convertSketchToProfile(op: Sketch2DOp) { x_dir: [op.x_dir.x, op.x_dir.y, op.x_dir.z], y_dir: [op.y_dir.x, op.y_dir.y, op.y_dir.z], segments: op.segments.map(convertSegment), + // Interior hole loops ride along on the profile JSON; the kernel honors + // them for extrude and rejects them for revolve/sweep/loft. + ...(op.holes && op.holes.length > 0 + ? { holes: op.holes.map((hole) => hole.map(convertSegment)) } + : {}), }; } diff --git a/packages/ir/src/generated.ts b/packages/ir/src/generated.ts index df1883b5e..5653f3b0d 100644 --- a/packages/ir/src/generated.ts +++ b/packages/ir/src/generated.ts @@ -326,7 +326,15 @@ y_dir: Vec3, /** * The segments forming the closed profile. */ -segments: Array, } | { "type": "Extrude", +segments: Array, +/** + * Optional interior hole loops. Each entry is a closed loop of + * segments in the same sketch coordinate system, lying strictly + * inside the outer profile and disjoint from the other holes. + * Extrude turns each loop into an interior wall directly — no + * boolean Difference pass. Loop winding may be CW or CCW. + */ +holes?: Array>, } | { "type": "Extrude", /** * The sketch node to extrude. */ From 14952ea0901d8df732ef596806b5f2c025e01079 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Fri, 3 Jul 2026 22:55:46 -0500 Subject: [PATCH 2/7] review: earcut winding from total signed area, threshold 256, test map on slotmap keys, vcode empty-outer error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Winding normalization now sums signed area over ALL earcut output triangles instead of probing the first non-degenerate one — immune to per-triangle degeneracy and to assumptions about ring order. - EARCUT_VERTEX_THRESHOLD 64 -> 256 with the quality/speed trade-off documented: ordinary curved caps stay on the Steiner path, chip-scale polygons take earcut. - no_crossing test helper keys its half-edge pairing map on VertexId tuples directly (slotmap keys are Hash+Eq) instead of hashing Debug strings. - planar_regions_touch doc now states why outer-region separation is sufficient (holes lie strictly inside their outer region), not merely conservative. - vcode: targeted parse error for a sketch whose hole loops (H) precede any outer-loop segment. Co-Authored-By: Claude Fable 5 --- crates/vcad-ir/src/vcode.rs | 8 +++++ .../vcad-kernel-booleans/src/no_crossing.rs | 30 +++++++++---------- crates/vcad-kernel-tessellate/src/lib.rs | 24 ++++++++------- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/crates/vcad-ir/src/vcode.rs b/crates/vcad-ir/src/vcode.rs index 9b4b17dcc..e0acf3704 100644 --- a/crates/vcad-ir/src/vcode.rs +++ b/crates/vcad-ir/src/vcode.rs @@ -2147,6 +2147,14 @@ where } } + if segments.is_empty() && !holes.is_empty() { + return Err(VCodeParseError { + line: *current_line, + message: + "sketch has hole loops (H) but no outer-loop segments before the first H" + .to_string(), + }); + } Ok(CsgOp::Sketch2D { origin, x_dir, diff --git a/crates/vcad-kernel-booleans/src/no_crossing.rs b/crates/vcad-kernel-booleans/src/no_crossing.rs index f116132d2..e338795da 100644 --- a/crates/vcad-kernel-booleans/src/no_crossing.rs +++ b/crates/vcad-kernel-booleans/src/no_crossing.rs @@ -103,8 +103,14 @@ fn face_outer_verts(brep: &BRepSolid, face: FaceId) -> Vec { /// Do two coplanar face regions touch or overlap? Projects both outer loops /// into the shared plane and tests vertex containment (boundary-inclusive) -/// plus edge-edge intersection. Inner loops are ignored, which errs on the -/// side of reporting contact (safe: the general pipeline takes over). +/// plus edge-edge intersection. +/// +/// Inner (hole) loops are deliberately not examined: a hole lies strictly +/// inside its face's outer region, so if two outer regions are disjoint, +/// every point of either face's hole boundaries is disjoint from the other +/// face too — outer-region separation is sufficient, not just conservative. +/// (When outer regions do overlap, this returns `true` and the general +/// pipeline handles the geometry, holes included.) fn planar_regions_touch( a: &BRepSolid, fa: FaceId, @@ -676,17 +682,11 @@ mod vcad_kernel_sketch_probe { .map(|&(x, y)| topo.add_vertex(vcad_kernel_math::Point3::new(x, y, h))) .collect(); let mut faces = Vec::new(); - let mut he_pairs: std::collections::HashMap<(u64, u64), vcad_kernel_topo::HalfEdgeId> = - Default::default(); - let key = |v: vcad_kernel_topo::VertexId| -> u64 { - // slotmap keys are unique; hash via Debug format - let s = format!("{v:?}"); - let mut acc = 0u64; - for b in s.bytes() { - acc = acc.wrapping_mul(131).wrapping_add(b as u64); - } - acc - }; + // slotmap keys are Hash + Eq — key the pairing map on them directly. + let mut he_pairs: std::collections::HashMap< + (vcad_kernel_topo::VertexId, vcad_kernel_topo::VertexId), + vcad_kernel_topo::HalfEdgeId, + > = Default::default(); let mut mk_face = |topo: &mut Topology, geom: &mut GeometryStore, ring: &[vcad_kernel_topo::VertexId], @@ -704,10 +704,10 @@ mod vcad_kernel_sketch_probe { for (i, &he) in hes.iter().enumerate() { let a = ring[i]; let b = ring[(i + 1) % ring.len()]; - if let Some(&other) = he_pairs.get(&(key(b), key(a))) { + if let Some(&other) = he_pairs.get(&(b, a)) { topo.add_edge(he, other); } else { - he_pairs.insert((key(a), key(b)), he); + he_pairs.insert((a, b), he); } } let l = topo.add_loop(&hes); diff --git a/crates/vcad-kernel-tessellate/src/lib.rs b/crates/vcad-kernel-tessellate/src/lib.rs index eb7dd1b57..8bede7a9c 100644 --- a/crates/vcad-kernel-tessellate/src/lib.rs +++ b/crates/vcad-kernel-tessellate/src/lib.rs @@ -1376,7 +1376,12 @@ fn tessellate_planar_face_with_holes( /// from the bridge/ear-clip path to earcut. Small faces keep the existing /// path (its Steiner-ring refinement yields nicer triangles); large faces /// need earcut's near-linear running time. -const EARCUT_VERTEX_THRESHOLD: usize = 64; +// Trade-off: below this, the Steiner-ring path yields nicer, more uniform +// triangles (better shading on ordinary curved caps); above it, its O(n^2) +// refinement is too slow and earcut wins. 256 keeps a 32-segment arc cap +// with a few holes on the quality path while chip-scale polygons (GDS +// layers, thousands of vertices) take the fast path. +const EARCUT_VERTEX_THRESHOLD: usize = 256; /// Triangulate a projected planar polygon (with optional holes) via earcut. /// @@ -1416,20 +1421,19 @@ fn earcut_polygon_with_holes( return mesh; }; - // earcut's output winding follows the input outer ring; normalize to the - // same convention as `ear_clip_triangulate` (CCW in the projected frame - // unless `reversed`). Probe the first non-degenerate triangle. - let mut flip = false; + // earcut's output triangles are uniformly wound; measure that winding + // from the TOTAL signed area of the output (immune to any individual + // degenerate triangle and to assumptions about which ring the first + // triangle came from) and normalize to the same convention as + // `ear_clip_triangulate` (CCW in the projected frame unless `reversed`). + let mut area2 = 0.0; for t in tris.chunks(3) { let (ax, ay) = (data[2 * t[0]], data[2 * t[0] + 1]); let (bx, by) = (data[2 * t[1]], data[2 * t[1] + 1]); let (cx, cy) = (data[2 * t[2]], data[2 * t[2] + 1]); - let area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); - if area.abs() > 1e-12 { - flip = (area > 0.0) == reversed; - break; - } + area2 += (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); } + let flip = (area2 > 0.0) == reversed; for t in tris.chunks(3) { if flip { From 4c95ae70e16c1bb5cc0c92b3efe15c7cad97b78b Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Fri, 3 Jul 2026 23:02:19 -0500 Subject: [PATCH 3/7] review round 2: proof tests for sweep uniqueness + earcut winding, vcode H-first error placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bbox: regression test proving sweep_candidate_face_pairs equals the quadratic reference as an exact multiset even when A and B faces share min.x — each event scans only the opposite active list before pushing itself, so a pair is emitted exactly once, by whichever face enters second. - tessellate: regression test on a symmetric square frame proving earcut output triangles share one winding and their signed areas sum to the frame area — the region between outer and holes is what gets triangulated, so cancellation to zero cannot occur; the total-area probe is well-defined. - vcode: empty-outer guard moved into the H arm — errors at the H line itself and cannot be bypassed by early loop exits. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + crates/vcad-ir/src/vcode.rs | 15 ++++--- crates/vcad-kernel-booleans/Cargo.toml | 1 + crates/vcad-kernel-booleans/src/bbox.rs | 46 +++++++++++++++++++++ crates/vcad-kernel-tessellate/src/lib.rs | 51 ++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7b9a365ec..6437dbf38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7049,6 +7049,7 @@ version = "0.9.4" dependencies = [ "criterion", "rayon", + "slotmap", "vcad-kernel-geom", "vcad-kernel-math", "vcad-kernel-primitives", diff --git a/crates/vcad-ir/src/vcode.rs b/crates/vcad-ir/src/vcode.rs index e0acf3704..20c560d2e 100644 --- a/crates/vcad-ir/src/vcode.rs +++ b/crates/vcad-ir/src/vcode.rs @@ -2090,6 +2090,13 @@ where let seg = match seg_parts[0] { "H" => { + if segments.is_empty() && holes.is_empty() { + return Err(VCodeParseError { + line: *current_line, + message: "sketch hole loop (H) before any outer-loop segment" + .to_string(), + }); + } holes.push(Vec::new()); continue; } @@ -2147,14 +2154,6 @@ where } } - if segments.is_empty() && !holes.is_empty() { - return Err(VCodeParseError { - line: *current_line, - message: - "sketch has hole loops (H) but no outer-loop segments before the first H" - .to_string(), - }); - } Ok(CsgOp::Sketch2D { origin, x_dir, diff --git a/crates/vcad-kernel-booleans/Cargo.toml b/crates/vcad-kernel-booleans/Cargo.toml index 799f250df..3bc193bd6 100644 --- a/crates/vcad-kernel-booleans/Cargo.toml +++ b/crates/vcad-kernel-booleans/Cargo.toml @@ -19,6 +19,7 @@ vcad-kernel-tessellate = { workspace = true } rayon = "1.10" [dev-dependencies] +slotmap = { workspace = true } criterion = { version = "0.5", features = ["html_reports"] } [[bench]] diff --git a/crates/vcad-kernel-booleans/src/bbox.rs b/crates/vcad-kernel-booleans/src/bbox.rs index c4db1c77f..7d921106e 100644 --- a/crates/vcad-kernel-booleans/src/bbox.rs +++ b/crates/vcad-kernel-booleans/src/bbox.rs @@ -522,4 +522,50 @@ mod tests { assert!((aabb.max.y - 10.0).abs() < 1e-10); assert!((aabb.max.z - 10.0).abs() < 1e-10); } + + #[test] + fn sweep_emits_each_pair_exactly_once_even_with_equal_min_x() { + // Grid of A boxes and B boxes deliberately sharing min.x values so + // event ordering between sides at equal x is arbitrary. The sweep + // must match the quadratic reference as a MULTISET: any duplicate + // emission would show up as a count mismatch. + let mk = |x0: f64, y0: f64| Aabb3 { + min: Point3::new(x0, y0, 0.0), + max: Point3::new(x0 + 2.0, y0 + 2.0, 1.0), + }; + let a_faces: Vec<(FaceId, Aabb3)> = (0..6) + .map(|i| { + ( + FaceId::from(slotmap::KeyData::from_ffi(i + 1)), + mk((i % 3) as f64, (i / 3) as f64), + ) + }) + .collect(); + // B boxes share the same min.x lattice and overlap the A boxes. + let b_faces: Vec<(FaceId, Aabb3)> = (0..6) + .map(|i| { + ( + FaceId::from(slotmap::KeyData::from_ffi(100 + i)), + mk((i % 3) as f64, 0.5 + (i / 3) as f64), + ) + }) + .collect(); + + let mut swept = sweep_candidate_face_pairs(&a_faces, &b_faces); + + let mut reference: Vec<(FaceId, FaceId)> = Vec::new(); + for (fa, ba) in &a_faces { + for (fb, bb) in &b_faces { + if ba.overlaps(bb) { + reference.push((*fa, *fb)); + } + } + } + swept.sort(); + reference.sort(); + assert_eq!( + swept, reference, + "sweep must equal quadratic reference exactly (no dups, no misses)" + ); + } } diff --git a/crates/vcad-kernel-tessellate/src/lib.rs b/crates/vcad-kernel-tessellate/src/lib.rs index 8bede7a9c..395efb95a 100644 --- a/crates/vcad-kernel-tessellate/src/lib.rs +++ b/crates/vcad-kernel-tessellate/src/lib.rs @@ -4740,4 +4740,55 @@ mod tests { area ); } + + #[test] + fn earcut_frame_triangles_share_winding_and_sum_to_region_area() { + // A symmetric square frame (10x10 outer, 6x6 centered hole): the + // review conjecture was that "hole triangles" cancel the outer + // triangles' signed area. Earcut triangulates the FRAME REGION + // only; every output triangle is wound the same way, so the signed + // areas share one sign and sum to +/- the frame area (64), never 0. + let outer_2d = vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]; + let hole_2d = vec![(2.0, 2.0), (8.0, 2.0), (8.0, 8.0), (2.0, 8.0)]; + let outer_3d: Vec = outer_2d + .iter() + .map(|&(x, y)| Point3::new(x, y, 0.0)) + .collect(); + let hole_3d: Vec = hole_2d + .iter() + .map(|&(x, y)| Point3::new(x, y, 0.0)) + .collect(); + + let mesh = + earcut_polygon_with_holes(&outer_2d, &[hole_2d.clone()], &outer_3d, &[hole_3d], false); + assert!(!mesh.indices.is_empty()); + let mut total = 0.0; + let mut signs = std::collections::HashSet::new(); + for t in mesh.indices.chunks(3) { + let v = |i: u32| { + ( + mesh.vertices[3 * i as usize] as f64, + mesh.vertices[3 * i as usize + 1] as f64, + ) + }; + let (ax, ay) = v(t[0]); + let (bx, by) = v(t[1]); + let (cx, cy) = v(t[2]); + let area2 = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); + if area2.abs() > 1e-12 { + signs.insert(area2 > 0.0); + } + total += area2; + } + assert_eq!( + signs.len(), + 1, + "earcut output triangles must share one winding" + ); + assert!( + (total.abs() / 2.0 - 64.0).abs() < 1e-9, + "signed areas sum to the frame area, got {}", + total / 2.0 + ); + } } From 8233bc828083787d1d49fb8811bbd4beda648e37 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Fri, 3 Jul 2026 23:06:02 -0500 Subject: [PATCH 4/7] perf(no_crossing): swept edge-contact test + single containment probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit planar_regions_touch was O(na·nb) twice over — every vertex of each polygon ray-cast against the other, then every edge pair tested. The gate runs for every coplanar candidate face pair in a union tree, and chip-layer islands carry thousands of vertices: profiling the 88k-poly die showed the contact gate itself dominating evaluation (>20 min). Now: edge contact is swept over tolerance-padded x-intervals (same pattern as the face broadphase), and when no edges touch the regions are provably either disjoint or nested, so one boundary-inclusive containment probe per side replaces the all-vertex scan. Co-Authored-By: Claude Fable 5 --- .../vcad-kernel-booleans/src/no_crossing.rs | 79 +++++++++++++++---- 1 file changed, 64 insertions(+), 15 deletions(-) diff --git a/crates/vcad-kernel-booleans/src/no_crossing.rs b/crates/vcad-kernel-booleans/src/no_crossing.rs index e338795da..34b8b66d9 100644 --- a/crates/vcad-kernel-booleans/src/no_crossing.rs +++ b/crates/vcad-kernel-booleans/src/no_crossing.rs @@ -136,25 +136,74 @@ fn planar_regions_touch( let poly_a: Vec<(f64, f64)> = verts_a.iter().map(&proj).collect(); let poly_b: Vec<(f64, f64)> = verts_b.iter().map(&proj).collect(); - // Any vertex of one polygon inside (or on the boundary of) the other? - if poly_b.iter().any(|p| point_in_poly_inclusive(*p, &poly_a)) - || poly_a.iter().any(|p| point_in_poly_inclusive(*p, &poly_b)) - { + // Any edge contact (crossing or touching within tolerance)? Swept over + // x-intervals rather than all-pairs: chip-layer islands have thousands + // of edges per face, and this gate runs for every coplanar candidate + // pair in a union tree — the blind O(na·nb) loop dominated evaluation. + if edges_touch_swept(&poly_a, &poly_b) { return true; } - // Any edge crossing (including touching within tolerance)? - let na = poly_a.len(); - let nb = poly_b.len(); - for i in 0..na { - let a1 = poly_a[i]; - let a2 = poly_a[(i + 1) % na]; - for j in 0..nb { - let b1 = poly_b[j]; - let b2 = poly_b[(j + 1) % nb]; - if segments_touch(a1, a2, b1, b2) { - return true; + // No boundary contact: the regions are either fully disjoint or one is + // nested inside the other, so a single containment probe per side + // decides (any shared-boundary case was caught above within TOL). + point_in_poly_inclusive(poly_b[0], &poly_a) || point_in_poly_inclusive(poly_a[0], &poly_b) +} + +/// Do any two edges of the polygons touch? Sweep over ascending edge +/// `min.x` with tolerance padding, pruning by `max.x` — O((n+m)·log + k) +/// for spread inputs instead of the all-pairs scan. +fn edges_touch_swept(poly_a: &[(f64, f64)], poly_b: &[(f64, f64)]) -> bool { + // (min_x, max_x, i) per edge, tolerance-padded. + let edges = |poly: &[(f64, f64)]| -> Vec<(f64, f64, usize)> { + let n = poly.len(); + let mut v: Vec<(f64, f64, usize)> = (0..n) + .map(|i| { + let p = poly[i]; + let q = poly[(i + 1) % n]; + (p.0.min(q.0) - TOL, p.0.max(q.0) + TOL, i) + }) + .collect(); + v.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap_or(std::cmp::Ordering::Equal)); + v + }; + let ea = edges(poly_a); + let eb = edges(poly_b); + let seg = |poly: &[(f64, f64)], i: usize| -> ((f64, f64), (f64, f64)) { + (poly[i], poly[(i + 1) % poly.len()]) + }; + + // Merge the two sorted event lists; keep the opposite side's active + // edges pruned by max_x (same pattern as sweep_candidate_face_pairs). + let (mut ia, mut ib) = (0usize, 0usize); + let mut active_a: Vec = Vec::new(); // indices into ea + let mut active_b: Vec = Vec::new(); + while ia < ea.len() || ib < eb.len() { + let take_a = ib >= eb.len() || (ia < ea.len() && ea[ia].0 <= eb[ib].0); + if take_a { + let (min_x, _, i) = ea[ia]; + active_b.retain(|&k| eb[k].1 >= min_x); + let (a1, a2) = seg(poly_a, i); + for &k in &active_b { + let (b1, b2) = seg(poly_b, eb[k].2); + if segments_touch(a1, a2, b1, b2) { + return true; + } + } + active_a.push(ia); + ia += 1; + } else { + let (min_x, _, j) = eb[ib]; + active_a.retain(|&k| ea[k].1 >= min_x); + let (b1, b2) = seg(poly_b, j); + for &k in &active_a { + let (a1, a2) = seg(poly_a, ea[k].2); + if segments_touch(a1, a2, b1, b2) { + return true; + } } + active_b.push(ib); + ib += 1; } } false From 33a433c5f82d7b537e60fda8a8bbf6635bd77ee5 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Fri, 3 Jul 2026 23:09:18 -0500 Subject: [PATCH 5/7] fix(tessellate): earcut failure falls back to the ear-clip path earcut_polygon_with_holes returned an empty mesh on earcutr::Err, silently dropping the cap face and leaving the tessellation non-watertight. It now returns Option; both call sites fall through to the bridge/ear-clip path on None, so a failed triangulation costs speed, never a hole in the solid. Co-Authored-By: Claude Fable 5 --- crates/vcad-kernel-tessellate/src/lib.rs | 37 ++++++++++++++---------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/crates/vcad-kernel-tessellate/src/lib.rs b/crates/vcad-kernel-tessellate/src/lib.rs index 395efb95a..401571cb4 100644 --- a/crates/vcad-kernel-tessellate/src/lib.rs +++ b/crates/vcad-kernel-tessellate/src/lib.rs @@ -1089,7 +1089,10 @@ fn tessellate_concave_polygon(verts: &[Point3], reversed: bool) -> TriangleMesh // Large concave polygons (e.g. 1000+-vertex chip-layer island caps) // make the O(n³) ear-clip loop below impractical; earcut is near-linear. if n > EARCUT_VERTEX_THRESHOLD { - return earcut_polygon_with_holes(&verts_2d, &[], verts, &[], reversed); + if let Some(mesh) = earcut_polygon_with_holes(&verts_2d, &[], verts, &[], reversed) { + return mesh; + } + // earcut failed — fall through to the ear-clip path below. } // Build mesh with all 3D vertices @@ -1357,13 +1360,12 @@ fn tessellate_planar_face_with_holes( // near-linear and handles holes natively without Steiner points. let total_verts = outer_2d.len() + inner_2d.iter().map(Vec::len).sum::(); if total_verts > EARCUT_VERTEX_THRESHOLD { - return earcut_polygon_with_holes( - &outer_2d, - &inner_2d, - &outer_verts, - &inner_loops, - reversed, - ); + if let Some(mesh) = + earcut_polygon_with_holes(&outer_2d, &inner_2d, &outer_verts, &inner_loops, reversed) + { + return mesh; + } + // earcut failed — fall through to the bridge + ear-clip path below. } // After merging overlapping arcs, use bridge+ear-clip directly. @@ -1394,7 +1396,7 @@ fn earcut_polygon_with_holes( outer_3d: &[Point3], inner_3d: &[Vec], reversed: bool, -) -> TriangleMesh { +) -> Option { let mut mesh = TriangleMesh::new(); let mut data: Vec = Vec::with_capacity(2 * (outer_2d.len() + inner_2d.len() * 4)); @@ -1417,9 +1419,13 @@ fn earcut_polygon_with_holes( mesh.vertices.push(v.z as f32); } - let Ok(tris) = earcutr::earcut(&data, &hole_starts, 2) else { - return mesh; - }; + // A failed or empty triangulation must NOT silently produce a capless + // (non-watertight) solid — signal the caller to fall back to the + // bridge/ear-clip path instead. + let tris = earcutr::earcut(&data, &hole_starts, 2).ok()?; + if tris.is_empty() { + return None; + } // earcut's output triangles are uniformly wound; measure that winding // from the TOTAL signed area of the output (immune to any individual @@ -1447,7 +1453,7 @@ fn earcut_polygon_with_holes( } } - mesh + Some(mesh) } /// Merge inner loops that overlap (e.g., two semicircular arcs forming a full circle). @@ -4760,9 +4766,10 @@ mod tests { .collect(); let mesh = - earcut_polygon_with_holes(&outer_2d, &[hole_2d.clone()], &outer_3d, &[hole_3d], false); + earcut_polygon_with_holes(&outer_2d, &[hole_2d.clone()], &outer_3d, &[hole_3d], false) + .expect("frame triangulates"); assert!(!mesh.indices.is_empty()); - let mut total = 0.0; + let mut total = 0.0_f64; let mut signs = std::collections::HashSet::new(); for t in mesh.indices.chunks(3) { let v = |i: u32| { From a32cc4cdf26d3defdb9bff4c9182f3e3b0b39782 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Fri, 3 Jul 2026 23:11:24 -0500 Subject: [PATCH 6/7] docs(no_crossing): point module header at the pipeline gate Co-Authored-By: Claude Fable 5 --- crates/vcad-kernel-booleans/src/no_crossing.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/vcad-kernel-booleans/src/no_crossing.rs b/crates/vcad-kernel-booleans/src/no_crossing.rs index 34b8b66d9..0893712dc 100644 --- a/crates/vcad-kernel-booleans/src/no_crossing.rs +++ b/crates/vcad-kernel-booleans/src/no_crossing.rs @@ -12,6 +12,13 @@ //! general pipeline so coincident faces are deduplicated; [`boundaries_touch`] //! detects coincident-surface candidate pairs whose 2D regions actually //! touch and vetoes the fast path. +//! +//! The gate lives in `pipeline.rs` (`brep_boolean`): this path is entered +//! only when SSI over all broadphase candidate pairs produced **zero** real +//! crossings AND [`boundaries_touch`] reports no coincident-region contact; +//! [`resolve_containment`] returning `None` (degenerate ray casts) also +//! falls back to the general pipeline. Every ambiguity resolves toward the +//! slow, general path. use vcad_kernel_geom::Plane; use vcad_kernel_math::{Point3, Vec3}; From 8118970d999a040cb627514ad65e30489f6e196e Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Fri, 3 Jul 2026 23:13:27 -0500 Subject: [PATCH 7/7] docs(sketch): document the vertex-cache quantization grid vs sew tolerance Co-Authored-By: Claude Fable 5 --- crates/vcad-kernel-sketch/src/extrude.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/vcad-kernel-sketch/src/extrude.rs b/crates/vcad-kernel-sketch/src/extrude.rs index a7cba7bd1..12415c183 100644 --- a/crates/vcad-kernel-sketch/src/extrude.rs +++ b/crates/vcad-kernel-sketch/src/extrude.rs @@ -96,7 +96,12 @@ pub fn extrude(profile: &SketchProfile, direction: Vec3) -> Result VertexId + // Vertex cache: quantized position -> VertexId. The 1e-9 grid (1 pm in + // mm units) is deliberately three orders finer than the sew tolerance + // (1e-6): this cache only unifies float-identical endpoints shared by + // consecutive segments and by the cap/lateral construction — proximity + // welding of genuinely distinct vertices is sew's job, and a coarser + // grid here would alias real geometry instead of preventing it. let mut vertex_cache: HashMap<[i64; 3], VertexId> = HashMap::new(); let quantize_pt = |p: Point3| -> [i64; 3] {