Files
OneCAD-Draft/src/scene/sketch_constraints.rs
T
Felix RiestraandClaude Sonnet 5 59ae38ae1a Add per-constraint delete; fix named-parameter reference bugs
Constraint delete: a Properties-panel row delete button, viewport
click-to-select on a constraint glyph pill with Delete-key support, and a
right-click "Delete Constraint" menu item — all routed through one
undoable Scene::selected_constraint-aware path.

Fixes two named-parameter bugs found while testing that feature:
- DIMCONSTRAINT: a parameter named like a mode-switch keyword ("r", "d",
  "a", "x", "y") could never be referenced, since the keyword check ran
  before the parameter-name check.
- PARAMETERS modal Apply rebuilt Scene::named_parameters but never
  refreshed the Properties panel, leaving renamed/deleted rows stale
  until an unrelated refresh (e.g. a selection change) happened to occur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 01:08:34 +02:00

1180 lines
47 KiB
Rust

//! Persistent sketch-constraint data types and scope management.
use super::named_parameters::DrivingValue;
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>,
}
const POLYLINE_SEGMENT_MARKER_BASE: i32 = -1_000_000;
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),
}
}
/// Select one straight segment of a polyline.
pub fn segment(entity: Handle, index: usize) -> Self {
Self {
entity,
marker: Some(POLYLINE_SEGMENT_MARKER_BASE - index as i32),
}
}
pub fn segment_index(self) -> Option<usize> {
let marker = self.marker?;
(marker <= POLYLINE_SEGMENT_MARKER_BASE)
.then(|| (POLYLINE_SEGMENT_MARKER_BASE - marker) as usize)
}
}
/// The friendly, user-facing constraint types — the "what button did they
/// click" vocabulary, one layer above the `cadkernel_constraints` 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::parametric::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,
/// An open spline endpoint remains curvature-continuous with another curve endpoint.
Smooth,
/// Two circles/arcs share a center — `refs`: `[center(a), center(b)]`.
/// Solves identically to `Coincident` (`sketch_solve.rs` broadens that
/// match arm rather than duplicating it) — only the DWG-native class
/// name (`ACCONCENTRICCONSTRAINT` vs `ACPOINTCOINCIDENCECONSTRAINT`)
/// and the UI entry point differ.
Concentric,
/// A point sits at a circle/arc's center — `refs`: `[point, center(circle)]`.
/// Same solver math as `Coincident`/`Concentric`, different DWG class
/// name (`ACCENTERPOINTCONSTRAINT`).
CenterPoint,
/// Two lines share the same infinite line — `refs`: `[whole(a), whole(b)]`.
Colinear,
/// A point sits at another line's midpoint — `refs`: `[point, whole(line)]`.
Midpoint,
/// Locks a whole entity at its current position — `refs`: `[whole(entity)]`.
/// No `driving_param`: the target is the entity's own live geometry at
/// solve time, not a typed value (see `sketch_solve.rs`'s `Fixed` arm).
Fixed,
/// A point lies anywhere along a line's or circle's curve (not
/// restricted to an endpoint/center) — `refs`: `[point, whole(entity)]`.
PointOnCurve,
/// The distance between one point pair equals the distance between
/// another — `refs`: `[p1, p2, p3, p4]` (`dist(p1,p2) == dist(p3,p4)`).
/// For the supported entity types, equal curvature is already covered
/// by the circle/circle radius branch of `Equal`.
EqualDistance,
/// Two circles/arcs are mirror images of each other across a line —
/// `refs`: `[center(a), center(b), whole(mirror_line)]`. Scoped to the
/// circle-center-pair case (unambiguous with whole-entity selection);
/// point-symmetry about a point, and symmetry between two lines, aren't
/// modeled.
Symmetric,
/// A circle/arc's diameter (twice `Radius`'s target) — `refs`:
/// `[whole(circle_or_arc)]`. Same DWG class as `Radius`
/// (`ACRADIUSDIAMETERCONSTRAINT`), distinguished only by the
/// `RadiusDiameterConstrType` mode byte
/// (`dwg_native_constraints.rs`), rather than a distinct object type.
Diameter,
/// The X-only (resp. Y-only) component of the distance between two
/// points — `refs`: `[p1, p2]`, same shape as `Distance`. Same DWG
/// class as `Distance` (`ACDISTANCECONSTRAINT`) with its
/// `DirectionType` set to a fixed `(1,0,0)`/`(0,1,0)` direction.
DistanceX,
DistanceY,
/// A line perpendicular to a circle/arc's tangent at their point of
/// contact — `refs`: `[whole(a), whole(b)]`, either order. For the
/// Line/Circle-only entity model this is equivalent to "the line
/// passes through the circle's center" (a circle's radius is always
/// normal to its own tangent), so it solves via the same `PointOnLine`
/// primitive `PointOnCurve` already uses. Distinct from
/// `Perpendicular` (line-to-line only). Line-Line has no meaning here
/// (that's plain `Perpendicular`) and isn't buildable.
Normal,
/// An arc's arc length (`radius * sweep angle`) — `refs`:
/// `[whole(arc)]`. Solves against the arc's own `start_angle`/
/// `end_angle`, registered separately from its center/radius
/// (`sketch_solve.rs`'s `arc_angles` cache) — no native DWG
/// representation exists for this (neither `AcExplicitConstr.h` nor
/// `acadrust`'s `AssocConstraintNodeData` has an ArcLength-shaped
/// class/variant), so it persists in this app's own XRecord format
/// only, the same "DWG can't carry everything" gap the dependency-chain
/// DXF-omission precedent already documents.
ArcLength,
}
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 target for a dimensional constraint (a `Distance`'s length, an
/// `Angle`'s degrees, a `Radius`'s radius) — a literal number or a
/// named-parameter reference resolved through `Scene::named_parameters` at solve time by
/// `sketch_solve::build_constraint`). `None` for every purely-geometric
/// kind (Coincident, Horizontal, Vertical, Parallel, Perpendicular,
/// Equal, Tangent).
pub driving_param: Option<DrivingValue>,
/// Lets a user suppress a constraint without losing it — a re-solve
/// skips a disabled constraint entirely.
pub enabled: bool,
}
/// A constraint scope: model space or one block definition.
#[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 owner handle under which this scope is persisted.
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`]. Solver state is rebuilt
/// on demand and is not stored here.
#[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). The status 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.
/// Also a derived cache, not
/// persisted; a `ConflictResolverPanel` reads this rather than calling
/// `cadkernel_constraints::diagnosis::classify_redundant` itself.
#[serde(skip)]
pub conflicts: Vec<(
ConstraintId,
cadkernel_constraints::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<DrivingValue>,
) -> 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`.
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` and returns the
/// removed constraint ids.
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 `cadkernel_constraints` `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).
///
/// Solver-side registration reads raw entity fields directly. This helper is
/// for UI-side consumers that need the current world-space position.
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 [`nearest_sketch_point`]'s purposes.
const COINCIDENT_EPSILON_SQ: f64 = 1.0e-12;
/// Finds the addressable entity point nearest a snapped world position.
/// Returns `None` when no point in the scope is within the coincidence
/// tolerance.
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)
}
impl ConstraintKind {
/// The short symbol a constraint glyph shows — matches the existing
/// ribbon icons (`crate::modules::parametric::{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",
ConstraintKind::Smooth => "G²",
ConstraintKind::Concentric => "◎",
ConstraintKind::CenterPoint => "⊕",
ConstraintKind::Colinear => "L",
ConstraintKind::Midpoint => "M",
ConstraintKind::Fixed => "F",
ConstraintKind::PointOnCurve => "∈",
ConstraintKind::EqualDistance => "≐",
ConstraintKind::Symmetric => "S",
ConstraintKind::Diameter => "⌀",
ConstraintKind::DistanceX => "↔ₓ",
ConstraintKind::DistanceY => "↔ᵧ",
ConstraintKind::Normal => "⊾",
ConstraintKind::ArcLength => "⌢",
}
}
}
/// 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(DrivingValue::Literal(value))) => {
format!("{} {value:.1}°", constraint.kind.glyph_symbol())
}
(_, Some(DrivingValue::Literal(value))) => {
format!("{} {value:.2}", constraint.kind.glyph_symbol())
}
// A named reference has no single resolved number to show without
// threading `ParameterTable` into every glyph-render call site
// (`src/ui/overlay.rs`) — showing the name itself is enough for now;
// stage 4's parameters panel is the natural place to reconsider this
// once a named `driving_param` can actually be authored through the
// UI (nothing can yet — this arm exists so the match is exhaustive
// and correct ahead of that UI, not because it's reachable today).
(_, Some(DrivingValue::Named(name))) => {
format!("{} {name}", constraint.kind.glyph_symbol())
}
(_, None) => constraint.kind.glyph_symbol().to_string(),
}
}
/// World-space anchor and outward direction for a constraint glyph.
pub(crate) fn glyph_placement(
document: &acadrust::CadDocument,
constraint: &SketchConstraint,
) -> Option<(Vector3, Vector3)> {
let r = constraint.refs.first()?;
let entity = document.get_entity(r.entity)?;
let line_midpoint = |line: &acadrust::entities::Line| {
Vector3::new(
(line.start.x + line.end.x) * 0.5,
(line.start.y + line.end.y) * 0.5,
(line.start.z + line.end.z) * 0.5,
)
};
let line_normal = |line: &acadrust::entities::Line| {
let direction = Vector3::new(
-(line.end.y - line.start.y),
line.end.x - line.start.x,
0.0,
);
(direction.length_squared() > 1e-24)
.then_some(direction)
.unwrap_or(Vector3::UNIT_Y)
};
match (entity, r.marker) {
(acadrust::EntityType::Line(line), None) => {
Some((line_midpoint(line), line_normal(line)))
}
(acadrust::EntityType::Circle(circle), None | Some(-3)) => {
let center = circle.center_wcs();
let anchor = circle.point_at_angle_wcs(0.0);
Some((anchor, anchor - center))
}
(acadrust::EntityType::Arc(arc), None | Some(-3)) => {
let center = arc.center_wcs();
let anchor = arc.midpoint_wcs();
Some((anchor, anchor - center))
}
(acadrust::EntityType::Line(line), Some(marker)) => {
let anchor = resolve_point(entity, marker)?;
let direction = anchor - line_midpoint(line);
Some((
anchor,
(direction.length_squared() > 1e-24)
.then_some(direction)
.unwrap_or_else(|| line_normal(line)),
))
}
(acadrust::EntityType::Arc(arc), Some(marker)) => {
let anchor = resolve_point(entity, marker)?;
let direction = anchor - arc.center_wcs();
Some((
anchor,
(direction.length_squared() > 1e-24)
.then_some(direction)
.unwrap_or(Vector3::UNIT_Y),
))
}
(_, Some(marker)) => {
let anchor = resolve_point(entity, marker)?;
Some((anchor, Vector3::UNIT_Y))
}
_ => None,
}
}
impl super::Scene {
pub fn is_sketch_constraint_visible(&self, scope: SketchScope, id: ConstraintId) -> bool {
!self.hidden_sketch_constraints.contains(&(scope, id))
}
pub fn set_sketch_constraint_visibility(
&mut self,
scope: SketchScope,
handles: Option<&[Handle]>,
dimensional: bool,
visible: bool,
) -> usize {
let ids: Vec<_> = self.sketch_constraint_set(scope).into_iter()
.flat_map(|set| set.constraints.iter())
.filter(|constraint| constraint.driving_param.is_some() == dimensional)
.filter(|constraint| handles.is_none_or(|handles| constraint.refs.iter()
.any(|reference| handles.contains(&reference.entity))))
.map(|constraint| constraint.id).collect();
for id in &ids {
if visible { self.hidden_sketch_constraints.remove(&(scope, *id)); }
else { self.hidden_sketch_constraints.insert((scope, *id)); }
}
ids.len()
}
/// Infers relations already present in the selected geometry.
pub fn inferred_sketch_constraints(
&self,
scope: SketchScope,
handles: &[Handle],
) -> Vec<(ConstraintKind, Vec<SketchRef>)> {
use cadkernel::geom2d::{
infer_constraints, Arc, Circle, ConstraintEndpoint, InferredConstraint, Line,
SketchPrimitive, Tolerance,
};
let mut sources = Vec::new();
for handle in handles {
let primitive = match self.document.get_entity(*handle) {
Some(acadrust::EntityType::Line(line)) => SketchPrimitive::Line(Line {
start: [line.start.x, line.start.y], end: [line.end.x, line.end.y],
}),
Some(acadrust::EntityType::Circle(circle)) => SketchPrimitive::Circle(Circle {
centre: [circle.center.x, circle.center.y], radius: circle.radius,
}),
Some(acadrust::EntityType::Arc(arc)) => SketchPrimitive::Arc(Arc {
centre: [arc.center.x, arc.center.y], radius: arc.radius,
start_angle: arc.start_angle, end_angle: arc.end_angle,
}),
_ => continue,
};
sources.push((*handle, primitive));
}
let primitives: Vec<_> = sources.iter().map(|(_, primitive)| *primitive).collect();
let marker = |endpoint| match endpoint {
ConstraintEndpoint::Start => 0,
ConstraintEndpoint::End => 1,
};
let mut mapped: Vec<_> = infer_constraints(
&primitives, Tolerance::new(1e-6), 0.5_f64.to_radians(),
).into_iter().map(|relation| match relation {
InferredConstraint::Coincident { first, first_endpoint, second, second_endpoint } => (
ConstraintKind::Coincident,
vec![SketchRef::point(sources[first].0, marker(first_endpoint)),
SketchRef::point(sources[second].0, marker(second_endpoint))],
),
InferredConstraint::Collinear { first, second } => (
ConstraintKind::Colinear,
vec![SketchRef::whole(sources[first].0), SketchRef::whole(sources[second].0)],
),
InferredConstraint::Concentric { first, second } => (
ConstraintKind::Concentric,
vec![SketchRef::center(sources[first].0), SketchRef::center(sources[second].0)],
),
InferredConstraint::Parallel { first, second } => (
ConstraintKind::Parallel,
vec![SketchRef::whole(sources[first].0), SketchRef::whole(sources[second].0)],
),
InferredConstraint::Perpendicular { first, second } => (
ConstraintKind::Perpendicular,
vec![SketchRef::whole(sources[first].0), SketchRef::whole(sources[second].0)],
),
InferredConstraint::Horizontal { entity } => (
ConstraintKind::Horizontal, vec![SketchRef::whole(sources[entity].0)],
),
InferredConstraint::Vertical { entity } => (
ConstraintKind::Vertical, vec![SketchRef::whole(sources[entity].0)],
),
InferredConstraint::Tangent { first, second } => (
ConstraintKind::Tangent,
vec![SketchRef::whole(sources[first].0), SketchRef::whole(sources[second].0)],
),
}).collect();
if let Some(existing) = self.sketch_constraint_set(scope) {
mapped.retain(|(kind, refs)| !existing.constraints.iter().any(|constraint| {
constraint.kind == *kind && (constraint.refs == *refs
|| (constraint.refs.len() == 2 && refs.len() == 2
&& constraint.refs[0] == refs[1] && constraint.refs[1] == refs[0]))
}));
}
mapped.retain(|(kind, refs)| self.validate_sketch_constraint(*kind, refs, None).is_ok());
mapped
}
pub fn smooth_constraint_refs(&self, handles: &[Handle]) -> Option<Vec<SketchRef>> {
let [first, second] = handles else { return None };
let first_entity = self.document.get_entity(*first)?;
let second_entity = self.document.get_entity(*second)?;
let (spline_handle, spline, target_handle, target) = match (first_entity, second_entity) {
(acadrust::EntityType::Spline(spline), target) => (*first, spline, *second, target),
(target, acadrust::EntityType::Spline(spline)) => (*second, spline, *first, target),
_ => return None,
};
if spline.flags.closed || spline.flags.periodic { return None; }
let spline_points = super::dimension_assoc::source_points(
&acadrust::EntityType::Spline(spline.clone()));
let target_points = super::dimension_assoc::source_points(target);
let spline_ends = [*spline_points.first()?, *spline_points.last()?];
let target_ends = [*target_points.first()?, *target_points.last()?];
let mut best = (f64::INFINITY, 0, 0);
for (source_marker, source) in spline_ends.iter().enumerate() {
for (target_marker, target) in target_ends.iter().enumerate() {
let distance = (*source - *target).length_squared();
if distance < best.0 { best = (distance, source_marker, target_marker); }
}
}
Some(vec![
SketchRef::point(spline_handle, best.1 as i32),
SketchRef::point(target_handle, best.2 as i32),
])
}
/// 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")
}
}
/// Screen-projected sketch-constraint glyph placements for `scope`:
/// `(id, anchor, outward_screen_direction, label, is_conflicting)` for
/// every enabled, visible constraint whose glyph projects on-screen.
/// `vp_size` is the full canvas size (as `SelectionState::vp_size`
/// reports it), matching what `viewport_edit_frame`/
/// `active_model_tile_bounds` expect. Mirrors the projection
/// `crate::app::view` builds its own render list with, and is reused by
/// [`constraint_glyph_hit`](Self::constraint_glyph_hit) — both feed the
/// same `(anchor, outward, label)` triples into
/// `crate::ui::overlay::constraint_glyph_box`/`constraint_glyph_offsets`,
/// so hit-testing can never drift from what's actually drawn.
pub fn constraint_glyph_placements_screen(
&self,
scope: SketchScope,
vp_size: (f32, f32),
show_values: bool,
) -> Vec<(ConstraintId, iced::Point, [f32; 2], String, bool)> {
let Some(set) = self.sketch_constraint_set(scope) else {
return Vec::new();
};
if set.constraints.is_empty() {
return Vec::new();
}
let edit_frame = self.viewport_edit_frame(vp_size);
let bounds = match &edit_frame {
Some((_, full)) => *full,
None => self.active_model_tile_bounds(vp_size.0, vp_size.1),
};
let (view_rot, eye) = if let Some((cam, _)) = &edit_frame {
(cam.view_proj_rte(bounds), cam.eye())
} else {
let cam = self.camera.borrow();
(cam.view_proj_rte(bounds), cam.eye())
};
set.constraints
.iter()
.filter(|c| c.enabled)
.filter(|c| self.is_sketch_constraint_visible(scope, c.id))
.filter_map(|c| {
let (anchor, outward) = glyph_placement(&self.document, c)?;
let screen = crate::scene::pick::grip::project_rte(
glam::DVec3::new(anchor.x, anchor.y, anchor.z),
view_rot,
eye,
bounds,
)?;
let outward_screen = crate::scene::pick::grip::project_rte(
glam::DVec3::new(
anchor.x + outward.x,
anchor.y + outward.y,
anchor.z + outward.z,
),
view_rot,
eye,
bounds,
)?;
let direction = (outward_screen - screen).normalize_or(glam::Vec2::NEG_Y);
let point = iced::Point::new(bounds.x + screen.x, bounds.y + screen.y);
let is_conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id);
let label = if show_values {
glyph_label(c)
} else {
c.kind.glyph_symbol().to_string()
};
point.x.is_finite().then_some((c.id, point, direction.to_array(), label, is_conflicting))
})
.collect()
}
/// Hit-tests screen point `p` (same coordinate space as `p_full` in the
/// viewport click handler) against the glyph pills from
/// [`constraint_glyph_placements_screen`](Self::constraint_glyph_placements_screen),
/// via `crate::ui::overlay::constraint_glyph_hit_test`'s shared layout
/// math, so a click only registers where the pill is actually drawn.
pub fn constraint_glyph_hit(
&self,
scope: SketchScope,
vp_size: (f32, f32),
show_values: bool,
p: iced::Point,
) -> Option<ConstraintId> {
let placements = self.constraint_glyph_placements_screen(scope, vp_size, show_values);
let glyphs: Vec<(iced::Point, [f32; 2], String, bool)> = placements
.iter()
.map(|(_, point, direction, label, is_conflicting)| {
(*point, *direction, label.clone(), *is_conflicting)
})
.collect();
let index = crate::ui::overlay::constraint_glyph_hit_test(&glyphs, p)?;
Some(placements[index].0)
}
/// Handle remapping lives 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<DrivingValue>)> =
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.clone()));
}
}
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);
}
/// Every persistent constraint, in any scope, currently driven by the
/// named parameter `name` — what the Named Parameters panel's "used by"
/// column shows. `entities` is the constraint's own referenced handles
/// (deduplicated; a two-point constraint on the same entity's own two
/// markers would otherwise list it twice), not resolved against the
/// live document — a caller wanting an entity's current type/position
/// still needs `Scene::document.get_entity`.
pub fn parameter_usage(&self, name: &str) -> Vec<ParameterUsage> {
let mut out = Vec::new();
for set in &self.sketch_constraints {
for c in &set.constraints {
let Some(DrivingValue::Named(n)) = &c.driving_param else {
continue;
};
if n != name {
continue;
}
let mut entities: Vec<Handle> = c.refs.iter().map(|r| r.entity).collect();
entities.sort();
entities.dedup();
out.push(ParameterUsage {
scope: set.scope,
constraint_id: c.id,
kind: c.kind,
entities,
});
}
}
out
}
}
/// One persistent constraint driven by a named parameter — [`Scene::parameter_usage`]'s
/// result type.
#[derive(Debug, Clone)]
pub struct ParameterUsage {
pub scope: SketchScope,
pub constraint_id: ConstraintId,
pub kind: ConstraintKind,
pub entities: Vec<Handle>,
}
#[cfg(test)]
mod tests {
use super::*;
fn h(v: u64) -> Handle {
Handle::new(v)
}
#[test]
fn glyph_placement_points_away_from_its_geometry() {
let mut document = acadrust::CadDocument::new();
let mut line = acadrust::entities::Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
);
line.common.handle = h(1);
document
.add_entity(acadrust::EntityType::Line(line))
.unwrap();
let mut circle = acadrust::entities::Circle::from_center_radius(Vector3::ZERO, 5.0);
circle.common.handle = h(2);
document
.add_entity(acadrust::EntityType::Circle(circle))
.unwrap();
let constraint = |reference| SketchConstraint {
id: 0,
kind: ConstraintKind::Fixed,
refs: vec![reference],
driving_param: None,
enabled: true,
};
let (anchor, direction) =
glyph_placement(&document, &constraint(SketchRef::whole(h(1)))).unwrap();
assert_eq!(anchor, Vector3::new(5.0, 0.0, 0.0));
assert_eq!(direction, Vector3::new(0.0, 10.0, 0.0));
let (anchor, direction) =
glyph_placement(&document, &constraint(SketchRef::point(h(1), 0))).unwrap();
assert_eq!(anchor, Vector3::ZERO);
assert_eq!(direction, Vector3::new(-5.0, 0.0, 0.0));
let (anchor, direction) =
glyph_placement(&document, &constraint(SketchRef::center(h(2)))).unwrap();
assert_eq!(anchor, Vector3::new(5.0, 0.0, 0.0));
assert_eq!(direction, Vector3::new(5.0, 0.0, 0.0));
}
#[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(DrivingValue::Literal(25.0)),
);
assert_ne!(a, b);
assert_eq!(set.get(a).unwrap().kind, ConstraintKind::Horizontal);
assert_eq!(
set.get(b).unwrap().driving_param,
Some(DrivingValue::Literal(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(DrivingValue::Literal(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(DrivingValue::Literal(12.5))
);
assert_eq!(restored.constraints[0].refs, set.constraints[0].refs);
}
#[test]
fn parameter_usage_finds_every_constraint_driven_by_the_named_parameter() {
let mut scene = super::super::Scene::new();
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.add(
ConstraintKind::Distance,
vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)],
Some(DrivingValue::Named("gap".to_string())),
);
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.add(
ConstraintKind::Radius,
vec![SketchRef::whole(h(2))],
Some(DrivingValue::Named("gap".to_string())),
);
// Unrelated: a literal-driven constraint and one driven by a
// different name must not show up.
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.add(
ConstraintKind::Radius,
vec![SketchRef::whole(h(3))],
Some(DrivingValue::Literal(5.0)),
);
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.add(
ConstraintKind::Distance,
vec![SketchRef::point(h(4), 0), SketchRef::point(h(4), 1)],
Some(DrivingValue::Named("other".to_string())),
);
let usage = scene.parameter_usage("gap");
assert_eq!(
usage.len(),
2,
"exactly the two constraints driven by 'gap', got {usage:?}"
);
assert!(usage
.iter()
.any(|u| u.kind == ConstraintKind::Distance && u.entities == vec![h(1)]));
assert!(usage
.iter()
.any(|u| u.kind == ConstraintKind::Radius && u.entities == vec![h(2)]));
assert_eq!(scene.parameter_usage("nonexistent").len(), 0);
}
#[test]
fn parameter_usage_searches_every_scope_not_just_model_space() {
let mut scene = super::super::Scene::new();
let block = h(99);
scene
.sketch_constraint_set_mut(SketchScope::Block(block))
.add(
ConstraintKind::Radius,
vec![SketchRef::whole(h(1))],
Some(DrivingValue::Named("r".to_string())),
);
let usage = scene.parameter_usage("r");
assert_eq!(usage.len(), 1);
assert_eq!(usage[0].scope, SketchScope::Block(block));
}
#[test]
fn parameter_usage_deduplicates_an_entity_referenced_by_two_markers() {
let mut scene = super::super::Scene::new();
// A Distance constraint whose two points are both on the same
// entity (e.g. a line's own start and end) must list that entity
// once, not twice.
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.add(
ConstraintKind::Distance,
vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)],
Some(DrivingValue::Named("len".to_string())),
);
let usage = scene.parameter_usage("len");
assert_eq!(usage.len(), 1);
assert_eq!(usage[0].entities, vec![h(1)]);
}
#[test]
fn automatic_inference_maps_relations_and_skips_existing_constraints() {
let mut scene = super::super::Scene::new();
let first = scene.add_entity(acadrust::EntityType::Line(
acadrust::entities::Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(5.0, 0.0, 0.0),
),
));
let second = scene.add_entity(acadrust::EntityType::Line(
acadrust::entities::Line::from_points(
Vector3::new(5.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(first)],
None,
);
let inferred = scene.inferred_sketch_constraints(
SketchScope::ModelSpace,
&[first, second],
);
assert!(!inferred.iter().any(|(kind, refs)| {
*kind == ConstraintKind::Horizontal && *refs == [SketchRef::whole(first)]
}));
assert!(inferred.iter().any(|(kind, refs)| {
*kind == ConstraintKind::Horizontal && *refs == [SketchRef::whole(second)]
}));
assert!(inferred.iter().any(|(kind, _)| *kind == ConstraintKind::Coincident));
assert!(inferred.iter().any(|(kind, _)| *kind == ConstraintKind::Colinear));
}
#[test]
fn visibility_toggles_geometric_and_dimensional_independently() {
let mut scene = super::super::Scene::new();
let line = scene.add_entity(acadrust::EntityType::Line(
acadrust::entities::Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(5.0, 0.0, 0.0),
),
));
let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace);
let geometric = set.add(
ConstraintKind::Horizontal,
vec![SketchRef::whole(line)],
None,
);
let dimensional = set.add(
ConstraintKind::Distance,
vec![SketchRef::point(line, 0), SketchRef::point(line, 1)],
Some(DrivingValue::Literal(5.0)),
);
assert_eq!(
scene.set_sketch_constraint_visibility(
SketchScope::ModelSpace,
None,
false,
false,
),
1
);
assert!(!scene.is_sketch_constraint_visible(SketchScope::ModelSpace, geometric));
assert!(scene.is_sketch_constraint_visible(SketchScope::ModelSpace, dimensional));
}
}