forked from 1b-agent/opencadstudio-upstream
Ports the fixes from fix/osnap-circle-arc-ellipse-intersection (and this session's audit follow-up) onto this branch. All are independent correctness/reliability fixes, unrelated to the parametric-constraint- system work also in progress here. 1. OSNAP Intersection snap used a tessellated approximation instead of exact geometry (GitHub discussion #1052): a circle/arc always tessellates at a fixed 48 segments in the resident wire set regardless of radius or zoom, so every circle/arc intersection snap was off by construction. Added exact_circle_intersections, a closed-form, frame-independent circle/arc-vs-circle/arc solver, wired into both Intersection-snap code paths ahead of the tessellated sweep. This branch's TangentGeom predates the PlanarEllipse variant added upstream, so the ellipse/line generalization from the upstream fix isn't ported here — this covers the literal reported bug (a circle-circle case) using only variants this branch already has. 2. Unbounded MINSERT allocation: array_offsets() reserved one Vec element per array instance with row/column counts parsed as unchecked u16s — a corrupt 65535x65535 pair requests ~96 GiB. Fixed with a documented instance budget (20,000), enforced at both the load-time corrupt-entity filter and a render-graph failsafe. 3. MINSERT spacing was scaled by the block's own scale: insert_instance_transform composed the offset translation before the instance transform, so x/y block scale multiplied the row/ column spacing too. Fixed by orienting the offset with rotation + OCS only, then translating the already-scaled instance transform. 4. Automation "open" skipped the finalization (block-origin normalization, raster-path resolution, source_path) and corrupt- entity purge a UI open always runs. Added io::load_bytes_finalized(path, bytes), used by automation's "open" handler, which now also reports the purge count. 5. Material maps were uploaded without checking device.limits().max_texture_dimension_2d, unlike raster-image rendering (which already tiles to that limit). A mesh material is sampled by UV, so it can't use the same tiling trick — instead, upload_rgba_texture now downscales an oversized image before upload, falling back to the 1x1 material color if malformed. 6. Sketch-constraint persistence/undo, audit-found while reviewing this branch's own constraint system: - Clearing every constraint in a scope after a prior non-empty save left the stale XRecord in place, resurrecting the deleted constraints on the next load. materialize_sketch_constraints_for_ save now actively removes the XRecord when a scope goes back to empty. - ERASE removing an entity took its constraints with it (design doc's deletion policy), but sketch_constraints lives on Scene, outside document/document.objects, so nothing captured that mutation for undo — the erased geometry came back on undo, but its constraints didn't. refresh_sketch_constraints's deletion pass now records the touched scope's before-image into the same UndoRecording the entity removal rides in, threaded through a new sketch_constraints channel on DeltaSnapshot, so one undo restores both together. HistorySnapshot::Delta is now boxed (clippy flagged the resulting size skew across undo/redo stack variants). 31 new/adapted tests across all six fixes. Full workspace suite passes except the one pre-existing, unrelated font-test failure also present on main. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
535 lines
24 KiB
Rust
535 lines
24 KiB
Rust
//! 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, PartialEq, 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);
|
|
}
|
|
}
|