forked from 1b-agent/opencadstudio-upstream
Merge remote-tracking branch 'origin/main' into codex/region-measurement-persistence
# Conflicts: # src/app/doc_api.rs
This commit is contained in:
commit
17efc86cc1
149 files changed
+15933
-16047
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()],
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2691,6 +2691,12 @@ impl Scene {
|
||||
self.solid_models = HashMap::default();
|
||||
*self.camera.borrow_mut() = Camera::default();
|
||||
self.camera_generation += 1;
|
||||
// A brand-new/replaced document has none of these yet — without
|
||||
// resetting them, a "new"/"open" (e.g. via automation, which reuses
|
||||
// this same reset) would leak the previous document's constraints
|
||||
// and named parameters into the fresh one.
|
||||
self.sketch_constraints.clear();
|
||||
self.named_parameters = crate::scene::named_parameters::ParameterTable::new();
|
||||
self.bump_geometry();
|
||||
}
|
||||
}
|
||||
+80
-18
@@ -31,11 +31,14 @@ mod camera_ops;
|
||||
pub(crate) mod centerline;
|
||||
pub(crate) mod dimension_assoc;
|
||||
pub(crate) mod centermark;
|
||||
mod dwg_native_constraints;
|
||||
mod entity;
|
||||
mod group_layer;
|
||||
mod layout;
|
||||
mod limits;
|
||||
mod modify;
|
||||
pub mod named_parameters;
|
||||
mod named_parameters_persist;
|
||||
mod mspace;
|
||||
mod page_setup;
|
||||
mod paper;
|
||||
@@ -43,6 +46,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,
|
||||
@@ -327,6 +333,16 @@ pub struct UndoRecording {
|
||||
/// by the command. `None` denotes a newly-created object.
|
||||
object_before: HashMap<Handle, Option<ObjectType>>,
|
||||
object_order: Vec<Handle>,
|
||||
/// First-touch before-images of every sketch-constraint scope a command
|
||||
/// changed (e.g. ERASE removing an entity's constraints along with it via
|
||||
/// `refresh_sketch_constraints`'s deletion policy). `sketch_constraints`
|
||||
/// lives on `Scene`, not in `document`/`document.objects`, so neither
|
||||
/// directory above ever sees this change — it needs its own directory.
|
||||
/// Unlike the two above, there is no "didn't exist before" case: a
|
||||
/// scope's `Vec` entry, once created, is never removed.
|
||||
sketch_constraints_before: HashMap<sketch_constraints::SketchScope, sketch_constraints::SketchConstraintSet>,
|
||||
sketch_constraints_order: Vec<sketch_constraints::SketchScope>,
|
||||
named_parameters_before: Option<named_parameters::ParameterTable>,
|
||||
poisoned: bool,
|
||||
}
|
||||
|
||||
@@ -336,18 +352,26 @@ impl UndoRecording {
|
||||
self.poisoned
|
||||
}
|
||||
|
||||
/// No entity or object entry was recorded (nothing to undo).
|
||||
/// No entity, object, constraint, or parameter state was recorded.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.order.is_empty() && self.object_order.is_empty()
|
||||
self.order.is_empty()
|
||||
&& self.object_order.is_empty()
|
||||
&& self.sketch_constraints_order.is_empty()
|
||||
&& self.named_parameters_before.is_none()
|
||||
}
|
||||
|
||||
/// Consume both recording directories in deterministic first-touch order.
|
||||
/// A `None` image means the entity/object was added by the command.
|
||||
/// Consume the recording directories in deterministic first-touch
|
||||
/// order. A `None` image means the entity/object was added by the
|
||||
/// command; every sketch-constraint scope's before-image is a real
|
||||
/// `SketchConstraintSet` (its `Vec` entry, once created, is never
|
||||
/// removed, so there is no "didn't exist before" case there).
|
||||
pub fn into_recorded_images(
|
||||
mut self,
|
||||
) -> (
|
||||
Vec<(Handle, Option<Arc<EntityType>>)>,
|
||||
Vec<(Handle, Option<ObjectType>)>,
|
||||
Vec<(sketch_constraints::SketchScope, sketch_constraints::SketchConstraintSet)>,
|
||||
Option<named_parameters::ParameterTable>,
|
||||
) {
|
||||
let entities = self
|
||||
.order
|
||||
@@ -359,7 +383,12 @@ impl UndoRecording {
|
||||
.drain(..)
|
||||
.map(|h| (h, self.object_before.remove(&h).flatten()))
|
||||
.collect();
|
||||
(entities, objects)
|
||||
let sketch_constraints = self
|
||||
.sketch_constraints_order
|
||||
.drain(..)
|
||||
.filter_map(|scope| self.sketch_constraints_before.remove(&scope).map(|before| (scope, before)))
|
||||
.collect();
|
||||
(entities, objects, sketch_constraints, self.named_parameters_before)
|
||||
}
|
||||
|
||||
/// Entity-only convenience used by the focused Scene delta tests.
|
||||
@@ -1839,13 +1868,6 @@ pub struct Scene {
|
||||
pub block_meshes: HashMap<Handle, MeshLodSet>,
|
||||
/// Kernel B-reps used by solid operations and exact-geometry saves.
|
||||
pub solid_models: HashMap<Handle, cadkernel::brep::Body>,
|
||||
/// Memoized DocApi volume/centroid per (handle, geometry_epoch) so repeated
|
||||
/// mass queries on the same solid are O(1). Persists across DocApi dispatches.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub doc_api_mass_cache: std::collections::HashMap<Handle, (u64, f64, [f64; 3])>,
|
||||
/// Bounds uncached mass computations in one API request.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub doc_api_cold_tess_used: usize,
|
||||
/// GPU render data for raster images (RasterImage entities), keyed by handle.
|
||||
pub images: HashMap<Handle, ImageModel>,
|
||||
/// The viewport that is currently "entered" (MSPACE mode).
|
||||
@@ -1901,6 +1923,12 @@ 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 sketch scope.
|
||||
/// Materialized into scoped XRecords during save and restored on open.
|
||||
pub(crate) sketch_constraints: Vec<sketch_constraints::SketchConstraintSet>,
|
||||
/// Document-wide named-parameter and expression table.
|
||||
/// Persisted through a single drawing XRecord.
|
||||
pub(crate) named_parameters: named_parameters::ParameterTable,
|
||||
/// 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
|
||||
@@ -2198,10 +2226,6 @@ impl Scene {
|
||||
material_base_dir: None,
|
||||
block_meshes: HashMap::default(),
|
||||
solid_models: HashMap::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
doc_api_mass_cache: std::collections::HashMap::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
doc_api_cold_tess_used: 0,
|
||||
images: HashMap::default(),
|
||||
active_viewport: None,
|
||||
bg_color: [33.0 / 255.0, 40.0 / 255.0, 48.0 / 255.0, 1.0],
|
||||
@@ -2218,6 +2242,8 @@ 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(),
|
||||
named_parameters: named_parameters::ParameterTable::new(),
|
||||
has_associative_centers: std::cell::Cell::new(None),
|
||||
block_defn_cache: RefCell::new(HashMap::default()),
|
||||
entity_index_cache: RefCell::new(None),
|
||||
@@ -2660,6 +2686,35 @@ impl Scene {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one sketch-constraint scope's whole-set image before its first
|
||||
/// mutation within the open recording (first touch wins) — e.g. ERASE
|
||||
/// about to remove some of a scope's constraints via
|
||||
/// `refresh_sketch_constraints`'s deletion policy. `sketch_constraints`
|
||||
/// lives on `Scene`, outside `document`/`document.objects`, so neither
|
||||
/// `record_undo_before` nor `record_undo_object_before` ever sees this
|
||||
/// change on their own.
|
||||
pub(crate) fn record_undo_sketch_constraints_before(
|
||||
&mut self,
|
||||
scope: sketch_constraints::SketchScope,
|
||||
before: sketch_constraints::SketchConstraintSet,
|
||||
) {
|
||||
if let Some(rec) = self.undo_recording.as_mut() {
|
||||
if !rec.sketch_constraints_before.contains_key(&scope) {
|
||||
rec.sketch_constraints_order.push(scope);
|
||||
rec.sketch_constraints_before.insert(scope, before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the drawing-wide parameter table before its first mutation in
|
||||
/// the current undo transaction.
|
||||
pub(crate) fn record_undo_named_parameters_before(&mut self) {
|
||||
let before = self.named_parameters.clone();
|
||||
if let Some(recording) = self.undo_recording.as_mut() {
|
||||
recording.named_parameters_before.get_or_insert(before);
|
||||
}
|
||||
}
|
||||
|
||||
/// Flag the open recording as touching non-entity state (a new layer, a
|
||||
/// group edit, a `*D` block record) that a pure-entity delta cannot
|
||||
/// restore. The app's per-command predicate keeps this from firing.
|
||||
@@ -2781,6 +2836,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);
|
||||
}
|
||||
@@ -12046,7 +12108,7 @@ mod delta_undo_tests {
|
||||
let handle = scene.add_entity(EntityType::RasterImage(image));
|
||||
let rec = scene.take_undo_recording().unwrap();
|
||||
assert!(!rec.is_poisoned());
|
||||
let (entities, objects) = rec.into_recorded_images();
|
||||
let (entities, objects, _sketch_constraints, _named_parameters) = rec.into_recorded_images();
|
||||
assert_eq!(entities.len(), 1);
|
||||
assert_eq!(entities[0].0, handle);
|
||||
assert_eq!(objects.len(), 1);
|
||||
@@ -12070,7 +12132,7 @@ mod delta_undo_tests {
|
||||
scene.erase_entities(&[h1]);
|
||||
let rec = scene.take_undo_recording().unwrap();
|
||||
assert!(!rec.is_poisoned());
|
||||
let (entities, objects) = rec.into_recorded_images();
|
||||
let (entities, objects, _sketch_constraints, _named_parameters) = rec.into_recorded_images();
|
||||
assert_eq!(entities.len(), 1);
|
||||
assert_eq!(entities[0].0, h1);
|
||||
assert_eq!(objects.len(), 1);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Shared value types used by the dispatch and grip systems.
|
||||
|
||||
use acadrust::types::{Color as AcadColor, LineWeight};
|
||||
use acadrust::types::{Color as AcadColor, Handle, LineWeight};
|
||||
use glam::DVec3;
|
||||
|
||||
/// The kind of value held by a property row.
|
||||
@@ -58,6 +58,28 @@ pub enum PropValue {
|
||||
/// an editable text_input. Unlike the other rows the routing key is the
|
||||
/// tag carried here, not the row's `&'static str` field.
|
||||
AttrText { tag: String, value: String },
|
||||
/// A clickable link to one or more entities — the Constraints section's
|
||||
/// row for one persistent sketch constraint. Clicking it selects every
|
||||
/// entity in `handles` in the viewport. `conflicting` mirrors
|
||||
/// `SketchConstraintSet::conflicts`, tinting the row the same danger
|
||||
/// color the constraint's glyph pill already uses.
|
||||
EntityLink { handles: Vec<Handle>, conflicting: bool },
|
||||
/// One row of the document-wide named-parameter table (Parameters
|
||||
/// section, shown when nothing is selected). `index` is the row's
|
||||
/// position in `ParameterTable::iter()` order — stable across edits to
|
||||
/// other rows, used to route `PropParamInput`/`PropParamCommit`/
|
||||
/// `PropParamDelete` back to the right parameter without re-keying on a
|
||||
/// name that might itself be mid-edit. `resolved` is the live value or
|
||||
/// error, recomputed fresh every render (same "cheap enough to rebuild"
|
||||
/// approach the old modal's preview column already used).
|
||||
ParamRow {
|
||||
index: usize,
|
||||
name: String,
|
||||
formula: String,
|
||||
resolved: Result<f64, String>,
|
||||
},
|
||||
/// The trailing "+ Add parameter" row in the Parameters section.
|
||||
ParamAddRow,
|
||||
}
|
||||
|
||||
/// A single property row in the Properties panel.
|
||||
|
||||
@@ -752,6 +752,8 @@ impl Scene {
|
||||
.map(|&h| (h, ChangeKind::Added))
|
||||
.collect();
|
||||
self.bump_entities(&changes);
|
||||
// Constraints wholly within the duplicated selection follow it.
|
||||
self.duplicate_sketch_constraints_for(&handle_map);
|
||||
self.refresh_meshes_for_handles(&refresh_solid_handles);
|
||||
new_handles
|
||||
}
|
||||
|
||||
@@ -0,0 +1,958 @@
|
||||
//! Named, formula-capable parameters stored per drawing.
|
||||
//! Formulas are parsed locally so dependency cycles can be rejected before evaluation.
|
||||
|
||||
use super::Scene;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
|
||||
/// A parsed formula. Ordinary arithmetic plus [`Expr::Ref`] (a reference to
|
||||
/// another parameter by name) and [`Expr::Call`] (a function call). Only the
|
||||
/// small builtin set in `call_builtin` is accepted.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Expr {
|
||||
Number(f64),
|
||||
Ref(String),
|
||||
Neg(Box<Expr>),
|
||||
Add(Box<Expr>, Box<Expr>),
|
||||
Sub(Box<Expr>, Box<Expr>),
|
||||
Mul(Box<Expr>, Box<Expr>),
|
||||
Div(Box<Expr>, Box<Expr>),
|
||||
Pow(Box<Expr>, Box<Expr>),
|
||||
Call(String, Vec<Expr>),
|
||||
}
|
||||
|
||||
impl Expr {
|
||||
/// Every parameter name this expression reads, deduplicated in
|
||||
/// first-seen order. This is the entire basis for cycle detection
|
||||
/// (`ParameterTable::set`): one dependency edge per name returned here.
|
||||
fn refs(&self, out: &mut Vec<String>) {
|
||||
match self {
|
||||
Expr::Number(_) => {}
|
||||
Expr::Ref(name) => {
|
||||
if !out.contains(name) {
|
||||
out.push(name.clone());
|
||||
}
|
||||
}
|
||||
Expr::Neg(a) => a.refs(out),
|
||||
Expr::Add(a, b)
|
||||
| Expr::Sub(a, b)
|
||||
| Expr::Mul(a, b)
|
||||
| Expr::Div(a, b)
|
||||
| Expr::Pow(a, b) => {
|
||||
a.refs(out);
|
||||
b.refs(out);
|
||||
}
|
||||
Expr::Call(_, args) => {
|
||||
for arg in args {
|
||||
arg.refs(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything that can go wrong parsing, defining, or resolving a
|
||||
/// parameter. Every case is reported back to the caller (and, in later
|
||||
/// stages, the UI) rather than panicking — matching this codebase's
|
||||
/// established pattern of isolating a bad derived value instead of letting
|
||||
/// it take down an unrelated computation (e.g. dangling-constraint handling
|
||||
/// in `sketch_solve.rs`).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ParamError {
|
||||
/// A formula's text doesn't parse. The `String` is a short, human-
|
||||
/// readable reason, not a machine-parseable code.
|
||||
Parse(String),
|
||||
/// Not a valid identifier, or collides with a builtin function name
|
||||
/// (`is_reserved_name`).
|
||||
InvalidName(String),
|
||||
/// Accepting this formula would close a circular reference right now.
|
||||
/// The path reads start-to-finish, e.g. `["a", "b", "a"]` for `a = b`,
|
||||
/// `b = a`.
|
||||
Cycle(Vec<String>),
|
||||
UnknownFunction(String),
|
||||
WrongArgCount {
|
||||
function: String,
|
||||
expected: usize,
|
||||
found: usize,
|
||||
},
|
||||
/// Raised by `resolve`/`resolve_all`, not `set` — a formula may
|
||||
/// legitimately reference a parameter that doesn't exist *yet* (order of
|
||||
/// definition shouldn't matter, mirroring a spreadsheet); this only
|
||||
/// surfaces once something actually tries to read the missing value.
|
||||
UndefinedReference(String),
|
||||
DivisionByZero,
|
||||
}
|
||||
|
||||
impl fmt::Display for ParamError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ParamError::Parse(msg) => write!(f, "formula error: {msg}"),
|
||||
ParamError::InvalidName(name) => write!(f, "'{name}' is not a valid parameter name"),
|
||||
ParamError::Cycle(path) => write!(f, "circular reference: {}", path.join(" -> ")),
|
||||
ParamError::UnknownFunction(name) => write!(f, "unknown function '{name}'"),
|
||||
ParamError::WrongArgCount {
|
||||
function,
|
||||
expected,
|
||||
found,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"{function}() expects {expected} argument(s), found {found}"
|
||||
)
|
||||
}
|
||||
ParamError::UndefinedReference(name) => write!(f, "'{name}' is not defined"),
|
||||
ParamError::DivisionByZero => write!(f, "division by zero"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ParamError {}
|
||||
|
||||
/// The builtin function set. It also supplies the reserved-word list that
|
||||
/// `is_reserved_name` checks a parameter name against, so a formula
|
||||
/// like `sqrt(x)` is never ambiguous between "call the builtin" and
|
||||
/// "multiply by a parameter named sqrt".
|
||||
const BUILTIN_FUNCTIONS: &[&str] = &["sqrt", "abs", "pow", "min", "max"];
|
||||
|
||||
pub fn is_reserved_name(name: &str) -> bool {
|
||||
BUILTIN_FUNCTIONS.contains(&name)
|
||||
}
|
||||
|
||||
/// A valid parameter name: starts with a letter or underscore, followed by
|
||||
/// letters, digits, or underscores.
|
||||
pub fn is_valid_name(name: &str) -> bool {
|
||||
let mut chars = name.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_alphabetic() || c == '_' => {}
|
||||
_ => return false,
|
||||
}
|
||||
chars.all(|c| c.is_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
/// A dimensional constraint's literal or named driving value.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DrivingValue {
|
||||
Literal(f64),
|
||||
Named(String),
|
||||
}
|
||||
|
||||
impl DrivingValue {
|
||||
/// Resolves to a number: a literal resolves to itself (never fails); a
|
||||
/// named reference resolves through `table`, which can fail the same
|
||||
/// ways any [`ParameterTable::resolve`] call can (undefined reference,
|
||||
/// division by zero — a cycle should be unreachable here since `set`
|
||||
/// already refuses to create one).
|
||||
pub fn resolve(&self, table: &ParameterTable) -> Result<f64, ParamError> {
|
||||
match self {
|
||||
DrivingValue::Literal(value) => Ok(*value),
|
||||
DrivingValue::Named(name) => table.resolve(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f64> for DrivingValue {
|
||||
fn from(value: f64) -> Self {
|
||||
DrivingValue::Literal(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn call_builtin(name: &str, args: &[f64]) -> Result<f64, ParamError> {
|
||||
fn check_arity(name: &str, args: &[f64], expected: usize) -> Result<(), ParamError> {
|
||||
if args.len() != expected {
|
||||
return Err(ParamError::WrongArgCount {
|
||||
function: name.to_string(),
|
||||
expected,
|
||||
found: args.len(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
match name {
|
||||
"sqrt" => {
|
||||
check_arity(name, args, 1)?;
|
||||
Ok(args[0].sqrt())
|
||||
}
|
||||
"abs" => {
|
||||
check_arity(name, args, 1)?;
|
||||
Ok(args[0].abs())
|
||||
}
|
||||
"pow" => {
|
||||
check_arity(name, args, 2)?;
|
||||
Ok(args[0].powf(args[1]))
|
||||
}
|
||||
"min" => {
|
||||
check_arity(name, args, 2)?;
|
||||
Ok(args[0].min(args[1]))
|
||||
}
|
||||
"max" => {
|
||||
check_arity(name, args, 2)?;
|
||||
Ok(args[0].max(args[1]))
|
||||
}
|
||||
_ => Err(ParamError::UnknownFunction(name.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Token {
|
||||
Number(f64),
|
||||
Ident(String),
|
||||
Plus,
|
||||
Minus,
|
||||
Star,
|
||||
Slash,
|
||||
Caret,
|
||||
LParen,
|
||||
RParen,
|
||||
Comma,
|
||||
}
|
||||
|
||||
fn tokenize(src: &str) -> Result<Vec<Token>, ParamError> {
|
||||
let chars: Vec<char> = src.chars().collect();
|
||||
let mut tokens = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
if c.is_whitespace() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'+' => {
|
||||
tokens.push(Token::Plus);
|
||||
i += 1;
|
||||
}
|
||||
'-' => {
|
||||
tokens.push(Token::Minus);
|
||||
i += 1;
|
||||
}
|
||||
'*' => {
|
||||
tokens.push(Token::Star);
|
||||
i += 1;
|
||||
}
|
||||
'/' => {
|
||||
tokens.push(Token::Slash);
|
||||
i += 1;
|
||||
}
|
||||
'^' => {
|
||||
tokens.push(Token::Caret);
|
||||
i += 1;
|
||||
}
|
||||
'(' => {
|
||||
tokens.push(Token::LParen);
|
||||
i += 1;
|
||||
}
|
||||
')' => {
|
||||
tokens.push(Token::RParen);
|
||||
i += 1;
|
||||
}
|
||||
',' => {
|
||||
tokens.push(Token::Comma);
|
||||
i += 1;
|
||||
}
|
||||
_ if c.is_ascii_digit() || c == '.' => {
|
||||
let start = i;
|
||||
let mut seen_dot = c == '.';
|
||||
i += 1;
|
||||
while i < chars.len() {
|
||||
let d = chars[i];
|
||||
if d.is_ascii_digit() {
|
||||
i += 1;
|
||||
} else if d == '.' && !seen_dot {
|
||||
seen_dot = true;
|
||||
i += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Scientific notation: 1e-3, 2.5E10. Only consumed when a
|
||||
// digit (optionally signed) actually follows the e/E, so a
|
||||
// bare trailing "e" is left for the identifier lexer instead
|
||||
// (not reachable today since no builtin/constant is named
|
||||
// "e", but keeps this branch from eating an identifier that
|
||||
// happens to start with it, e.g. a formula like `2 * ecc`).
|
||||
if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') {
|
||||
let mut j = i + 1;
|
||||
if j < chars.len() && (chars[j] == '+' || chars[j] == '-') {
|
||||
j += 1;
|
||||
}
|
||||
if j < chars.len() && chars[j].is_ascii_digit() {
|
||||
while j < chars.len() && chars[j].is_ascii_digit() {
|
||||
j += 1;
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
let text: String = chars[start..i].iter().collect();
|
||||
let value: f64 = text
|
||||
.parse()
|
||||
.map_err(|_| ParamError::Parse(format!("invalid number '{text}'")))?;
|
||||
tokens.push(Token::Number(value));
|
||||
}
|
||||
_ if c.is_alphabetic() || c == '_' => {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
|
||||
i += 1;
|
||||
}
|
||||
tokens.push(Token::Ident(chars[start..i].iter().collect()));
|
||||
}
|
||||
other => return Err(ParamError::Parse(format!("unexpected character '{other}'"))),
|
||||
}
|
||||
}
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
tokens: &'a [Token],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
fn peek(&self) -> Option<&Token> {
|
||||
self.tokens.get(self.pos)
|
||||
}
|
||||
|
||||
fn bump(&mut self) -> Option<Token> {
|
||||
let t = self.tokens.get(self.pos).cloned();
|
||||
if t.is_some() {
|
||||
self.pos += 1;
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
fn expect(&mut self, expected: Token) -> Result<(), ParamError> {
|
||||
match self.bump() {
|
||||
Some(t) if t == expected => Ok(()),
|
||||
Some(t) => Err(ParamError::Parse(format!(
|
||||
"expected {expected:?}, found {t:?}"
|
||||
))),
|
||||
None => Err(ParamError::Parse(format!(
|
||||
"expected {expected:?}, found end of formula"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
// Precedence, low to high: expr (+ -) > term (* /) > unary (-) > power (^) > primary.
|
||||
// `^` binds tighter than unary minus on its left operand but is
|
||||
// right-associative on its own exponent (`parse_unary` recursing back
|
||||
// into `parse_power` covers `2^-2` == `2^(-2)`, and `2^3^2` ==
|
||||
// `2^(3^2)`).
|
||||
fn parse_expr(&mut self) -> Result<Expr, ParamError> {
|
||||
let mut lhs = self.parse_term()?;
|
||||
loop {
|
||||
match self.peek() {
|
||||
Some(Token::Plus) => {
|
||||
self.pos += 1;
|
||||
lhs = Expr::Add(Box::new(lhs), Box::new(self.parse_term()?));
|
||||
}
|
||||
Some(Token::Minus) => {
|
||||
self.pos += 1;
|
||||
lhs = Expr::Sub(Box::new(lhs), Box::new(self.parse_term()?));
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
Ok(lhs)
|
||||
}
|
||||
|
||||
fn parse_term(&mut self) -> Result<Expr, ParamError> {
|
||||
let mut lhs = self.parse_unary()?;
|
||||
loop {
|
||||
match self.peek() {
|
||||
Some(Token::Star) => {
|
||||
self.pos += 1;
|
||||
lhs = Expr::Mul(Box::new(lhs), Box::new(self.parse_unary()?));
|
||||
}
|
||||
Some(Token::Slash) => {
|
||||
self.pos += 1;
|
||||
lhs = Expr::Div(Box::new(lhs), Box::new(self.parse_unary()?));
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
Ok(lhs)
|
||||
}
|
||||
|
||||
fn parse_unary(&mut self) -> Result<Expr, ParamError> {
|
||||
match self.peek() {
|
||||
Some(Token::Minus) => {
|
||||
self.pos += 1;
|
||||
Ok(Expr::Neg(Box::new(self.parse_unary()?)))
|
||||
}
|
||||
Some(Token::Plus) => {
|
||||
self.pos += 1;
|
||||
self.parse_unary()
|
||||
}
|
||||
_ => self.parse_power(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_power(&mut self) -> Result<Expr, ParamError> {
|
||||
let base = self.parse_primary()?;
|
||||
if let Some(Token::Caret) = self.peek() {
|
||||
self.pos += 1;
|
||||
let exponent = self.parse_unary()?;
|
||||
return Ok(Expr::Pow(Box::new(base), Box::new(exponent)));
|
||||
}
|
||||
Ok(base)
|
||||
}
|
||||
|
||||
fn parse_primary(&mut self) -> Result<Expr, ParamError> {
|
||||
match self.bump() {
|
||||
Some(Token::Number(n)) => Ok(Expr::Number(n)),
|
||||
Some(Token::Ident(name)) => {
|
||||
if matches!(self.peek(), Some(Token::LParen)) {
|
||||
self.pos += 1;
|
||||
let mut args = Vec::new();
|
||||
if !matches!(self.peek(), Some(Token::RParen)) {
|
||||
loop {
|
||||
args.push(self.parse_expr()?);
|
||||
if matches!(self.peek(), Some(Token::Comma)) {
|
||||
self.pos += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.expect(Token::RParen)?;
|
||||
Ok(Expr::Call(name, args))
|
||||
} else {
|
||||
Ok(Expr::Ref(name))
|
||||
}
|
||||
}
|
||||
Some(Token::LParen) => {
|
||||
let inner = self.parse_expr()?;
|
||||
self.expect(Token::RParen)?;
|
||||
Ok(inner)
|
||||
}
|
||||
Some(other) => Err(ParamError::Parse(format!("unexpected token {other:?}"))),
|
||||
None => Err(ParamError::Parse("unexpected end of formula".to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a formula's text into an [`Expr`], without evaluating or
|
||||
/// resolving any reference. `ParameterTable::set` is the usual entry point;
|
||||
/// exposed standalone so a UI stage can validate-as-you-type without
|
||||
/// touching the table.
|
||||
pub fn parse(source: &str) -> Result<Expr, ParamError> {
|
||||
let tokens = tokenize(source)?;
|
||||
if tokens.is_empty() {
|
||||
return Err(ParamError::Parse("empty formula".to_string()));
|
||||
}
|
||||
let mut parser = Parser {
|
||||
tokens: &tokens,
|
||||
pos: 0,
|
||||
};
|
||||
let expr = parser.parse_expr()?;
|
||||
if parser.pos != tokens.len() {
|
||||
return Err(ParamError::Parse(format!(
|
||||
"unexpected trailing token {:?}",
|
||||
tokens[parser.pos]
|
||||
)));
|
||||
}
|
||||
Ok(expr)
|
||||
}
|
||||
|
||||
/// One named parameter: a name plus its formula, both the original typed
|
||||
/// text (`source`, for redisplay/re-editing) and the parsed [`Expr`]
|
||||
/// (`expr`, for evaluation).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Parameter {
|
||||
pub name: String,
|
||||
pub source: String,
|
||||
pub expr: Expr,
|
||||
}
|
||||
|
||||
/// Every named parameter for one document.
|
||||
///
|
||||
/// `parameters` is intentionally not `pub` (unlike the sibling
|
||||
/// `SketchConstraintSet::constraints`): this table's entire value is that
|
||||
/// every stored formula is already known to be parse-valid and cycle-free,
|
||||
/// an invariant only `set`/`remove` maintain. A direct external push could
|
||||
/// silently violate it.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ParameterTable {
|
||||
parameters: Vec<Parameter>,
|
||||
}
|
||||
|
||||
impl ParameterTable {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.parameters.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.parameters.len()
|
||||
}
|
||||
|
||||
/// Insertion order — the order parameters were first defined in, stable
|
||||
/// across edits to existing ones. The natural listing order for a
|
||||
/// future parameters panel.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &Parameter> {
|
||||
self.parameters.iter()
|
||||
}
|
||||
|
||||
pub fn get(&self, name: &str) -> Option<&Parameter> {
|
||||
self.parameters.iter().find(|p| p.name == name)
|
||||
}
|
||||
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.get(name).is_some()
|
||||
}
|
||||
|
||||
fn index_of(&self, name: &str) -> Option<usize> {
|
||||
self.parameters.iter().position(|p| p.name == name)
|
||||
}
|
||||
|
||||
/// Defines a new parameter or redefines an existing one's formula (one
|
||||
/// upsert operation — there is no separate "must not already exist"
|
||||
/// entry point; a future UI stage that wants add-vs-rename semantics can
|
||||
/// check `contains` itself first).
|
||||
///
|
||||
/// Rejects an invalid or reserved identifier, malformed formula, or
|
||||
/// circular reference. A reference
|
||||
/// to a parameter that doesn't exist *yet* is not rejected (order of
|
||||
/// definition shouldn't matter, mirroring a spreadsheet); it only
|
||||
/// surfaces as [`ParamError::UndefinedReference`] when [`Self::resolve`]
|
||||
/// actually needs that value.
|
||||
pub fn set(&mut self, name: &str, source: &str) -> Result<(), ParamError> {
|
||||
if !is_valid_name(name) || is_reserved_name(name) {
|
||||
return Err(ParamError::InvalidName(name.to_string()));
|
||||
}
|
||||
let expr = parse(source)?;
|
||||
self.check_no_cycle(name, &expr)?;
|
||||
match self.index_of(name) {
|
||||
Some(i) => {
|
||||
self.parameters[i].source = source.to_string();
|
||||
self.parameters[i].expr = expr;
|
||||
}
|
||||
None => self.parameters.push(Parameter {
|
||||
name: name.to_string(),
|
||||
source: source.to_string(),
|
||||
expr,
|
||||
}),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes a parameter by name. Returns whether one was actually
|
||||
/// removed. Deliberately does not check whether another parameter's
|
||||
/// formula still references `name` — that becomes an
|
||||
/// [`ParamError::UndefinedReference`] for the referencing parameter the
|
||||
/// next time it's resolved, the same graceful-degradation treatment an
|
||||
/// unresolvable reference gets everywhere else in this module, rather
|
||||
/// than a special "can't delete, still in use" rule this stage doesn't
|
||||
/// need to invent (a future UI stage may still want to warn before
|
||||
/// deleting — that's a UI-layer decision, not a data-layer one).
|
||||
pub fn remove(&mut self, name: &str) -> bool {
|
||||
match self.index_of(name) {
|
||||
Some(i) => {
|
||||
self.parameters.remove(i);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Would accepting `new_expr` as `name`'s formula close a circular
|
||||
/// reference right now? Walks the dependency graph from each of
|
||||
/// `new_expr`'s direct references, following each visited parameter's
|
||||
/// *existing* stored formula, looking for a path back to `name`. A
|
||||
/// reference to a name not currently in the table just terminates that
|
||||
/// branch — no cycle through something that doesn't exist yet.
|
||||
fn check_no_cycle(&self, name: &str, new_expr: &Expr) -> Result<(), ParamError> {
|
||||
let mut direct_refs = Vec::new();
|
||||
new_expr.refs(&mut direct_refs);
|
||||
for start in &direct_refs {
|
||||
let mut visiting = Vec::new();
|
||||
if let Some(mut path) = self.find_path(start, name, &mut visiting) {
|
||||
let mut full = vec![name.to_string()];
|
||||
full.append(&mut path);
|
||||
return Err(ParamError::Cycle(full));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Depth-first search from `current` to `target`, following each
|
||||
/// visited parameter's existing formula's references. Returns the path
|
||||
/// (starting with `current`) if `target` is reachable.
|
||||
fn find_path(
|
||||
&self,
|
||||
current: &str,
|
||||
target: &str,
|
||||
visiting: &mut Vec<String>,
|
||||
) -> Option<Vec<String>> {
|
||||
if current == target {
|
||||
return Some(vec![current.to_string()]);
|
||||
}
|
||||
if visiting.iter().any(|v| v == current) {
|
||||
// A cycle that doesn't involve `target` shouldn't be reachable
|
||||
// (`set` refuses to create one), but bail out rather than loop
|
||||
// forever if that invariant is ever violated some other way
|
||||
// (e.g. a foreign/hand-edited file deserialized straight into a
|
||||
// table without going through `set`).
|
||||
return None;
|
||||
}
|
||||
let param = self.get(current)?;
|
||||
visiting.push(current.to_string());
|
||||
let mut refs = Vec::new();
|
||||
param.expr.refs(&mut refs);
|
||||
for r in &refs {
|
||||
if let Some(mut rest) = self.find_path(r, target, visiting) {
|
||||
let mut path = vec![current.to_string()];
|
||||
path.append(&mut rest);
|
||||
visiting.pop();
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
visiting.pop();
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolves one parameter's numeric value and dependency chain using a
|
||||
/// fresh memo cache.
|
||||
pub fn resolve(&self, name: &str) -> Result<f64, ParamError> {
|
||||
let mut cache = HashMap::new();
|
||||
let mut stack = Vec::new();
|
||||
self.resolve_inner(name, &mut cache, &mut stack)
|
||||
}
|
||||
|
||||
/// Resolves every parameter, isolating a failure to just the parameters
|
||||
/// it actually affects rather than aborting the whole table — the same
|
||||
/// don't-let-one-bad-item-take-down-everything-else treatment this
|
||||
/// codebase already gives dangling constraints and similar derived-data
|
||||
/// failures elsewhere.
|
||||
pub fn resolve_all(&self) -> HashMap<String, Result<f64, ParamError>> {
|
||||
let mut out = HashMap::with_capacity(self.parameters.len());
|
||||
for param in &self.parameters {
|
||||
out.insert(param.name.clone(), self.resolve(¶m.name));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn resolve_inner(
|
||||
&self,
|
||||
name: &str,
|
||||
cache: &mut HashMap<String, f64>,
|
||||
stack: &mut Vec<String>,
|
||||
) -> Result<f64, ParamError> {
|
||||
if let Some(v) = cache.get(name) {
|
||||
return Ok(*v);
|
||||
}
|
||||
if stack.iter().any(|s| s == name) {
|
||||
// Defense in depth only — `set` already refuses to create a
|
||||
// cycle; see `find_path`'s doc comment for how one could still
|
||||
// end up here.
|
||||
let mut cyc = stack.clone();
|
||||
cyc.push(name.to_string());
|
||||
return Err(ParamError::Cycle(cyc));
|
||||
}
|
||||
let param = self
|
||||
.get(name)
|
||||
.ok_or_else(|| ParamError::UndefinedReference(name.to_string()))?;
|
||||
stack.push(name.to_string());
|
||||
let value = self.eval(¶m.expr, cache, stack)?;
|
||||
stack.pop();
|
||||
cache.insert(name.to_string(), value);
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn eval(
|
||||
&self,
|
||||
expr: &Expr,
|
||||
cache: &mut HashMap<String, f64>,
|
||||
stack: &mut Vec<String>,
|
||||
) -> Result<f64, ParamError> {
|
||||
Ok(match expr {
|
||||
Expr::Number(n) => *n,
|
||||
Expr::Ref(name) => self.resolve_inner(name, cache, stack)?,
|
||||
Expr::Neg(a) => -self.eval(a, cache, stack)?,
|
||||
Expr::Add(a, b) => self.eval(a, cache, stack)? + self.eval(b, cache, stack)?,
|
||||
Expr::Sub(a, b) => self.eval(a, cache, stack)? - self.eval(b, cache, stack)?,
|
||||
Expr::Mul(a, b) => self.eval(a, cache, stack)? * self.eval(b, cache, stack)?,
|
||||
Expr::Div(a, b) => {
|
||||
let denom = self.eval(b, cache, stack)?;
|
||||
if denom == 0.0 {
|
||||
return Err(ParamError::DivisionByZero);
|
||||
}
|
||||
self.eval(a, cache, stack)? / denom
|
||||
}
|
||||
Expr::Pow(a, b) => self
|
||||
.eval(a, cache, stack)?
|
||||
.powf(self.eval(b, cache, stack)?),
|
||||
Expr::Call(fname, args) => {
|
||||
let mut values = Vec::with_capacity(args.len());
|
||||
for arg in args {
|
||||
values.push(self.eval(arg, cache, stack)?);
|
||||
}
|
||||
call_builtin(fname, &values)?
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// The document's named-parameter table. Use
|
||||
/// [`Scene::named_parameters_mut`] to
|
||||
/// define/redefine/remove a parameter.
|
||||
pub fn named_parameters(&self) -> &ParameterTable {
|
||||
&self.named_parameters
|
||||
}
|
||||
|
||||
pub fn named_parameters_mut(&mut self) -> &mut ParameterTable {
|
||||
&mut self.named_parameters
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_arithmetic_with_ordinary_precedence() {
|
||||
// 2 + 3 * 4 == 14, not 20 -- proves * binds tighter than +.
|
||||
assert_eq!(eval_literal("2 + 3 * 4"), 14.0);
|
||||
// (2 + 3) * 4 == 20 -- parens override precedence.
|
||||
assert_eq!(eval_literal("(2 + 3) * 4"), 20.0);
|
||||
// 2 ^ 3 ^ 2 == 2 ^ (3 ^ 2) == 512 -- right-associative power.
|
||||
assert_eq!(eval_literal("2 ^ 3 ^ 2"), 512.0);
|
||||
// Unary minus binds tighter than power's left operand is *read*,
|
||||
// but -2^2 must still equal -(2^2) = -4 under this grammar (unary
|
||||
// parses before power), matching common calculator convention.
|
||||
assert_eq!(eval_literal("-2^2"), -4.0);
|
||||
assert_eq!(eval_literal("2^-2"), 0.25);
|
||||
assert_eq!(eval_literal("10 / 4"), 2.5);
|
||||
assert_eq!(eval_literal("-(3 + 4)"), -7.0);
|
||||
}
|
||||
|
||||
fn eval_literal(src: &str) -> f64 {
|
||||
let table = ParameterTable::new();
|
||||
let expr = parse(src).unwrap_or_else(|e| panic!("parse '{src}': {e}"));
|
||||
table
|
||||
.eval(&expr, &mut HashMap::new(), &mut Vec::new())
|
||||
.unwrap_or_else(|e| panic!("eval '{src}': {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_scientific_notation() {
|
||||
assert_eq!(eval_literal("1e3"), 1000.0);
|
||||
assert_eq!(eval_literal("1.5e-2"), 0.015);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_formulas() {
|
||||
assert!(matches!(parse(""), Err(ParamError::Parse(_))));
|
||||
assert!(matches!(parse("1 +"), Err(ParamError::Parse(_))));
|
||||
assert!(matches!(parse("(1 + 2"), Err(ParamError::Parse(_))));
|
||||
assert!(matches!(parse("1 2"), Err(ParamError::Parse(_))));
|
||||
assert!(matches!(parse("1 @ 2"), Err(ParamError::Parse(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_functions_evaluate_and_check_arity() {
|
||||
assert_eq!(eval_literal("sqrt(16)"), 4.0);
|
||||
assert_eq!(eval_literal("abs(-5)"), 5.0);
|
||||
assert_eq!(eval_literal("min(3, 7)"), 3.0);
|
||||
assert_eq!(eval_literal("max(3, 7)"), 7.0);
|
||||
assert_eq!(eval_literal("pow(2, 10)"), 1024.0);
|
||||
|
||||
let table = ParameterTable::new();
|
||||
let expr = parse("sqrt(1, 2)").unwrap();
|
||||
let err = table
|
||||
.eval(&expr, &mut HashMap::new(), &mut Vec::new())
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
ParamError::WrongArgCount {
|
||||
function: "sqrt".to_string(),
|
||||
expected: 1,
|
||||
found: 2
|
||||
}
|
||||
);
|
||||
|
||||
let expr = parse("bogus(1)").unwrap();
|
||||
let err = table
|
||||
.eval(&expr, &mut HashMap::new(), &mut Vec::new())
|
||||
.unwrap_err();
|
||||
assert_eq!(err, ParamError::UnknownFunction("bogus".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn division_by_zero_is_reported_not_panicked() {
|
||||
let table = ParameterTable::new();
|
||||
let expr = parse("1 / 0").unwrap();
|
||||
assert_eq!(
|
||||
table.eval(&expr, &mut HashMap::new(), &mut Vec::new()),
|
||||
Err(ParamError::DivisionByZero)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_rejects_invalid_and_reserved_names() {
|
||||
let mut table = ParameterTable::new();
|
||||
assert!(matches!(
|
||||
table.set("2bad", "1"),
|
||||
Err(ParamError::InvalidName(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
table.set("has space", "1"),
|
||||
Err(ParamError::InvalidName(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
table.set("", "1"),
|
||||
Err(ParamError::InvalidName(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
table.set("sqrt", "1"),
|
||||
Err(ParamError::InvalidName(_))
|
||||
));
|
||||
assert!(table.set("_valid1", "1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_literal_parameter_resolves_to_its_number() {
|
||||
let mut table = ParameterTable::new();
|
||||
table.set("plate_len", "42.5").unwrap();
|
||||
assert_eq!(table.resolve("plate_len"), Ok(42.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_formula_resolves_through_a_reference_chain() {
|
||||
let mut table = ParameterTable::new();
|
||||
table.set("hole_dia", "5").unwrap();
|
||||
table.set("hole_spacing", "2 * hole_dia + 1.5").unwrap();
|
||||
table.set("row_span", "3 * hole_spacing").unwrap();
|
||||
assert_eq!(table.resolve("hole_spacing"), Ok(11.5));
|
||||
assert_eq!(table.resolve("row_span"), Ok(34.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolving_an_undefined_reference_reports_which_name_is_missing() {
|
||||
let mut table = ParameterTable::new();
|
||||
table.set("a", "b + 1").unwrap();
|
||||
assert_eq!(
|
||||
table.resolve("a"),
|
||||
Err(ParamError::UndefinedReference("b".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_rejects_a_direct_self_reference() {
|
||||
let mut table = ParameterTable::new();
|
||||
let err = table.set("a", "a + 1").unwrap_err();
|
||||
assert!(matches!(err, ParamError::Cycle(_)));
|
||||
assert!(
|
||||
!table.contains("a"),
|
||||
"a rejected formula must not be stored"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_rejects_an_indirect_cycle_at_the_definition_that_closes_it() {
|
||||
let mut table = ParameterTable::new();
|
||||
table.set("a", "b").unwrap();
|
||||
// b doesn't exist yet -- not a cycle, a is just unresolved for now.
|
||||
assert_eq!(
|
||||
table.resolve("a"),
|
||||
Err(ParamError::UndefinedReference("b".to_string()))
|
||||
);
|
||||
// Defining b = a closes the loop right here and must be rejected,
|
||||
// leaving b undefined rather than silently accepted.
|
||||
let err = table.set("b", "a").unwrap_err();
|
||||
assert!(matches!(err, ParamError::Cycle(_)));
|
||||
assert!(!table.contains("b"));
|
||||
// a's formula (still "b") is untouched -- it stays unresolved, not
|
||||
// corrupted by the rejected attempt.
|
||||
assert_eq!(
|
||||
table.resolve("a"),
|
||||
Err(ParamError::UndefinedReference("b".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_allows_redefining_an_existing_parameter_to_break_a_would_be_cycle() {
|
||||
let mut table = ParameterTable::new();
|
||||
table.set("a", "1").unwrap();
|
||||
table.set("b", "a + 1").unwrap();
|
||||
// Redefining a in terms of b must be rejected (a <- b <- a)...
|
||||
assert!(table.set("a", "b + 1").is_err());
|
||||
// ...but redefining a to something unrelated is always fine.
|
||||
table.set("a", "5").unwrap();
|
||||
assert_eq!(table.resolve("a"), Ok(5.0));
|
||||
assert_eq!(table.resolve("b"), Ok(6.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_all_isolates_a_failure_to_the_affected_parameters_only() {
|
||||
let mut table = ParameterTable::new();
|
||||
table.set("good", "10").unwrap();
|
||||
table.set("bad", "missing + 1").unwrap();
|
||||
table.set("depends_on_bad", "bad * 2").unwrap();
|
||||
|
||||
let results = table.resolve_all();
|
||||
assert_eq!(results.get("good"), Some(&Ok(10.0)));
|
||||
assert_eq!(
|
||||
results.get("bad"),
|
||||
Some(&Err(ParamError::UndefinedReference("missing".to_string())))
|
||||
);
|
||||
assert_eq!(
|
||||
results.get("depends_on_bad"),
|
||||
Some(&Err(ParamError::UndefinedReference("missing".to_string())))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_drops_a_parameter_and_leaves_dependents_undefined_not_panicking() {
|
||||
let mut table = ParameterTable::new();
|
||||
table.set("a", "1").unwrap();
|
||||
table.set("b", "a + 1").unwrap();
|
||||
assert!(table.remove("a"));
|
||||
assert!(
|
||||
!table.remove("a"),
|
||||
"removing an already-removed name reports false"
|
||||
);
|
||||
assert_eq!(
|
||||
table.resolve("b"),
|
||||
Err(ParamError::UndefinedReference("a".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_upserts_an_existing_parameters_formula() {
|
||||
let mut table = ParameterTable::new();
|
||||
table.set("a", "1").unwrap();
|
||||
table.set("a", "2").unwrap();
|
||||
assert_eq!(table.len(), 1, "redefining must not create a second entry");
|
||||
assert_eq!(table.resolve("a"), Ok(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_round_trips_through_bincode() {
|
||||
let mut table = ParameterTable::new();
|
||||
table.set("hole_dia", "5").unwrap();
|
||||
table.set("hole_spacing", "2 * hole_dia + 1.5").unwrap();
|
||||
|
||||
let bytes = bincode::serialize(&table).expect("serialize");
|
||||
let restored: ParameterTable = bincode::deserialize(&bytes).expect("deserialize");
|
||||
|
||||
assert_eq!(restored.len(), 2);
|
||||
assert_eq!(restored.resolve("hole_spacing"), Ok(11.5));
|
||||
assert_eq!(restored.get("hole_dia").unwrap().source, "5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_preserves_insertion_order_across_redefinitions() {
|
||||
let mut table = ParameterTable::new();
|
||||
table.set("z", "1").unwrap();
|
||||
table.set("a", "2").unwrap();
|
||||
table.set("z", "3").unwrap(); // redefine, should not move to the end
|
||||
let names: Vec<&str> = table.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["z", "a"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Persists the drawing-wide named-parameter table in a versioned XRecord.
|
||||
|
||||
use super::named_parameters::ParameterTable;
|
||||
use super::Scene;
|
||||
use bincode::Options;
|
||||
|
||||
const XRECORD_KEY: &str = "OCS_NAMED_PARAMETERS";
|
||||
|
||||
/// See `sketch_persist::MAX_CHUNK_BYTES`'s doc comment: the vendored DWG
|
||||
/// writer truncates any single `XRecordValue::Chunk` entry to `u8::MAX`
|
||||
/// bytes, so a table past a handful of parameters must be split across
|
||||
/// multiple same-code `310` entries on write and concatenated back on read.
|
||||
const MAX_CHUNK_BYTES: usize = u8::MAX as usize;
|
||||
const MAX_RECORD_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// Prefixed onto the serialized blob so a future schema change can be
|
||||
/// detected and gracefully skipped rather than silently misreading bytes —
|
||||
/// same call-out as `sketch_persist::FORMAT_VERSION`, same caveat that the
|
||||
/// migration path itself is still undesigned.
|
||||
const FORMAT_VERSION: u8 = 1;
|
||||
|
||||
fn encode(table: &ParameterTable) -> Option<Vec<u8>> {
|
||||
let mut bytes = vec![FORMAT_VERSION];
|
||||
let body = bincode::serialize(table).ok()?;
|
||||
if body.len() >= MAX_RECORD_BYTES {
|
||||
return None;
|
||||
}
|
||||
bytes.extend(body);
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Option<ParameterTable> {
|
||||
let (&version, body) = bytes.split_first()?;
|
||||
if version != FORMAT_VERSION {
|
||||
return None;
|
||||
}
|
||||
bincode::DefaultOptions::new()
|
||||
.with_fixint_encoding()
|
||||
.allow_trailing_bytes()
|
||||
.with_limit(MAX_RECORD_BYTES as u64)
|
||||
.deserialize(body)
|
||||
.ok()
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// Writes `named_parameters` into the document's `OCS_NAMED_PARAMETERS`
|
||||
/// XRecord, ready for whatever save call happens next to serialize
|
||||
/// `self.document` as-is. Called from the same save entry points as
|
||||
/// `materialize_sketch_constraints_for_save`
|
||||
/// (`OpenCADStudio::prepare_native_save`, the wasm save path).
|
||||
///
|
||||
/// An empty table is skipped rather than writing an empty XRecord — an
|
||||
/// unconstrained-by-parameters drawing (the common case, at least until
|
||||
/// this feature is used) should not gain persisted-but-empty
|
||||
/// bookkeeping on every save.
|
||||
pub(crate) fn materialize_named_parameters_for_save(&mut self) {
|
||||
if self.named_parameters.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(bytes) = encode(&self.named_parameters) else {
|
||||
return;
|
||||
};
|
||||
let owner = self.document.header.model_space_block_handle;
|
||||
if owner.is_null() {
|
||||
return;
|
||||
}
|
||||
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 more Chunk entries every time.
|
||||
record.entries.clear();
|
||||
for chunk in bytes.chunks(MAX_CHUNK_BYTES) {
|
||||
record.entries.push(acadrust::objects::XRecordEntry::new(
|
||||
310,
|
||||
acadrust::objects::XRecordValue::Chunk(chunk.to_vec()),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Populates `named_parameters` from the just-loaded `self.document`'s
|
||||
/// `OCS_NAMED_PARAMETERS` XRecord, if any — called right after a
|
||||
/// document open installs its `CadDocument` into this `Scene`, the same
|
||||
/// call sites `load_sketch_constraints_from_document` already has
|
||||
/// (`OpenCADStudio::on_file_opened`, the automation `"open"`/`"new"`
|
||||
/// ops). Replaces whatever was already in `named_parameters` (a fresh
|
||||
/// table for a real open; `"new"`'s blank document has nothing to find
|
||||
/// anyway).
|
||||
pub(crate) fn load_named_parameters_from_document(&mut self) {
|
||||
self.named_parameters = ParameterTable::new();
|
||||
let owner = self.document.header.model_space_block_handle;
|
||||
if owner.is_null() {
|
||||
return;
|
||||
}
|
||||
let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else {
|
||||
return;
|
||||
};
|
||||
// Concatenate every Chunk entry in order, not just the first — see
|
||||
// `MAX_CHUNK_BYTES`'s doc comment.
|
||||
let mut bytes = Vec::new();
|
||||
for entry in &record.entries {
|
||||
if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value {
|
||||
if bytes.len().saturating_add(chunk.len()) > MAX_RECORD_BYTES {
|
||||
bytes.clear();
|
||||
break;
|
||||
}
|
||||
bytes.extend_from_slice(chunk);
|
||||
}
|
||||
}
|
||||
if bytes.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(table) = decode(&bytes) {
|
||||
self.named_parameters = table;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_parameter_table_survives_materialize_and_reload() {
|
||||
let mut scene = Scene::new();
|
||||
scene.named_parameters_mut().set("hole_dia", "5").unwrap();
|
||||
scene
|
||||
.named_parameters_mut()
|
||||
.set("hole_spacing", "2 * hole_dia + 1.5")
|
||||
.unwrap();
|
||||
|
||||
scene.materialize_named_parameters_for_save();
|
||||
// Simulate the load-time side of a save/reload round trip: drop the
|
||||
// in-memory table (a fresh open starts empty) and repopulate purely
|
||||
// from what materialize just wrote into `document.objects`.
|
||||
scene.named_parameters = ParameterTable::new();
|
||||
scene.load_named_parameters_from_document();
|
||||
|
||||
assert_eq!(scene.named_parameters().len(), 2);
|
||||
assert_eq!(scene.named_parameters().resolve("hole_spacing"), Ok(11.5));
|
||||
assert_eq!(
|
||||
scene.named_parameters().get("hole_dia").unwrap().source,
|
||||
"5"
|
||||
);
|
||||
}
|
||||
|
||||
/// The above test only proves `materialize`/`load` agree with each
|
||||
/// other on the same in-memory `CadDocument` — 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, mirroring
|
||||
/// `sketch_persist`'s own real-bytes tests.
|
||||
fn full_bytes_roundtrip(ext: &str) -> ParameterTable {
|
||||
let mut scene = Scene::new();
|
||||
scene
|
||||
.named_parameters_mut()
|
||||
.set("plate_len", "42.5")
|
||||
.unwrap();
|
||||
scene
|
||||
.named_parameters_mut()
|
||||
.set("plate_width", "plate_len / 2")
|
||||
.unwrap();
|
||||
|
||||
scene.materialize_named_parameters_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!("named_params_roundtrip.{ext}"), bytes)
|
||||
.unwrap_or_else(|e| panic!("reload {ext} bytes: {e}"));
|
||||
reloaded_scene.load_named_parameters_from_document();
|
||||
|
||||
reloaded_scene.named_parameters
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parameter_table_survives_a_real_dxf_save_and_load_round_trip() {
|
||||
let restored = full_bytes_roundtrip("dxf");
|
||||
assert_eq!(restored.len(), 2);
|
||||
assert_eq!(restored.resolve("plate_width"), Ok(21.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parameter_table_survives_a_real_dwg_save_and_load_round_trip() {
|
||||
let restored = full_bytes_roundtrip("dwg");
|
||||
assert_eq!(restored.len(), 2);
|
||||
assert_eq!(restored.resolve("plate_width"), Ok(21.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_table_is_not_materialized() {
|
||||
let mut scene = Scene::new();
|
||||
scene.materialize_named_parameters_for_save();
|
||||
let owner = scene.document.header.model_space_block_handle;
|
||||
assert!(
|
||||
scene.document.xrecord(owner, XRECORD_KEY).is_none(),
|
||||
"an empty table 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(&ParameterTable::new()).unwrap());
|
||||
assert!(
|
||||
decode(&bytes).is_none(),
|
||||
"a future/unknown format version must be rejected, not misread"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -238,8 +238,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 {
|
||||
|
||||
@@ -735,7 +735,10 @@ impl Scene {
|
||||
PropValue::Stepper { .. }
|
||||
| PropValue::ColorVaries
|
||||
| PropValue::LwVaries
|
||||
| PropValue::FieldLwVaries { .. } => continue,
|
||||
| PropValue::FieldLwVaries { .. }
|
||||
| PropValue::EntityLink { .. }
|
||||
| PropValue::ParamRow { .. }
|
||||
| PropValue::ParamAddRow => continue,
|
||||
}
|
||||
};
|
||||
out.push(choice(prop.field, prop.label, editor));
|
||||
@@ -883,7 +886,10 @@ impl Scene {
|
||||
PropValue::Stepper { display, .. } => display,
|
||||
PropValue::ColorVaries
|
||||
| PropValue::LwVaries
|
||||
| PropValue::FieldLwVaries { .. } => return None,
|
||||
| PropValue::FieldLwVaries { .. }
|
||||
| PropValue::EntityLink { .. }
|
||||
| PropValue::ParamRow { .. }
|
||||
| PropValue::ParamAddRow => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,834 @@
|
||||
//! 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>,
|
||||
}
|
||||
|
||||
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 `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::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,
|
||||
/// 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 [`infer_coincident_refs`]/[`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)
|
||||
}
|
||||
|
||||
/// For each point on `new_entity`, 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.
|
||||
///
|
||||
/// Matching uses a tight world-space tolerance because this runs after the
|
||||
/// snapped entity has been committed.
|
||||
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",
|
||||
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 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")
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
//! Persists each sketch constraint set in a versioned, chunked XRecord.
|
||||
|
||||
use super::sketch_constraints::SketchConstraintSet;
|
||||
use super::Scene;
|
||||
use acadrust::{CadDocument, Handle};
|
||||
use bincode::Options;
|
||||
|
||||
const XRECORD_KEY: &str = "OCS_SKETCH_CONSTRAINTS";
|
||||
|
||||
/// The vendored DWG writer's `XRecordValue::Chunk` encoder packs a single
|
||||
/// `u8` length prefix per entry and silently truncates anything longer
|
||||
/// (`cadcodec`'s `io/dwg/dwg_stream_writers/object_writer/objects.rs`,
|
||||
/// `encode_xrecord_entries`) — unlike the DXF writer, which has no such
|
||||
/// limit. A constraint set past a handful of entries serializes past 255
|
||||
/// bytes easily, so a single `XRecordEntry` silently loses the tail and
|
||||
/// `bincode::deserialize` then fails on the truncated bytes, dropping the
|
||||
/// *entire* scope's constraints on the next DWG load — reproduced and
|
||||
/// root-caused live (not from a change log): a 5-entry set round-tripped
|
||||
/// clean, a 6-entry set of the same shapes lost everything. Splitting the
|
||||
/// blob across as many same-code `310` entries as needed (below) works
|
||||
/// around it without touching the vendored crate — `CadDocument::xrecord`'s
|
||||
/// `entries: Vec<XRecordEntry>` already supports repeating a code.
|
||||
const MAX_CHUNK_BYTES: usize = u8::MAX as usize;
|
||||
const MAX_RECORD_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// Removes a named XRecord from `owner`'s extension dictionary, if present.
|
||||
/// The dictionary itself is left in place even if now empty — other code may
|
||||
/// already reference it via `xdictionary_handle`, and an empty extension
|
||||
/// dictionary is harmless bookkeeping, unlike a stale XRecord that would
|
||||
/// resurrect deleted data on the next load.
|
||||
fn remove_xrecord(document: &mut CadDocument, owner: Handle, key: &str) {
|
||||
let Some(dictionary_handle) = document.extension_dictionary_handle(owner) else {
|
||||
return;
|
||||
};
|
||||
let Some(acadrust::objects::ObjectType::Dictionary(dictionary)) =
|
||||
document.objects.get_mut(&dictionary_handle)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(index) = dictionary
|
||||
.entries
|
||||
.iter()
|
||||
.position(|(name, _)| name.eq_ignore_ascii_case(key))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (_, record_handle) = dictionary.entries.remove(index);
|
||||
document.objects.remove(&record_handle);
|
||||
}
|
||||
|
||||
/// Serialized schema version. Unknown versions are skipped without affecting
|
||||
/// the rest of the drawing.
|
||||
const FORMAT_VERSION: u8 = 2;
|
||||
|
||||
fn encode(set: &SketchConstraintSet) -> Option<Vec<u8>> {
|
||||
let mut bytes = vec![FORMAT_VERSION];
|
||||
let body = bincode::serialize(set).ok()?;
|
||||
if body.len() >= MAX_RECORD_BYTES {
|
||||
return None;
|
||||
}
|
||||
bytes.extend(body);
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Option<SketchConstraintSet> {
|
||||
let (&version, body) = bytes.split_first()?;
|
||||
if version != FORMAT_VERSION {
|
||||
return None;
|
||||
}
|
||||
bincode::DefaultOptions::new()
|
||||
.with_fixint_encoding()
|
||||
.allow_trailing_bytes()
|
||||
.with_limit(MAX_RECORD_BYTES as u64)
|
||||
.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 writes no XRecord — an
|
||||
/// unconstrained drawing (the common case) should not gain
|
||||
/// persisted-but-empty bookkeeping on every save. If a *previous* save
|
||||
/// already materialized a non-empty XRecord for that scope, the now-empty
|
||||
/// set actively removes it instead: leaving the stale blob in place would
|
||||
/// resurrect the deleted constraints the next time the document loads.
|
||||
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() {
|
||||
let owner = set.scope.owner_handle(&self.document);
|
||||
if !owner.is_null() {
|
||||
remove_xrecord(&mut self.document, owner, XRECORD_KEY);
|
||||
}
|
||||
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 more Chunk entries every time.
|
||||
record.entries.clear();
|
||||
for chunk in bytes.chunks(MAX_CHUNK_BYTES) {
|
||||
record.entries.push(acadrust::objects::XRecordEntry::new(
|
||||
310,
|
||||
acadrust::objects::XRecordValue::Chunk(chunk.to_vec()),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
};
|
||||
// Concatenate every Chunk entry in order, not just the first —
|
||||
// a set materialized past `MAX_CHUNK_BYTES` spans more than one
|
||||
// same-code `310` entry (see that constant's doc comment).
|
||||
let mut bytes = Vec::new();
|
||||
for entry in &record.entries {
|
||||
if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value {
|
||||
if bytes.len().saturating_add(chunk.len()) > MAX_RECORD_BYTES {
|
||||
bytes.clear();
|
||||
break;
|
||||
}
|
||||
bytes.extend_from_slice(chunk);
|
||||
}
|
||||
}
|
||||
if bytes.is_empty() {
|
||||
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(crate::scene::named_parameters::DrivingValue::Literal(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(crate::scene::named_parameters::DrivingValue::Literal(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(crate::scene::named_parameters::DrivingValue::Literal(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 clearing_a_previously_materialized_set_removes_its_stale_xrecord() {
|
||||
// Regression for the audit-flagged case `an_empty_constraint_set_is_not_materialized`
|
||||
// doesn't cover: a scope that already had a *non-empty* XRecord from a
|
||||
// prior save must have that XRecord actively removed once its
|
||||
// in-memory set goes back to empty — otherwise the stale blob
|
||||
// survives the save and resurrects the deleted constraints on the
|
||||
// next load.
|
||||
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();
|
||||
|
||||
let owner = scene.document.header.model_space_block_handle;
|
||||
assert!(
|
||||
scene.document.xrecord(owner, XRECORD_KEY).is_some(),
|
||||
"sanity: the first save should have materialized an XRecord"
|
||||
);
|
||||
|
||||
// Clear every constraint (mirrors deleting the constrained entity, or
|
||||
// removing the last constraint by hand) and save again.
|
||||
scene
|
||||
.sketch_constraint_set_mut(SketchScope::ModelSpace)
|
||||
.constraints
|
||||
.clear();
|
||||
scene.materialize_sketch_constraints_for_save();
|
||||
|
||||
assert!(
|
||||
scene.document.xrecord(owner, XRECORD_KEY).is_none(),
|
||||
"the stale XRecord from the earlier non-empty save must be removed, not left behind"
|
||||
);
|
||||
|
||||
// And the load path must agree: nothing resurrects on reload.
|
||||
scene.sketch_constraints.clear();
|
||||
scene.load_sketch_constraints_from_document();
|
||||
assert!(
|
||||
scene
|
||||
.sketch_constraint_set(SketchScope::ModelSpace)
|
||||
.is_none(),
|
||||
"no constraints should come back after the set was cleared and re-saved"
|
||||
);
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user