Add persistent sketch-constraint system (ocs_gcs port) and named-parameters handoff docs

Ports FreeCAD's planegcs 2D geometric constraint solver to Rust as a new
crates/ocs_gcs workspace member, and wires it into the document/scene model
as a persistent SketchConstraintSet: Coincident, Horizontal/Vertical,
Parallel/Perpendicular, Equal, Tangent, Distance, Angle and Radius
constraints that re-solve live (including mid-drag, not just on release),
render glyphs and a DOF badge, flag conflicts with a one-click resolver
pill, and round-trip through save/load via an XRecord blob.

Also includes docs/command_reference.md (a statically generated reference
of every registered command and its prompt sequence) and
docs/named_parameters_design.md + docs/backlog.md, which hand off the next
queued feature (a named-parameter/expression table) and consolidate every
deliberate scope-down and open gap found along the way, so a fresh session
has a complete starting point.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-08 22:19:23 +02:00
1 parent a39c7b8f71
commit 63a74b19a9
63 files changed
+12193 -49

No files matched your search

+7 -1
View File
@@ -264,7 +264,13 @@ fn polyline_arc_center(entity: &EntityType, segment: usize) -> Option<Vector3> {
}
}
fn source_points(entity: &EntityType) -> Vec<Vector3> {
/// Ordered, named points for an entity (line start/end, polyline vertices,
/// ...), indexed by the same non-negative GsMarker convention
/// `AssocDimensionReference::main_gs_marker` uses. Promoted to
/// `pub(crate)` so `sketch::constraint_set` can address constraint
/// endpoints the same way associative dimensions already address theirs,
/// rather than inventing a second sub-element scheme.
pub(crate) fn source_points(entity: &EntityType) -> Vec<Vector3> {
match entity {
EntityType::Line(line) => vec![line.start, line.end],
EntityType::Arc(arc) => vec![arc.start_point_wcs(), arc.end_point_wcs()],
+24
View File
@@ -43,6 +43,9 @@ mod preview;
mod project;
mod scene_markers;
mod selection;
pub mod sketch_constraints;
mod sketch_persist;
mod sketch_solve;
pub(crate) use boundary::{
boundary_entities, boundary_entities_from_sources, boundary_faces,
@@ -1863,6 +1866,19 @@ pub struct Scene {
/// Conservative association hint: unknown until scanned, then updated from
/// changed entities. Retaining `true` after deletion only costs an extra scan.
has_associative_centers: std::cell::Cell<Option<bool>>,
/// Persistent parametric constraint sets, one per active
/// [`sketch_constraints::SketchScope`]. Lives on `Scene` (not
/// `DocumentTab`, despite `docs/parametric_system_design.md` §2.1's
/// original suggestion) because `bump_entities` — the hook
/// `refresh_sketch_constraints` rides, per that doc's §4.1 — is a
/// `Scene` method with no access to the owning `DocumentTab`; discovered
/// while implementing that stage, not anticipated in the design doc.
/// Synced with each scope's own `OCS_SKETCH_CONSTRAINTS` XRecord around
/// open/save (design doc §8 stage 4, `sketch_persist.rs`) — the "lazy"
/// model: this `Vec` is the live in-memory state every add/remove/solve
/// touches directly, materialized into `document.objects` only right
/// before a save and read back right after a load.
pub(crate) sketch_constraints: Vec<sketch_constraints::SketchConstraintSet>,
/// Tessellated block definitions in block-local coords, keyed by render
/// background and block epoch. Model and Paper adapt black/white colours
/// differently; retaining both variants prevents a full block rebuild on
@@ -2094,6 +2110,7 @@ impl Scene {
layout_type_names_cache: RefCell::new(None),
dependency_index_cache: RefCell::new(None),
associative_hatch_source_cache: RefCell::new(None),
sketch_constraints: Vec::new(),
has_associative_centers: std::cell::Cell::new(None),
block_defn_cache: RefCell::new(HashMap::default()),
entity_index_cache: RefCell::new(None),
@@ -2642,6 +2659,13 @@ impl Scene {
changes.push(change);
}
}
if !self.sketch_constraints.is_empty() {
for change in self.refresh_sketch_constraints(&changes) {
if !changes.iter().any(|(handle, _)| *handle == change.0) {
changes.push(change);
}
}
}
if !changes.is_empty() {
self.refresh_dependency_index_for_changes(&changes);
}
+5
View File
@@ -752,6 +752,11 @@ impl Scene {
.map(|&h| (h, ChangeKind::Added))
.collect();
self.bump_entities(&changes);
// Design doc §12: a constraint entirely between duplicated entities
// follows them (Coincident between two copied points, Parallel
// between two copied lines, ...); one straddling a duplicated and a
// non-duplicated entity can't sensibly follow, so is left alone.
self.duplicate_sketch_constraints_for(&handle_map);
self.refresh_meshes_for_handles(&refresh_solid_handles);
new_handles
}
+4 -2
View File
@@ -217,8 +217,10 @@ pub fn find_hit_grip(
/// Project an f64 world point with an explicit relative-to-eye `(view_rot,
/// eye)` pair — the camera-less form of `Camera::project`. Used by the
/// in-viewport editing path, which supplies a *composed* model→screen view
/// (see `Scene::composed_viewport_view`) instead of a real camera.
fn project_rte(world: DVec3, view_rot: Mat4, eye: DVec3, bounds: Rectangle) -> Option<Vec2> {
/// (see `Scene::composed_viewport_view`) instead of a real camera. `pub(crate)`
/// so any other single-point world→screen projection (e.g. constraint-glyph
/// anchors) can reuse it instead of re-deriving the same NDC math.
pub(crate) fn project_rte(world: DVec3, view_rot: Mat4, eye: DVec3, bounds: Rectangle) -> Option<Vec2> {
let rel = (world - eye).as_vec3();
let clip = view_rot * rel.extend(1.0);
if clip.w.abs() < 1e-9 {
+534
View File
@@ -0,0 +1,534 @@
//! Persistent, parametric geometric constraints — data model.
//!
//! Design: `docs/parametric_system_design.md`. This is Stage 1 of that
//! doc's §8 staged plan: pure data types plus unit tests, no document/scene
//! integration yet (that's `refresh_sketch_constraints`, wired into
//! `Scene::bump_entities` in a later stage).
//!
//! Distinct from the one-shot "Constraints" ribbon commands
//! (`crate::modules::draw::constrain`), which solve once and forget. A
//! [`SketchConstraint`] is a persisted record: it stays attached to its
//! entities and is meant to be re-solved every time referenced geometry
//! changes, not applied once.
use acadrust::types::{Handle, Vector3};
use serde::{Deserialize, Serialize};
/// One endpoint a constraint attaches to: an entity plus which sub-element
/// of it.
///
/// Reuses the GsMarker convention `AssocDimensionReference::main_gs_marker`
/// already carries for associative-dimension endpoints
/// (`src/scene/dimension_assoc.rs`), rather than inventing a second
/// sub-element addressing scheme: `marker` indexes into
/// [`dimension_assoc::source_points`](super::dimension_assoc::source_points)'s
/// ordered per-entity-type point list when non-negative (0/1 = a line's
/// start/end, ...), or names a special case when negative (-3 = a
/// circle/arc's center; -2 = a point at a stored parameter, not currently
/// used by constraint endpoints but reserved for consistency with the
/// dimension scheme).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SketchRef {
pub entity: Handle,
/// `None` addresses the entity as a whole — what a Radius, Length, or
/// whole-curve constraint (Parallel, Perpendicular, Equal, Horizontal,
/// Vertical) needs; a point-level constraint (Coincident, Distance
/// between two points, Angle at a shared vertex) sets `Some(marker)`.
pub marker: Option<i32>,
}
impl SketchRef {
pub fn whole(entity: Handle) -> Self {
Self { entity, marker: None }
}
pub fn point(entity: Handle, marker: i32) -> Self {
Self { entity, marker: Some(marker) }
}
/// The circle/arc-center special case (`marker == -3`), broken out as
/// its own constructor since `-3` alone reads as a magic number
/// everywhere it would otherwise appear.
pub fn center(entity: Handle) -> Self {
Self { entity, marker: Some(-3) }
}
}
/// The friendly, user-facing constraint types — the "what button did they
/// click" vocabulary, one layer above the `ocs_gcs` primitives each maps
/// onto (that mapping is `constraint_map`, a later stage; see the design
/// doc §2). Named and grouped the same way the existing one-shot ribbon
/// tools are (`crate::modules::draw::constrain::tools`), plus the
/// endpoint-picking kinds (`Coincident`, `Radius`, `Tangent`) that one-shot
/// group never needed.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum ConstraintKind {
Coincident,
Horizontal,
Vertical,
Parallel,
Perpendicular,
Equal,
/// Distance between two points, or a single line's length, or a
/// circle/arc's diameter — which reading applies depends on `refs`'
/// shape, mirroring how `DistanceConstraintCommand`
/// (`src/modules/draw/constrain/value.rs`) already infers it from the
/// selected entity.
Distance,
Angle,
Radius,
Tangent,
}
pub type ConstraintId = u32;
/// One persisted constraint record: a friendly [`ConstraintKind`], the
/// entities/points it relates, and — for a dimensional kind — the value
/// driving it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SketchConstraint {
pub id: ConstraintId,
pub kind: ConstraintKind,
pub refs: Vec<SketchRef>,
/// The typed target for a dimensional constraint (a `Distance`'s
/// length, an `Angle`'s degrees, a `Radius`'s radius). `None` for every
/// purely-geometric kind (Coincident, Horizontal, Vertical, Parallel,
/// Perpendicular, Equal, Tangent).
pub driving_param: Option<f64>,
/// Lets a user suppress a constraint without losing it — a re-solve
/// skips a disabled constraint entirely.
pub enabled: bool,
}
/// What "one sketch" scopes to, given the app has no dedicated Sketch
/// grouping (design doc §3.1): the only existing sub-document boundary is a
/// block record, so model space and each block definition get their own
/// independent constraint set.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SketchScope {
ModelSpace,
/// A block definition's `BlockRecord` handle — matches
/// `BlockEditSession::br_handle`
/// (`src/modules/draw/modify/block_edit.rs`).
Block(Handle),
}
impl SketchScope {
/// The handle a [`SketchConstraintSet`] for this scope is persisted
/// under — `CadDocument::ensure_xrecord`/`xrecord`/`xrecord_mut`'s
/// `owner` argument (design doc §1.4/§5.2). Model space resolves
/// through the document rather than being a fixed handle because a
/// document's model-space block record handle is assigned when the
/// document is built, not a constant.
pub fn owner_handle(&self, document: &acadrust::CadDocument) -> Handle {
match self {
SketchScope::ModelSpace => document.header.model_space_block_handle,
SketchScope::Block(handle) => *handle,
}
}
}
/// Every persisted constraint for one [`SketchScope`]. Rebuilt-and-solved
/// wholesale on every trigger (design doc §4.2) — this struct holds only
/// the constraint records themselves, no solver state.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SketchConstraintSet {
pub scope: SketchScope,
pub constraints: Vec<SketchConstraint>,
next_id: ConstraintId,
/// Cached total remaining degrees of freedom, summed across every
/// independent solve partition in this scope — updated by
/// `sketch_solve::solve_scope` each time this set is resolved. `None`
/// until the first resolve (e.g. right after loading from disk, before
/// any edit has touched this scope yet). Design doc §6.3's DOF badge
/// reads this rather than recomputing it every frame. Not persisted:
/// it's a derived cache, not real constraint state.
#[serde(skip)]
pub dof: Option<usize>,
/// Cached redundant/conflicting constraints found by the last resolve
/// (design doc §6.4, stage 11) — empty whenever the scope isn't
/// over-constrained, which is the common case. Also a derived cache, not
/// persisted; a `ConflictResolverPanel` reads this rather than calling
/// `ocs_gcs::diagnosis::classify_redundant` itself.
#[serde(skip)]
pub conflicts: Vec<(ConstraintId, ocs_gcs::diagnosis::RedundancyKind)>,
}
impl SketchConstraintSet {
pub fn new(scope: SketchScope) -> Self {
Self { scope, constraints: Vec::new(), next_id: 0, dof: None, conflicts: Vec::new() }
}
/// Appends a constraint, assigning it a fresh id unique within this set.
pub fn add(&mut self, kind: ConstraintKind, refs: Vec<SketchRef>, driving_param: Option<f64>) -> ConstraintId {
let id = self.next_id;
self.next_id += 1;
self.constraints.push(SketchConstraint { id, kind, refs, driving_param, enabled: true });
id
}
/// Removes a constraint by id. Returns whether one was actually removed.
pub fn remove(&mut self, id: ConstraintId) -> bool {
let before = self.constraints.len();
self.constraints.retain(|c| c.id != id);
self.constraints.len() != before
}
pub fn get(&self, id: ConstraintId) -> Option<&SketchConstraint> {
self.constraints.iter().find(|c| c.id == id)
}
/// Every enabled constraint referencing `entity`, regardless of which
/// of its sub-elements — the query `refresh_sketch_constraints`'
/// reverse index (design doc §4.1) is built from.
pub fn constraints_touching(&self, entity: Handle) -> impl Iterator<Item = &SketchConstraint> {
self.constraints.iter().filter(move |c| c.enabled && c.refs.iter().any(|r| r.entity == entity))
}
/// Drops every constraint that references `entity` at all — the
/// dangling-constraint-on-delete policy (design doc §5.3, open question
/// 4): entities silently take their constraints with them when erased,
/// rather than leaving a dangling reference around. Returns the
/// removed constraints' ids, so a caller can log/report what vanished.
pub fn remove_all_touching(&mut self, entity: Handle) -> Vec<ConstraintId> {
let (removed, kept): (Vec<_>, Vec<_>) =
self.constraints.drain(..).partition(|c| c.refs.iter().any(|r| r.entity == entity));
self.constraints = kept;
removed.into_iter().map(|c| c.id).collect()
}
}
/// Resolves a [`SketchRef`] to its current world-space point, for building
/// an `ocs_gcs` `ParamStore` from live document geometry — the constraint
/// endpoint's equivalent of `dimension_assoc::resolve_reference`, restricted
/// to the marker conventions constraint endpoints actually use (whole-entity
/// `None`, an ordinary `source_points()` index, or the `-3` center case).
/// Returns `None` for a dangling reference (handle doesn't resolve) or a
/// marker this scheme doesn't (yet) support (e.g. `-2`, or an out-of-range
/// index).
///
/// `sketch_solve`'s solver-side entity registration reads entity fields
/// directly rather than through this (it needs raw, not-yet-WCS-resolved
/// coordinates to seed `ParamId`s) — this is for UI-side consumers instead:
/// currently `glyph_anchor` (below), eventually Phase B live inference
/// (design doc §6.2) querying "what point is near the cursor".
pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Option<Vector3> {
if marker == -3 {
return match entity {
acadrust::EntityType::Circle(circle) => Some(circle.center_wcs()),
acadrust::EntityType::Arc(arc) => Some(arc.center_wcs()),
_ => None,
};
}
if marker < 0 {
return None;
}
super::dimension_assoc::source_points(entity).get(marker as usize).copied()
}
/// Below this squared distance (1e-6 world units), two points count as
/// already coincident for [`infer_coincident_refs`]/[`nearest_sketch_point`]'s
/// purposes.
const COINCIDENT_EPSILON_SQ: f64 = 1.0e-12;
/// Design doc §6.1's manual Coincident UI: given a world point (from a
/// `CadCommand::on_point` pick — trusted to already be OSNAP-snapped onto a
/// real feature, the same trust `infer_coincident_refs` places in a
/// just-drawn entity's own coordinates, for the same reason: no camera or
/// pixel radius is available once a point reaches this far from the click),
/// finds the closest addressable point (an ordinary `source_points()` index,
/// or a circle/arc's center) on any entity in `scope` other than `exclude`,
/// within [`COINCIDENT_EPSILON_SQ`]. `None` means the pick didn't land on a
/// real point — the caller should ask the user to enable an Endpoint/Center
/// object snap and try again, not silently constrain nothing.
pub(crate) fn nearest_sketch_point(
document: &acadrust::CadDocument,
scope: SketchScope,
world_point: Vector3,
exclude: Option<Handle>,
) -> Option<SketchRef> {
let owner = scope.owner_handle(document);
let mut best: Option<(f64, SketchRef)> = None;
let mut consider = |handle: Handle, marker: i32, point: Vector3| {
let dx = point.x - world_point.x;
let dy = point.y - world_point.y;
let dz = point.z - world_point.z;
let dist_sq = dx * dx + dy * dy + dz * dz;
if dist_sq <= COINCIDENT_EPSILON_SQ && best.as_ref().is_none_or(|(d, _)| dist_sq < *d) {
best = Some((dist_sq, SketchRef::point(handle, marker)));
}
};
for candidate in document.entities() {
let common = candidate.common();
if common.owner_handle != owner || Some(common.handle) == exclude {
continue;
}
for (marker, point) in super::dimension_assoc::source_points(candidate).into_iter().enumerate() {
consider(common.handle, marker as i32, point);
}
match candidate {
acadrust::EntityType::Circle(c) => consider(common.handle, -3, c.center_wcs()),
acadrust::EntityType::Arc(a) => consider(common.handle, -3, a.center_wcs()),
_ => {}
}
}
best.map(|(_, r)| r)
}
/// Design doc §6.2 (stage 8, Phase B live inference): for each of
/// `new_entity`'s own points, finds an existing entity in `scope` (other
/// than `new_handle` itself) whose corresponding point already coincides,
/// and returns a `(new, existing)` `SketchRef` pair per match — the caller
/// adds each as a `Coincident` constraint.
///
/// **Deviation from this doc**: the doc's own §6.2 suggests reusing OSNAP's
/// screen-space tolerance/query; this instead checks for near-exact
/// world-space coincidence. The natural hook point is where a committed
/// entity becomes a document write (`CmdResult::CommitEntity`'s dispatch in
/// `command_driver.rs`) — well after the cursor's pixel position and camera
/// are available, with only the entity's final world coordinates left. If
/// OSNAP's endpoint mode grabbed the point while the user was drawing, the
/// new entity's coordinate already matches the existing one to floating-point
/// precision, so a tight world-space epsilon reproduces the same result
/// without re-deriving OSNAP's screen-space math or threading camera state
/// into this dispatch site.
pub(crate) fn infer_coincident_refs(
document: &acadrust::CadDocument,
scope: SketchScope,
new_handle: Handle,
new_entity: &acadrust::EntityType,
) -> Vec<(SketchRef, SketchRef)> {
let owner = scope.owner_handle(document);
let new_points = super::dimension_assoc::source_points(new_entity);
if new_points.is_empty() {
return Vec::new();
}
let mut pairs = Vec::new();
for (new_marker, new_point) in new_points.iter().enumerate() {
for candidate in document.entities() {
let common = candidate.common();
if common.handle == new_handle || common.owner_handle != owner {
continue;
}
for (marker, point) in super::dimension_assoc::source_points(candidate).iter().enumerate() {
let dx = point.x - new_point.x;
let dy = point.y - new_point.y;
let dz = point.z - new_point.z;
if dx * dx + dy * dy + dz * dz <= COINCIDENT_EPSILON_SQ {
pairs.push((SketchRef::point(new_handle, new_marker as i32), SketchRef::point(common.handle, marker as i32)));
break;
}
}
}
}
pairs
}
impl ConstraintKind {
/// The short symbol a constraint glyph shows — matches the existing
/// ribbon icons (`crate::modules::draw::constrain::{tools,value}`) for
/// the kinds that have a one-click button, so the same glyph means the
/// same thing in both places.
pub fn glyph_symbol(&self) -> &'static str {
match self {
ConstraintKind::Coincident => "≡",
ConstraintKind::Horizontal => "—",
ConstraintKind::Vertical => "│",
ConstraintKind::Parallel => "∥",
ConstraintKind::Perpendicular => "⊥",
ConstraintKind::Equal => "=",
ConstraintKind::Distance => "↔",
ConstraintKind::Angle => "∠",
ConstraintKind::Radius => "R",
ConstraintKind::Tangent => "T",
}
}
}
/// The full glyph text for one constraint: its symbol, plus the driving
/// value for a dimensional kind (Distance/Angle/Radius).
pub(crate) fn glyph_label(constraint: &SketchConstraint) -> String {
match (constraint.kind, constraint.driving_param) {
(ConstraintKind::Angle, Some(value)) => format!("{} {value:.1}°", constraint.kind.glyph_symbol()),
(_, Some(value)) => format!("{} {value:.2}", constraint.kind.glyph_symbol()),
(_, None) => constraint.kind.glyph_symbol().to_string(),
}
}
/// World-space point to anchor `constraint`'s glyph at, or `None` if it
/// can't be resolved (dangling ref, unsupported entity/marker). A point ref
/// resolves straight through [`resolve_point`]; a whole-entity ref falls
/// back to a representative point on the entity (a line's midpoint, a
/// circle's rightmost point) since there's no single named point to anchor
/// on otherwise.
pub(crate) fn glyph_anchor(document: &acadrust::CadDocument, constraint: &SketchConstraint) -> Option<Vector3> {
let r = constraint.refs.first()?;
let entity = document.get_entity(r.entity)?;
if let Some(marker) = r.marker {
return resolve_point(entity, marker);
}
match entity {
acadrust::EntityType::Line(l) => {
Some(Vector3::new((l.start.x + l.end.x) * 0.5, (l.start.y + l.end.y) * 0.5, (l.start.z + l.end.z) * 0.5))
}
acadrust::EntityType::Circle(c) => Some(c.point_at_angle_wcs(0.0)),
_ => None,
}
}
impl super::Scene {
/// The constraint set for `scope`, if one has been created.
pub fn sketch_constraint_set(&self, scope: SketchScope) -> Option<&SketchConstraintSet> {
self.sketch_constraints.iter().find(|s| s.scope == scope)
}
/// The constraint set for `scope`, creating an empty one on first use.
/// The `pub(crate)` `sketch_constraints` field itself stays private so
/// nothing outside this module can end up with two sets for the same
/// scope — this is the one way to reach a scope's set for both reading
/// and mutating.
pub fn sketch_constraint_set_mut(&mut self, scope: SketchScope) -> &mut SketchConstraintSet {
if let Some(index) = self.sketch_constraints.iter().position(|s| s.scope == scope) {
&mut self.sketch_constraints[index]
} else {
self.sketch_constraints.push(SketchConstraintSet::new(scope));
self.sketch_constraints.last_mut().expect("just pushed")
}
}
/// Design doc §5.3/§12 (open question 3, now resolved): copy/paste's
/// handle remapping lives locally in each command that duplicates
/// entities — `Scene::copy_entities`' `handle_map` (COPY/ARRAY/MIRROR,
/// `src/scene/modify.rs`) and `OpenCADStudio::finalize_paste`'s own
/// (clipboard paste, `src/app/command_driver.rs`) — rather than in one
/// shared table, so each call site passes its own `handle_map` here
/// after adding the duplicated entities.
///
/// For every enabled constraint whose *every* referenced entity was
/// duplicated (a constraint straddling a duplicated and a
/// non-duplicated entity can't sensibly follow — only one side moved),
/// adds an equivalent constraint over the new handles to the same
/// scope, then triggers a solve for the newly duplicated geometry the
/// same way any other edit would. A no-op when `handle_map` is empty or
/// nothing constrained was duplicated.
pub fn duplicate_sketch_constraints_for(&mut self, handle_map: &rustc_hash::FxHashMap<Handle, Handle>) {
if handle_map.is_empty() {
return;
}
let mut to_add: Vec<(usize, ConstraintKind, Vec<SketchRef>, Option<f64>)> = Vec::new();
for (scope_index, set) in self.sketch_constraints.iter().enumerate() {
for c in &set.constraints {
if !c.enabled || !c.refs.iter().all(|r| handle_map.contains_key(&r.entity)) {
continue;
}
let new_refs: Vec<SketchRef> =
c.refs.iter().map(|r| SketchRef { entity: handle_map[&r.entity], marker: r.marker }).collect();
to_add.push((scope_index, c.kind, new_refs, c.driving_param));
}
}
if to_add.is_empty() {
return;
}
let mut touched: Vec<Handle> = Vec::new();
for (scope_index, kind, refs, driving_param) in to_add {
touched.extend(refs.iter().map(|r| r.entity));
self.sketch_constraints[scope_index].add(kind, refs, driving_param);
}
touched.sort();
touched.dedup();
let changes: Vec<(Handle, super::ChangeKind)> = touched.into_iter().map(|h| (h, super::ChangeKind::Modified)).collect();
self.bump_entities(&changes);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn h(v: u64) -> Handle {
Handle::new(v)
}
#[test]
fn add_assigns_increasing_ids_and_get_finds_them() {
let mut set = SketchConstraintSet::new(SketchScope::ModelSpace);
let a = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(1))], None);
let b = set.add(ConstraintKind::Distance, vec![SketchRef::whole(h(1))], Some(25.0));
assert_ne!(a, b);
assert_eq!(set.get(a).unwrap().kind, ConstraintKind::Horizontal);
assert_eq!(set.get(b).unwrap().driving_param, Some(25.0));
}
#[test]
fn remove_drops_only_the_matching_id() {
let mut set = SketchConstraintSet::new(SketchScope::ModelSpace);
let a = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(1))], None);
let b = set.add(ConstraintKind::Vertical, vec![SketchRef::whole(h(2))], None);
assert!(set.remove(a));
assert!(!set.remove(a), "removing twice should report nothing removed the second time");
assert!(set.get(a).is_none());
assert!(set.get(b).is_some());
}
#[test]
fn constraints_touching_finds_entity_regardless_of_marker() {
let mut set = SketchConstraintSet::new(SketchScope::ModelSpace);
set.add(ConstraintKind::Coincident, vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], None);
set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(3))], None);
let touching_1: Vec<_> = set.constraints_touching(h(1)).collect();
assert_eq!(touching_1.len(), 1);
let touching_3: Vec<_> = set.constraints_touching(h(3)).collect();
assert_eq!(touching_3.len(), 1);
assert_eq!(set.constraints_touching(h(99)).count(), 0);
}
#[test]
fn constraints_touching_skips_disabled() {
let mut set = SketchConstraintSet::new(SketchScope::ModelSpace);
let id = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(1))], None);
set.constraints.iter_mut().find(|c| c.id == id).unwrap().enabled = false;
assert_eq!(set.constraints_touching(h(1)).count(), 0);
}
#[test]
fn remove_all_touching_drops_every_constraint_referencing_the_entity() {
let mut set = SketchConstraintSet::new(SketchScope::ModelSpace);
let coincident = set.add(ConstraintKind::Coincident, vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], None);
let horizontal_other = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(h(3))], None);
let removed = set.remove_all_touching(h(1));
assert_eq!(removed, vec![coincident]);
assert!(set.get(coincident).is_none());
assert!(set.get(horizontal_other).is_some(), "unrelated entity's constraint must survive");
}
#[test]
fn scope_owner_handle_resolves_block_directly() {
let block_handle = h(42);
let scope = SketchScope::Block(block_handle);
let doc = acadrust::CadDocument::new();
assert_eq!(scope.owner_handle(&doc), block_handle);
}
#[test]
fn ref_center_constructor_matches_the_dash_three_convention() {
let r = SketchRef::center(h(7));
assert_eq!(r, SketchRef { entity: h(7), marker: Some(-3) });
}
#[test]
fn sketch_constraint_set_round_trips_through_bincode() {
let mut set = SketchConstraintSet::new(SketchScope::Block(h(5)));
set.add(ConstraintKind::Coincident, vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], None);
set.add(ConstraintKind::Distance, vec![SketchRef::whole(h(3))], Some(12.5));
let bytes = bincode::serialize(&set).expect("serialize");
let restored: SketchConstraintSet = bincode::deserialize(&bytes).expect("deserialize");
assert_eq!(restored.scope, set.scope);
assert_eq!(restored.constraints.len(), set.constraints.len());
assert_eq!(restored.constraints[1].driving_param, Some(12.5));
assert_eq!(restored.constraints[0].refs, set.constraints[0].refs);
}
}
+248
View File
@@ -0,0 +1,248 @@
//! Save/load integration for [`super::sketch_constraints::SketchConstraintSet`]
//! — design doc §5.2/§8 stage 4, the "lazy" persistence model that section
//! recommends: constraint sets live only in `Scene::sketch_constraints`
//! in-memory (§8 stage 3) and are materialized into `document.objects` as
//! XRecords only around a save, and read back out right after a load. The
//! XRecord round-trip mechanism itself (`CadDocument::ensure_xrecord`/
//! `xrecord`/`xrecord_mut`) was proven in stage 2
//! (`tests/sketch_constraints_xrecord_roundtrip.rs`) — this module is just
//! the app-level wiring: which owner handles to check, and when.
//!
//! One named XRecord (keyed [`XRECORD_KEY`]) hangs off each scope's own
//! owner handle's extension dictionary — model space's `BlockRecord` handle,
//! or a `SketchScope::Block`'s handle — rather than a single shared
//! dictionary, since [`super::sketch_constraints::SketchScope::owner_handle`]
//! already gives that handle directly and `ensure_xrecord`/`xrecord` are
//! themselves per-owner.
use super::sketch_constraints::SketchConstraintSet;
use super::Scene;
const XRECORD_KEY: &str = "OCS_SKETCH_CONSTRAINTS";
/// Prefixed onto every serialized blob so a future schema change can be
/// detected and gracefully skipped (dropping just that one scope's
/// constraints, not the whole load) rather than silently misreading bytes
/// or panicking — design doc §5.2's explicit call-out, and §7's flagged
/// "no migration path designed yet": bumping this is the signal a real
/// migration is needed, not a substitute for one.
const FORMAT_VERSION: u8 = 1;
fn encode(set: &SketchConstraintSet) -> Option<Vec<u8>> {
let mut bytes = vec![FORMAT_VERSION];
bytes.extend(bincode::serialize(set).ok()?);
Some(bytes)
}
fn decode(bytes: &[u8]) -> Option<SketchConstraintSet> {
let (&version, body) = bytes.split_first()?;
if version != FORMAT_VERSION {
return None;
}
bincode::deserialize(body).ok()
}
impl Scene {
/// Writes every current `sketch_constraints` entry into its scope's
/// owner-handle XRecord, ready for whatever save call happens next to
/// serialize `self.document` as-is. Called right before a native/web
/// save (`OpenCADStudio::prepare_native_save` and the wasm save path)
/// — see those call sites' doc comments for why "right before save"
/// rather than keeping the XRecord resident and live-updated (design
/// doc §5.2's "live" model, deferred: it would also let constraint-set
/// edits ride the ordinary object-delta undo path per §5.1(b), but this
/// lazy model was the doc's own recommended first step).
///
/// A scope with an empty constraint set is skipped rather than writing
/// an empty XRecord — an unconstrained drawing (the common case) should
/// not gain persisted-but-empty bookkeeping on every save.
pub(crate) fn materialize_sketch_constraints_for_save(&mut self) {
for index in 0..self.sketch_constraints.len() {
let set = &self.sketch_constraints[index];
if set.constraints.is_empty() {
continue;
}
let owner = set.scope.owner_handle(&self.document);
if owner.is_null() {
continue;
}
let Some(bytes) = encode(set) else { continue };
self.document.ensure_xrecord(owner, XRECORD_KEY);
if let Some(record) = self.document.xrecord_mut(owner, XRECORD_KEY) {
// Overwrite, not append: a resave must replace the prior
// blob, not accumulate one more Chunk entry every time.
record.entries.clear();
record.entries.push(acadrust::objects::XRecordEntry::new(
310,
acadrust::objects::XRecordValue::Chunk(bytes),
));
}
}
}
/// Populates `sketch_constraints` from every `OCS_SKETCH_CONSTRAINTS`
/// XRecord found in the just-loaded `self.document` — called right
/// after a document open installs its `CadDocument` into this `Scene`
/// (`OpenCADStudio::on_file_opened` and the automation `"open"`/`"new"`
/// ops). Every `BlockRecord` (model space, paper space, and named
/// block definitions) is a candidate owner; most will have no such
/// XRecord and are skipped cheaply. Replaces whatever was already in
/// `sketch_constraints` (a fresh/empty `Vec` for a real open; `"new"`'s
/// blank document has nothing to find anyway).
pub(crate) fn load_sketch_constraints_from_document(&mut self) {
self.sketch_constraints.clear();
let owners: Vec<acadrust::Handle> =
self.document.block_records.iter().map(|record| record.handle).collect();
for owner in owners {
let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else { continue };
let Some(bytes) = record.entries.iter().find_map(|entry| match &entry.value {
acadrust::objects::XRecordValue::Chunk(bytes) => Some(bytes.clone()),
_ => None,
}) else {
continue;
};
if let Some(set) = decode(&bytes) {
self.sketch_constraints.push(set);
}
}
}
}
#[cfg(test)]
mod tests {
use super::super::sketch_constraints::{ConstraintKind, SketchRef, SketchScope};
use super::*;
use acadrust::entities::EntityType;
use acadrust::types::Vector3;
#[test]
fn a_model_space_constraint_set_survives_materialize_and_reload() {
let mut scene = Scene::new();
let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
)));
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
ConstraintKind::Horizontal,
vec![SketchRef::whole(a)],
None,
);
scene.materialize_sketch_constraints_for_save();
// Simulate the load-time side of a save/reload round trip: drop the
// in-memory sets (a fresh open starts with none) and repopulate
// purely from what materialize just wrote into `document.objects`.
scene.sketch_constraints.clear();
scene.load_sketch_constraints_from_document();
let set = scene.sketch_constraint_set(SketchScope::ModelSpace).expect("constraint set should have been reloaded");
assert_eq!(set.constraints.len(), 1);
assert_eq!(set.constraints[0].kind, ConstraintKind::Horizontal);
assert_eq!(set.constraints[0].refs, vec![SketchRef::whole(a)]);
}
/// The above test only proves `materialize`/`load` agree with each
/// other on the same in-memory `CadDocument` — it never actually
/// serializes anything. This drives a real `save_to_bytes`/`load_bytes`
/// round trip (the same primitives `OpenCADStudio::on_file_opened`'s
/// native save/open path uses), through both supported formats, closing
/// the gap stage 2's spike proved for the raw XRecord mechanism up to
/// this stage's actual app-level wiring.
fn full_bytes_roundtrip(ext: &str) -> SketchConstraintSet {
let mut scene = Scene::new();
let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 5.0, 0.0),
)));
let b = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
Vector3::new(10.0, 5.0, 0.0),
Vector3::new(20.0, 5.0, 0.0),
)));
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
ConstraintKind::Coincident,
vec![SketchRef::point(a, 1), SketchRef::point(b, 0)],
None,
);
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(ConstraintKind::Distance, vec![SketchRef::whole(b)], Some(12.5));
scene.materialize_sketch_constraints_for_save();
let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version)
.unwrap_or_else(|e| panic!("save to {ext} bytes: {e}"));
let mut reloaded_scene = Scene::new();
reloaded_scene.document = crate::io::load_bytes(&format!("stage4_roundtrip.{ext}"), bytes)
.unwrap_or_else(|e| panic!("reload {ext} bytes: {e}"));
reloaded_scene.load_sketch_constraints_from_document();
reloaded_scene.sketch_constraint_set(SketchScope::ModelSpace).cloned().unwrap_or_else(|| {
panic!("no ModelSpace constraint set survived the {ext} save/load round trip")
})
}
#[test]
fn a_constraint_set_survives_a_real_dxf_save_and_load_round_trip() {
let restored = full_bytes_roundtrip("dxf");
assert_eq!(restored.constraints.len(), 2);
assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident);
assert_eq!(restored.constraints[1].kind, ConstraintKind::Distance);
assert_eq!(restored.constraints[1].driving_param, Some(12.5));
}
#[test]
fn a_constraint_set_survives_a_real_dwg_save_and_load_round_trip() {
let restored = full_bytes_roundtrip("dwg");
assert_eq!(restored.constraints.len(), 2);
assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident);
assert_eq!(restored.constraints[1].kind, ConstraintKind::Distance);
assert_eq!(restored.constraints[1].driving_param, Some(12.5));
}
#[test]
fn a_block_scoped_constraint_set_survives_materialize_and_reload() {
let mut scene = Scene::new();
let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(1.0, 0.0, 0.0),
)));
// `create_block_from_entities` returns the placed INSERT's handle,
// not the BlockRecord's own handle — `SketchScope::Block` needs the
// latter (it's what `BlockEditSession::br_handle` and
// `SketchScope::owner_handle` both mean by "a block definition's
// handle"), so look it up from the table by name instead of using
// the return value directly.
let _insert_handle = scene
.create_block_from_entities(&[a], "OCS_STAGE4_TEST_BLOCK", &acadrust::types::Transform::identity(), &acadrust::types::Transform::identity())
.expect("create a block definition to own the constraint set");
let br_handle = scene.document.block_records.get("OCS_STAGE4_TEST_BLOCK").expect("block record should exist").handle;
scene.sketch_constraint_set_mut(SketchScope::Block(br_handle)).add(ConstraintKind::Vertical, vec![SketchRef::whole(a)], None);
scene.materialize_sketch_constraints_for_save();
scene.sketch_constraints.clear();
scene.load_sketch_constraints_from_document();
let set = scene
.sketch_constraint_set(SketchScope::Block(br_handle))
.expect("block-scoped constraint set should have been reloaded");
assert_eq!(set.constraints.len(), 1);
assert_eq!(set.constraints[0].kind, ConstraintKind::Vertical);
}
#[test]
fn an_empty_constraint_set_is_not_materialized() {
// A scope touched (e.g. `sketch_constraint_set_mut` called once,
// then every constraint removed) but left with zero constraints
// should not persist an empty XRecord.
let mut scene = Scene::new();
let _ = scene.sketch_constraint_set_mut(SketchScope::ModelSpace);
scene.materialize_sketch_constraints_for_save();
let owner = scene.document.header.model_space_block_handle;
assert!(scene.document.xrecord(owner, XRECORD_KEY).is_none(), "an empty constraint set should not create an XRecord");
}
#[test]
fn decode_rejects_a_mismatched_format_version() {
let mut bytes = vec![FORMAT_VERSION.wrapping_add(1)];
bytes.extend(bincode::serialize(&SketchConstraintSet::new(SketchScope::ModelSpace)).unwrap());
assert!(decode(&bytes).is_none(), "a future/unknown format version must be rejected, not misread");
}
}
+562
View File
@@ -0,0 +1,562 @@
//! Wires [`sketch_constraints::SketchConstraintSet`] into `Scene::bump_entities`
//! — design doc §4.1/§4.2, stage 3 of its §8 staged plan.
//!
//! `refresh_sketch_constraints` is one more link in `bump_entities`'s
//! existing in-line derived-geometry chain, alongside
//! `refresh_associative_dimensions`/`_hatches`/`_centerlines`: given the
//! handles that just changed, find which constraint scopes are affected,
//! rebuild each affected scope's `ocs_gcs::System` from scratch (full
//! rebuild-and-solve, no incremental state — see the design doc §4.2 for
//! why), solve, and write any moved geometry back through the same
//! undo-recording path those other passes use.
//!
//! Maps every [`ConstraintKind`]. `Tangent` covers Line-Circle
//! (`C2LDistance` with a driven zero-distance target aliased to the
//! circle's own radius via `internal: false`) and Circle-Circle
//! (`TangentCircumf`) — Line-Line has no meaning and Arc isn't a supported
//! [`EntityGeom`] yet (matches that type's own documented scope), so those
//! ref shapes build nothing, same "skip, don't panic" contract every other
//! unbuildable constraint already gets. `ccw`/`internal` are picked from
//! the pair's *current* geometry (which side of the line the circle already
//! sits on; whether the circles are already nested) so the very first solve
//! doesn't have to cross a sign-flip singularity to reach the nearest valid
//! tangent configuration.
use std::collections::HashMap;
use std::rc::Rc;
use acadrust::entities::EntityType;
use acadrust::types::Handle;
use ocs_gcs::constraints::angle_distance::L2LAngle;
use ocs_gcs::constraints::circle_arc::{C2LDistance, TangentCircumf};
use ocs_gcs::constraints::point_line::{
Equal, EqualLineLength, Parallel as ParallelConstraint, Perpendicular as PerpendicularConstraint, P2PDistance,
};
use ocs_gcs::constraints::Constraint;
use ocs_gcs::geo::{Circle as GCircle, Line as GLine, Point as GPoint};
use ocs_gcs::solvers::dogleg::solve_dl;
use ocs_gcs::system::System;
use super::sketch_constraints::{ConstraintId, ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef};
use super::{ChangeKind, Scene};
/// One referenced entity's geometry, registered into an `ocs_gcs::System`'s
/// parameter store. Only the two entity types the existing one-shot
/// constraint commands already support (`crate::modules::draw::constrain`)
/// — extending this to arcs/ellipses is future work, not a gap introduced
/// here.
#[derive(Clone, Copy)]
enum EntityGeom {
Line(GLine),
Circle(GCircle),
}
impl EntityGeom {
/// The `ocs_gcs::geo::Point` for a marker on this entity — `None` for
/// a marker this entity type/value doesn't support (see
/// `sketch_constraints::resolve_point` for the same convention on the
/// read side).
fn point_for_marker(&self, marker: i32) -> Option<GPoint> {
match (self, marker) {
(EntityGeom::Line(l), 0) => Some(l.p1),
(EntityGeom::Line(l), 1) => Some(l.p2),
(EntityGeom::Circle(c), -3) => Some(c.center),
_ => None,
}
}
}
/// Reads `handle`'s live geometry and registers it as fresh, free (never
/// `driven`) parameters in `sys` — every referenced entity is solved for,
/// nothing is a priori fixed; per planegcs-style solvers, an under-
/// constrained scope simply converges to the nearest configuration to its
/// current one, which is the correct behavior for a persistent system where
/// "what stays put" is a property of how many constraints exist, not a
/// convention about which entity was picked first (contrast the one-shot
/// `constrain::apply_*` commands, which do fix the first-picked entity —
/// they solve exactly one constraint in isolation, so they need that
/// convention; a whole scope's constraint graph does not).
fn register_entity(document: &acadrust::CadDocument, sys: &mut System, handle: Handle) -> Option<EntityGeom> {
match document.get_entity(handle)? {
EntityType::Line(l) => {
let p1 = GPoint::new(sys.add_param(l.start.x, false), sys.add_param(l.start.y, false));
let p2 = GPoint::new(sys.add_param(l.end.x, false), sys.add_param(l.end.y, false));
Some(EntityGeom::Line(GLine { p1, p2 }))
}
EntityType::Circle(c) => {
let center = GPoint::new(sys.add_param(c.center.x, false), sys.add_param(c.center.y, false));
let rad = sys.add_param(c.radius, false);
Some(EntityGeom::Circle(GCircle { center, rad }))
}
_ => None,
}
}
/// Resolves one [`SketchRef`] against the entity-geometry cache, registering
/// the entity on first use. `None` if the handle is dangling, isn't a
/// supported entity type, or (for a point ref) uses a marker that entity
/// type doesn't support.
fn resolve_ref(
document: &acadrust::CadDocument,
sys: &mut System,
cache: &mut HashMap<Handle, EntityGeom>,
r: SketchRef,
) -> Option<EntityGeom> {
if !cache.contains_key(&r.entity) {
let geom = register_entity(document, sys, r.entity)?;
cache.insert(r.entity, geom);
}
Some(*cache.get(&r.entity)?)
}
/// One [`SketchConstraint`]'s `ocs_gcs` construction, or `None` if it can't
/// be built (an unsupported/not-yet-mapped kind, a dangling ref, a ref
/// shape the kind doesn't expect — e.g. `Parallel` needs two whole-line
/// refs). A constraint that can't be built is simply skipped for this
/// solve, not an error: geometry it would have constrained is left alone,
/// matching how a dangling associative-dimension reference degrades today
/// rather than aborting the whole recompute.
fn build_constraint(
document: &acadrust::CadDocument,
sys: &mut System,
cache: &mut HashMap<Handle, EntityGeom>,
c: &SketchConstraint,
) -> Option<Rc<dyn Constraint>> {
if !c.enabled {
return None;
}
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(_) => None,
};
let whole_circle = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| match resolve_ref(document, sys, cache, r)? {
EntityGeom::Circle(circ) => Some(circ),
EntityGeom::Line(_) => None,
};
let point_ref = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| {
let marker = r.marker?;
resolve_ref(document, sys, cache, r)?.point_for_marker(marker)
};
match c.kind {
ConstraintKind::Coincident => {
let [a, b] = c.refs.as_slice() else { return None };
let (pa, pb) = (point_ref(sys, cache, *a)?, point_ref(sys, cache, *b)?);
// Two coordinate-equal constraints packaged as one via a small
// local combinator isn't worth it for a single call site — the
// caller (`solve_scope`) already `extend`s a `Vec` per
// constraint, so this returns just the X constraint and Y is
// added as a second, independent `SketchConstraint`-less
// push — see `solve_scope`'s handling of this arm specifically.
Some(Rc::new(Equal::new(pa.x, pb.x, 1.0)))
}
ConstraintKind::Horizontal => {
let l = whole_line(sys, cache, *c.refs.first()?)?;
Some(Rc::new(Equal::new(l.p1.y, l.p2.y, 1.0)))
}
ConstraintKind::Vertical => {
let l = whole_line(sys, cache, *c.refs.first()?)?;
Some(Rc::new(Equal::new(l.p1.x, l.p2.x, 1.0)))
}
ConstraintKind::Parallel => {
let [a, b] = c.refs.as_slice() else { return None };
let (fixed, moving) = (whole_line(sys, cache, *a)?, whole_line(sys, cache, *b)?);
Some(Rc::new(ParallelConstraint::new(sys.store(), moving, fixed)))
}
ConstraintKind::Perpendicular => {
let [a, b] = c.refs.as_slice() else { return None };
let (fixed, moving) = (whole_line(sys, cache, *a)?, whole_line(sys, cache, *b)?);
Some(Rc::new(PerpendicularConstraint::new(sys.store(), moving, fixed)))
}
ConstraintKind::Equal => {
let [a, b] = c.refs.as_slice() else { return None };
if let (Some(la), Some(lb)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) {
return Some(Rc::new(EqualLineLength::new(lb, la)));
}
let (ca, cb) = (whole_circle(sys, cache, *a)?, whole_circle(sys, cache, *b)?);
Some(Rc::new(Equal::new(cb.rad, ca.rad, 1.0)))
}
ConstraintKind::Distance => {
let [a, b] = c.refs.as_slice() else { return None };
let (pa, pb) = (point_ref(sys, cache, *a)?, point_ref(sys, cache, *b)?);
let target = sys.add_param(c.driving_param?, true);
Some(Rc::new(P2PDistance::new(pa, pb, target)))
}
ConstraintKind::Angle => {
let [a, b] = c.refs.as_slice() else { return None };
let (fixed, moving) = (whole_line(sys, cache, *a)?, whole_line(sys, cache, *b)?);
let angle = sys.add_param(c.driving_param?.to_radians(), true);
Some(Rc::new(L2LAngle::new(fixed, moving, angle)))
}
ConstraintKind::Radius => {
let circle = whole_circle(sys, cache, *c.refs.first()?)?;
let target = sys.add_param(c.driving_param?, true);
Some(Rc::new(Equal::new(circle.rad, target, 1.0)))
}
ConstraintKind::Tangent => {
let [a, b] = c.refs.as_slice() else { return None };
let (ga, gb) = (resolve_ref(document, sys, cache, *a)?, resolve_ref(document, sys, cache, *b)?);
match (ga, gb) {
(EntityGeom::Circle(c1), EntityGeom::Circle(c2)) => {
let store = sys.store();
let (x1, y1, r1) = (store.get(c1.center.x), store.get(c1.center.y), store.get(c1.rad));
let (x2, y2, r2) = (store.get(c2.center.x), store.get(c2.center.y), store.get(c2.rad));
let center_dist = ((x1 - x2).powi(2) + (y1 - y2).powi(2)).sqrt();
// One circle already sits inside the other's span, rather
// than the two side by side — pick internal tangency so
// the first solve doesn't have to cross the singularity
// between the two tangency configurations.
let internal = center_dist < (r1 - r2).abs();
Some(Rc::new(TangentCircumf::new(c1.center, c2.center, c1.rad, c2.rad, internal)))
}
(EntityGeom::Circle(circ), EntityGeom::Line(line)) | (EntityGeom::Line(line), EntityGeom::Circle(circ)) => {
let store = sys.store();
let (cx, cy) = (store.get(circ.center.x), store.get(circ.center.y));
let (x1, y1) = (store.get(line.p1.x), store.get(line.p1.y));
let (x2, y2) = (store.get(line.p2.x), store.get(line.p2.y));
// Signed area of (p2-p1) × (center-p1) — same formula
// `C2LDistance::signed_value` itself uses — so `ccw`'s
// sign matches whichever side the circle already sits on.
let area = (x2 - x1) * (cy - y1) - (y2 - y1) * (cx - x1);
let ccw = area >= 0.0;
// A driven, fixed zero: with `internal: false` this makes
// `C2LDistance`'s target exactly the circle's own radius
// (see its `error_grad`), i.e. plain tangency rather than
// an offset distance.
let zero = sys.add_param(0.0, true);
Some(Rc::new(C2LDistance::new(circ, line, zero, ccw, false)))
}
// Line-Line tangency has no meaning; Arc isn't a supported
// `EntityGeom` yet (see that type's own doc comment).
_ => None,
}
}
}
}
const MOVE_EPS: f64 = 1e-9;
/// Rebuilds `set`'s entire `ocs_gcs::System` from current document
/// geometry, solves it, and returns the resulting entity states for every
/// handle whose registered coordinates actually moved beyond floating-point
/// noise, plus the scope's total remaining degrees of freedom (summed across
/// every independent `SubSystem` — design doc §6.3's DOF badge), plus (design
/// doc §6.4, stage 11) any redundant/conflicting constraints found, resolved
/// back to the `ConstraintId`s a `ConflictResolverPanel` can name and offer
/// to remove. `None` if nothing in the scope could be built (no constraint
/// resolved to anything). DOF/conflicts are still returned even when every
/// subsystem fails to solve (rank is a structural property of the Jacobian,
/// not of whether Dogleg converged) — only the geometry write-back is gated
/// on a successful solve.
fn solve_scope(
document: &acadrust::CadDocument,
set: &SketchConstraintSet,
) -> Option<(Vec<(Handle, EntityType)>, usize, Vec<(ConstraintId, ocs_gcs::diagnosis::RedundancyKind)>)> {
let mut sys = System::new();
let mut cache: HashMap<Handle, EntityGeom> = HashMap::new();
// Tracks which system-level `ocs_gcs` constraint came from which
// `SketchConstraint` — a `SubSystem`'s redundant-row indices (from
// `ocs_gcs::diagnosis`) are local to that partition's own constraint
// list, not `set.constraints`' indices, and Coincident contributes two
// system-level constraints (X and Y halves) for one `SketchConstraint`.
// `Rc::ptr_eq` against this after partitioning resolves a row back to
// the `ConstraintId` the UI actually names.
let mut owner: Vec<(Rc<dyn Constraint>, ConstraintId)> = Vec::new();
for c in &set.constraints {
let Some(constraint) = build_constraint(document, &mut sys, &mut cache, c) else { continue };
owner.push((constraint.clone(), c.id));
sys.add_constraint(constraint);
// Coincident needs both X and Y equal; `build_constraint` returns
// only the X half (see its doc comment there) since one
// `SketchConstraint` maps to one `Rc<dyn Constraint>` everywhere
// else — add the Y half here instead of complicating that
// one-constraint-per-kind contract for a single kind.
if c.kind == ConstraintKind::Coincident {
if let [a, b] = c.refs.as_slice() {
if let (Some(pa), Some(pb)) = (
a.marker.and_then(|m| cache.get(&a.entity).and_then(|g| g.point_for_marker(m))),
b.marker.and_then(|m| cache.get(&b.entity).and_then(|g| g.point_for_marker(m))),
) {
let y_half: Rc<dyn Constraint> = Rc::new(Equal::new(pa.y, pb.y, 1.0));
owner.push((y_half.clone(), c.id));
sys.add_constraint(y_half);
}
}
}
}
if cache.is_empty() {
return None;
}
let partitions = sys.partition();
for sub in &partitions {
solve_dl(sub, sys.store_mut());
}
// `System::partition`'s subsystems only include params actually
// referenced by some constraint (design doc's own note on
// `System::partition` — the driven-param fix found while building stage
// 3) — so a registered entity coordinate nothing constrains yet (e.g. a
// line's X after only a Horizontal constraint pins its Ys) is invisible
// to `diagnose` entirely, undercounting DOF. Every such untouched free
// param is unconstrained on its own, i.e. exactly 1 DOF each — added
// back here rather than fixed in `ocs_gcs::diagnosis`, which correctly
// has no opinion on params outside the `SubSystem` it was handed.
let total_free: usize = cache
.values()
.map(|g| match g {
EntityGeom::Line(_) => 4,
EntityGeom::Circle(_) => 3,
})
.sum();
let touched_free: usize = partitions.iter().map(|s| s.p_size()).sum();
let mut dof = total_free.saturating_sub(touched_free);
let mut conflicts: Vec<(ConstraintId, ocs_gcs::diagnosis::RedundancyKind)> = Vec::new();
for sub in &partitions {
let diag = ocs_gcs::diagnosis::diagnose(sub, sys.store());
dof += diag.dof;
if diag.redundant.is_empty() {
continue;
}
// Opt-in per `classify_redundant`'s own doc comment (one extra solve
// per redundant row) — only reached when a partition is actually
// over-constrained, which is rare, so this never costs anything on
// the common "no redundancy" path.
for (row, kind) in ocs_gcs::diagnosis::classify_redundant(sub, sys.store(), &diag.redundant) {
let row_constraint = &sub.constraints()[row];
if let Some(&(_, id)) = owner.iter().find(|(rc, _)| Rc::ptr_eq(rc, row_constraint)) {
conflicts.push((id, kind));
}
}
}
let store = sys.store();
let mut results = Vec::new();
for (&handle, geom) in &cache {
let Some(entity) = document.get_entity(handle) else { continue };
match (entity, geom) {
(EntityType::Line(l), EntityGeom::Line(g)) => {
let (x1, y1, x2, y2) = (store.get(g.p1.x), store.get(g.p1.y), store.get(g.p2.x), store.get(g.p2.y));
if (x1 - l.start.x).abs() > MOVE_EPS
|| (y1 - l.start.y).abs() > MOVE_EPS
|| (x2 - l.end.x).abs() > MOVE_EPS
|| (y2 - l.end.y).abs() > MOVE_EPS
{
let mut updated = l.clone();
updated.start.x = x1;
updated.start.y = y1;
updated.end.x = x2;
updated.end.y = y2;
results.push((handle, EntityType::Line(updated)));
}
}
(EntityType::Circle(c), EntityGeom::Circle(g)) => {
let (cx, cy, r) = (store.get(g.center.x), store.get(g.center.y), store.get(g.rad));
if (cx - c.center.x).abs() > MOVE_EPS || (cy - c.center.y).abs() > MOVE_EPS || (r - c.radius).abs() > MOVE_EPS
{
let mut updated = c.clone();
updated.center.x = cx;
updated.center.y = cy;
updated.radius = r;
results.push((handle, EntityType::Circle(updated)));
}
}
_ => {}
}
}
Some((results, dof, conflicts))
}
impl Scene {
/// Design doc §4.1's `bump_entities` hook: for every constraint scope
/// touched by `changes`, re-solve it and write back what moved. Returns
/// the resulting `(Handle, ChangeKind::Modified)` entries the same way
/// `refresh_associative_dimensions`/`_hatches` do, for `bump_entities`
/// to fold into its own `changes` vec.
pub(crate) fn refresh_sketch_constraints(&mut self, changes: &[(Handle, ChangeKind)]) -> Vec<(Handle, ChangeKind)> {
// Deletion policy (design doc §5.3/§12, open question 4): an erased
// entity silently takes its constraints with it — matching FreeCAD
// — rather than leaving a dangling `SketchRef` around. Done first,
// and unconditionally over every scope (not just ones a `touched`
// check would catch), so a scope left with zero constraints after
// this doesn't attempt a pointless resolve below.
for (handle, kind) in changes {
if *kind != ChangeKind::Removed {
continue;
}
for set in &mut self.sketch_constraints {
set.remove_all_touching(*handle);
}
}
let mut result = Vec::new();
for i in 0..self.sketch_constraints.len() {
let touched = changes
.iter()
.any(|(handle, _)| self.sketch_constraints[i].constraints_touching(*handle).next().is_some());
if !touched {
continue;
}
let Some((solved, dof, conflicts)) = solve_scope(&self.document, &self.sketch_constraints[i]) else { continue };
self.sketch_constraints[i].dof = Some(dof);
self.sketch_constraints[i].conflicts = conflicts;
for (handle, new_entity) in solved {
if let Some(before) = self.document.get_entity_arc(handle) {
self.record_undo_before(handle, Some(before));
}
if let Some(slot) = self.document.get_entity_mut(handle) {
*slot = new_entity;
}
result.push((handle, ChangeKind::Modified));
}
}
result
}
/// Design doc §7 open question 6, live-drag re-solve: a lighter sibling
/// of `refresh_sketch_constraints` for a grip drag's per-mouse-move
/// update — same "find touched scopes, rebuild the `ocs_gcs::System`
/// from scratch, solve" core, but:
/// - only reports what moved (`(Handle, EntityType)`, the actual new
/// state) rather than writing it into `self.document` itself. A live
/// grip drag already writes the directly-dragged handle's geometry
/// straight into the document each frame (`Scene::apply_grip`), so
/// this can read that live state via `solve_scope`'s normal document
/// read — but the caller (the drag's per-frame update in
/// `viewport.rs`) owns deciding how/when to write the *solved*
/// result back, since it also has to fold the touched handles into
/// this frame's preview/mesh/hatch refresh and the eventual
/// grip-release undo group.
/// - skips `record_undo_before` entirely: a grip drag tracks its
/// before/after through its own `grip_originals`/`grip_preview_handles`
/// arrays, committed as one `push_entity_group_history` group on
/// release, not through the recording session
/// `refresh_sketch_constraints` otherwise participates in — calling
/// `record_undo_before` here with no such session active for a grip
/// drag would either no-op uselessly or, worse, assume a recording
/// context that doesn't exist for this call path.
/// - skips the deletion-policy pass: nothing is erased mid-drag.
///
/// Cheap no-op when there are no sketch constraints at all (the common
/// case), and per the design doc's own §4.2 architecture this still
/// does a full rebuild-and-solve per touched scope on every call — i.e.
/// every mouse-move frame during a drag that touches constrained
/// geometry. Acceptable for the sketch scales this system has been
/// exercised at so far; §7 open question 9 ("performance at scale
/// untested") already flags this as an accepted, unprofiled risk this
/// doesn't newly introduce.
pub(crate) fn solve_sketch_constraints_preview(&self, touched: &[Handle]) -> Vec<(Handle, EntityType)> {
if self.sketch_constraints.is_empty() || touched.is_empty() {
return Vec::new();
}
let mut result = Vec::new();
for set in &self.sketch_constraints {
let is_touched = touched.iter().any(|handle| set.constraints_touching(*handle).next().is_some());
if !is_touched {
continue;
}
let Some((solved, _dof, _conflicts)) = solve_scope(&self.document, set) else { continue };
result.extend(solved);
}
result
}
}
#[cfg(test)]
mod tests {
use super::super::sketch_constraints::{ConstraintKind, SketchConstraintSet, SketchRef, SketchScope};
use super::Scene;
use acadrust::entities::EntityType;
use acadrust::types::Vector3;
// The bulk of this stage's coverage (Horizontal/Parallel/Distance/
// Coincident solving, unrelated edits not triggering a resolve) lives
// in `tests/sketch_constraints_solve.rs`, which only needs `pub` API.
// This one test needs `record_undo_before`/`take_undo_recording`
// (`pub(crate)`), so it stays internal.
#[test]
fn one_edit_that_ripples_through_a_constraint_still_records_as_one_undo_step() {
let mut scene = Scene::new();
let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
)));
let b = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
Vector3::new(0.0, 5.0, 0.0),
Vector3::new(10.0, 5.0, 0.0),
)));
let mut set = SketchConstraintSet::new(SketchScope::ModelSpace);
set.add(ConstraintKind::Parallel, vec![SketchRef::whole(a), SketchRef::whole(b)], None);
scene.sketch_constraints.push(set);
scene.begin_undo_recording();
// A real command (Move, grip-drag commit, ...) records its own
// before-image before mutating.
let before_a = scene.document.get_entity_arc(a);
scene.record_undo_before(a, before_a);
if let Some(EntityType::Line(l)) = scene.document.get_entity_mut(a) {
l.end = Vector3::new(10.0, 6.0, 0.0);
}
scene.bump_entities(&[(a, super::ChangeKind::Modified)]);
let recording = scene.take_undo_recording().expect("an undo recording should still be open");
let (entities, _objects) = recording.into_recorded_images();
let touched: std::collections::HashSet<_> = entities.iter().map(|(h, _)| *h).collect();
assert!(touched.contains(&a), "the directly-edited line must be in the undo delta");
assert!(touched.contains(&b), "the constraint-solved neighbor must ride the same undo delta");
}
/// Design doc §7 open question 6 (live-drag re-solve):
/// `solve_sketch_constraints_preview` must report the constrained
/// neighbor's solved position without writing anything into the
/// document itself — the grip-drag caller owns applying it.
#[test]
fn preview_solve_reports_the_neighbors_new_state_without_mutating_the_document() {
let mut scene = Scene::new();
let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
)));
let b = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
Vector3::new(0.0, 5.0, 0.0),
Vector3::new(10.0, 5.0, 0.0),
)));
let mut set = SketchConstraintSet::new(SketchScope::ModelSpace);
set.add(ConstraintKind::Parallel, vec![SketchRef::whole(a), SketchRef::whole(b)], None);
scene.sketch_constraints.push(set);
// Simulate a live grip drag: mutate `a` directly (as `apply_grip`
// would each frame), without going through `bump_entities` at all.
if let Some(EntityType::Line(l)) = scene.document.get_entity_mut(a) {
l.end = Vector3::new(10.0, 6.0, 0.0);
}
let b_before = scene.document.get_entity(b).cloned();
let solved = scene.solve_sketch_constraints_preview(&[a]);
assert_eq!(scene.document.get_entity(b), b_before.as_ref(), "preview must not mutate the document");
// The whole scope solves together (every registered entity's params
// are free, not just `b`'s — see `register_entity`'s doc comment),
// so `a` itself may also have shifted slightly to reach the nearest
// mutually-parallel configuration; read whichever position `a` ends
// up at from `solved` too, rather than assuming it stayed exactly
// where the simulated drag put it.
let line_dir = |entity: &EntityType| {
let EntityType::Line(l) = entity else { panic!("expected a Line") };
(l.end.x - l.start.x, l.end.y - l.start.y)
};
let dir_a = solved
.iter()
.find(|(h, _)| *h == a)
.map(|(_, e)| line_dir(e))
.unwrap_or((10.0, 6.0));
let (_, moved_entity) = solved.iter().find(|(h, _)| *h == b).expect("b should be reported as moved");
let dir_b = line_dir(moved_entity);
let cross = dir_a.0 * dir_b.1 - dir_a.1 * dir_b.0;
assert!(cross.abs() < 1e-6, "the previewed positions should be mutually parallel: dir_a={dir_a:?} dir_b={dir_b:?}");
}
}