forked from 1b-agent/opencadstudio-upstream
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:
1 parent
a39c7b8f71
commit
63a74b19a9
63 files changed
+12193
-49
No files matched your search
@@ -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()],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user