feat: preserve body history and placed modelling workflows
Record ownership transfers in the timeline so earlier Join/Cut membership, world placement, machining data and styles survive component moves, edits, independent copying, undo and reopening. Preserve pending positions in history snapshots and keep constructor transfers ordered during feature redefinition. Retain shallow sheet pockets and exact planar intersection islands through unfold/refold, with depth-aware flat previews and DXF export. Store explicit 3D wire coordinate spaces and migrate known legacy provenance without moving saved points or analytic fits. Infer unique eligible cylindrical Emboss targets, align Revolve manipulators with actual extent geometry, and discard obsolete preview errors after input correction. Certify constructed and immutable imported face styles using exact source metadata while preserving legacy face names. Upgrade designs to format10 with bounded validation and geometry-preserving legacy migration.
This commit is contained in:
1 parent
0398a813c8
commit
c1d819f235
86 files changed
+6011
-488
No files matched your search
@@ -33,7 +33,7 @@ pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions};
|
||||
pub use shape::{Shape, intersections};
|
||||
pub use solver::{Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_holding};
|
||||
pub use text::{MAX_TEXT_CHARS, text_geometry};
|
||||
pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, fit_polyline};
|
||||
pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireSpace, fit_polyline};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
+180
-19
@@ -1,4 +1,4 @@
|
||||
//! 3D sketch curves (wires): polylines in world coordinates that a sketch carries besides its
|
||||
//! 3D sketch curves (wires): polylines with explicit coordinates that a sketch carries besides its
|
||||
//! planar geometry — included 3D edges, curves projected onto surfaces, intersection curves.
|
||||
//! They are reference geometry: drawn and usable as paths, not part of profiles or the solver.
|
||||
|
||||
@@ -11,11 +11,35 @@ use crate::model::{Result, Sketch, SketchError};
|
||||
/// Most points one wire may have.
|
||||
pub const MAX_WIRE_POINTS: usize = 100_000;
|
||||
|
||||
/// Coordinate meaning of a stored wire and its editable fit points.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WireSpace {
|
||||
/// Legacy drawn or detached geometry remains physically fixed in the world.
|
||||
#[default]
|
||||
World,
|
||||
/// Geometry follows the sketch's owning component placement.
|
||||
Component,
|
||||
}
|
||||
|
||||
impl WireSpace {
|
||||
/// Coordinate semantics of a retained legacy producer, including a currently lost link.
|
||||
pub fn from_legacy_link(link: &Link) -> Self {
|
||||
match (&link.kind, &link.source) {
|
||||
(LinkKind::Include, LinkSource::Edge { .. } | LinkSource::SketchCurve { .. })
|
||||
| (_, LinkSource::OnSurface { .. } | LinkSource::Intersection { .. } | LinkSource::Iso { .. }) => Self::Component,
|
||||
_ => Self::World,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Wire {
|
||||
pub id: String,
|
||||
/// World-space polyline.
|
||||
/// Polyline in `space` coordinates.
|
||||
pub pts: Vec<Vec3>,
|
||||
#[serde(default)]
|
||||
pub space: WireSpace,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<String>,
|
||||
/// Drawn in a 3D sketch: the points the curve was drawn through (one: a point; two: a
|
||||
@@ -68,8 +92,11 @@ pub fn fit_polyline(fit: &[Vec3]) -> Vec<Vec3> {
|
||||
}
|
||||
|
||||
fn clean(w: &[Vec3]) -> Option<Vec<Vec3>> {
|
||||
if w.len() > MAX_WIRE_POINTS {
|
||||
return None;
|
||||
}
|
||||
let mut out: Vec<Vec3> = Vec::new();
|
||||
for p in w.iter().take(MAX_WIRE_POINTS) {
|
||||
for p in w {
|
||||
if !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9) {
|
||||
return None;
|
||||
}
|
||||
@@ -80,7 +107,77 @@ fn clean(w: &[Vec3]) -> Option<Vec<Vec3>> {
|
||||
(out.len() >= 2).then_some(out)
|
||||
}
|
||||
|
||||
fn clean_wires(wires: &[Vec<Vec3>]) -> Result<Vec<Vec<Vec3>>> {
|
||||
if wires.len() > crate::link::MAX_LINK_ENTITIES || wires.iter().fold(0usize, |n, w| n.saturating_add(w.len())) > 1_000_000 {
|
||||
return Err(SketchError::Invalid("3D linked curves exceed the stored geometry budget".into()));
|
||||
}
|
||||
wires.iter().map(|w| clean(w).ok_or_else(|| SketchError::Invalid("3D linked curve points out of range or coincident".into()))).collect()
|
||||
}
|
||||
|
||||
fn drawn_polyline(fit: &[Vec3]) -> Result<Vec<Vec3>> {
|
||||
if fit.is_empty() || fit.len() > MAX_FIT_POINTS {
|
||||
return Err(SketchError::Invalid(format!("a 3D curve needs 1 to {MAX_FIT_POINTS} points")));
|
||||
}
|
||||
if fit.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) {
|
||||
return Err(SketchError::Invalid("3D point out of range".into()));
|
||||
}
|
||||
if fit.windows(2).any(|w| w[0].dist(w[1]) < 1e-9) {
|
||||
return Err(SketchError::Invalid("the points of a 3D curve must differ".into()));
|
||||
}
|
||||
let pts = fit_polyline(fit);
|
||||
if pts.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) {
|
||||
return Err(SketchError::Invalid("3D spline samples out of range".into()));
|
||||
}
|
||||
Ok(pts)
|
||||
}
|
||||
|
||||
impl Sketch {
|
||||
fn wire_budget(&self, replacing: Option<&str>, new_count: usize, new_points: usize) -> Result<()> {
|
||||
let mut count = new_count;
|
||||
let mut points = new_points;
|
||||
for w in self.wires.iter().filter(|w| replacing.is_none() || w.link.as_deref() != replacing) {
|
||||
count = count.saturating_add(1);
|
||||
points = points.saturating_add(w.pts.len()).saturating_add(w.fit.len());
|
||||
}
|
||||
if count > crate::link::MAX_LINK_ENTITIES || points > 1_000_000 {
|
||||
return Err(SketchError::Invalid("3D curves exceed the stored geometry budget".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate saved arrays without regenerating legacy geometry or changing point values.
|
||||
pub fn validate_wires(&self) -> Result<()> {
|
||||
if self.wires.len() > crate::link::MAX_LINK_ENTITIES {
|
||||
return Err(SketchError::Invalid("too many 3D curves".into()));
|
||||
}
|
||||
let mut work = 0usize;
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for w in &self.wires {
|
||||
work = work.saturating_add(w.pts.len()).saturating_add(w.fit.len());
|
||||
if work > 1_000_000
|
||||
|| w.id.is_empty()
|
||||
|| w.id.len() > 512
|
||||
|| !ids.insert(&w.id)
|
||||
|| w.link.as_ref().is_some_and(|id| id.is_empty() || id.len() > 512)
|
||||
|| w.pts.is_empty()
|
||||
|| w.pts.len() > MAX_WIRE_POINTS
|
||||
|| w.fit.len() > MAX_FIT_POINTS
|
||||
|| (w.pts.len() == 1 && w.fit.len() != 1)
|
||||
|| w.pts.iter().chain(&w.fit).any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9))
|
||||
{
|
||||
return Err(SketchError::Invalid("3D curve arrays, ids or point bounds are invalid".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Producer semantics for legacy wires with retained association provenance. Breaking a
|
||||
/// link removes this evidence, so detached wires conservatively retain world coordinates.
|
||||
pub fn legacy_wire_space(&self, wire: &Wire) -> WireSpace {
|
||||
let Some(link) = wire.link.as_deref().and_then(|id| self.link(id)) else { return WireSpace::World };
|
||||
WireSpace::from_legacy_link(link)
|
||||
}
|
||||
|
||||
pub fn wire_index(&self, id: &str) -> Option<usize> {
|
||||
self.wires.iter().position(|w| w.id == id)
|
||||
}
|
||||
@@ -92,7 +189,8 @@ impl Sketch {
|
||||
|
||||
/// Add a link with planar geometry and/or 3D wires.
|
||||
pub fn add_link_with_wires(&mut self, kind: LinkKind, source: LinkSource, geom: &[LinkGeom], wires: &[Vec<Vec3>]) -> Result<String> {
|
||||
let ws: Vec<Vec<Vec3>> = wires.iter().filter_map(|w| clean(w)).collect();
|
||||
let ws = clean_wires(wires)?;
|
||||
self.wire_budget(None, ws.len(), ws.iter().map(Vec::len).sum())?;
|
||||
if ws.is_empty() {
|
||||
return self.add_link(kind, source, geom);
|
||||
}
|
||||
@@ -112,7 +210,8 @@ impl Sketch {
|
||||
if self.link(id).is_none() {
|
||||
return Err(SketchError::Unknown(id.into()));
|
||||
}
|
||||
let ws: Vec<Vec<Vec3>> = wires.iter().filter_map(|w| clean(w)).collect();
|
||||
let ws = clean_wires(wires)?;
|
||||
self.wire_budget(Some(id), ws.len(), ws.iter().map(Vec::len).sum())?;
|
||||
let old: Vec<String> = self.link_wires(id).iter().filter_map(|i| self.wires.get(*i).map(|w| w.id.clone())).collect();
|
||||
self.wires.retain(|w| w.link.as_deref() != Some(id));
|
||||
for (k, pts) in ws.into_iter().enumerate() {
|
||||
@@ -120,24 +219,22 @@ impl Sketch {
|
||||
Some(o) => o.clone(),
|
||||
None => self.fresh("w"),
|
||||
};
|
||||
self.wires.push(Wire { id: wid, pts, link: Some(id.to_string()), fit: Vec::new() });
|
||||
self.wires.push(Wire { id: wid, pts, space: WireSpace::Component, link: Some(id.to_string()), fit: Vec::new() });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a curve drawn in a 3D sketch through `fit` (see [`Wire::fit`]).
|
||||
pub fn add_drawn_wire(&mut self, fit: &[Vec3]) -> Result<String> {
|
||||
if fit.is_empty() || fit.len() > MAX_FIT_POINTS {
|
||||
return Err(SketchError::Invalid(format!("a 3D curve needs 1 to {MAX_FIT_POINTS} points")));
|
||||
}
|
||||
if fit.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) {
|
||||
return Err(SketchError::Invalid("3D point out of range".into()));
|
||||
}
|
||||
if fit.windows(2).any(|w| w[0].dist(w[1]) < 1e-9) {
|
||||
return Err(SketchError::Invalid("the points of a 3D curve must differ".into()));
|
||||
}
|
||||
self.add_drawn_wire_in(fit, WireSpace::World)
|
||||
}
|
||||
|
||||
/// Add a drawn curve without changing the coordinate meaning supplied by its author.
|
||||
pub fn add_drawn_wire_in(&mut self, fit: &[Vec3], space: WireSpace) -> Result<String> {
|
||||
let pts = drawn_polyline(fit)?;
|
||||
self.wire_budget(None, 1, pts.len() + fit.len())?;
|
||||
let id = self.fresh(if fit.len() == 1 { "q" } else { "w" });
|
||||
self.wires.push(Wire { id: id.clone(), pts: fit_polyline(fit), link: None, fit: fit.to_vec() });
|
||||
self.wires.push(Wire { id: id.clone(), pts, space, link: None, fit: fit.to_vec() });
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
@@ -148,17 +245,32 @@ impl Sketch {
|
||||
return Err(SketchError::Invalid("3D point out of range".into()));
|
||||
}
|
||||
let w = self.wire_index(wire).ok_or_else(|| SketchError::Unknown(wire.into()))?;
|
||||
let space = self
|
||||
.wires
|
||||
.get(w)
|
||||
.filter(|w| w.link.is_none())
|
||||
.map(|w| w.space)
|
||||
.ok_or_else(|| SketchError::Invalid("linked 3D geometry cannot be moved".into()))?;
|
||||
let from = self.wires.get(w).and_then(|w| w.fit.get(index)).copied().ok_or_else(|| SketchError::Unknown(format!("{wire} point {index}")))?;
|
||||
let mut n = 0;
|
||||
for w in self.wires.iter_mut().filter(|w| !w.fit.is_empty()) {
|
||||
let mut changes = Vec::new();
|
||||
for (index, w) in self.wires.iter().enumerate().filter(|(_, w)| !w.fit.is_empty() && w.link.is_none() && w.space == space) {
|
||||
let mut fit = w.fit.clone();
|
||||
let mut changed = false;
|
||||
for p in w.fit.iter_mut().filter(|p| p.dist(from) < 1e-9) {
|
||||
for p in fit.iter_mut().filter(|p| p.dist(from) < 1e-9) {
|
||||
*p = to;
|
||||
changed = true;
|
||||
n += 1;
|
||||
}
|
||||
if changed {
|
||||
w.pts = fit_polyline(&w.fit);
|
||||
let pts = drawn_polyline(&fit)?;
|
||||
changes.push((index, fit, pts));
|
||||
}
|
||||
}
|
||||
for (index, fit, pts) in changes {
|
||||
if let Some(w) = self.wires.get_mut(index) {
|
||||
w.fit = fit;
|
||||
w.pts = pts;
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
@@ -170,3 +282,52 @@ impl Sketch {
|
||||
self.prune_links();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wire_space_defaults_and_preserves_detached_provenance() {
|
||||
let mut sk = Sketch::new();
|
||||
let id = sk.add_drawn_wire(&[Vec3::new(1.0, 2.0, 3.0)]).unwrap();
|
||||
assert_eq!(sk.wires[0].space, WireSpace::World);
|
||||
let mut raw = serde_json::to_value(&sk.wires[0]).unwrap();
|
||||
raw.as_object_mut().unwrap().remove("space");
|
||||
assert_eq!(serde_json::from_value::<Wire>(raw).unwrap().space, WireSpace::World);
|
||||
let link = sk
|
||||
.add_link_with_wires(
|
||||
LinkKind::Include,
|
||||
LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO },
|
||||
&[],
|
||||
&[vec![Vec3::new(1.0, 2.0, 3.0), Vec3::new(1.0, 2.0, 4.0)]],
|
||||
)
|
||||
.unwrap();
|
||||
sk.set_link_lost(&link, true);
|
||||
let wire = sk.wires.iter().find(|w| w.link.as_deref() == Some(&link)).unwrap();
|
||||
assert_eq!(sk.legacy_wire_space(wire), WireSpace::Component);
|
||||
let wid = wire.id.clone();
|
||||
sk.break_link(&link).unwrap();
|
||||
let wire = sk.wires.iter().find(|w| w.id == wid).unwrap();
|
||||
assert_eq!(wire.space, WireSpace::Component);
|
||||
assert_eq!(sk.legacy_wire_space(wire), WireSpace::World);
|
||||
assert_eq!(sk.wires.iter().find(|w| w.id == id).unwrap().space, WireSpace::World);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_space_invalid_producers_and_move_are_atomic() {
|
||||
let mut sk = Sketch::new();
|
||||
let wire = sk.add_drawn_wire_in(&[Vec3::ZERO, Vec3::Z], WireSpace::Component).unwrap();
|
||||
sk.add_drawn_wire(&[Vec3::ZERO]).unwrap();
|
||||
assert_eq!(sk.move_drawn_point(&wire, 0, Vec3::X).unwrap(), 1);
|
||||
assert_eq!(sk.wires[1].fit[0], Vec3::ZERO);
|
||||
let before = sk.clone();
|
||||
assert!(sk.move_drawn_point(&wire, 0, Vec3::Z).is_err());
|
||||
assert_eq!(sk, before);
|
||||
assert!(sk.add_link_with_wires(LinkKind::Include, LinkSource::Origin, &[], &[vec![Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)]]).is_err());
|
||||
assert_eq!(sk, before);
|
||||
assert!(sk.add_link_with_wires(LinkKind::Include, LinkSource::Origin, &[], &[vec![Vec3::ZERO; MAX_WIRE_POINTS + 1]]).is_err());
|
||||
assert_eq!(sk, before);
|
||||
sk.validate_wires().unwrap();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user