From 7c9f8a58955a6a89ef0b102d63fd2d045bed4437 Mon Sep 17 00:00:00 2001 From: selune <35337584+0xlillith@users.noreply.github.com> Date: Sun, 18 Oct 2026 06:29:12 +0200 Subject: [PATCH] Drawing: validate section geometry contracts (#2053) (#2085) Thanks @0xlillith. --- ROADMAP.md | 5 + crates/geom/src/lib.rs | 1 + crates/geom/src/section.rs | 291 ++++++++++++++++++++++ crates/geom/src/section_tests.rs | 409 +++++++++++++++++++++++++++++++ docs/drawing-foundations.md | 138 +++++++++++ docs/drawing-geometry.md | 253 +++++++++++++++++++ 6 files changed, 1097 insertions(+) create mode 100644 crates/geom/src/section.rs create mode 100644 crates/geom/src/section_tests.rs create mode 100644 docs/drawing-foundations.md create mode 100644 docs/drawing-geometry.md diff --git a/ROADMAP.md b/ROADMAP.md index 852c3348..7c04725d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -73,6 +73,11 @@ hierarchies are shared; bounded desktop preparation and occlusion use up to eigh Curves are approximated; drawing sheets, annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md). +Drawing foundations: [RFC #860](https://github.com/tangency-labs/solidcraft/issues/860) +and the [foundation ledger](docs/drawing-foundations.md) track the bounded implementation +packages. The first geometry increment defines validated section records in a plane-local +frame; the ledger separates that contract from section producers and the Drawing workspace. + Desktop view DXF export runs in the file-operation worker with five-stage progress and cooperative cancellation. Curve tolerance (0.0001–1 mm, default 0.01 mm) is remembered alongside the hidden-edge setting. Cancelled exports preserve the model and the previous destination; diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 9b968092..f34c852f 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -23,6 +23,7 @@ pub mod mesh_distance; pub mod network; mod plane; mod profile; +pub mod section; pub mod spline_space; pub mod tess; pub mod tspline; diff --git a/crates/geom/src/section.rs b/crates/geom/src/section.rs new file mode 100644 index 00000000..ee2d9923 --- /dev/null +++ b/crates/geom/src/section.rs @@ -0,0 +1,291 @@ +//! Backend-neutral section geometry in a model-space plane, in millimetres and radians. +//! +//! Curve and wire indices identify items in this result only, not persistent model geometry. +//! Producers own source identity, topology and accuracy evidence. [`SectionGeometry::validate_structure`] +//! checks bounded payloads, references and connected wires; it does not certify simple loops, +//! winding, hole nesting, region separation, source coincidence or an approximation bound. +//! Material producers must supply CCW outer loops and CW holes in the plane's x/y basis, +//! whose normal is x cross y. Conversion preserves those supplied orientations. +//! +//! Stored coordinates are finite and at most 1e12 mm in absolute value. Radii and conic +//! weights lie in 1e-12..=1e12; arc starts in -1e6..=1e6 radians and absolute sweeps in +//! 1e-12..=one turn (with 1e-12 radians of allowance). Positive conic weights are the supported +//! subset. These arithmetic limits do not promise a small absolute error at extreme magnitudes. +//! Closure tolerance is 1e-9..=1 mm, independent of approximation accuracy. +//! +//! Validate after deserialization and before use or serialization. The DTOs deliberately retain +//! [`Plane`]'s existing serde representation and construction behavior. Validation after parsing +//! cannot bound parsing allocations: an encoded-input boundary must also limit bytes and nesting. +//! The per-wire/per-region conversions revalidate the whole result on each call; they are not an +//! optimized batch-conversion API. Existing profile area/containment methods are not general +//! exact or certified measures for conics and freeform curves. Profile values contain geometry +//! only: callers retain the original result's accuracy declarations and local identities. + +use std::f64::consts::TAU; + +use serde::{Deserialize, Serialize}; + +use crate::{Loop2, MAX_BEZIER_DEGREE, Plane, Region2, Seg2, Vec2, Vec3}; + +/// Aggregate input records/references, and the selected conversion's output items, at most. +/// Input counts curves, wires, regions, contacts, curve uses and hole references together. +pub const MAX_ITEMS: usize = 100_000; +const MAX_NUMBER: f64 = 1e12; +const MIN_POSITIVE: f64 = 1e-12; +const MAX_START_ANGLE: f64 = 1e6; +const FRAME_TOLERANCE: f64 = 1e-8; + +/// A section's geometric result, without source identity, freshness or persistence semantics. +/// Empty, open-wire and contact-only results remain distinct; no material is inferred from them. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SectionGeometry { + pub plane: Plane, + pub curves: Vec, + pub wires: Vec, + pub regions: Vec, + /// Isolated contacts in plane coordinates, not invented closed regions. + pub contacts: Vec, +} + +/// One curve and the producer's declared accuracy relative to its source. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SectionCurve { + pub geometry: Seg2, + pub accuracy: CurveAccuracy, +} + +/// How the producer represents its source; structure validation cannot certify this declaration. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum CurveAccuracy { + /// An analytic representation without fitting; not a certificate about the source or topology. + Analytic, + /// A producer-established bound, not merely a requested fitting tolerance. + Bounded { max_deviation_mm: f64 }, + /// A deviation measured at samples only, with no bound between them. Zero remains sampled. + Sampled { max_sample_deviation_mm: f64 }, +} + +/// An ordered connected chain. `closed` is explicit, never inferred or repaired. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SectionWire { + pub curves: Vec, + pub closed: bool, +} + +/// An oriented use of a curve by its result-local index. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CurveUse { + pub curve: u32, + pub forward: bool, +} + +/// A producer-declared material region referring to closed wires. Shared wires are permitted. +/// The outer wire cannot also be a hole, and a hole cannot occur twice in this one region. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SectionRegion { + pub outer: u32, + pub holes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[error("section geometry: {0}")] +pub struct Invalid(pub &'static str); + +type Result = std::result::Result; + +fn require(condition: bool, message: &'static str) -> Result<()> { + if condition { Ok(()) } else { Err(Invalid(message)) } +} + +struct Budget(usize); +impl Budget { + fn add(&mut self, n: usize) -> Result<()> { + self.0 = self.0.checked_add(n).ok_or(Invalid("item budget"))?; + require(self.0 <= MAX_ITEMS, "item budget") + } +} + +fn numbers(values: impl IntoIterator) -> Result<()> { + require(values.into_iter().all(|v| v.is_finite() && v.abs() <= MAX_NUMBER), "non-finite or excessive coordinate") +} + +fn point(p: Vec2) -> Result<()> { + numbers([p.x, p.y]) +} + +fn vector(p: Vec3) -> Result<()> { + numbers([p.x, p.y, p.z]) +} + +/// Strictly check an existing plane without re-orthonormalizing it or changing its serde format. +/// Unit-length and orthogonality checks use 1e-8; the normal is derived as x cross y. +pub fn validate_plane(plane: &Plane) -> Result<()> { + vector(plane.origin)?; + vector(plane.x)?; + vector(plane.y)?; + require( + (plane.x.len() - 1.0).abs() <= FRAME_TOLERANCE + && (plane.y.len() - 1.0).abs() <= FRAME_TOLERANCE + && plane.x.dot(plane.y).abs() <= FRAME_TOLERANCE, + "nonorthonormal plane", + ) +} + +fn controls(points: &[Vec2]) -> Result<()> { + for p in points { + point(*p)?; + } + let Some((first, rest)) = points.split_first() else { return Err(Invalid("empty controls")) }; + require(rest.iter().any(|p| p != first), "collapsed curve") +} + +fn curve(curve: &SectionCurve) -> Result<()> { + match curve.geometry { + Seg2::Line { a, b } => controls(&[a, b])?, + Seg2::Arc { center, radius, start, sweep } => { + point(center)?; + require(radius.is_finite() && (MIN_POSITIVE..=MAX_NUMBER).contains(&radius), "arc radius")?; + require(start.is_finite() && start.abs() <= MAX_START_ANGLE, "arc start angle")?; + require(sweep.is_finite() && (MIN_POSITIVE..=TAU + 1e-12).contains(&sweep.abs()), "arc sweep")?; + } + Seg2::Cubic { p0, p1, p2, p3 } => controls(&[p0, p1, p2, p3])?, + Seg2::Conic { a, apex, b, w } => { + controls(&[a, apex, b])?; + require(w.is_finite() && (MIN_POSITIVE..=MAX_NUMBER).contains(&w), "conic weight")?; + } + Seg2::Bezier { n, ref p } => { + require((4..=MAX_BEZIER_DEGREE).contains(&usize::from(n)), "Bezier degree")?; + // The unused slots are serialized too: reject hidden non-finite/excessive values. + for q in p { + point(*q)?; + } + controls(p.get(..=usize::from(n)).ok_or(Invalid("Bezier degree"))?)?; + } + } + require(curve.geometry.start().is_finite() && curve.geometry.end().is_finite(), "non-finite curve endpoint")?; + match curve.accuracy { + CurveAccuracy::Analytic => Ok(()), + CurveAccuracy::Bounded { max_deviation_mm } => deviation(max_deviation_mm), + CurveAccuracy::Sampled { max_sample_deviation_mm } => deviation(max_sample_deviation_mm), + } +} + +fn deviation(value: f64) -> Result<()> { + require(value.is_finite() && (0.0..=MAX_NUMBER).contains(&value), "accuracy deviation") +} + +impl SectionGeometry { + /// Check finite supported payloads, aggregate counts, references and wire connectivity. + /// This does not certify material validity, winding, nesting, source identity or accuracy. + /// Count preflight precedes geometry evaluation; encoded input needs separate parsing limits. + pub fn validate_structure(&self, closure_tolerance_mm: f64) -> Result<()> { + let mut budget = Budget(0); + budget.add(self.curves.len())?; + budget.add(self.wires.len())?; + budget.add(self.regions.len())?; + budget.add(self.contacts.len())?; + for wire in &self.wires { + budget.add(wire.curves.len())?; + } + for region in &self.regions { + budget.add(region.holes.len())?; + } + require(closure_tolerance_mm.is_finite() && (1e-9..=1.0).contains(&closure_tolerance_mm), "closure tolerance must be between 1e-9 and 1 mm")?; + validate_plane(&self.plane)?; + for c in &self.curves { + curve(c)?; + } + for p in &self.contacts { + point(*p)?; + } + for wire in &self.wires { + require(!wire.curves.is_empty(), "empty wire")?; + let mut first = None; + let mut last: Option = None; + for use_ in &wire.curves { + let c = &self.curves.get(use_.curve as usize).ok_or(Invalid("curve reference"))?.geometry; + let (a, b) = (c.start(), c.end()); + let (start, end) = if use_.forward { (a, b) } else { (b, a) }; + require(last.is_none_or(|p| p.dist(start) <= closure_tolerance_mm), "disconnected wire")?; + first.get_or_insert(start); + last = Some(end); + } + if wire.closed { + require(first.zip(last).is_some_and(|(a, b)| a.dist(b) <= closure_tolerance_mm), "open closed wire")?; + } + } + // One stamp array for all regions: neither pairwise duplicate checks nor clearing the + // entire array per region. Sharing the same wire between different regions is allowed. + let mut seen = vec![usize::MAX; self.wires.len()]; + for (stamp, region) in self.regions.iter().enumerate() { + for id in std::iter::once(®ion.outer).chain(®ion.holes) { + let wire = self.wires.get(*id as usize).ok_or(Invalid("wire reference"))?; + require(wire.closed, "region boundary is not closed")?; + let mark = seen.get_mut(*id as usize).ok_or(Invalid("wire reference"))?; + require(*mark != stamp, "duplicate region boundary")?; + *mark = stamp; + } + } + Ok(()) + } + + /// Copy one closed wire to a profile loop, preserving its oriented curve uses. + /// Revalidates the whole result; an open wire is never silently closed. Floating-point + /// reversal must also keep the converted loop connected within the same tolerance. + pub fn to_loop(&self, wire: u32, closure_tolerance_mm: f64) -> Result { + self.validate_structure(closure_tolerance_mm)?; + let wire = self.wires.get(wire as usize).ok_or(Invalid("wire reference"))?; + require(wire.closed, "wire is not closed")?; + let mut budget = Budget(0); + budget.add(1)?; + budget.add(wire.curves.len())?; + self.copy_loop(wire, closure_tolerance_mm) + } + + /// Copy one declared region to a profile, preserving winding and hole order. + /// Revalidates the whole result and preflights expanded output before allocating it. + /// This does not certify the producer's region. The original result is unchanged; the + /// returned profile contains no accuracy declarations or result-local identities. + pub fn to_region(&self, region: u32, closure_tolerance_mm: f64) -> Result { + self.validate_structure(closure_tolerance_mm)?; + let region = self.regions.get(region as usize).ok_or(Invalid("region reference"))?; + let mut budget = Budget(0); + budget.add(1)?; + budget.add(region.holes.len())?; + for id in std::iter::once(®ion.outer).chain(®ion.holes) { + let wire = self.wires.get(*id as usize).ok_or(Invalid("wire reference"))?; + budget.add(wire.curves.len())?; + } + let outer = self.copy_loop(self.wires.get(region.outer as usize).ok_or(Invalid("wire reference"))?, closure_tolerance_mm)?; + let mut holes = Vec::with_capacity(region.holes.len()); + for id in ®ion.holes { + holes.push(self.copy_loop(self.wires.get(*id as usize).ok_or(Invalid("wire reference"))?, closure_tolerance_mm)?); + } + Ok(Region2 { outer, holes }) + } + + // Called only while the publicly validated result remains immutably borrowed and the + // selected output's expansion has been counted. Index access stays fallible nonetheless. + fn copy_loop(&self, wire: &SectionWire, closure_tolerance_mm: f64) -> Result { + let mut segs = Vec::with_capacity(wire.curves.len()); + for use_ in &wire.curves { + let c = self.curves.get(use_.curve as usize).ok_or(Invalid("curve reference"))?.geometry; + segs.push(if use_.forward { c } else { c.reversed() }); + } + let loop_ = Loop2 { segs }; + // Reversing an arc changes its stored start to start + sweep. Subtracting the sweep + // at its new end need not recover the original angle exactly at large radii. + require(loop_.is_closed(closure_tolerance_mm), "converted loop is not closed within tolerance")?; + Ok(loop_) + } +} + +#[cfg(test)] +#[path = "section_tests.rs"] +mod tests; diff --git a/crates/geom/src/section_tests.rs b/crates/geom/src/section_tests.rs new file mode 100644 index 00000000..961409ac --- /dev/null +++ b/crates/geom/src/section_tests.rs @@ -0,0 +1,409 @@ +use std::f64::consts::PI; + +use super::*; +use crate::{MAX_BEZIER_CTRL, bezier}; + +const TOL: f64 = 1e-6; + +fn empty(plane: Plane) -> SectionGeometry { + SectionGeometry { plane, curves: vec![], wires: vec![], regions: vec![], contacts: vec![] } +} + +fn analytic(geometry: Seg2) -> SectionCurve { + SectionCurve { geometry, accuracy: CurveAccuracy::Analytic } +} + +fn used(curve: u32, forward: bool) -> CurveUse { + CurveUse { curve, forward } +} + +fn plate() -> SectionGeometry { + let p = Vec2::new; + SectionGeometry { + plane: Plane::XY.offset(10.0), + curves: vec![ + // Deliberately stored backwards: the wire's use restores the CCW rectangle. + analytic(Seg2::Line { a: p(100.0, 0.0), b: p(0.0, 0.0) }), + analytic(Seg2::Line { a: p(100.0, 0.0), b: p(100.0, 60.0) }), + analytic(Seg2::Line { a: p(100.0, 60.0), b: p(0.0, 60.0) }), + analytic(Seg2::Line { a: p(0.0, 60.0), b: p(0.0, 0.0) }), + analytic(Seg2::Arc { center: p(50.0, 30.0), radius: 5.0, start: 0.0, sweep: PI }), + analytic(Seg2::Arc { center: p(50.0, 30.0), radius: 5.0, start: PI, sweep: PI }), + ], + wires: vec![ + SectionWire { curves: vec![used(0, false), used(1, true), used(2, true), used(3, true)], closed: true }, + SectionWire { curves: vec![used(5, false), used(4, false)], closed: true }, + ], + regions: vec![SectionRegion { outer: 0, holes: vec![1] }], + contacts: vec![], + } +} + +#[test] +fn section_frame_preserves_named_translated_tilted_offset_and_flip_conventions() { + for plane in [Plane::XY, Plane::XZ, Plane::YZ] { + validate_plane(&plane).unwrap(); + } + assert_eq!(Plane::XZ.normal(), Vec3::Y); + assert_eq!(Plane::XZ.to_world(Vec2::new(3.0, 4.0)), Vec3::new(3.0, 0.0, -4.0)); + let plane = Plane::from_normal(Vec3::new(13.0, -7.0, 19.0), Vec3::new(2.0, -3.0, 5.0)).unwrap(); + validate_plane(&plane).unwrap(); + let local = Vec2::new(7.0, -11.0); + let height = 4.5; + let point = plane.to_world(local) + plane.normal() * height; + assert!(plane.to_local(point).dist(local) < 1e-12); + assert!((plane.height(point) - height).abs() < 1e-12); + let moved = plane.offset(8.0); + validate_plane(&moved).unwrap(); + assert!((plane.height(moved.origin) - 8.0).abs() < 1e-12); + for about_y in [false, true] { + let flipped = moved.flipped(about_y); + validate_plane(&flipped).unwrap(); + assert_eq!(flipped.origin, moved.origin); + assert_eq!(flipped.normal(), -moved.normal()); + assert_eq!(flipped.flipped(about_y), moved); + let on_plane = moved.to_world(local); + let mirrored = if about_y { Vec2::new(-local.x, local.y) } else { Vec2::new(local.x, -local.y) }; + assert!(flipped.to_local(on_plane).dist(mirrored) < 1e-12); + assert!((flipped.height(point) + moved.height(point)).abs() < 1e-12); + } +} + +#[test] +fn strict_frame_validation_does_not_repair_raw_or_deserialized_axes() { + let bad = Plane { x: Vec3::X * 2.0, ..Plane::XY }; + assert!(Plane::new(bad.origin, bad.x, bad.y).is_some()); + assert_eq!(validate_plane(&bad), Err(Invalid("nonorthonormal plane"))); + assert_eq!(bad.x, Vec3::X * 2.0); + let restored: Plane = serde_json::from_value(serde_json::json!({ + "origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [1, 1, 0] + })) + .unwrap(); + assert_eq!(validate_plane(&restored), Err(Invalid("nonorthonormal plane"))); + for plane in [ + Plane { origin: Vec3::new(f64::NAN, 0.0, 0.0), ..Plane::XY }, + Plane { origin: Vec3::new(1e12 + 1.0, 0.0, 0.0), ..Plane::XY }, + Plane { x: Vec3::new(f64::MAX, 0.0, 0.0), ..Plane::XY }, + Plane { y: Vec3::new(0.0, f64::INFINITY, 0.0), ..Plane::XY }, + Plane { x: Vec3::ZERO, ..Plane::XY }, + Plane { y: Vec3::X, ..Plane::XY }, + ] { + assert!(validate_plane(&plane).is_err()); + } + validate_plane(&Plane { origin: Vec3::new(1e12, -1e12, 1e12), ..Plane::XY }).unwrap(); +} + +#[test] +fn line_arc_region_conversion_preserves_winding_and_analytic_area() { + let section = plate(); + let before = section.clone(); + section.validate_structure(TOL).unwrap(); + let outer = section.to_loop(0, TOL).unwrap(); + let hole = section.to_loop(1, TOL).unwrap(); + assert!(outer.is_closed(TOL) && hole.is_closed(TOL)); + assert!((outer.signed_area() - 6000.0).abs() < 1e-10); + assert!((hole.signed_area() + 25.0 * PI).abs() < 1e-10); + let region = section.to_region(0, TOL).unwrap(); + assert_eq!(region.outer, outer); + assert_eq!(region.holes, vec![hole]); + assert!((region.area() - (6000.0 - 25.0 * PI)).abs() < 1e-10); + assert_eq!(section, before); +} + +#[test] +fn empty_contacts_and_open_wires_remain_distinct_without_inferred_material() { + let empty = empty(Plane::XY); + empty.validate_structure(TOL).unwrap(); + assert_eq!(empty.to_region(0, TOL), Err(Invalid("region reference"))); + let mut contact = empty.clone(); + contact.contacts.push(Vec2::new(2.0, 3.0)); + contact.validate_structure(TOL).unwrap(); + assert_ne!(contact, empty); + let mut open = empty.clone(); + open.curves = vec![analytic(Seg2::Line { a: Vec2::ZERO, b: Vec2::X }), analytic(Seg2::Line { a: Vec2::X, b: Vec2::new(1.0, 1.0) })]; + open.wires = vec![SectionWire { curves: vec![used(0, true), used(1, true)], closed: false }]; + open.validate_structure(TOL).unwrap(); + assert_eq!(open.to_loop(0, TOL), Err(Invalid("wire is not closed"))); + open.regions.push(SectionRegion { outer: 0, holes: vec![] }); + assert_eq!(open.validate_structure(TOL), Err(Invalid("region boundary is not closed"))); + // Even a geometrically closed chain remains explicitly open when the producer says so. + let mut declared_open = plate(); + declared_open.regions.clear(); + declared_open.wires[0].closed = false; + declared_open.validate_structure(TOL).unwrap(); + assert_eq!(declared_open.to_loop(0, TOL), Err(Invalid("wire is not closed"))); +} + +#[test] +fn bad_references_empty_disconnected_and_unclosed_wires_fail() { + let mut section = plate(); + section.wires[0].curves[0].curve = u32::MAX; + assert_eq!(section.validate_structure(TOL), Err(Invalid("curve reference"))); + let mut section = plate(); + section.wires[0].curves.clear(); + assert_eq!(section.validate_structure(TOL), Err(Invalid("empty wire"))); + let mut section = plate(); + section.wires[0].curves[1].forward = false; + assert_eq!(section.validate_structure(TOL), Err(Invalid("disconnected wire"))); + let mut section = plate(); + section.wires[0].curves.pop(); + assert_eq!(section.validate_structure(TOL), Err(Invalid("open closed wire"))); + let mut section = plate(); + section.regions[0].outer = u32::MAX; + assert_eq!(section.validate_structure(TOL), Err(Invalid("wire reference"))); + let mut section = plate(); + section.regions[0].holes[0] = u32::MAX; + assert_eq!(section.validate_structure(TOL), Err(Invalid("wire reference"))); + assert_eq!(plate().to_loop(u32::MAX, TOL), Err(Invalid("wire reference"))); + assert_eq!(plate().to_region(u32::MAX, TOL), Err(Invalid("region reference"))); +} + +#[test] +fn duplicate_boundaries_are_local_to_a_region_and_large_reference_tables_are_bounded() { + let mut section = plate(); + section.regions[0].holes.push(1); + assert_eq!(section.validate_structure(TOL), Err(Invalid("duplicate region boundary"))); + let mut section = plate(); + section.regions[0].holes.push(0); + assert_eq!(section.validate_structure(TOL), Err(Invalid("duplicate region boundary"))); + let mut section = plate(); + section.regions.push(section.regions[0].clone()); + section.validate_structure(TOL).unwrap(); + assert_eq!(section.to_region(0, TOL).unwrap(), section.to_region(1, TOL).unwrap()); + // Deliberately coincident boundaries: this tests a large legal reference graph, not material. + let mut many = empty(Plane::XY); + many.curves.push(analytic(Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start: 0.0, sweep: TAU })); + many.wires = vec![SectionWire { curves: vec![used(0, true)], closed: true }; 201]; + many.regions = vec![SectionRegion { outer: 0, holes: (1..=200).collect() }; 200]; + many.validate_structure(TOL).unwrap(); + let last = many.regions.last_mut().unwrap(); + *last.holes.last_mut().unwrap() = 1; + assert_eq!(many.validate_structure(TOL), Err(Invalid("duplicate region boundary"))); +} + +#[test] +fn aggregate_counts_fail_before_geometry_or_reference_inspection() { + let mut section = empty(Plane::XY); + section.contacts = vec![Vec2::new(f64::NAN, 0.0); MAX_ITEMS + 1]; + assert_eq!(section.validate_structure(TOL), Err(Invalid("item budget"))); + let mut section = empty(Plane::XY); + section.wires.push(SectionWire { curves: vec![used(u32::MAX, false); MAX_ITEMS], closed: false }); + assert_eq!(section.validate_structure(TOL), Err(Invalid("item budget"))); + let mut section = empty(Plane::XY); + section.regions.push(SectionRegion { outer: u32::MAX, holes: vec![u32::MAX; MAX_ITEMS] }); + assert_eq!(section.validate_structure(TOL), Err(Invalid("item budget"))); + let mut section = empty(Plane::XY); + section.contacts = vec![Vec2::ZERO; MAX_ITEMS / 2]; + section.wires.push(SectionWire { curves: vec![used(u32::MAX, false); MAX_ITEMS / 2], closed: false }); + assert_eq!(section.validate_structure(TOL), Err(Invalid("item budget"))); + // Exactly the item limit is accepted, independently of these points' material meaning. + let mut boundary = empty(Plane::XY); + boundary.contacts = vec![Vec2::ZERO; MAX_ITEMS]; + boundary.validate_structure(TOL).unwrap(); + let mut count = Budget(MAX_ITEMS); + assert_eq!(count.add(usize::MAX), Err(Invalid("item budget"))); +} + +#[test] +fn closure_tolerance_is_bounded_and_not_used_to_collapse_short_curves() { + for tolerance in [f64::NAN, f64::INFINITY, -1.0, 0.0, 1e-10, 1.01] { + assert!(plate().validate_structure(tolerance).is_err()); + } + for tolerance in [1e-9, 1.0] { + plate().validate_structure(tolerance).unwrap(); + } + let mut section = empty(Plane::XY); + section.curves.push(analytic(Seg2::Line { a: Vec2::ZERO, b: Vec2::new(1e-10, 0.0) })); + section.validate_structure(1.0).unwrap(); + let mut gap = plate(); + if let Seg2::Line { b, .. } = &mut gap.curves[3].geometry { + b.x = 5e-7; + } + gap.validate_structure(1e-6).unwrap(); + assert_eq!(gap.validate_structure(1e-8), Err(Invalid("open closed wire"))); +} + +#[test] +fn curve_number_and_domain_errors_are_rejected_before_evaluation() { + let p = Vec2::new; + let arc = |radius, start, sweep| Seg2::Arc { center: Vec2::ZERO, radius, start, sweep }; + let conic = |w| Seg2::Conic { a: Vec2::X, apex: p(1.0, 1.0), b: Vec2::Y, w }; + let invalid = [ + Seg2::Line { a: Vec2::ZERO, b: Vec2::ZERO }, + Seg2::Line { a: Vec2::ZERO, b: p(f64::NAN, 0.0) }, + Seg2::Line { a: Vec2::ZERO, b: p(f64::MAX, 0.0) }, + arc(-1.0, 0.0, PI), + arc(0.0, 0.0, PI), + arc(1e-13, 0.0, PI), + arc(1e12 + 1.0, 0.0, PI), + arc(f64::INFINITY, 0.0, PI), + arc(1.0, f64::NAN, PI), + arc(1.0, 1e6 + 1.0, PI), + arc(1.0, 0.0, 0.0), + arc(1.0, 0.0, 1e-13), + arc(1.0, 0.0, TAU + 1e-8), + arc(1.0, 0.0, f64::INFINITY), + conic(-0.5), + conic(0.0), + conic(1e-13), + conic(1e12 + 1.0), + conic(f64::NAN), + Seg2::Cubic { p0: Vec2::X, p1: Vec2::X, p2: Vec2::X, p3: Vec2::X }, + Seg2::Conic { a: Vec2::X, apex: Vec2::X, b: Vec2::X, w: 1.0 }, + ]; + for geometry in invalid { + let mut section = empty(Plane::XY); + section.curves.push(analytic(geometry)); + assert!(section.validate_structure(TOL).is_err(), "accepted {geometry:?}"); + } + let mut section = empty(Plane::XY); + section.contacts.push(p(f64::INFINITY, 0.0)); + assert!(section.validate_structure(TOL).is_err()); +} + +#[test] +fn bezier_degree_and_every_serialized_control_slot_are_validated() { + for degree in [4u8, 9] { + let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL]; + for (i, point) in p.iter_mut().enumerate().take(usize::from(degree) + 1) { + *point = Vec2::new(i as f64, (i % 2) as f64); + } + let mut section = empty(Plane::XY); + section.curves.push(analytic(Seg2::Bezier { n: degree, p })); + section.validate_structure(TOL).unwrap(); + for wrong in [0, 3, 10, u8::MAX] { + section.curves[0].geometry = Seg2::Bezier { n: wrong, p }; + assert_eq!(section.validate_structure(TOL), Err(Invalid("Bezier degree"))); + } + } + let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL]; + p[4] = Vec2::X; + for value in [f64::NAN, f64::INFINITY, 1e12 + 1.0] { + p[MAX_BEZIER_CTRL - 1] = Vec2::new(value, 0.0); + let mut section = empty(Plane::XY); + section.curves.push(analytic(Seg2::Bezier { n: 4, p })); + assert_eq!(section.validate_structure(TOL), Err(Invalid("non-finite or excessive coordinate"))); + } + let mut section = empty(Plane::XY); + section.curves.push(analytic(Seg2::Bezier { n: 4, p: [Vec2::ZERO; MAX_BEZIER_CTRL] })); + assert_eq!(section.validate_structure(TOL), Err(Invalid("collapsed curve"))); +} + +#[test] +fn supported_curve_limits_keep_evaluation_finite_without_claiming_accuracy() { + let p = Vec2::new; + let mut section = empty(Plane::XY); + section.curves = vec![ + analytic(Seg2::Line { a: p(-1e12, 1e12), b: p(1e12, -1e12) }), + analytic(Seg2::Arc { center: p(1e12, 1e12), radius: 1e12, start: 1e6, sweep: -TAU }), + analytic(Seg2::Arc { center: Vec2::ZERO, radius: 1e-12, start: 0.0, sweep: 1e-12 }), + analytic(Seg2::Conic { a: p(-1e12, 0.0), apex: p(0.0, 1e12), b: p(1e12, 0.0), w: 1e12 }), + analytic(Seg2::Conic { a: p(-1e12, 0.0), apex: p(0.0, 1e12), b: p(1e12, 0.0), w: 1e-12 }), + analytic(bezier(&[p(0.0, 0.0), p(1e12, 0.0), p(0.0, 1e12), p(-1e12, 0.0)]).unwrap()), + ]; + let controls: Vec = (0..=9).map(|i| p(i as f64 * 1e11, if i % 2 == 0 { 1e12 } else { -1e12 })).collect(); + section.curves.push(analytic(bezier(&controls).unwrap())); + section.validate_structure(TOL).unwrap(); + for curve in §ion.curves { + for t in [0.0, 0.5, 1.0] { + assert!(curve.geometry.point_at(t).is_finite()); + assert!(curve.geometry.derivative(t).is_finite()); + assert!(curve.geometry.derivatives(t, 4).is_some_and(|d| d.iter().all(|v| v.is_finite()))); + } + } +} + +#[test] +fn transient_serialization_preserves_accuracy_tags_and_plane_names() { + let mut section = plate(); + section.curves[1].accuracy = CurveAccuracy::Bounded { max_deviation_mm: 0.01 }; + section.curves[2].accuracy = CurveAccuracy::Sampled { max_sample_deviation_mm: 0.0 }; + section.validate_structure(TOL).unwrap(); + let value = serde_json::to_value(§ion).unwrap(); + assert!(value["plane"].get("x_dir").is_some()); + assert!(value["plane"].get("y_dir").is_some()); + assert_eq!(value["curves"][0]["accuracy"], serde_json::json!({"kind": "analytic"})); + assert_eq!(value["curves"][1]["accuracy"], serde_json::json!({"kind": "bounded", "max_deviation_mm": 0.01})); + assert_eq!(value["curves"][2]["accuracy"], serde_json::json!({"kind": "sampled", "max_sample_deviation_mm": 0.0})); + let restored: SectionGeometry = serde_json::from_value(value.clone()).unwrap(); + restored.validate_structure(TOL).unwrap(); + assert_eq!(section, restored); + let mut unknown = value; + unknown["certificate"] = serde_json::json!(true); + assert!(serde_json::from_value::(unknown).is_err()); + assert!(serde_json::from_value::(serde_json::json!({"kind": "bounded", "max_deviation_mm": 0.1, "exact": true})).is_err()); + assert!(serde_json::from_value::(serde_json::json!({"kind": "exact"})).is_err()); +} + +#[test] +fn accuracy_values_are_validated_without_promoting_zero_sampled_error() { + for value in [f64::NAN, f64::INFINITY, -0.01, 1e12 + 1.0] { + for accuracy in [CurveAccuracy::Bounded { max_deviation_mm: value }, CurveAccuracy::Sampled { max_sample_deviation_mm: value }] { + let mut section = plate(); + section.curves[0].accuracy = accuracy; + assert_eq!(section.validate_structure(TOL), Err(Invalid("accuracy deviation"))); + } + } + for value in [0.0, 1e12] { + let mut section = plate(); + section.curves[0].accuracy = CurveAccuracy::Sampled { max_sample_deviation_mm: value }; + section.validate_structure(TOL).unwrap(); + section.to_region(0, TOL).unwrap(); + assert_eq!(section.curves[0].accuracy, CurveAccuracy::Sampled { max_sample_deviation_mm: value }); + } +} + +#[test] +fn conversion_revalidates_mutated_data_and_does_not_trust_an_earlier_success() { + let mut section = plate(); + section.validate_structure(TOL).unwrap(); + section.curves[0].geometry = Seg2::Line { a: Vec2::ZERO, b: Vec2::new(f64::NAN, 0.0) }; + assert_eq!(section.to_loop(0, TOL), Err(Invalid("non-finite or excessive coordinate"))); + assert_eq!(section.to_region(0, TOL), Err(Invalid("non-finite or excessive coordinate"))); + let mut section = plate(); + section.to_region(0, TOL).unwrap(); + section.contacts = vec![Vec2::ZERO; MAX_ITEMS]; + assert_eq!(section.to_region(0, TOL), Err(Invalid("item budget"))); +} + +#[test] +fn reversed_large_arc_conversion_rechecks_actual_connectivity_without_snapping() { + let arc = Seg2::Arc { center: Vec2::ZERO, radius: 1e12, start: 0.1, sweep: 1.0 }; + let mut section = empty(Plane::XY); + section.curves = vec![analytic(arc), analytic(Seg2::Line { a: arc.start(), b: arc.end() })]; + section.wires.push(SectionWire { curves: vec![used(0, false), used(1, true)], closed: true }); + section.regions.push(SectionRegion { outer: 0, holes: vec![] }); + let before = section.clone(); + // Source endpoints coincide exactly, but (start + sweep) - sweep changes the reversed + // arc's endpoint by more than this tolerance at this radius. + section.validate_structure(1e-9).unwrap(); + assert!(arc.reversed().end().dist(arc.start()) > 1e-9); + assert_eq!(section.to_loop(0, 1e-9), Err(Invalid("converted loop is not closed within tolerance"))); + assert_eq!(section.to_region(0, 1e-9), Err(Invalid("converted loop is not closed within tolerance"))); + assert_eq!(section, before); + // A caller's explicit larger tolerance can accept the same result without moving endpoints. + let converted = section.to_loop(0, 0.01).unwrap(); + assert_eq!(converted.segs[0], arc.reversed()); + assert!(converted.is_closed(0.01)); +} + +#[test] +fn connected_bow_tie_and_wrong_winding_are_not_certified_or_silently_repaired() { + let p = Vec2::new; + let loop_ = Loop2::polygon(&[p(0.0, 0.0), p(2.0, 2.0), p(0.0, 2.0), p(2.0, 0.0)]); + let mut bow = empty(Plane::XY); + bow.curves = loop_.segs.iter().copied().map(analytic).collect(); + bow.wires.push(SectionWire { curves: (0..4).map(|i| used(i, true)).collect(), closed: true }); + bow.regions.push(SectionRegion { outer: 0, holes: vec![] }); + bow.validate_structure(TOL).unwrap(); + assert_eq!(bow.to_loop(0, TOL).unwrap(), loop_); + // A successful structural check does not establish a material region, including its winding. + let mut reversed = plate(); + reversed.wires[0].curves.reverse(); + for use_ in &mut reversed.wires[0].curves { + use_.forward = !use_.forward; + } + reversed.validate_structure(TOL).unwrap(); + let converted = reversed.to_region(0, TOL).unwrap(); + assert!((converted.outer.signed_area() + 6000.0).abs() < 1e-10); +} diff --git a/docs/drawing-foundations.md b/docs/drawing-foundations.md new file mode 100644 index 00000000..d203d415 --- /dev/null +++ b/docs/drawing-foundations.md @@ -0,0 +1,138 @@ +# Drawing foundations: first geometry slice + +This is the W00 bootstrap ledger for the [published Drawing plan][plan] in +[#860][rfc]. The first implementation is W01a, [#2053][w01-issue] / +[PR #2085][w01-pr], owned by `0xlillith`: neutral section-geometry records, +structural validation and conversion to existing planar types. Its concrete API +and limitations are in [the geometry contract](drawing-geometry.md). + +W01a is part of W01, not completion of W00, the full W01 contract, Stage A, or the +Drawing workspace. It does not extract a body section, calculate visibility, +register `inspect.section_curves` or `drawing.view`, export a drawing, or save a +Drawing document. The independent formatter slice W09a is tracked in +[#2054][w09-issue] / [PR #2086][w09-pr]; it is not a module supplied by W01a. + +## Scope, sources and status + +The initial scope was checked against upstream `f7343e9161bc6243cdb076d3b6c3594db97279ff`. +Recheck the implementation revision, current claims, [AGENTS](../AGENTS.md) and +[CONTRIBUTING](../CONTRIBUTING.md) before taking the next slice. Local `plan/` +documents were unavailable when this work began; this file does not reconstruct +them or replace project instructions. + +The published plan reconciles the [original RFC][rfc], +[SychPL's investigation][visibility], [NOWN-DEV's two-stage proposal][fabrication] +and CamWight's [study and questionnaire][study-comment]. The [attachment][study] +retains the detailed requirements, alternatives and sources. Product research is +requirements evidence; its descriptions and proposed standards rules are not +implementation code, independently verified conformance or permission to copy +protected content. + +Here, **W01a** means code in this slice; **future** means a requirement still to +implement or decide. A merged representation or validator does not establish a +geometry producer's capability. Drawing milestones in the RFC are distinct from +the repository roadmap's M0-M14; the Drawing workspace remains roadmap M11. + +## First-section requirements and fixtures + +Fixture IDs below identify checks, not saved designs or registered commands. +The current fixture details and future producer gates are in +[the geometry contract](drawing-geometry.md#fixtures-and-handoff). + +| ID | Requirement and source | Package | Fixture / status | +| --- | --- | --- | --- | +| S01 | Model mm/radians, explicit plane axes and correct XZ sign; plan §4.1/W01 | W01a | F01: plane validation and coordinates; W01a | +| S02 | Reuse supported analytic/conic/Bézier representations without silently fitting them; plan §4.1/W01 | W01a, then W02-W03 | F02: representation/serde checks; W01a. Producing exact sections is future | +| S03 | Keep accuracy categories explicit; plan §4.1/W01 | W01a, then W02-W03 | F02/F04: analytic/bounded/sampled metadata and finite values; W01a. Evidence for producer bounds is future | +| S04 | Indexed curves, oriented wire uses and region references; plan W01 and study §§10-12 | W01a | F03: manually constructed rectangle with a hole, orientation-preserving conversion; W01a | +| S05 | Certify closed section boundaries, winding, nesting, material regions and areas; plan W03 | W03 | F05/F06: body-derived hole/oblique-cylinder sections; future. PR #2068 proposes producer tests for review. Structural validation alone does not meet this requirement | +| S06 | Empty, tangent, open, coincident, degenerate and unsupported cuts are distinguishable; plan W03 | W01 follow-up, W02-W03 | F07: producer outcomes; future. Current empty vectors/contact points are only representable data | +| S07 | Complete source/body/occurrence identity, provenance and freshness; plan §4.1/W01, NOWN-DEV Stage A | W01 follow-up, W02-W04 | F08: repeated placements and failure in one selected body; future. Current indices are result-local | +| S08 | Bound input/work and fail without mutation or partial success; project rules, plan W01-W04 | W01a, then W02-W04 | F04: structural bounds; W01a. Byte/allocation limits, producer work budgets and cancellation are future caller responsibilities | +| S09 | Standalone read-only `inspect.section_curves`, existing plane forms, explicit selection and no sketch/viewport prerequisite; NOWN-DEV Stage A | W04 after W03 | F09: command/CLI/MCP parity, unchanged document/undo and error cases; future | +| S10 | SVG/DXF with units, source groups/layers, declared format accuracy and atomic optional file output; NOWN-DEV Stage A | W04, W10 geometry subset | F09: physical coordinates and complete output; future | +| S11 | One section provider shared with the visibility investigation; SychPL/NOWN-DEV and plan W03/W05 | W02-W06 | F05-F08 and a reviewed capability matrix; future | +| S12 | Transient dimensioned snapshots remain distinct from associative drawings; NOWN-DEV Stage B | W07-W08 | Future snapshot/measurement/output fixtures after their contracts settle | + +The remaining industrial requirements are retained in the published plan's +coverage ledger and W09-W47: formatting, persistent sheets/references, dimensions, +BOM/balloons, standards, derived views, production output and specialist domains. +Expanding this bootstrap into atomic traceability for all 48 packages is remaining +W00 work; absence from this first-section table does not remove a requirement. + +## Dependencies, ownership and review + +Claim links below identify the bounded slices already tracked here. Other rows do +not reserve work or assign the contributors who proposed it. Check live issues and +draft PRs before claiming. Review columns name the expertise needed, not reviewers +who have already agreed to an assignment. + +| Package | Depends on | Ownership / scope here | Review and next handoff | +| --- | --- | --- | --- | +| W00 | RFC and project rules | Bootstrap in W01a; full ledger/decision ownership remains open | Integration review: preserve sources, unresolved decisions and child-issue boundaries | +| W01 | W00 immediate scope | `0xlillith`, [#2053][w01-issue]/[#2085][w01-pr], W01a only | Geometry/kernel and engine review: validate representation limits; finish source/provenance/outcome contracts before producer handoff | +| W02 | Reviewed W01 producer contract and backend placement | Review overlap with NOWN-DEV's [#2068][section-pr] before another claim | Kernel/solidcore review: guarded live-body adapter, supported surfaces, complete source identity and no cache-byte authority | +| W03 | W02 | Review/reuse producer proposal [#2068][section-pr]; exact supported subset remains to be agreed | Geometry/document review: analytic cuts, classified outcomes, winding/nesting/material checks and error evidence | +| W04 | W03 and W10 geometry-only subset | NOWN-DEV's [#2068][section-pr] proposes Stage A command/output; not merged at the recorded review | Engine/IO review: explicit selection, request limits, no mutation, physical SVG/DXF, DTO adaptation and complete-or-error output | +| W05 | Reviewed W01; W02 where extraction is needed | Coordinate with [SychPL's offer][visibility]; this is not an assignment | Geometry/kernel review: bounded HLR investigation and findings in the RFC before a substantial backend proposal | +| W06 | Reviewed W05 findings and W02 | Future projection/visibility slice | Geometry/document review: supported scene-wide visibility, split intervals, provenance and cache keys | +| W07 | W01, W09; W03 for cuts; an agreed preview adapter or W06 | Future transient `drawing.view` | Engine/presentation review: meaningful measurements, snapshot status and explicit approximation | +| W08 | W07 and W10 text/dimension subset; W04 adapters as reused | Future preview and SVG/DXF/PNG slice | UI/IO review: shared navigation/layout, output scale and UI/CLI/MCP parity | + +Stage A does not depend on persistent Drawing storage, PDF, fonts, a drafting +profile or the W09 formatter. W10's geometry-only primitives are a separate +increment from its text/dimension layout. W09a's [independent PR][w09-pr] can advance +in parallel without introducing a dependency into W01a. + +NOWN-DEV's [PR #2068][section-pr] is an overlapping Stage A producer/export +proposal to review before further W02-W04 claims. It was open and ready, not +merged, when inspected at head `4e945d8af594b8cc252cc4e5ffede3684b7bf3bd` on +2026-10-17. It proposes `doc/project_section.rs` and the +`inspect.section_curves` engine command with mesh/fitted loops and SVG/DXF. +Its body-name/loop JSON and `sweep_deg` angles do not imply compatibility with +W01a's radians, accuracy records and indexed regions. Agree an explicit adapter, +accuracy and source/provenance contract before integration; preserve that +contributor's claim and branch. Its tilted-cylinder test is proposed producer +evidence to review, not an implemented W01a or current-main capability. + +## Decision ledger + +An **implemented slice choice** below describes W01a, not approval of every +proposal in the RFC. Record later decisions with their evidence, reviewer, date, +affected contract revision and dependent child issues. + +| Decision | Current disposition | Next owner/review and gate | +| --- | --- | --- | +| D01: representation placement | W01a uses existing `geom::Plane`, `Seg2`, `Loop2` and `Region2`; neutral section records live at L0 | W01a geometry review. This does not decide the section/HLR algorithm's kernel placement | +| D02: identity/status scope | W01a has only indices within one value and per-curve accuracy metadata; no persistent source names, provenance or freshness record | W01 follow-up with kernel/document/engine reviewers before W02-W04 depend on source contracts | +| D03: validity claim | W01a validates structure and preserves supplied orientation. It does not certify winding, nesting, self-intersections or a material section | W03 owns producer/geometric validity evidence; callers must not promote structure checks into manufacturing certification | +| D04: shared producer/backend | One reusable section provider is the plan; guarded extraction and substantial HLR placement must be coordinated with [solidcore][solidcore] | Kernel/solidcore and integration review before W02/W05 expand into a substantial backend | +| D05: supported geometry/accuracy | Representation support is documented; producer surface/curve coverage and defensible approximation bounds remain future | W02-W03 publish per-case capabilities and F05-F08 results; W05 does the same for visibility | +| D06: public commands | [#2068][section-pr] proposes `inspect.section_curves`; transient `drawing.view` remains future. W01a registers neither | Review #2068's distinct output schema before W04 integration; live registry/claim check in W04/W07; preserve existing viewport commands | +| D07: later Drawing choices | Storage/undo, PDF/fonts, standards/profile semantics and release rules remain in the RFC decision register | Their relevant later packages decide them; none blocks this representation slice or standalone section work | + +## Completion and handoff + +Each subsequent child issue should state supported cases, owned paths, contract +revision, exact dependencies, analytic expectations, error behavior and required +cross-lane review. Follow current draft-claim, isolation and quality-gate rules; +use `Refs #860` and close the bounded child issue rather than the umbrella RFC. + +Report what was actually tested at the proposed merge revision. A structure-only +API has no new CAD result to photograph; later producer, command and UI changes +need real app evidence when their effect can be shown. Keep generated reference +documentation, assets and native-format changes outside this slice. Root project +instructions govern any later change to those areas. + +[rfc]: https://github.com/tangency-labs/solidcraft/issues/860 +[plan]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6234779815 +[visibility]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6167030912 +[fabrication]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6231770565 +[study-comment]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6216908058 +[study]: https://github.com/user-attachments/files/33548772/inventor-drawings-research.md +[w01-issue]: https://github.com/tangency-labs/solidcraft/issues/2053 +[w01-pr]: https://github.com/tangency-labs/solidcraft/pull/2085 +[w09-issue]: https://github.com/tangency-labs/solidcraft/issues/2054 +[w09-pr]: https://github.com/tangency-labs/solidcraft/pull/2086 +[section-pr]: https://github.com/tangency-labs/solidcraft/pull/2068 +[solidcore]: https://github.com/tangency-labs/solidcraft/issues/1597 diff --git a/docs/drawing-geometry.md b/docs/drawing-geometry.md new file mode 100644 index 00000000..3ae49a78 --- /dev/null +++ b/docs/drawing-geometry.md @@ -0,0 +1,253 @@ +# Neutral section geometry (W01a) + +W01a, [#2053][issue] / [PR #2085][pr], adds the L0 +[`solidcraft_geom::section`][section-source] representation and structural validation. See the +[foundation ledger](drawing-foundations.md) for dependencies and the remaining +W01 work. This contract is deliberately smaller than the eventual section and +projection result described in the [Drawing plan][plan]. + +The module can hold geometry supplied by a caller, validate its structure and +convert selected closed wires/regions to existing `Loop2`/`Region2` values. It does +not intersect model bodies, determine visibility, certify material regions, +attach persistent references or register an engine command. No native design +format, exporter, formatter or new runtime dependency is introduced here. + +## Coordinates and frames + +Distances and coordinates are millimetres; angular values in `Seg2` are radians. +The model is Z-up. Each result contains the existing [`Plane`][plane-source]: + +```text +world(u, v) = origin + u * x + v * y +normal = x cross y +``` + +`validate_plane(&Plane)` checks a supplied frame without normalizing it. Origin +and both axes must be finite, with every component's absolute value at most +`1e12`. Each axis must be unit length within `1e-8`, and their absolute dot product +must be at most `1e-8`. The normal is derived from the axes; no separate normal or +handedness flag is serialized. + +The existing named frames are: + +| Plane | Local x | Local y | Derived normal | +| --- | --- | --- | --- | +| XY | +X | +Y | +Z | +| XZ | +X | -Z | +Y | +| YZ | +Y | +Z | +X | + +In particular, XZ's local positive y points toward **negative world Z**. Replacing +it with +Z would reverse the normal. `Plane::flipped` reverses an in-plane axis +and therefore the derived normal; validation does not choose an orientation. + +The future Stage A request adapter must preserve the existing +[`inspect.section`][section-command] conventions: `offset` follows the named +plane's normal; `at` is the world Z/Y/X coordinate for XY/XZ/YZ respectively; +`offset` and `at` are mutually exclusive; `flip` changes the cut direction without +moving the requested plane. These request fields are not part of W01a's DTO. + +Mapping a point already on the plane to local coordinates and back is meaningful. +Mapping an arbitrary 3D point to two coordinates loses its normal component; it +does not have a unique 3D inverse. + +## Data and serialization + +| Type | Fields and meaning | +| --- | --- | +| `SectionGeometry` | `plane: Plane`, `curves: Vec`, `wires: Vec`, `regions: Vec`, `contacts: Vec` | +| `SectionCurve` | `geometry: Seg2`, `accuracy: CurveAccuracy` | +| `SectionWire` | Ordered `curves: Vec` and explicit `closed: bool` | +| `CurveUse` | `curve: u32` indexes `SectionGeometry.curves`; `forward: bool` selects its direction of use | +| `SectionRegion` | `outer: u32` indexes a wire; `holes: Vec` indexes its supplied hole wires | + +All indices are **local to this one value**. They do not identify a model body, +face, occurrence, revision or an entity across recomputation. W01a has no source +fingerprint, provenance, freshness, persistent-ID or public schema-version field. +Those require a reviewed producer/integration contract in a later W01 slice. + +The new section DTOs reject unknown fields through serde. Existing `Plane`, +`Vec2`, `Vec3` and [`Seg2`][segment-source] retain their existing serialization: +vectors are numeric arrays; plane axes are `x_dir` and `y_dir`; segment variants +use a `type` tag with snake-case names. Do not apply a blanket strict-schema claim +to the reused nested types. + +`Seg2` represents lines, circular arcs, cubic Béziers, rational quadratic conics +and supported higher-degree polynomial Bézier pieces. This is not every curve in +the neutral B-rep schema, nor evidence of an exact producer for each kind. An +adapter must preserve supported geometry or report its actual conversion and +accuracy; it must not silently coerce unsupported B-rep curves into this shape. + +`CurveAccuracy` uses the internally tagged serde field `kind`: + +| JSON form | Meaning supplied by the producer | +| --- | --- | +| `{"kind":"analytic"}` | The producer declares an analytic representation without fitting | +| `{"kind":"bounded","max_deviation_mm":0.001}` | The producer claims a deviation bound in model millimetres | +| `{"kind":"sampled","max_sample_deviation_mm":0.001}` | The recorded deviation applies to evaluated samples; it is not a bound between samples | + +Validation checks the metadata's numeric domain. It does not independently prove +an analytic classification or a claimed bound against a source surface. Accuracy +is per curve, not a global `exact: true` flag. There is no implicit default tag. +In particular, a requested fitting tolerance is not an established bound, and a +zero sampled deviation remains sampled. + +This complete value shows the actual serde spelling for one open line. It is +data, not a request to an implemented Drawing command: + +```json +{ + "plane": { + "origin": [0, 0, 0], + "x_dir": [1, 0, 0], + "y_dir": [0, 1, 0] + }, + "curves": [ + { + "geometry": {"type": "line", "a": [0, 0], "b": [10, 0]}, + "accuracy": {"kind": "analytic"} + } + ], + "wires": [ + {"curves": [{"curve": 0, "forward": true}], "closed": false} + ], + "regions": [], + "contacts": [] +} +``` + +The value can pass structural validation. Converting wire 0 to a `Loop2` must +fail because it is explicitly open. Merely meeting endpoints does not change a +wire's `closed` flag. Contact points are representable, but W01a does not classify +them as tangent intersections or certify their source. + +## Validation and conversion boundary + +The public operations are `validate_plane(&Plane)` and +`SectionGeometry::validate_structure(tol)`, `to_loop(wire, tol)` and +`to_region(region, tol)`. Errors use the module's `Invalid` error type. There is +no geometric `bounds()` API or batch region-expansion API in this slice. + +`tol` must be finite and in `1e-9..=1` mm. It is a structural endpoint-connectivity +tolerance, separate from each curve's accuracy metadata and from future model +intersection tolerances, paper deviation and dimension display precision. + +The public `MAX_ITEMS` is `100_000`. Validation counts the aggregate of curves, +wires, regions, contacts, all curve-use references and all hole references before +walking the geometry. Counting references prevents a small curve set from hiding +an unbounded number of uses. This limit is not a maximum JSON byte size. + +Structural validation checks the frame, numeric domains, finite curve/contact +data, bounded collection counts and local reference integrity. It checks all +serialized Bézier control slots, including unused tail slots. Every wire must be +nonempty; oriented consecutive endpoints must meet within `tol`. A wire declared +closed must also meet last-to-first. Region boundaries must reference closed +wires; a region cannot list its outer wire as a hole or repeat the same hole. +Different regions may share a wire. + +The arithmetic domains are explicit, not a geometric precision certificate: + +| Stored value | Accepted domain | +| --- | --- | +| Coordinates and control slots | Finite; absolute component at most `1e12` mm | +| Arc radius | `1e-12..=1e12` mm | +| Arc start | `-1e6..=1e6` radians | +| Absolute arc sweep | `1e-12..=2*pi + 1e-12` radians | +| Conic weight | `1e-12..=1e12`; only positive weights are supported here | +| Higher-degree Bézier `n` | `4..=9`, using the existing ten-slot control array | +| Bounded/sample deviation | Finite, `0..=1e12` mm | + +Line and Bézier/conic controls must not all coincide; evaluated segment endpoints +must be finite. These tests do not certify a minimum physical feature size or a +small absolute arithmetic error at extreme magnitudes. In particular, the stored +coordinate limit is not a computed geometric bounding box. + +`to_loop` and `to_region` revalidate the entire value, check the selected index and +preflight the selected output's segment expansion before conversion. A +`forward: false` use copies the reversed segment. They preserve the supplied wire +orientation, outer/hole classification and geometry; they do not fit, repair, +close or correct a boundary's winding to make it acceptable. Repeated conversions +repeat validation. Callers planning many conversions must account for that work; +this API does not provide a shared validation certificate or a batch result. + +Conversion also checks the actual converted loop's closure with the same `tol`. +Reversing an arc computes a new start angle; at large radii, floating-point +rounding can make its evaluated endpoints disagree even when the original wire +passed validation. The converter then returns +`converted loop is not closed within tolerance`; it does not snap endpoints. +`Loop2`/`Region2` contain geometry only, not `CurveAccuracy` or the result-local +indices. A caller that needs those records must retain the original metadata; +conversion neither carries an accuracy certificate nor promotes accuracy. + +**Structural success does not certify winding, nesting, self-intersection, +separation of regions or a physically valid material section.** Converters do not +establish those properties either. The future section producer must supply and +verify outer-CCW/hole-CW boundaries in `(x,y)` coordinates, nesting, signed/net area +semantics and legitimate material regions. A result may be structurally valid +while violating those future geometric requirements. + +Existing profile area and containment methods also have their own curve-specific +limitations; converting to `Loop2` or `Region2` does not make conic/freeform area or +containment exact or certified. + +Deserialization does not call structural validation automatically. Validate before +use or serialization; the public data may have changed since an earlier check. +Serde can allocate vectors before these validators run. A future file, command +or transport adapter must impose byte, allocation, nesting and request limits +before or during decoding, then call structural validation before downstream +geometry operations. Producer traversal/iteration budgets, cancellation and +complete source selection are also future caller responsibilities. None is +provided by the `MAX_ITEMS` check after deserialization. + +## Fixtures and handoff + +These labels connect to the [requirement ledger](drawing-foundations.md). +Current checks exercise manually supplied representation values, not extracted +model geometry; see [`section_tests.rs`][tests-source]. Test results belong to the +tested PR revision, not to this list. + +| Fixture | Current check or remaining acceptance gate | +| --- | --- | +| F01: frames | W01a: finite/orthonormal plane validation, XZ sign and plane-coordinate mapping. Degenerate, nonfinite and out-of-range frames fail | +| F02: representation/accuracy | W01a: supported segments and actual serde shape; accuracy numeric-domain checks; sampled error remains distinct from a bound | +| F03: rectangle with a hole | W01a: manual `100 x 60 mm` rectangle and radius-5 circular hole at `(50,30)`, in `Plane::XY.offset(10)`. Conversion preserves signed areas `6000` and `-25*pi`, and net area `6000 - 25*pi mm²`. It is not a body-section or nesting certificate | +| F04: malformed structure/conversion | W01a: invalid indices, disconnected/empty wires, closure, invalid region references, excessive aggregate items, nonfinite data, malformed curves and post-conversion endpoint agreement; no mutation | +| F05: plate through a hole | W03: a `100 x 60 x 20 mm` box with a radius-5 through hole parallel to Z, cut by an XY plane strictly between its end faces, has section area `6000 - 25*pi mm²`; producer must identify and certify outer/hole boundaries | +| F06: tilted cylinder | W03: radius 10, axis Z, plane normal 30° from Z, away from caps: ellipse semiaxes `10` and `10/cos(30°)`, area `100*pi/cos(30°)`. Analytic representation or justified conversion evidence is required | +| F07: section outcomes | W03: empty/tangent/coincident/open/degenerate/unsupported cases, with explicit outcome semantics rather than a guessed empty success | +| F08: source completeness | W02-W04: repeated/nested/mirrored occurrences retain source identity; one unsupported selected body cannot silently disappear from a complete result | +| F09: command/output | W04: explicit plane/selection, same CLI/MCP/engine result, no model/undo mutation, complete physical SVG/DXF output and bounded cancellation/error behavior | + +F03 is `line_arc_region_conversion_preserves_winding_and_analytic_area`: one +rectangle edge is stored backwards and used in reverse; two semicircular arcs +are used in reverse order/direction to form the hole. It checks orientation and +unchanged input as well as these analytic line/arc areas. In contrast, +`connected_bow_tie_and_wrong_winding_are_not_certified_or_silently_repaired` +deliberately accepts and preserves structurally valid but geometrically unsuitable +boundaries. Neither test extracts a cut from a solid. + +`reversed_large_arc_conversion_rechecks_actual_connectivity_without_snapping` +checks the converted result too: a reversed radius-`1e12` arc with start `0.1` and +sweep `1.0` radians fails at `tol=1e-9` mm but is accepted at `0.01` mm. This is an +arithmetic/closure regression, not evidence of that accuracy against a source. + +[NOWN-DEV's PR #2068][producer-pr], reviewed as an open proposal rather than +merged code, contains Stage A producer/export work and a tilted-cylinder test. +Review that evidence and its actual mesh/fitting guarantees before reusing it. +Its body-name/loop/`sweep_deg` output is a distinct schema; W01a supplies no adapter +and promises no automatic compatibility. Agree conversion, accuracy and source +identity handling with that contributor before further overlapping claims. + +W01a can unblock review of representation and structural invariants. W02-W04 still +need the producer contract for source/provenance, outcomes, completeness and +accuracy, plus an agreed kernel/solidcore boundary. Persistent Drawing files, +fonts, profiles and PDF are not prerequisites for standalone section work. + +[issue]: https://github.com/tangency-labs/solidcraft/issues/2053 +[pr]: https://github.com/tangency-labs/solidcraft/pull/2085 +[plan]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6234779815 +[producer-pr]: https://github.com/tangency-labs/solidcraft/pull/2068 +[section-source]: ../crates/geom/src/section.rs +[tests-source]: ../crates/geom/src/section_tests.rs +[plane-source]: ../crates/geom/src/plane.rs +[segment-source]: ../crates/geom/src/profile.rs +[section-command]: ../crates/engine/src/cmd/section.rs