From 753df67ee48b2881aab2ee41079920ead50c0095 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 14 Sep 2026 23:51:37 +0300 Subject: [PATCH] Complete parametric drawing interoperability --- Cargo.lock | 6 +- Cargo.toml | 6 +- crates/ocs_plugin_api/Cargo.toml | 4 +- crates/ocs_web_worker/Cargo.toml | 2 +- crates/plugin-template-api2/Cargo.toml | 2 +- docs/plugin-template-v2/Cargo.toml | 2 +- src/app/automation.rs | 2 +- src/app/update/file.rs | 7 +- src/scene/dwg_native_constraints.rs | 1014 ++++++++++++++++++++++-- src/scene/sketch_constraints.rs | 16 + src/scene/sketch_persist.rs | 34 +- src/scene/sketch_solve.rs | 439 ++++++++-- tests/sketch_constraints_solve.rs | 206 +++++ 13 files changed, 1602 insertions(+), 138 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 49635a72c..5d95a48f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,7 +83,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=4d7b359b0571f9b6e0f85a62a21c67508722b83e#4d7b359b0571f9b6e0f85a62a21c67508722b83e" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=7709884f301cfe292f58a002a514939932be67d0#7709884f301cfe292f58a002a514939932be67d0" dependencies = [ "ahash 0.8.12", "anyhow", @@ -919,7 +919,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=50f020377c01fcebb5f31844b9900f046b05192f#50f020377c01fcebb5f31844b9900f046b05192f" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b#2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b" dependencies = [ "acadrust", "cavalier_contours", @@ -930,7 +930,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=50f020377c01fcebb5f31844b9900f046b05192f#50f020377c01fcebb5f31844b9900f046b05192f" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b#2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 1467496a5..4a83d5bcd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,9 +32,9 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4d7b359b0571f9b6e0f85a62a21c67508722b83e", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "50f020377c01fcebb5f31844b9900f046b05192f", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "50f020377c01fcebb5f31844b9900f046b05192f" } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index aea33085c..854dc014b 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] } # Pulled in only by the `host` feature, which adds the `acadrust`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4d7b359b0571f9b6e0f85a62a21c67508722b83e", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -38,7 +38,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # acadrust is scanned at build time to generate the embedded type registry. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4d7b359b0571f9b6e0f85a62a21c67508722b83e", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 809c2090a..0bb5e1460 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4d7b359b0571f9b6e0f85a62a21c67508722b83e", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/crates/plugin-template-api2/Cargo.toml b/crates/plugin-template-api2/Cargo.toml index 290103605..bcb242dce 100644 --- a/crates/plugin-template-api2/Cargo.toml +++ b/crates/plugin-template-api2/Cargo.toml @@ -10,4 +10,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4d7b359b0571f9b6e0f85a62a21c67508722b83e", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0", features = ["serde"] } diff --git a/docs/plugin-template-v2/Cargo.toml b/docs/plugin-template-v2/Cargo.toml index f46779ba5..300cf870a 100644 --- a/docs/plugin-template-v2/Cargo.toml +++ b/docs/plugin-template-v2/Cargo.toml @@ -12,4 +12,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4d7b359b0571f9b6e0f85a62a21c67508722b83e" } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0" } diff --git a/src/app/automation.rs b/src/app/automation.rs index a862a29cf..7089fead6 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -317,8 +317,8 @@ impl OpenCADStudio { let i = self.active_tab; self.tabs[i].scene.clear(); self.tabs[i].scene.document = doc; - self.tabs[i].scene.load_sketch_constraints_from_document(); self.tabs[i].scene.load_named_parameters_from_document(); + self.tabs[i].scene.load_sketch_constraints_from_document(); self.tabs[i].scene.material_base_dir = path_buf.parent().map(PathBuf::from); crate::app::style_ops::ensure_standard_styles( &mut self.tabs[i].scene.document, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 1bad3d3ce..7487d5b75 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1446,11 +1446,10 @@ pub(super) fn on_open_file(&mut self) -> Task { self.tabs[i].scene.material_base_dir = path.parent().map(std::path::Path::to_path_buf); self.tabs[i].scene.document = doc; - // Load persisted constraints after installing the document. - self.tabs[i].scene.load_sketch_constraints_from_document(); - // named_parameters_design.md stage 2: same load-time hook, - // for the document-wide parameter table. + // Load parameters first so imported dimensional constraints + // can resolve their named driving values. self.tabs[i].scene.load_named_parameters_from_document(); + self.tabs[i].scene.load_sketch_constraints_from_document(); self.tabs[i].active_layer = self.tabs[i] .scene .document diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index 0f7d76cec..11e0a7da7 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -12,7 +12,9 @@ use acadrust::{CadDocument, EntityType}; use rustc_hash::FxHashMap; use super::named_parameters::{DrivingValue, ParameterTable}; -use super::sketch_constraints::{ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef}; +use super::sketch_constraints::{ + ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef, SketchScope, +}; use super::Scene; /// Default sub-dictionary key used for an associative network. @@ -39,6 +41,7 @@ mod implicit_point_type { struct EntityNodes { geometry_node_id: i32, points: FxHashMap, + segments: FxHashMap, } /// Builds one scope's `Assoc2dConstraintGroup` graph incrementally, @@ -51,6 +54,8 @@ struct GroupBuilder<'a> { /// Real entity handles that ended up with a geometry node — what /// `AssocGeomDependency` objects get created for, in first-touch order. referenced_entities: Vec, + horizontal_datum: Option, + vertical_datum: Option, } impl<'a> GroupBuilder<'a> { @@ -61,6 +66,8 @@ impl<'a> GroupBuilder<'a> { next_node_id: FIRST_NODE_ID, entities: FxHashMap::default(), referenced_entities: Vec::new(), + horizontal_datum: None, + vertical_datum: None, } } @@ -92,24 +99,7 @@ impl<'a> GroupBuilder<'a> { }); } - /// The geometry node id for `entity`'s whole shape, creating a `Line` - /// (well: `BoundedLine`, §4 — nothing we draw is an infinite - /// construction line), `Circle`, or `Arc` node the first time this - /// entity is referenced. Returns `None` for an entity kind this pass - /// doesn't support as constraint geometry — currently including - /// `Ellipse` (constraint-parity Phase 5): the solver - /// (`sketch_solve.rs`) and this app's own XRecord format both support - /// ellipse-referencing constraints already, but `acadrust`'s - /// `AssocConstraintNodeData::Ellipse`/`BoundedEllipse` variants have a - /// different field shape from `Circle`/`Arc`'s here (`owner_id`/ - /// `is_implied`/`is_active` instead of `geometry_dependency`/ - /// `geometry_node_id`, i.e. they don't obviously look like the same - /// "geometry dependency for an entity reference" role) — without - /// confirming that against a real externally written file, guessing would - /// risk writing a wrong-but-plausible-looking object graph, which is - /// worse than the honest "not persisted to DWG yet" gap this already - /// degrades to (same contract as `ArcLength`, which has no native - /// representation at all). + /// Returns the whole-geometry node for an entity, creating it once. fn geometry_node(&mut self, handle: Handle) -> Option { if let Some(existing) = self.entities.get(&handle) { if existing.geometry_node_id != 0 { @@ -119,6 +109,71 @@ impl<'a> GroupBuilder<'a> { let entity = self.document.get_entity(handle)?; let node_id = self.alloc_node_id(); match entity { + EntityType::Point(point) => { + self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(point.location), + }, + ); + } + EntityType::Insert(insert) => self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(insert.insert_point), + }, + ), + EntityType::Text(text) => self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(text.insertion_point), + }, + ), + EntityType::MText(text) => self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(text.insertion_point), + }, + ), + EntityType::AttributeDefinition(attribute) => self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(attribute.insertion_point), + }, + ), + EntityType::AttributeEntity(attribute) => self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(attribute.insertion_point), + }, + ), + EntityType::Table(table) => self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(table.insertion_point), + }, + ), EntityType::Line(l) => { let point = l.start; let direction = (l.end - l.start).normalize(); @@ -187,6 +242,50 @@ impl<'a> GroupBuilder<'a> { }, ); } + EntityType::Ellipse(e) => { + let major_length = e.major_axis.length(); + let major = if major_length > f64::EPSILON { + e.major_axis / major_length + } else { + Vector3::UNIT_X + }; + let normal = e.normal.normalize(); + let minor = Vector3::new( + normal.y * major.z - normal.z * major.y, + normal.z * major.x - normal.x * major.z, + normal.x * major.y - normal.y * major.x, + ); + let point_at = |parameter: f64| { + e.center + + e.major_axis * parameter.cos() + + minor * (major_length * e.minor_axis_ratio * parameter.sin()) + }; + let data = if e.is_full() { + AssocConstraintNodeData::Ellipse { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + center: e.center, + major_axis: e.major_axis, + axis_ratio: e.minor_axis_ratio, + } + } else { + AssocConstraintNodeData::BoundedEllipse { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + center: e.center, + major_axis: e.major_axis, + axis_ratio: e.minor_axis_ratio, + start_point: point_at(e.start_parameter), + end_point: point_at(e.end_parameter), + } + }; + let class_name = if e.is_full() { + "AcConstrainedEllipse" + } else { + "AcConstrainedBoundedEllipse" + }; + self.push_node(node_id, class_name, data); + } _ => return None, } self.entities.entry(handle).or_default().geometry_node_id = node_id; @@ -206,12 +305,37 @@ impl<'a> GroupBuilder<'a> { { return Some(*existing); } - let curve_id = self.geometry_node(handle)?; - let point_type = match marker { - 0 => implicit_point_type::START, - 1 => implicit_point_type::END, - -3 => implicit_point_type::CENTER, - _ => return None, + let polyline = matches!( + self.document.get_entity(handle), + Some(EntityType::LwPolyline(_) | EntityType::Polyline2D(_)) + ); + let (curve_id, point_type) = if polyline && marker >= 0 { + let entity = self.document.get_entity(handle)?; + let points = super::dimension_assoc::source_points(entity); + let closed = match entity { + EntityType::LwPolyline(polyline) => polyline.is_closed, + EntityType::Polyline2D(polyline) => polyline.is_closed(), + _ => false, + }; + let vertex = marker as usize; + if vertex >= points.len() { + return None; + } + let (segment, point_type) = if vertex + 1 < points.len() || closed { + (vertex, implicit_point_type::START) + } else { + (vertex.checked_sub(1)?, implicit_point_type::END) + }; + (self.segment_node(handle, segment)?, point_type) + } else { + let curve_id = self.geometry_node(handle)?; + let point_type = match marker { + 0 => implicit_point_type::START, + 1 => implicit_point_type::END, + -3 => implicit_point_type::CENTER, + _ => return None, + }; + (curve_id, point_type) }; let node_id = self.alloc_node_id(); self.push_node( @@ -226,6 +350,22 @@ impl<'a> GroupBuilder<'a> { curve_id, }, ); + let relation_id = self.alloc_node_id(); + self.push_node( + relation_id, + if point_type == implicit_point_type::CENTER { + "AcCenterPointConstraint" + } else { + "AcPointCurveConstraint" + }, + AssocConstraintNodeData::Geometrical { + owner_id: 0, + is_implied: false, + is_active: true, + }, + ); + self.connect(relation_id, node_id); + self.connect(relation_id, curve_id); self.entities .entry(handle) .or_default() @@ -237,12 +377,121 @@ impl<'a> GroupBuilder<'a> { /// The node id `SketchRef` resolves to: a point node for a marked /// reference, the whole geometry node otherwise. fn ref_node(&mut self, r: SketchRef) -> Option { + if let Some(segment) = r.segment_index() { + return self.segment_node(r.entity, segment); + } + if r.marker == Some(0) + && matches!( + self.document.get_entity(r.entity), + Some( + EntityType::Point(_) + | EntityType::Insert(_) + | EntityType::Text(_) + | EntityType::MText(_) + | EntityType::AttributeDefinition(_) + | EntityType::AttributeEntity(_) + | EntityType::Table(_) + ) + ) + { + return self.geometry_node(r.entity); + } match r.marker { Some(marker) => self.point_node(r.entity, marker), None => self.geometry_node(r.entity), } } + fn segment_node(&mut self, handle: Handle, index: usize) -> Option { + if let Some(node_id) = self + .entities + .get(&handle) + .and_then(|entity| entity.segments.get(&index)) + { + return Some(*node_id); + } + let entity = self.document.get_entity(handle)?; + let points = super::dimension_assoc::source_points(entity); + let closed = match entity { + EntityType::LwPolyline(polyline) => { + if polyline.vertices.get(index)?.bulge.abs() > 1e-9 { + return None; + } + polyline.is_closed + } + EntityType::Polyline2D(polyline) => { + if polyline.vertices.get(index)?.bulge.abs() > 1e-9 { + return None; + } + polyline.is_closed() + } + _ => return None, + }; + let start = *points.get(index)?; + let end = if index + 1 < points.len() { + points[index + 1] + } else if closed { + *points.first()? + } else { + return None; + }; + let node_id = self.alloc_node_id(); + self.push_node( + node_id, + "AcConstrainedBoundedLine", + AssocConstraintNodeData::BoundedLine { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: start, + direction: (end - start).normalize(), + is_ray: false, + start_point: start, + end_point: end, + }, + ); + self.entities + .entry(handle) + .or_default() + .segments + .insert(index, node_id); + if !self.referenced_entities.contains(&handle) { + self.referenced_entities.push(handle); + } + Some(node_id) + } + + fn datum_line(&mut self, horizontal: bool) -> i32 { + let cached = if horizontal { + self.horizontal_datum + } else { + self.vertical_datum + }; + if let Some(node_id) = cached { + return node_id; + } + let node_id = self.alloc_node_id(); + self.push_node( + node_id, + "AcConstrainedDatumLine", + AssocConstraintNodeData::Line { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Vector3::ZERO, + direction: if horizontal { + Vector3::UNIT_X + } else { + Vector3::UNIT_Y + }, + }, + ); + if horizontal { + self.horizontal_datum = Some(node_id); + } else { + self.vertical_datum = Some(node_id); + } + node_id + } + /// Records an undirected edge on both nodes. fn connect(&mut self, a: i32, b: i32) { if let Some(node) = self.nodes.iter_mut().find(|n| n.node_id == a) { @@ -258,8 +507,6 @@ impl<'a> GroupBuilder<'a> { fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { match kind { ConstraintKind::Coincident => Some("AcPointCoincidenceConstraint"), - ConstraintKind::Horizontal => Some("AcHorizontalConstraint"), - ConstraintKind::Vertical => Some("AcVerticalConstraint"), ConstraintKind::Perpendicular => Some("AcPerpendicularConstraint"), ConstraintKind::Tangent => Some("AcTangentConstraint"), ConstraintKind::Smooth => Some("AcG2SmoothConstraint"), @@ -281,6 +528,8 @@ fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { ConstraintKind::EqualDistance => Some("AcEqualDistanceConstraint"), // `Equal` needs the resolved entity types, so the caller handles it. ConstraintKind::Equal + | ConstraintKind::Horizontal + | ConstraintKind::Vertical | ConstraintKind::Parallel | ConstraintKind::Distance | ConstraintKind::Angle @@ -353,6 +602,36 @@ fn constraint_node( return Some(node_id); } match constraint.kind { + ConstraintKind::Horizontal | ConstraintKind::Vertical => { + if !matches!(refs.len(), 1 | 2) { + return None; + } + let targets: Vec<_> = refs + .iter() + .map(|reference| builder.ref_node(*reference)) + .collect::>()?; + let horizontal = constraint.kind == ConstraintKind::Horizontal; + let datum = builder.datum_line(horizontal); + let node_id = builder.alloc_node_id(); + builder.push_node( + node_id, + if horizontal { + "AcHorizontalConstraint" + } else { + "AcVerticalConstraint" + }, + AssocConstraintNodeData::Parallel { + owner_id: targets[0], + is_implied: false, + is_active: true, + datum_line_index: Some(datum), + }, + ); + for target in targets { + builder.connect(node_id, target); + } + Some(node_id) + } ConstraintKind::Parallel => { let [a, b] = refs else { return None }; let (na, nb) = (builder.ref_node(*a)?, builder.ref_node(*b)?); @@ -374,8 +653,17 @@ fn constraint_node( ConstraintKind::Equal => { let [a, b] = refs else { return None }; let (na, nb) = (builder.ref_node(*a)?, builder.ref_node(*b)?); - let both_lines = matches!(document.get_entity(a.entity), Some(EntityType::Line(_))) - && matches!(document.get_entity(b.entity), Some(EntityType::Line(_))); + let is_straight = |reference: &SketchRef| { + matches!( + document.get_entity(reference.entity), + Some(EntityType::Line(_)) + ) || (reference.segment_index().is_some() + && matches!( + document.get_entity(reference.entity), + Some(EntityType::LwPolyline(_) | EntityType::Polyline2D(_)) + )) + }; + let both_lines = is_straight(a) && is_straight(b); let class_name = if both_lines { "AcEqualLengthConstraint" } else { @@ -657,7 +945,11 @@ impl Allocator<'_> { /// null dependency and refer to their owning geometry node by `curve_id`. fn set_geometry_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { match data { - AssocConstraintNodeData::BoundedLine { + AssocConstraintNodeData::Point { + geometry_dependency, + .. + } + | AssocConstraintNodeData::BoundedLine { geometry_dependency, .. } @@ -668,6 +960,14 @@ fn set_geometry_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { | AssocConstraintNodeData::Arc { geometry_dependency, .. + } + | AssocConstraintNodeData::Ellipse { + geometry_dependency, + .. + } + | AssocConstraintNodeData::BoundedEllipse { + geometry_dependency, + .. } => { *geometry_dependency = handle; } @@ -848,6 +1148,413 @@ fn has_native_network(document: &CadDocument, owner: Handle) -> bool { dictionary.get(NETWORK_DICTIONARY_KEY).is_some() } +fn native_group(document: &CadDocument, owner: Handle) -> Option<&Assoc2dConstraintGroup> { + let network_handle = native_scope_network_handle(document, owner)?; + document.objects.values().find_map(|object| { + let ObjectType::Associative(object) = object else { + return None; + }; + let AssociativeData::ConstraintGroup(group) = &object.data else { + return None; + }; + (object.owner == network_handle || group.action.owning_network == network_handle) + .then_some(group) + }) +} + +fn native_scope_network_handle(document: &CadDocument, owner: Handle) -> Option { + let dictionary = document.extension_dictionary_handle(owner)?; + let ObjectType::Dictionary(dictionary) = document.objects.get(&dictionary)? else { + return None; + }; + let mut network_handle = dictionary.get(NETWORK_DICTIONARY_KEY)?; + while let Some(ObjectType::Dictionary(dictionary)) = document.objects.get(&network_handle) { + network_handle = dictionary.get(NETWORK_DICTIONARY_KEY)?; + } + matches!( + document.objects.get(&network_handle), + Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::Network(_), + .. + })) + ) + .then_some(network_handle) +} + +fn geometry_dependency(data: &AssocConstraintNodeData) -> Option { + match data { + AssocConstraintNodeData::Point { + geometry_dependency, + .. + } + | AssocConstraintNodeData::Line { + geometry_dependency, + .. + } + | AssocConstraintNodeData::BoundedLine { + geometry_dependency, + .. + } + | AssocConstraintNodeData::Circle { + geometry_dependency, + .. + } + | AssocConstraintNodeData::Arc { + geometry_dependency, + .. + } + | AssocConstraintNodeData::Ellipse { + geometry_dependency, + .. + } + | AssocConstraintNodeData::BoundedEllipse { + geometry_dependency, + .. + } if !geometry_dependency.is_null() => Some(*geometry_dependency), + _ => None, + } +} + +fn dependency_entity( + document: &CadDocument, + dependency: Handle, + geometry: &AssocConstraintNodeData, +) -> Option { + let ObjectType::Associative(object) = document.objects.get(&dependency)? else { + return None; + }; + let AssociativeData::GeomDependency(dependency) = &object.data else { + return None; + }; + let entity = dependency.dependency.dependent_on; + let supported = match (document.get_entity(entity), geometry) { + ( + Some( + EntityType::Point(_) + | EntityType::Insert(_) + | EntityType::Text(_) + | EntityType::MText(_) + | EntityType::AttributeDefinition(_) + | EntityType::AttributeEntity(_) + | EntityType::Table(_), + ), + AssocConstraintNodeData::Point { .. }, + ) => true, + ( + Some(EntityType::Line(_) | EntityType::LwPolyline(_) | EntityType::Polyline2D(_)), + AssocConstraintNodeData::Line { .. } | AssocConstraintNodeData::BoundedLine { .. }, + ) => true, + (Some(EntityType::Circle(_)), AssocConstraintNodeData::Circle { .. }) => true, + (Some(EntityType::Arc(_)), AssocConstraintNodeData::Arc { .. }) => true, + ( + Some(EntityType::Ellipse(_)), + AssocConstraintNodeData::Ellipse { .. } + | AssocConstraintNodeData::BoundedEllipse { .. }, + ) => true, + _ => false, + }; + supported.then_some(entity) +} + +fn native_group_requires_preservation(document: &CadDocument, owner: Handle) -> bool { + native_group(document, owner).is_some_and(|group| { + group.nodes.iter().any(|node| { + if matches!(node.data, AssocConstraintNodeData::RigidSet { .. }) { + return true; + } + if let Some(dependency) = geometry_dependency(&node.data) { + if dependency_entity(document, dependency, &node.data).is_none() { + return true; + } + } + node.class_name.to_ascii_uppercase().ends_with("CONSTRAINT") + && constraint_kind(node).is_none() + }) + }) +} + +fn polyline_segment_reference( + document: &CadDocument, + entity: Handle, + data: &AssocConstraintNodeData, +) -> Option { + let AssocConstraintNodeData::BoundedLine { + start_point, + end_point, + .. + } = data + else { + return None; + }; + let entity_value = document.get_entity(entity)?; + let closed = match entity_value { + EntityType::LwPolyline(polyline) => polyline.is_closed, + EntityType::Polyline2D(polyline) => polyline.is_closed(), + _ => return None, + }; + let points = super::dimension_assoc::source_points(entity_value); + let segment_count = points.len().saturating_sub(usize::from(!closed)); + (0..segment_count) + .min_by(|first, second| { + let score = |index: usize| { + let a = points[index]; + let b = points[(index + 1) % points.len()]; + let forward = + (a - *start_point).length_squared() + (b - *end_point).length_squared(); + let reverse = + (b - *start_point).length_squared() + (a - *end_point).length_squared(); + forward.min(reverse) + }; + score(*first).total_cmp(&score(*second)) + }) + .map(|index| SketchRef::segment(entity, index)) +} + +fn numeric_value(value: &AssocEvalVariant) -> Option { + match value.value { + AssocEvalValue::Real(value) => Some(value), + AssocEvalValue::Long(value) => Some(value as f64), + AssocEvalValue::Short(value) => Some(value as f64), + AssocEvalValue::Byte(value) => Some(value as f64), + _ => None, + } +} + +fn driving_value( + document: &CadDocument, + dependency: Handle, + parameters: &mut ParameterTable, +) -> Option { + let ObjectType::Associative(object) = document.objects.get(&dependency)? else { + return None; + }; + let AssociativeData::ValueDependency(value_dependency) = &object.data else { + return None; + }; + let literal = numeric_value(&value_dependency.value); + let ObjectType::Associative(variable) = document + .objects + .get(&value_dependency.dependency.dependent_on)? + else { + return literal.map(DrivingValue::Literal); + }; + let AssociativeData::Variable(variable) = &variable.data else { + return literal.map(DrivingValue::Literal); + }; + let source = if variable.expression.trim().is_empty() { + numeric_value(&variable.value)?.to_string() + } else { + variable.expression.clone() + }; + if parameters.set(&variable.name, &source).is_ok() { + Some(DrivingValue::Named(variable.name.clone())) + } else { + literal + .or_else(|| numeric_value(&variable.value)) + .map(DrivingValue::Literal) + } +} + +fn constraint_owner(data: &AssocConstraintNodeData) -> Option { + match data { + AssocConstraintNodeData::Geometrical { owner_id, .. } + | AssocConstraintNodeData::Angle { owner_id, .. } + | AssocConstraintNodeData::Parallel { owner_id, .. } + | AssocConstraintNodeData::Distance { owner_id, .. } + | AssocConstraintNodeData::RadiusDiameter { owner_id, .. } => Some(*owner_id), + _ => None, + } +} + +fn constraint_is_active(data: &AssocConstraintNodeData) -> bool { + match data { + AssocConstraintNodeData::Geometrical { is_active, .. } + | AssocConstraintNodeData::Angle { is_active, .. } + | AssocConstraintNodeData::Parallel { is_active, .. } + | AssocConstraintNodeData::Distance { is_active, .. } + | AssocConstraintNodeData::RadiusDiameter { is_active, .. } => *is_active, + _ => true, + } +} + +fn constraint_kind(node: &AssocConstraintNode) -> Option { + Some(match node.class_name.to_ascii_uppercase().as_str() { + "ACPOINTCOINCIDENCECONSTRAINT" => ConstraintKind::Coincident, + "ACHORIZONTALCONSTRAINT" => ConstraintKind::Horizontal, + "ACVERTICALCONSTRAINT" => ConstraintKind::Vertical, + "ACPARALLELCONSTRAINT" => ConstraintKind::Parallel, + "ACPERPENDICULARCONSTRAINT" => ConstraintKind::Perpendicular, + "ACEQUALLENGTHCONSTRAINT" | "ACEQUALRADIUSCONSTRAINT" => ConstraintKind::Equal, + "ACANGLECONSTRAINT" | "AC3POINTANGLECONSTRAINT" => ConstraintKind::Angle, + "ACTANGENTCONSTRAINT" => ConstraintKind::Tangent, + "ACG2SMOOTHCONSTRAINT" => ConstraintKind::Smooth, + "ACCONCENTRICCONSTRAINT" => ConstraintKind::Concentric, + "ACCENTERPOINTCONSTRAINT" => ConstraintKind::CenterPoint, + "ACCOLINEARCONSTRAINT" => ConstraintKind::Colinear, + "ACMIDPOINTCONSTRAINT" => ConstraintKind::Midpoint, + "ACFIXEDCONSTRAINT" => ConstraintKind::Fixed, + "ACPOINTCURVECONSTRAINT" => ConstraintKind::PointOnCurve, + "ACEQUALDISTANCECONSTRAINT" => ConstraintKind::EqualDistance, + "ACSYMMETRICCONSTRAINT" => ConstraintKind::Symmetric, + "ACNORMALCONSTRAINT" => ConstraintKind::Normal, + "ACRADIUSDIAMETERCONSTRAINT" => { + let AssocConstraintNodeData::RadiusDiameter { mode, .. } = node.data else { + return None; + }; + if mode == 1 { + ConstraintKind::Diameter + } else { + ConstraintKind::Radius + } + } + "ACDISTANCECONSTRAINT" => { + let AssocConstraintNodeData::Distance { + direction_type, + distance, + .. + } = &node.data + else { + return None; + }; + if *direction_type == 0 { + ConstraintKind::Distance + } else if distance.is_some_and(|direction| direction.x.abs() >= direction.y.abs()) { + ConstraintKind::DistanceX + } else { + ConstraintKind::DistanceY + } + } + _ => return None, + }) +} + +pub(super) fn native_constraint_set( + document: &CadDocument, + owner: Handle, + parameters: &mut ParameterTable, +) -> Option { + let group = native_group(document, owner)?; + let mut refs = FxHashMap::default(); + for node in &group.nodes { + let Some(dependency) = geometry_dependency(&node.data) else { + continue; + }; + if let Some(entity) = dependency_entity(document, dependency, &node.data) { + let reference = polyline_segment_reference(document, entity, &node.data) + .unwrap_or_else(|| { + if matches!(node.data, AssocConstraintNodeData::Point { .. }) { + SketchRef::point(entity, 0) + } else { + SketchRef::whole(entity) + } + }); + refs.insert(node.node_id, reference); + } + } + for node in &group.nodes { + let AssocConstraintNodeData::ImplicitPoint { + point_type, + curve_id, + .. + } = node.data + else { + continue; + }; + let Some(curve) = refs.get(&curve_id).copied() else { + continue; + }; + let marker = if let Some(segment) = curve.segment_index() { + let point_count = + super::dimension_assoc::source_points(document.get_entity(curve.entity)?).len(); + (match point_type { + implicit_point_type::START => segment, + implicit_point_type::END if point_count > 0 => (segment + 1) % point_count, + _ => continue, + }) as i32 + } else { + match point_type { + implicit_point_type::START => 0, + implicit_point_type::END => 1, + implicit_point_type::CENTER => -3, + _ => continue, + } + }; + refs.insert(node.node_id, SketchRef::point(curve.entity, marker)); + } + + let scope = if owner == document.header.model_space_block_handle { + SketchScope::ModelSpace + } else { + SketchScope::Block(owner) + }; + let mut set = SketchConstraintSet::new(scope); + for node in &group.nodes { + let Some(kind) = constraint_kind(node) else { + continue; + }; + if !constraint_is_active(&node.data) { + continue; + } + let mut targets: Vec = node + .connections + .iter() + .filter_map(|id| refs.get(id).copied()) + .collect(); + if let Some(owner) = constraint_owner(&node.data).and_then(|id| refs.get(&id).copied()) { + if !targets.contains(&owner) { + targets.insert(0, owner); + } + } + if matches!( + kind, + ConstraintKind::PointOnCurve | ConstraintKind::CenterPoint + ) && targets.len() == 2 + && targets[0].entity == targets[1].entity + { + continue; + } + let driving = match &node.data { + AssocConstraintNodeData::Angle { + value_dependency, .. + } + | AssocConstraintNodeData::Distance { + value_dependency, .. + } + | AssocConstraintNodeData::RadiusDiameter { + value_dependency, .. + } => driving_value(document, *value_dependency, parameters), + _ => None, + }; + let expected = match kind { + ConstraintKind::Fixed | ConstraintKind::Radius | ConstraintKind::Diameter => 1, + ConstraintKind::Symmetric => 3, + ConstraintKind::EqualDistance => 4, + _ => 2, + }; + if (matches!(kind, ConstraintKind::Horizontal | ConstraintKind::Vertical) + && !matches!(targets.len(), 1 | 2)) + || (!matches!(kind, ConstraintKind::Horizontal | ConstraintKind::Vertical) + && targets.len() != expected) + { + continue; + } + if matches!( + kind, + ConstraintKind::Distance + | ConstraintKind::DistanceX + | ConstraintKind::DistanceY + | ConstraintKind::Angle + | ConstraintKind::Radius + | ConstraintKind::Diameter + ) && driving.is_none() + { + continue; + } + set.add(kind, targets, driving); + } + (!set.constraints.is_empty()).then_some(set) +} + /// One scope's worth of /// [`Scene::materialize_dwg_native_constraints_for_save`] — see that /// method's doc comment for the save-time contract this implements. @@ -919,15 +1626,19 @@ fn materialize_scope( continue; }; if entity_nodes.geometry_node_id == 0 { - continue; + if entity_nodes.segments.is_empty() { + continue; + } } let dep_handle = allocator.geom_dependency(group_handle, *entity_handle, index as i32 + 1); geometry_dependencies.push(dep_handle); - if let Some(node) = nodes - .iter_mut() - .find(|n| n.node_id == entity_nodes.geometry_node_id) + for node_id in std::iter::once(entity_nodes.geometry_node_id) + .filter(|node_id| *node_id != 0) + .chain(entity_nodes.segments.values().copied()) { - set_geometry_dependency(&mut node.data, dep_handle); + if let Some(node) = nodes.iter_mut().find(|node| node.node_id == node_id) { + set_geometry_dependency(&mut node.data, dep_handle); + } } } @@ -1039,6 +1750,7 @@ impl Scene { /// toggling this off never leaves a stale graph sitting in the file. pub(crate) fn materialize_dwg_native_constraints_for_save(&mut self, enabled: bool) { let mut scopes = Vec::new(); + let mut preserved_networks = Vec::new(); for index in 0..self.sketch_constraints.len() { let set = self.sketch_constraints[index].clone(); let owner = set.scope.owner_handle(&self.document); @@ -1048,6 +1760,12 @@ impl Scene { if !enabled && !has_native_network(&self.document, owner) { continue; } + if native_group_requires_preservation(&self.document, owner) { + if let Some(network) = native_scope_network_handle(&self.document, owner) { + preserved_networks.push(network); + } + continue; + } scopes.push((owner, set)); } if scopes.is_empty() { @@ -1062,7 +1780,7 @@ impl Scene { NETWORK_DICTIONARY_KEY, ); let root_network_handle = self.document.allocate_handle(); - let mut child_networks = Vec::new(); + let mut child_networks = preserved_networks; for (index, (owner, set)) in scopes.into_iter().enumerate() { if let Some(handle) = materialize_scope( &mut self.document, @@ -1119,7 +1837,9 @@ impl Scene { mod tests { use super::super::sketch_constraints::SketchScope; use super::*; - use acadrust::entities::{Arc, Circle, Line}; + use acadrust::entities::{Arc, Circle, Insert, Line, LwPolyline}; + use acadrust::tables::BlockRecord; + use acadrust::types::Vector2; fn line_entity(scene: &mut Scene, start: (f64, f64), end: (f64, f64)) -> Handle { scene.add_entity(EntityType::Line(Line::from_points( @@ -1207,13 +1927,12 @@ mod tests { scene.materialize_dwg_native_constraints_for_save(true); let owner = scene.document.header.model_space_block_handle; - // Sanity before the round trip even happens: root + 2 geometry - // nodes + 2 constraint nodes. + // Root + two geometry nodes + two datums + two constraints. let before = native_group(&scene.document, owner); assert_eq!( before.nodes.len(), - 5, - "expected root + 2 geometry + 2 constraint nodes, got {:#?}", + 7, + "expected root + 2 geometry + 2 datums + 2 constraints, got {:#?}", before.nodes ); @@ -1241,9 +1960,157 @@ mod tests { 2, "{ext}: expected both lines' geometry nodes to survive, got {class_names:?}" ); + let datums: Vec = group + .nodes + .iter() + .filter_map(|node| match node.data { + AssocConstraintNodeData::Parallel { + datum_line_index: Some(datum), + .. + } if matches!( + node.class_name.as_str(), + "AcHorizontalConstraint" | "AcVerticalConstraint" + ) => + { + Some(datum) + } + _ => None, + }) + .collect(); + assert_eq!(datums.len(), 2, "{ext}: both constraints need a datum"); + assert!(datums.iter().all(|datum| group.nodes.iter().any(|node| { + node.node_id == *datum && node.class_name == "AcConstrainedDatumLine" + }))); } } + #[test] + fn native_only_constraints_and_parameters_are_imported() { + let mut scene = Scene::new(); + let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + let b = line_entity(&mut scene, (0.0, 5.0), (10.0, 5.0)); + scene.named_parameters.set("gap", "5").unwrap(); + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); + set.add( + ConstraintKind::Parallel, + vec![SketchRef::whole(a), SketchRef::whole(b)], + None, + ); + set.add( + ConstraintKind::DistanceY, + vec![SketchRef::point(a, 0), SketchRef::point(b, 0)], + Some(DrivingValue::Named("gap".to_string())), + ); + + scene.materialize_dwg_native_constraints_for_save(true); + let bytes = crate::io::save_to_bytes(&scene.document, "dwg", scene.document.version) + .expect("save native-only graph"); + let mut restored = Scene::new(); + restored.document = crate::io::load_bytes("native_only.dwg", bytes).unwrap(); + restored.load_named_parameters_from_document(); + restored.load_sketch_constraints_from_document(); + + let set = restored + .sketch_constraint_set(SketchScope::ModelSpace) + .expect("native group should become an editable constraint set"); + let kinds: Vec<_> = set + .constraints + .iter() + .map(|constraint| constraint.kind) + .collect(); + assert_eq!( + kinds, + vec![ + ConstraintKind::Horizontal, + ConstraintKind::Parallel, + ConstraintKind::DistanceY, + ] + ); + assert_eq!(restored.named_parameters.resolve("gap"), Ok(5.0)); + assert_eq!( + set.constraints[2].driving_param, + Some(DrivingValue::Named("gap".to_string())) + ); + } + + #[test] + fn point_and_two_point_horizontal_constraints_round_trip_natively() { + let mut scene = Scene::new(); + let mut block = BlockRecord::new("fixture"); + block.handle = scene.document.allocate_handle(); + scene.document.block_records.add(block).unwrap(); + let point = scene.add_entity(EntityType::Point(acadrust::entities::Point::at( + Vector3::new(1.0, 2.0, 0.0), + ))); + let insert = scene.add_entity(EntityType::Insert(Insert::new( + "fixture", + Vector3::new(3.0, 4.0, 0.0), + ))); + let line = line_entity(&mut scene, (5.0, 7.0), (10.0, 9.0)); + let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + set.add( + ConstraintKind::Horizontal, + vec![SketchRef::point(point, 0), SketchRef::point(insert, 0)], + None, + ); + set.add( + ConstraintKind::Midpoint, + vec![SketchRef::point(point, 0), SketchRef::whole(line)], + None, + ); + + scene.materialize_dwg_native_constraints_for_save(true); + let bytes = crate::io::save_to_bytes(&scene.document, "dwg", scene.document.version) + .expect("save point graph"); + let mut restored = Scene::new(); + restored.document = crate::io::load_bytes("point_constraints.dwg", bytes).unwrap(); + restored.load_sketch_constraints_from_document(); + + let constraints = &restored + .sketch_constraint_set(SketchScope::ModelSpace) + .expect("native point constraints should be imported") + .constraints; + assert_eq!(constraints.len(), 2); + assert_eq!(constraints[0].kind, ConstraintKind::Horizontal); + assert_eq!(constraints[0].refs.len(), 2); + assert_eq!(constraints[1].kind, ConstraintKind::Midpoint); + } + + #[test] + fn polyline_segment_constraints_round_trip_through_the_native_graph() { + let mut scene = Scene::new(); + let polyline = scene.add_entity(EntityType::LwPolyline(LwPolyline::from_points(vec![ + Vector2::new(0.0, 0.0), + Vector2::new(5.0, 2.0), + Vector2::new(10.0, 7.0), + ]))); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![SketchRef::segment(polyline, 1)], + None, + ); + + scene.materialize_dwg_native_constraints_for_save(true); + let bytes = crate::io::save_to_bytes(&scene.document, "dwg", scene.document.version) + .expect("save polyline segment graph"); + let mut restored = Scene::new(); + restored.document = crate::io::load_bytes("polyline_segment.dwg", bytes).unwrap(); + restored.load_sketch_constraints_from_document(); + + let set = restored + .sketch_constraint_set(SketchScope::ModelSpace) + .expect("native segment constraint should be imported"); + assert_eq!(set.constraints.len(), 1); + assert_eq!(set.constraints[0].kind, ConstraintKind::Horizontal); + assert_eq!( + set.constraints[0].refs, + vec![SketchRef::segment(polyline, 1)] + ); + } + fn distance_with_named_parameter_scene() -> (Scene, Handle) { let mut scene = Scene::new(); let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); @@ -1415,6 +2282,53 @@ mod tests { ); } + #[test] + fn resaving_preserves_a_native_group_with_uneditable_geometry() { + let mut scene = Scene::new(); + let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); + scene.materialize_dwg_native_constraints_for_save(true); + + let owner = scene.document.header.model_space_block_handle; + let group_handle = native_group_handle(&scene.document, owner); + let Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ConstraintGroup(group), + .. + })) = scene.document.objects.get_mut(&group_handle) + else { + panic!("expected a native constraint group"); + }; + group.nodes.push(AssocConstraintNode { + node_id: 99, + class_name: "AcConstrainedRigidSet".to_string(), + data: AssocConstraintNodeData::RigidSet { + geometry_dependency: Handle::NULL, + geometry_node_id: 99, + reserved: false, + transform: [0.0; 16], + geometry_ids: vec![1], + }, + ..Default::default() + }); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add(ConstraintKind::Vertical, vec![SketchRef::whole(a)], None); + scene.materialize_dwg_native_constraints_for_save(true); + + let group = native_group(&scene.document, owner); + assert!(group + .nodes + .iter() + .any(|node| matches!(node.data, AssocConstraintNodeData::RigidSet { .. }))); + assert!(!group + .nodes + .iter() + .any(|node| node.class_name == "AcVerticalConstraint")); + } + #[test] fn an_empty_constraint_set_leaves_no_native_network() { let mut scene = Scene::new(); @@ -1711,16 +2625,8 @@ mod tests { } } - /// Constraint-parity Phase 5: an ellipse isn't a supported geometry - /// node type in `geometry_node` yet (see its own doc comment for why), - /// so a Concentric constraint referencing one degrades to "not in the - /// native graph" — the same "skip, don't error" contract every other - /// unbuildable native-constraint case gets — while the other - /// constraint in the same scope (Fixed, on a plain circle) still - /// persists normally, and the ellipse constraint itself still survives - /// in this app's own XRecord format. #[test] - fn a_constraint_referencing_an_ellipse_is_absent_from_the_native_graph_but_not_from_xrecord() { + fn an_ellipse_constraint_round_trips_in_the_native_graph() { for ext in ["dxf", "dwg"] { let mut scene = Scene::new(); let ellipse = scene.add_entity(EntityType::Ellipse( @@ -1757,8 +2663,12 @@ mod tests { "{ext}: the other constraint should still persist, got {class_names:?}" ); assert!( - !class_names.contains(&"AcConcentricConstraint"), - "{ext}: the ellipse-referencing Concentric constraint has no supported geometry node and must not appear, got {class_names:?}" + class_names.contains(&"AcConcentricConstraint"), + "{ext}: missing ellipse-referencing constraint, got {class_names:?}" + ); + assert!( + class_names.contains(&"AcConstrainedEllipse"), + "{ext}: missing ellipse geometry node, got {class_names:?}" ); let mut reloaded_scene = Scene::new(); diff --git a/src/scene/sketch_constraints.rs b/src/scene/sketch_constraints.rs index cdbb50c9f..661c9ab9a 100644 --- a/src/scene/sketch_constraints.rs +++ b/src/scene/sketch_constraints.rs @@ -27,6 +27,8 @@ pub struct SketchRef { pub marker: Option, } +const POLYLINE_SEGMENT_MARKER_BASE: i32 = -1_000_000; + impl SketchRef { pub fn whole(entity: Handle) -> Self { Self { @@ -51,6 +53,20 @@ impl SketchRef { marker: Some(-3), } } + + /// Select one straight segment of a polyline. + pub fn segment(entity: Handle, index: usize) -> Self { + Self { + entity, + marker: Some(POLYLINE_SEGMENT_MARKER_BASE - index as i32), + } + } + + pub fn segment_index(self) -> Option { + let marker = self.marker?; + (marker <= POLYLINE_SEGMENT_MARKER_BASE) + .then(|| (POLYLINE_SEGMENT_MARKER_BASE - marker) as usize) + } } /// The friendly, user-facing constraint types — the "what button did they diff --git a/src/scene/sketch_persist.rs b/src/scene/sketch_persist.rs index 0d76c66b1..00a6404e7 100644 --- a/src/scene/sketch_persist.rs +++ b/src/scene/sketch_persist.rs @@ -140,26 +140,28 @@ impl Scene { .map(|record| record.handle) .collect(); for owner in owners { - let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else { - continue; - }; - // Concatenate every Chunk entry in order, not just the first — - // a set materialized past `MAX_CHUNK_BYTES` spans more than one - // same-code `310` entry (see that constant's doc comment). - let mut bytes = Vec::new(); - for entry in &record.entries { - if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value { - if bytes.len().saturating_add(chunk.len()) > MAX_RECORD_BYTES { - bytes.clear(); - break; + if let Some(record) = self.document.xrecord(owner, XRECORD_KEY) { + let mut bytes = Vec::new(); + for entry in &record.entries { + if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value { + if bytes.len().saturating_add(chunk.len()) > MAX_RECORD_BYTES { + bytes.clear(); + break; + } + bytes.extend_from_slice(chunk); } - bytes.extend_from_slice(chunk); } - } - if bytes.is_empty() { + if let Some(set) = decode(&bytes) { + self.sketch_constraints.push(set); + } continue; } - if let Some(set) = decode(&bytes) { + + if let Some(set) = super::dwg_native_constraints::native_constraint_set( + &self.document, + owner, + &mut self.named_parameters, + ) { self.sketch_constraints.push(set); } } diff --git a/src/scene/sketch_solve.rs b/src/scene/sketch_solve.rs index 1562a093c..48bb5a530 100644 --- a/src/scene/sketch_solve.rs +++ b/src/scene/sketch_solve.rs @@ -5,12 +5,15 @@ use std::collections::HashMap; use std::rc::Rc; use acadrust::entities::EntityType; -use acadrust::types::Handle; +use acadrust::types::{Handle, Vector3}; use cadkernel_constraints::constraints::angle_distance::L2LAngle; use cadkernel_constraints::constraints::circle_arc::{ ArcLength, C2LDistance, P2CDistance, TangentCircumf, }; +use cadkernel_constraints::constraints::conic::{ + EqualMajorAxesConic, PointOnEllipse, TangentEllipseLine, +}; use cadkernel_constraints::constraints::curve_generic::CurveValue; use cadkernel_constraints::constraints::point_line::{ CenterOfGravity, Difference, Equal, EqualLineLength, MidpointOnLine, P2PDistance, @@ -18,7 +21,8 @@ use cadkernel_constraints::constraints::point_line::{ }; use cadkernel_constraints::constraints::Constraint; use cadkernel_constraints::geo::{ - Arc as GArc, Circle as GCircle, Ellipse as GEllipse, Line as GLine, Point as GPoint, + Arc as GArc, Circle as GCircle, Conic as GConic, Ellipse as GEllipse, Line as GLine, + Point as GPoint, }; use cadkernel_constraints::solvers::dogleg::solve_dl; use cadkernel_constraints::system::System; @@ -31,22 +35,22 @@ use super::{ChangeKind, Scene}; /// One referenced entity's geometry, registered into an `cadkernel_constraints::System`'s /// parameter store. -#[derive(Clone, Copy)] +#[derive(Clone)] enum EntityGeom { + Point(GPoint), Line(GLine), + Polyline { + points: Rc>, + straight: Rc>, + closed: bool, + }, Circle(GCircle), /// Full `geo::Arc` — center/radius (via `.circle`), `start_angle`/ /// `end_angle`, and real `start`/`end` points kept consistent with /// those by the arc-rules `CurveValue` constraints `solve_scope` adds /// for every registered arc. See this module's doc comment. Arc(GArc), - /// Center-and-shape support only — see `register_entity`'s `Ellipse` - /// arm for the acadrust-to-`cadkernel_constraints` parametrization conversion, the - /// ellipse-rules `Difference` constraints `solve_scope` adds for every - /// registered ellipse to keep `focus1` translating rigidly with - /// `center` (this module's doc comment), and that same doc comment for - /// what isn't wired up yet (major/minor axis dimensional constraints, - /// `PointOnEllipse`-based Coincident/Tangent). + /// See `register_entity` for the entity-to-kernel parametrization. Ellipse(GEllipse), } @@ -57,8 +61,12 @@ impl EntityGeom { /// read side). fn point_for_marker(&self, marker: i32) -> Option { match (self, marker) { + (EntityGeom::Point(point), 0) => Some(*point), (EntityGeom::Line(l), 0) => Some(l.p1), (EntityGeom::Line(l), 1) => Some(l.p2), + (EntityGeom::Polyline { points, .. }, marker) if marker >= 0 => { + points.get(marker as usize).copied() + } (EntityGeom::Circle(c), -3) => Some(c.center), (EntityGeom::Arc(a), 0) => Some(a.start), (EntityGeom::Arc(a), 1) => Some(a.end), @@ -67,6 +75,29 @@ impl EntityGeom { _ => None, } } + + fn line_segment(&self, index: usize) -> Option { + let EntityGeom::Polyline { + points, + straight, + closed, + } = self + else { + return None; + }; + if !straight.get(index).copied().unwrap_or(false) { + return None; + } + let p1 = *points.get(index)?; + let p2 = if index + 1 < points.len() { + points[index + 1] + } else if *closed { + *points.first()? + } else { + return None; + }; + Some(GLine { p1, p2 }) + } } /// `Tangent`/`Normal` only ever care about "a whole circle-shaped curve" or @@ -76,15 +107,22 @@ impl EntityGeom { enum CircleOrLine { Circle(GCircle), Line(GLine), + Ellipse(GEllipse), Other, } -fn as_circle_or_line(g: EntityGeom) -> CircleOrLine { +fn as_circle_or_line(g: EntityGeom, reference: SketchRef) -> CircleOrLine { match g { EntityGeom::Circle(c) => CircleOrLine::Circle(c), EntityGeom::Arc(a) => CircleOrLine::Circle(a.circle), EntityGeom::Line(l) => CircleOrLine::Line(l), - EntityGeom::Ellipse(_) => CircleOrLine::Other, + EntityGeom::Ellipse(ellipse) => CircleOrLine::Ellipse(ellipse), + EntityGeom::Point(_) => CircleOrLine::Other, + EntityGeom::Polyline { .. } => reference + .segment_index() + .and_then(|index| g.line_segment(index)) + .map(CircleOrLine::Line) + .unwrap_or(CircleOrLine::Other), } } @@ -103,7 +141,72 @@ fn register_entity( sys: &mut System, handle: Handle, ) -> Option { - match document.get_entity(handle)? { + let entity = document.get_entity(handle)?; + match entity { + EntityType::Point(point) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(point.location.x, false), + sys.add_param(point.location.y, false), + ))), + EntityType::Insert(insert) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(insert.insert_point.x, false), + sys.add_param(insert.insert_point.y, false), + ))), + EntityType::Text(text) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(text.insertion_point.x, false), + sys.add_param(text.insertion_point.y, false), + ))), + EntityType::MText(text) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(text.insertion_point.x, false), + sys.add_param(text.insertion_point.y, false), + ))), + EntityType::AttributeDefinition(attribute) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(attribute.insertion_point.x, false), + sys.add_param(attribute.insertion_point.y, false), + ))), + EntityType::AttributeEntity(attribute) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(attribute.insertion_point.x, false), + sys.add_param(attribute.insertion_point.y, false), + ))), + EntityType::Table(table) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(table.insertion_point.x, false), + sys.add_param(table.insertion_point.y, false), + ))), + EntityType::LwPolyline(polyline) => Some(EntityGeom::Polyline { + points: Rc::new( + super::dimension_assoc::source_points(entity) + .into_iter() + .map(|point| { + GPoint::new(sys.add_param(point.x, false), sys.add_param(point.y, false)) + }) + .collect(), + ), + straight: Rc::new( + polyline + .vertices + .iter() + .map(|vertex| vertex.bulge.abs() <= 1e-9) + .collect(), + ), + closed: polyline.is_closed, + }), + EntityType::Polyline2D(polyline) => Some(EntityGeom::Polyline { + points: Rc::new( + super::dimension_assoc::source_points(entity) + .into_iter() + .map(|point| { + GPoint::new(sys.add_param(point.x, false), sys.add_param(point.y, false)) + }) + .collect(), + ), + straight: Rc::new( + polyline + .vertices + .iter() + .map(|vertex| vertex.bulge.abs() <= 1e-9) + .collect(), + ), + closed: polyline.is_closed(), + }), EntityType::Line(l) => { let p1 = GPoint::new( sys.add_param(l.start.x, false), @@ -202,11 +305,11 @@ fn resolve_ref( cache: &mut HashMap, r: SketchRef, ) -> Option { - if let Some(&geometry) = cache.get(&r.entity) { - return Some(geometry); + if let Some(geometry) = cache.get(&r.entity) { + return Some(geometry.clone()); } let geometry = register_entity(document, sys, r.entity)?; - cache.insert(r.entity, geometry); + cache.insert(r.entity, geometry.clone()); Some(geometry) } @@ -221,6 +324,17 @@ fn is_world_z(normal: acadrust::types::Vector3) -> bool { /// Returns the world-XY plane elevation supported by the current 2D bridge. fn entity_plane_z(entity: &EntityType) -> Option { let z = match entity { + EntityType::Point(point) if is_world_z(point.normal) => point.location.z, + EntityType::Insert(insert) if is_world_z(insert.normal) => insert.insert_point.z, + EntityType::Text(text) if is_world_z(text.normal) => text.insertion_point.z, + EntityType::MText(text) if is_world_z(text.normal) => text.insertion_point.z, + EntityType::AttributeDefinition(attribute) if is_world_z(attribute.normal) => { + attribute.insertion_point.z + } + EntityType::AttributeEntity(attribute) if is_world_z(attribute.normal) => { + attribute.insertion_point.z + } + EntityType::Table(table) if is_world_z(table.normal) => table.insertion_point.z, EntityType::Line(line) if is_world_z(line.normal) && (line.start.z - line.end.z).abs() <= PLANE_EPS => { @@ -330,11 +444,13 @@ fn build_constraint( return Vec::new(); } - let whole_line = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| match resolve_ref( - document, sys, cache, r, - )? { - EntityGeom::Line(l) => Some(l), - EntityGeom::Circle(_) | EntityGeom::Arc(_) | EntityGeom::Ellipse(_) => None, + let whole_line = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| { + let geometry = resolve_ref(document, sys, cache, r)?; + match geometry { + EntityGeom::Line(line) => Some(line), + EntityGeom::Polyline { .. } => geometry.line_segment(r.segment_index()?), + _ => None, + } }; let whole_circle = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| match resolve_ref( @@ -342,7 +458,10 @@ fn build_constraint( )? { EntityGeom::Circle(circ) => Some(circ), EntityGeom::Arc(a) => Some(a.circle), - EntityGeom::Line(_) | EntityGeom::Ellipse(_) => None, + EntityGeom::Point(_) + | EntityGeom::Line(_) + | EntityGeom::Polyline { .. } + | EntityGeom::Ellipse(_) => None, }; let point_ref = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| { let marker = r.marker?; @@ -371,24 +490,30 @@ fn build_constraint( ConstraintKind::Coincident | ConstraintKind::Concentric | ConstraintKind::CenterPoint => { point_pair_equal(sys, cache, &c.refs) } - ConstraintKind::Horizontal => { - let Some(r) = c.refs.first() else { - return Vec::new(); - }; - let Some(l) = whole_line(sys, cache, *r) else { - return Vec::new(); - }; - vec![Rc::new(Equal::new(l.p1.y, l.p2.y, 1.0))] - } - ConstraintKind::Vertical => { - let Some(r) = c.refs.first() else { - return Vec::new(); - }; - let Some(l) = whole_line(sys, cache, *r) else { - return Vec::new(); - }; - vec![Rc::new(Equal::new(l.p1.x, l.p2.x, 1.0))] - } + ConstraintKind::Horizontal => match c.refs.as_slice() { + [r] => whole_line(sys, cache, *r) + .map(|line| { + vec![Rc::new(Equal::new(line.p1.y, line.p2.y, 1.0)) as Rc] + }) + .unwrap_or_default(), + [a, b] => match (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) { + (Some(a), Some(b)) => vec![Rc::new(Equal::new(a.y, b.y, 1.0))], + _ => Vec::new(), + }, + _ => Vec::new(), + }, + ConstraintKind::Vertical => match c.refs.as_slice() { + [r] => whole_line(sys, cache, *r) + .map(|line| { + vec![Rc::new(Equal::new(line.p1.x, line.p2.x, 1.0)) as Rc] + }) + .unwrap_or_default(), + [a, b] => match (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) { + (Some(a), Some(b)) => vec![Rc::new(Equal::new(a.x, b.x, 1.0))], + _ => Vec::new(), + }, + _ => Vec::new(), + }, ConstraintKind::Parallel => { let [a, b] = c.refs.as_slice() else { return Vec::new(); @@ -422,6 +547,15 @@ fn build_constraint( if let (Some(la), Some(lb)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) { return vec![Rc::new(EqualLineLength::new(lb, la))]; } + if let (Some(EntityGeom::Ellipse(a)), Some(EntityGeom::Ellipse(b))) = ( + resolve_ref(document, sys, cache, *a), + resolve_ref(document, sys, cache, *b), + ) { + return vec![Rc::new(EqualMajorAxesConic::new( + GConic::Ellipse(a), + GConic::Ellipse(b), + ))]; + } let (Some(ca), Some(cb)) = (whole_circle(sys, cache, *a), whole_circle(sys, cache, *b)) else { return Vec::new(); @@ -507,10 +641,15 @@ fn build_constraint( let zero = sys.add_param(0.0, true); vec![Rc::new(P2CDistance::new(a.circle, p, zero))] } - // `PointOnEllipse` (`cadkernel_constraints::constraints::conic`) isn't - // wired up yet — deliberately deferred alongside - // ellipse-tangency, same as this module's doc comment. - EntityGeom::Ellipse(_) => Vec::new(), + EntityGeom::Ellipse(ellipse) => { + vec![Rc::new(PointOnEllipse::new(p, ellipse))] + } + EntityGeom::Polyline { .. } => b + .segment_index() + .and_then(|index| geom.line_segment(index)) + .map(|line| vec![Rc::new(PointOnLine::new(p, line)) as Rc]) + .unwrap_or_default(), + EntityGeom::Point(_) => Vec::new(), } } ConstraintKind::Symmetric => { @@ -534,10 +673,73 @@ fn build_constraint( let Some(r) = c.refs.first() else { return Vec::new(); }; + if r.segment_index().is_some() { + let Some(line) = whole_line(sys, cache, *r) else { + return Vec::new(); + }; + let (x1, y1, x2, y2) = { + let store = sys.store(); + ( + store.get(line.p1.x), + store.get(line.p1.y), + store.get(line.p2.x), + store.get(line.p2.y), + ) + }; + return vec![ + Rc::new(Equal::new(line.p1.x, sys.add_param(x1, true), 1.0)), + Rc::new(Equal::new(line.p1.y, sys.add_param(y1, true), 1.0)), + Rc::new(Equal::new(line.p2.x, sys.add_param(x2, true), 1.0)), + Rc::new(Equal::new(line.p2.y, sys.add_param(y2, true), 1.0)), + ]; + } + if r.marker.is_some() { + let Some(point) = point_ref(sys, cache, *r) else { + return Vec::new(); + }; + let (x, y) = { + let store = sys.store(); + (store.get(point.x), store.get(point.y)) + }; + return vec![ + Rc::new(Equal::new(point.x, sys.add_param(x, true), 1.0)), + Rc::new(Equal::new(point.y, sys.add_param(y, true), 1.0)), + ]; + } let Some(geom) = resolve_ref(document, sys, cache, *r) else { return Vec::new(); }; match geom { + EntityGeom::Point(point) => { + let (x, y) = { + let store = sys.store(); + (store.get(point.x), store.get(point.y)) + }; + vec![ + Rc::new(Equal::new(point.x, sys.add_param(x, true), 1.0)), + Rc::new(Equal::new(point.y, sys.add_param(y, true), 1.0)), + ] + } + EntityGeom::Polyline { points, .. } => { + let values: Vec<_> = points + .iter() + .map(|point| { + let store = sys.store(); + (point, store.get(point.x), store.get(point.y)) + }) + .collect(); + values + .into_iter() + .flat_map(|(point, x, y)| { + [ + Rc::new(Equal::new(point.x, sys.add_param(x, true), 1.0)) + as Rc, + Rc::new(Equal::new(point.y, sys.add_param(y, true), 1.0)) + as Rc, + ] + }) + .collect() + } EntityGeom::Line(l) => { let (x1, y1, x2, y2) = { let store = sys.store(); @@ -724,7 +926,7 @@ fn build_constraint( // for tangency math — normalize both refs down first so the // match below doesn't need every Circle/Arc combination // written out separately. - match (as_circle_or_line(ga), as_circle_or_line(gb)) { + match (as_circle_or_line(ga, *a), as_circle_or_line(gb, *b)) { (CircleOrLine::Circle(c1), CircleOrLine::Circle(c2)) => { let store = sys.store(); let (x1, y1, r1) = ( @@ -765,9 +967,10 @@ fn build_constraint( let zero = sys.add_param(0.0, true); vec![Rc::new(C2LDistance::new(circ, line, zero, ccw, false))] } - // Line-Line tangency has no meaning; either side being an - // Ellipse falls here too (`PointOnEllipse`/tangency isn't - // wired up yet). + (CircleOrLine::Ellipse(ellipse), CircleOrLine::Line(line)) + | (CircleOrLine::Line(line), CircleOrLine::Ellipse(ellipse)) => { + vec![Rc::new(TangentEllipseLine::new(line, ellipse))] + } _ => Vec::new(), } } @@ -788,7 +991,7 @@ fn build_constraint( ) else { return Vec::new(); }; - match (as_circle_or_line(ga), as_circle_or_line(gb)) { + match (as_circle_or_line(ga, *a), as_circle_or_line(gb, *b)) { (CircleOrLine::Circle(circ), CircleOrLine::Line(line)) | (CircleOrLine::Line(line), CircleOrLine::Circle(circ)) => { vec![Rc::new(PointOnLine::new(circ.center, line))] @@ -1031,7 +1234,9 @@ fn solve_scope( let total_free: usize = cache .values() .map(|g| match g { + EntityGeom::Point(_) => 2, EntityGeom::Line(_) => 4, + EntityGeom::Polyline { points, .. } => points.len() * 2, EntityGeom::Circle(_) => 3, // Raw param count (center×2, rad, start×2, end×2, both // angles) — same "raw, not netted against its own @@ -1077,6 +1282,98 @@ fn solve_scope( continue; }; match (entity, geom) { + (EntityType::Point(point), EntityGeom::Point(geometry)) => { + let x = store.get(geometry.x); + let y = store.get(geometry.y); + if (x - point.location.x).abs() > MOVE_EPS + || (y - point.location.y).abs() > MOVE_EPS + { + let mut updated = point.clone(); + updated.location.x = x; + updated.location.y = y; + results.push((handle, EntityType::Point(updated))); + } + } + (EntityType::Insert(insert), EntityGeom::Point(geometry)) => { + let mut updated = insert.clone(); + let next = Vector3::new( + store.get(geometry.x), + store.get(geometry.y), + insert.insert_point.z, + ); + if (next - insert.insert_point).length() > MOVE_EPS { + updated.insert_point = next; + results.push((handle, EntityType::Insert(updated))); + } + } + (EntityType::Text(text), EntityGeom::Point(geometry)) => { + let mut updated = text.clone(); + let next = Vector3::new( + store.get(geometry.x), + store.get(geometry.y), + text.insertion_point.z, + ); + let delta = next - text.insertion_point; + if delta.length() > MOVE_EPS { + updated.insertion_point = next; + if let Some(alignment) = &mut updated.alignment_point { + *alignment = *alignment + delta; + } + results.push((handle, EntityType::Text(updated))); + } + } + (EntityType::MText(text), EntityGeom::Point(geometry)) => { + let mut updated = text.clone(); + let next = Vector3::new( + store.get(geometry.x), + store.get(geometry.y), + text.insertion_point.z, + ); + if (next - text.insertion_point).length() > MOVE_EPS { + updated.insertion_point = next; + results.push((handle, EntityType::MText(updated))); + } + } + (EntityType::AttributeDefinition(attribute), EntityGeom::Point(geometry)) => { + let mut updated = attribute.clone(); + let next = Vector3::new( + store.get(geometry.x), + store.get(geometry.y), + attribute.insertion_point.z, + ); + let delta = next - attribute.insertion_point; + if delta.length() > MOVE_EPS { + updated.insertion_point = next; + updated.alignment_point = updated.alignment_point + delta; + results.push((handle, EntityType::AttributeDefinition(updated))); + } + } + (EntityType::AttributeEntity(attribute), EntityGeom::Point(geometry)) => { + let mut updated = attribute.clone(); + let next = Vector3::new( + store.get(geometry.x), + store.get(geometry.y), + attribute.insertion_point.z, + ); + let delta = next - attribute.insertion_point; + if delta.length() > MOVE_EPS { + updated.insertion_point = next; + updated.alignment_point = updated.alignment_point + delta; + results.push((handle, EntityType::AttributeEntity(updated))); + } + } + (EntityType::Table(table), EntityGeom::Point(geometry)) => { + let mut updated = table.clone(); + let next = Vector3::new( + store.get(geometry.x), + store.get(geometry.y), + table.insertion_point.z, + ); + if (next - table.insertion_point).length() > MOVE_EPS { + updated.insertion_point = next; + results.push((handle, EntityType::Table(updated))); + } + } (EntityType::Line(l), EntityGeom::Line(g)) => { let (x1, y1, x2, y2) = ( store.get(g.p1.x), @@ -1097,6 +1394,42 @@ fn solve_scope( results.push((handle, EntityType::Line(updated))); } } + (EntityType::LwPolyline(polyline), EntityGeom::Polyline { points, .. }) => { + let mut updated = polyline.clone(); + let mut changed = false; + for (vertex, point) in updated.vertices.iter_mut().zip(points.iter()) { + let x = store.get(point.x); + let y = store.get(point.y); + if (x - vertex.location.x).abs() > MOVE_EPS + || (y - vertex.location.y).abs() > MOVE_EPS + { + vertex.location.x = x; + vertex.location.y = y; + changed = true; + } + } + if changed { + results.push((handle, EntityType::LwPolyline(updated))); + } + } + (EntityType::Polyline2D(polyline), EntityGeom::Polyline { points, .. }) => { + let mut updated = polyline.clone(); + let mut changed = false; + for (vertex, point) in updated.vertices.iter_mut().zip(points.iter()) { + let x = store.get(point.x); + let y = store.get(point.y); + if (x - vertex.location.x).abs() > MOVE_EPS + || (y - vertex.location.y).abs() > MOVE_EPS + { + vertex.location.x = x; + vertex.location.y = y; + changed = true; + } + } + if changed { + results.push((handle, EntityType::Polyline2D(updated))); + } + } (EntityType::Circle(c), EntityGeom::Circle(g)) => { let (cx, cy, r) = ( store.get(g.center.x), @@ -1671,11 +2004,9 @@ mod tests { panic!("expected spline"); }; let curve = crate::entities::spline::nurbs3(spline).unwrap(); - let jet = cadkernel::space::CurveJet::from_nurbs( - &curve, - cadkernel::space::SplineEnd::Start, - ) - .unwrap(); + let jet = + cadkernel::space::CurveJet::from_nurbs(&curve, cadkernel::space::SplineEnd::Start) + .unwrap(); assert!((jet.point[0] - 0.0).abs() < 1e-9); assert!((jet.point[1] - 1.0).abs() < 1e-9); assert!(jet.tangent[1].abs() < 1e-9, "jet={jet:?}"); diff --git a/tests/sketch_constraints_solve.rs b/tests/sketch_constraints_solve.rs index 6741759c3..ebb379c3d 100644 --- a/tests/sketch_constraints_solve.rs +++ b/tests/sketch_constraints_solve.rs @@ -127,6 +127,77 @@ fn horizontal_constraint_levels_the_line_when_an_endpoint_moves() { ); } +#[test] +fn horizontal_constraint_levels_a_polyline_segment() { + let mut scene = Scene::new(); + let polyline = scene.add_entity(EntityType::LwPolyline( + acadrust::entities::LwPolyline::from_points(vec![ + acadrust::types::Vector2::new(0.0, 0.0), + acadrust::types::Vector2::new(5.0, 2.0), + acadrust::types::Vector2::new(10.0, 7.0), + ]), + )); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![SketchRef::segment(polyline, 1)], + None, + ); + + scene.bump_entities(&[(polyline, ChangeKind::Modified)]); + let EntityType::LwPolyline(polyline) = scene.document.get_entity(polyline).unwrap() else { + panic!("expected a lightweight polyline"); + }; + assert!( + (polyline.vertices[1].location.y - polyline.vertices[2].location.y).abs() < 1e-6, + "the selected segment should be horizontal" + ); +} + +#[test] +fn horizontal_constraint_does_not_flatten_a_polyline_arc_segment() { + let mut source = acadrust::entities::LwPolyline::new(); + source.add_point_with_bulge(acadrust::types::Vector2::new(0.0, 0.0), 0.5); + source.add_point(acadrust::types::Vector2::new(5.0, 2.0)); + let mut scene = Scene::new(); + let polyline = scene.add_entity(EntityType::LwPolyline(source)); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![SketchRef::segment(polyline, 0)], + None, + ); + + scene.bump_entities(&[(polyline, ChangeKind::Modified)]); + let EntityType::LwPolyline(polyline) = scene.document.get_entity(polyline).unwrap() else { + panic!("expected a lightweight polyline"); + }; + assert_eq!(polyline.vertices[0].location.y, 0.0); + assert_eq!(polyline.vertices[1].location.y, 2.0); + assert_eq!(polyline.vertices[0].bulge, 0.5); +} + +#[test] +fn horizontal_constraint_aligns_two_selected_points() { + let mut scene = Scene::new(); + let a = add_line(&mut scene, 0.0, 1.0, 4.0, 3.0); + let b = add_line(&mut scene, 8.0, 7.0, 12.0, 9.0); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![SketchRef::point(a, 0), SketchRef::point(b, 0)], + None, + ); + + scene.bump_entities(&[(b, ChangeKind::Modified)]); + let (a_start, _) = line_endpoints(&scene, a); + let (b_start, _) = line_endpoints(&scene, b); + assert!((a_start.y - b_start.y).abs() < 1e-6); +} + #[test] fn parallel_constraint_rotates_the_other_line_when_one_moves() { let mut scene = Scene::new(); @@ -655,6 +726,55 @@ fn midpoint_constraint_pulls_a_point_onto_a_lines_midpoint() { ); } +#[test] +fn midpoint_constraint_supports_a_point_entity() { + let mut scene = Scene::new(); + let base = add_line(&mut scene, 0.0, 0.0, 10.0, 6.0); + let point = scene.add_entity(EntityType::Point(acadrust::entities::Point::at( + Vector3::new(20.0, 20.0, 0.0), + ))); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Midpoint, + vec![SketchRef::point(point, 0), SketchRef::whole(base)], + None, + ); + + scene.bump_entities(&[(base, ChangeKind::Modified)]); + let (start, end) = line_endpoints(&scene, base); + let EntityType::Point(point) = scene.document.get_entity(point).unwrap() else { + panic!("expected a point"); + }; + assert!((point.location.x - (start.x + end.x) / 2.0).abs() < 1e-6); + assert!((point.location.y - (start.y + end.y) / 2.0).abs() < 1e-6); +} + +#[test] +fn coincident_constraint_supports_a_block_insertion_point() { + let mut scene = Scene::new(); + let line = add_line(&mut scene, 0.0, 0.0, 10.0, 4.0); + let insert = scene.add_entity(EntityType::Insert(acadrust::entities::Insert::new( + "fixture", + Vector3::new(30.0, 20.0, 0.0), + ))); + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Coincident, + vec![SketchRef::point(line, 1), SketchRef::point(insert, 0)], + None, + ); + + scene.bump_entities(&[(line, ChangeKind::Modified)]); + let (_, end) = line_endpoints(&scene, line); + let EntityType::Insert(insert) = scene.document.get_entity(insert).unwrap() else { + panic!("expected a block reference"); + }; + assert!((insert.insert_point.x - end.x).abs() < 1e-6); + assert!((insert.insert_point.y - end.y).abs() < 1e-6); +} + #[test] fn fixed_constraint_holds_an_entity_in_place_despite_a_connected_edit() { let mut scene = Scene::new(); @@ -723,6 +843,92 @@ fn point_on_curve_constraint_pulls_a_point_onto_a_circles_circumference() { ); } +#[test] +fn point_on_curve_constraint_pulls_a_point_onto_an_ellipse() { + let mut scene = Scene::new(); + let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (5.0, 0.0), 0.6); + let marker = add_line(&mut scene, 12.0, 8.0, 13.0, 8.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::PointOnCurve, + vec![SketchRef::point(marker, 0), SketchRef::whole(ellipse)], + None, + ); + scene.bump_entities(&[(marker, ChangeKind::Modified)]); + + let (center, major, ratio) = ellipse_geom(&scene, ellipse); + let (point, _) = line_endpoints(&scene, marker); + let a = major.length(); + let b = a * ratio; + let u = major / a; + let v = Vector3::new(-u.y, u.x, 0.0); + let offset = point - center; + let equation = (offset.dot(&u) / a).powi(2) + (offset.dot(&v) / b).powi(2); + assert!( + (equation - 1.0).abs() < 1e-5, + "point should satisfy the solved ellipse equation: {equation}" + ); +} + +#[test] +fn tangent_constraint_solves_an_ellipse_and_line() { + let mut scene = Scene::new(); + let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (5.0, 0.0), 0.6); + let line = add_line(&mut scene, -8.0, 8.0, 8.0, 8.0); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Tangent, + vec![SketchRef::whole(ellipse), SketchRef::whole(line)], + None, + ); + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let (center, major, ratio) = ellipse_geom(&scene, ellipse); + let (start, end) = line_endpoints(&scene, line); + let direction = (end - start).normalize(); + let normal = Vector3::new(-direction.y, direction.x, 0.0); + let unit_major = major.normalize(); + let unit_minor = Vector3::new(-unit_major.y, unit_major.x, 0.0); + let a = major.length(); + let b = a * ratio; + let support = + ((a * normal.dot(&unit_major)).powi(2) + (b * normal.dot(&unit_minor)).powi(2)).sqrt(); + let distance = (center - start).dot(&normal).abs(); + assert!( + (distance - support).abs() < 1e-5, + "line should be tangent to the solved ellipse: distance={distance} support={support}" + ); +} + +#[test] +fn equal_constraint_matches_ellipse_major_axes() { + let mut scene = Scene::new(); + let a = add_ellipse(&mut scene, 0.0, 0.0, (5.0, 0.0), 0.6); + let b = add_ellipse(&mut scene, 20.0, 0.0, (2.0, 0.0), 0.5); + + scene + .sketch_constraint_set_mut(SketchScope::ModelSpace) + .add( + ConstraintKind::Equal, + vec![SketchRef::whole(a), SketchRef::whole(b)], + None, + ); + scene.bump_entities(&[(b, ChangeKind::Modified)]); + + let (_, major_a, _) = ellipse_geom(&scene, a); + let (_, major_b, _) = ellipse_geom(&scene, b); + assert!( + (major_a.length() - major_b.length()).abs() < 1e-5, + "ellipse major radii should match: {} vs {}", + major_a.length(), + major_b.length() + ); +} + #[test] fn equal_distance_constraint_matches_a_second_point_pairs_separation() { let mut scene = Scene::new();