From b5051b274478efc8ddeeaaabadb81b3802250b07 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 00:34:47 +0000 Subject: [PATCH] Rib and Web (to the next face or a depth, flip), Emboss/deboss on planar faces (EmbossCmd), Replace Face onto a parallel plane or face, Align (point to point, face to face), Remove (SoftDeleteCommand), timeline Copy/Paste of features (independent copies: own dimension parameters and input names, references remapped, optional translation) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/assembly.rs | 17 ++ crates/doc/src/clipboard.rs | 73 ++++++ crates/doc/src/document.rs | 70 ++++++ crates/doc/src/eval.rs | 8 + crates/doc/src/eval_more.rs | 241 +++++++++++++++++++ crates/doc/src/lib.rs | 1 + crates/doc/src/params.rs | 10 + crates/engine/src/cmd/clipboard.rs | 156 ++++++++++++ crates/engine/src/cmd/features.rs | 12 +- crates/engine/src/cmd/features_more.rs | 164 +++++++++++++ crates/engine/src/cmd/features_more_tests.rs | 110 +++++++++ crates/engine/src/cmd/mod.rs | 4 + crates/engine/src/lib.rs | 4 + 13 files changed, 864 insertions(+), 6 deletions(-) create mode 100644 crates/doc/src/clipboard.rs create mode 100644 crates/doc/src/eval_more.rs create mode 100644 crates/engine/src/cmd/clipboard.rs create mode 100644 crates/engine/src/cmd/features_more.rs create mode 100644 crates/engine/src/cmd/features_more_tests.rs diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 9a54872b..6784d6b0 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -187,6 +187,20 @@ impl FeatureKind { dir(direction); } FeatureKind::Thread { face, .. } => pt(face), + FeatureKind::ReplaceFace { faces, target, .. } => { + faces.iter_mut().for_each(pt); + map_plane_ref(m, target); + } + FeatureKind::Align { from, to, from_normal, to_normal, .. } => { + pt(from); + pt(to); + if let Some(n) = from_normal { + dir(n); + } + if let Some(n) = to_normal { + dir(n); + } + } FeatureKind::Scale { origin, .. } => pt(origin), FeatureKind::Move { rotate_axis, .. } => { if let Some(a) = rotate_axis { @@ -200,6 +214,9 @@ impl FeatureKind { | FeatureKind::Sweep { .. } | FeatureKind::BoundingSolid { .. } | FeatureKind::Pipe { .. } + | FeatureKind::Emboss { .. } + | FeatureKind::Rib { .. } + | FeatureKind::Remove { .. } | FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => {} } diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs new file mode 100644 index 00000000..853cc164 --- /dev/null +++ b/crates/doc/src/clipboard.rs @@ -0,0 +1,73 @@ +//! Copies of features (timeline Copy / Paste): references to sketches and features inside the +//! copied set are moved to the copies. + +use std::collections::BTreeMap; + +use crate::{FeatureKind, PatternKind, PlaneRef}; + +fn plane_names(p: &mut PlaneRef, names: &BTreeMap) { + let mut p = p; + for _ in 0..64 { + match p { + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => p = base, + PlaneRef::Construction { name } => { + if let Some(n) = names.get(name) { + *name = n.clone(); + } + return; + } + _ => return, + } + } +} + +impl FeatureKind { + /// Point references to sketches (by id) and features (by name) at their copies. + pub fn remap_refs(&mut self, sketches: &BTreeMap, names: &BTreeMap) { + let sk = |id: &mut u64| { + if let Some(n) = sketches.get(id) { + *id = *n; + } + }; + let feats = |list: &mut Vec| { + for x in list.iter_mut() { + if let Some(n) = names.get(x) { + *x = n.clone(); + } + } + }; + match self { + FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Split { plane, .. } => { + plane_names(plane, names) + } + FeatureKind::Extrude { sketch, .. } + | FeatureKind::Revolve { sketch, .. } + | FeatureKind::Emboss { sketch, .. } + | FeatureKind::Rib { sketch, .. } => sk(sketch), + FeatureKind::Sweep { sketch, path_sketch, .. } => { + sk(sketch); + sk(path_sketch); + } + FeatureKind::Pipe { path_sketch, .. } => sk(path_sketch), + FeatureKind::Loft { sections, .. } => sections.iter_mut().for_each(|s| sk(&mut s.sketch)), + FeatureKind::Hole { points, .. } => { + if let Some(p) = points { + sk(&mut p.sketch); + } + } + FeatureKind::Pattern { features, pattern, .. } => { + feats(features); + if let PatternKind::Path { path_sketch, .. } = pattern { + sk(path_sketch); + } + } + FeatureKind::Mirror { features, plane, .. } => { + feats(features); + plane_names(plane, names); + } + FeatureKind::Draft { neutral, .. } => plane_names(neutral, names), + FeatureKind::ReplaceFace { target, .. } => plane_names(target, names), + _ => {} + } + } +} diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 639690e0..354349b0 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -333,6 +333,56 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Vec::is_empty")] targets: Vec, }, + /// Raise (emboss) or sink (deboss) sketch profiles on the planar face they lie on. + Emboss { + sketch: u64, + profiles: ProfileSel, + depth: String, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + deboss: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + targets: Vec, + }, + /// A thin wall from open sketch curves: `thickness` across the sketch plane, filling from + /// the curves toward the body (to the next face, or `depth`). A web makes one per curve. + Rib { + sketch: u64, + curves: Vec, + thickness: String, + /// Depth from the curves; `None` = to the next face of the body. + #[serde(default, skip_serializing_if = "Option::is_none")] + depth: Option, + /// Fill toward the other side of the curves. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + flip: bool, + /// Web: each curve is its own wall. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + web: bool, + }, + /// Move planar faces (at the given points) onto a target plane parallel to them. + ReplaceFace { + faces: Vec, + target: PlaneRef, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + }, + /// Move bodies so that `from` lands on `to` (and, with normals, the faces meet face to face). + Align { + bodies: Vec, + from: Vec3, + to: Vec3, + #[serde(default, skip_serializing_if = "Option::is_none")] + from_normal: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + to_normal: Option, + /// Normals the same way instead of facing each other. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + flip: bool, + }, + /// Remove bodies from the model (from here on in the timeline). + Remove { + bodies: Vec, + }, Move { bodies: Vec, translate: [String; 3], @@ -512,6 +562,12 @@ impl FeatureKind { FeatureKind::OffsetFace { .. } => "OffsetFacesFeature", FeatureKind::BoundingSolid { .. } => "BoundingSolidFeature", FeatureKind::Pipe { .. } => "PipeFeature", + FeatureKind::Emboss { .. } => "EmbossFeature", + FeatureKind::Rib { web: false, .. } => "RibFeature", + FeatureKind::Rib { .. } => "WebFeature", + FeatureKind::ReplaceFace { .. } => "ReplaceFaceFeature", + FeatureKind::Align { .. } => "AlignFeature", + FeatureKind::Remove { .. } => "RemoveFeature", FeatureKind::Import { .. } => "BaseFeature", FeatureKind::MeshImport { .. } => "MeshFeature", } @@ -546,6 +602,12 @@ impl FeatureKind { FeatureKind::OffsetFace { .. } => "OffsetFace", FeatureKind::BoundingSolid { .. } => "BoundingSolid", FeatureKind::Pipe { .. } => "Pipe", + FeatureKind::Emboss { .. } => "Emboss", + FeatureKind::Rib { web: false, .. } => "Rib", + FeatureKind::Rib { .. } => "Web", + FeatureKind::ReplaceFace { .. } => "ReplaceFace", + FeatureKind::Align { .. } => "Align", + FeatureKind::Remove { .. } => "Remove", FeatureKind::Import { .. } => "Import", FeatureKind::MeshImport { .. } => "Mesh", } @@ -620,6 +682,13 @@ impl FeatureKind { v.push(a); } } + FeatureKind::Emboss { depth, .. } => v.push(depth), + FeatureKind::Rib { thickness, depth, .. } => { + v.push(thickness); + v.extend(depth.iter().map(String::as_str)); + } + FeatureKind::ReplaceFace { target, .. } => plane_exprs(target, &mut v), + FeatureKind::Align { .. } | FeatureKind::Remove { .. } => {} FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => {} } v @@ -851,6 +920,7 @@ impl Document { FeatureKind::Sweep { sketch, path_sketch, .. } if *sketch == id || *path_sketch == id => gone.push(f.id), FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.sketch == id) => gone.push(f.id), FeatureKind::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } if *path_sketch == id => gone.push(f.id), + FeatureKind::Emboss { sketch, .. } | FeatureKind::Rib { sketch, .. } if *sketch == id => gone.push(f.id), _ => {} } } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index fe60b23a..ee8a724a 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1305,8 +1305,16 @@ fn replay(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut Ok(()) } +#[path = "eval_more.rs"] +mod more; + fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState, warning: &mut Option) -> Result<()> { match &f.kind { + FeatureKind::Emboss { .. } + | FeatureKind::Rib { .. } + | FeatureKind::ReplaceFace { .. } + | FeatureKind::Align { .. } + | FeatureKind::Remove { .. } => more::eval(doc, vals, f, st), FeatureKind::Sketch { plane, sketch } => { let plane = doc.resolve_plane(vals, plane, 0)?; let mut sk = sketch.clone(); diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs new file mode 100644 index 00000000..78de5569 --- /dev/null +++ b/crates/doc/src/eval_more.rs @@ -0,0 +1,241 @@ +//! Evaluation of Emboss, Rib/Web, Replace Face, Align and Remove (features built from the +//! kernel's extrude, boolean, offset and transform operations). + +use super::*; +use solvecraft_geom::{Loop2, Seg2}; + +pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState) -> Result<()> { + match &f.kind { + FeatureKind::Emboss { sketch, profiles, depth, deboss, targets } => { + let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?; + let regions = select_profiles(ss, profiles)?; + let d = val(vals, depth, Kind::Length)?; + if !(d > 0.0) { + return Err(DocError::Invalid("the emboss depth must be positive".into())); + } + let (a, b) = if *deboss { (-d, 0.0) } else { (0.0, d) }; + let tools = kernel::extrude(&ss.plane, ®ions, a, b)?; + if st.bodies.is_empty() { + return Err(DocError::Invalid("there is no body to emboss".into())); + } + apply_op(st, f, tools, if *deboss { Operation::Cut } else { Operation::Join }, targets) + } + FeatureKind::Rib { sketch, curves, thickness, depth, flip, web } => { + let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone(); + let t = val(vals, thickness, Kind::Length)?; + if !(t > 0.0) { + return Err(DocError::Invalid("the rib thickness must be positive".into())); + } + let depth = match depth { + Some(e) => Some(val(vals, e, Kind::Length)?) + .filter(|d| *d > 0.0) + .ok_or_else(|| DocError::Invalid("the rib depth must be positive".into())) + .map(Some)?, + None => None, + }; + let groups: Vec> = if *web { curves.iter().map(|c| vec![c.clone()]).collect() } else { vec![curves.clone()] }; + if groups.is_empty() || curves.is_empty() || curves.len() > 1000 { + return Err(DocError::Invalid("a rib needs 1…1000 sketch curves".into())); + } + let mut tools = Vec::new(); + for g in &groups { + tools.push(rib_tool(st, &ss, g, t, depth, *flip)?); + } + apply_op(st, f, tools, Operation::Join, &[]) + } + FeatureKind::ReplaceFace { faces, target, body } => { + let tp = doc.resolve_plane(vals, target, 0)?; + let tn = tp.normal(); + let i = body_at(st, body, faces)?; + let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) }; + let mut b = mb.body.clone(); + for p in faces { + let n = planar_normal_at(&b, *p).ok_or_else(|| DocError::Invalid("not supported yet: replacing a face that is not planar".into()))?; + if n.dot(tn).abs() < 1.0 - 1e-9 { + return Err(DocError::Invalid("not supported yet: a target that is not parallel to the face".into())); + } + let d = (tp.origin - *p).dot(n); + if d.abs() > 1e-9 { + b = kernel::offset_faces(&b, &[*p], d)?; + } + } + if let Some(slot) = st.bodies.get_mut(i) { + *slot = ModelBody::new(mb.name, b, mb.feature); + } + Ok(()) + } + FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip } => { + let mut m = translation(Vec3::ZERO); + if let (Some(a), Some(b)) = (from_normal.and_then(Vec3::normalized), to_normal.and_then(Vec3::normalized)) { + // Turn `a` onto −b (faces meet) or b (flipped), about the `from` point. + let want = if *flip { b } else { b * -1.0 }; + let axis = a.cross(want); + let ang = axis.len().atan2(a.dot(want)); + let axis = axis.normalized().unwrap_or_else(|| a.any_perp()); + if ang.abs() > 1e-12 { + m = rotation(*from, axis, ang); + } + } + let d = *to - *from; + m[3][0] += d.x; + m[3][1] += d.y; + m[3][2] += d.z; + if bodies.is_empty() { + return Err(DocError::Invalid("no bodies to align".into())); + } + for n in bodies { + let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?; + if let Some(mb) = st.bodies.get_mut(i) { + let nb = kernel::transform_matrix(&mb.body, m)?; + *mb = ModelBody::new(mb.name.clone(), nb, mb.feature); + } + } + Ok(()) + } + FeatureKind::Remove { bodies } => { + if bodies.is_empty() { + return Err(DocError::Invalid("no bodies to remove".into())); + } + for n in bodies { + if st.body(n).is_none() { + return Err(DocError::Unknown(format!("body `{n}`"))); + } + } + st.bodies.retain(|b| !bodies.contains(&b.name)); + Ok(()) + } + _ => Err(DocError::Invalid(format!("{} is not evaluated here", f.name))), + } +} + +/// Unit outward normal of the planar face through `p`. +fn planar_normal_at(b: &Body, p: Vec3) -> Option { + let tol = (b.size() * 2e-3).max(1e-3); + let faces = b.faces(tol).ok()?; + faces + .iter() + .filter_map(|f| f.plane_normal.map(|n| (n, ((p - f.centroid).dot(n)).abs()))) + .filter(|(_, d)| *d < tol * 5.0) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(n, _)| n) +} + +/// The curves of a chain as one polyline in sketch coordinates, in order. +fn chain_points(ss: &SolvedSketch, ids: &[String]) -> Result> { + let mut segs: Vec = Vec::new(); + for id in ids { + let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}`")))?; + segs.extend(ss.sketch.segs(ci)); + } + let Some(first) = segs.first().copied() else { return Err(DocError::Invalid("no curves".into())) }; + // Orient the first segment toward the second, then chain. + let mut chain = vec![first]; + if let Some(n) = segs.get(1) + && (first.start().dist(n.start()) < 1e-6 || first.start().dist(n.end()) < 1e-6) + { + chain[0] = first.reversed(); + } + for s in segs.iter().skip(1) { + let prev = chain.last().map(Seg2::end).unwrap_or_default(); + let s = if s.end().dist(prev) < s.start().dist(prev) { s.reversed() } else { *s }; + if s.start().dist(prev) > 1e-5 { + return Err(DocError::Invalid("the rib curves are not connected end to end".into())); + } + chain.push(s); + } + let mut pts = vec![chain[0].start()]; + for s in &chain { + let n = if matches!(s, Seg2::Line { .. }) { 1 } else { 24 }; + for k in 1..=n { + pts.push(s.point_at(k as f64 / n as f64)); + } + } + Ok(pts) +} + +/// A rib wall: the band swept from the curves across the plane by `depth` (or to the body), +/// `t` thick about the sketch plane. +fn rib_tool(st: &ModelState, ss: &SolvedSketch, ids: &[String], t: f64, depth: Option, flip: bool) -> Result { + let pts = chain_points(ss, ids)?; + let (Some(a), Some(b)) = (pts.first().copied(), pts.last().copied()) else { return Err(DocError::Invalid("empty rib curve".into())) }; + let chord = (b - a).normalized().ok_or_else(|| DocError::Invalid("a closed or zero-length rib curve".into()))?; + let mid = pts.get(pts.len() / 2).copied().unwrap_or(a); + // Toward the material: the side of the curve where the bodies are (unless flipped). + let mut n = chord.perp(); + let reach = st.bodies.iter().map(|b| b.mesh().bounds()).fold(0.0f64, |acc, bb| acc.max((bb.max - bb.min).len())); + let probe = |side: Vec2| { + let mut hits = 0; + for k in 1..=20 { + let q = ss.plane.to_world(mid + side * (reach * k as f64 / 20.0)); + if st.bodies.iter().any(|b| b.mesh().contains(q)) { + hits += 1; + } + } + hits + }; + if probe(n * -1.0) > probe(n) { + n = n * -1.0; + } + if flip { + n = n * -1.0; + } + // From each point of the curves, walk across the plane until the body begins (or `depth`); + // walls that reach the body go a little into it so they merge with it. + let inside = |q: Vec2| { + let w = ss.plane.to_world(q); + st.bodies.iter().any(|b| b.mesh().contains(w)) + }; + let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum(); + let samples = ((len / (t.min(len) * 0.25).max(len / 400.0)).ceil() as usize).clamp(2, 400); + let along: Vec = (0..=samples).map(|k| point_on_polyline(&pts, len * k as f64 / samples as f64)).collect(); + let limit = depth.unwrap_or(reach * 2.0 + 1.0); + let step = (limit.min(reach * 2.0 + 1.0) / 400.0).max(1e-3); + let overlap = (len / samples as f64 * 2.0).max(step * 2.0).min(t); + let mut far = Vec::with_capacity(along.len()); + for q in &along { + let mut hit = None; + let mut k = 1; + while (k as f64) * step <= limit + step && k < 100_000 { + if inside(*q + n * (k as f64 * step)) { + // Refine between the last outside and the first inside sample. + let (mut lo, mut hi) = ((k - 1) as f64 * step, k as f64 * step); + for _ in 0..30 { + let m = 0.5 * (lo + hi); + if inside(*q + n * m) { hi = m } else { lo = m } + } + hit = Some(hi); + break; + } + k += 1; + } + let h = match (hit, depth) { + (Some(h), Some(d)) if h >= d => d, + (Some(h), _) => h + overlap, + (None, Some(d)) => d, + (None, None) => return Err(DocError::Invalid("the rib curves do not face a body (give a depth)".into())), + }; + far.push(*q + n * h); + } + let mut poly = along.clone(); + poly.extend(far.into_iter().rev()); + let mut lp = Loop2::polygon(&poly); + if lp.signed_area() < 0.0 { + lp = lp.reversed(); + } + let region = Region2 { outer: lp, holes: Vec::new() }; + let tools = kernel::extrude(&ss.plane, &[region], -t / 2.0, t / 2.0)?; + tools.into_iter().next().ok_or_else(|| DocError::Invalid("the rib made no solid".into())) +} + +/// The point `d` along a polyline. +fn point_on_polyline(pts: &[Vec2], d: f64) -> Vec2 { + let mut left = d.max(0.0); + for w in pts.windows(2) { + let l = w[0].dist(w[1]); + if left <= l && l > 0.0 { + return w[0].lerp(w[1], left / l); + } + left -= l; + } + pts.last().copied().unwrap_or_default() +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index f981dba6..4ec4df51 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -6,6 +6,7 @@ #![forbid(unsafe_code)] mod assembly; +mod clipboard; mod document; mod eval; pub mod expr; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 58035106..28fe6d2c 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -137,7 +137,17 @@ impl FeatureKind { v.push(("Angle", a, A)); } } + FeatureKind::Emboss { depth, .. } => v.push(("Depth", depth, L)), + FeatureKind::Rib { thickness, depth, .. } => { + v.push(("Thickness", thickness, L)); + if let Some(d) = depth { + v.push(("Depth", d, L)); + } + } + FeatureKind::ReplaceFace { target, .. } => plane_inputs_mut(target, &mut v), FeatureKind::Combine { .. } + | FeatureKind::Align { .. } + | FeatureKind::Remove { .. } | FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } | FeatureKind::Import { .. } diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs new file mode 100644 index 00000000..b0a8375e --- /dev/null +++ b/crates/engine/src/cmd/clipboard.rs @@ -0,0 +1,156 @@ +//! Copy and paste of timeline features. Pasted features are independent copies: sketches get +//! their own dimension parameters, feature inputs their own names, and references inside the +//! copied set point at the copies. + +use std::collections::BTreeMap; + +use serde_json::{Value, json}; +use solvecraft_doc::{FeatureKind, PlaneRef}; + +use super::CommandSpec; +use crate::params::{bad, string_list, vec3}; +use crate::{Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("timeline.copy", "Copy Features", copy).noundo().params("features: [ids or names] (in timeline order on paste)"), + CommandSpec::new("timeline.paste", "Paste Features", paste) + .params("translate?: [x,y,z] (moves the copies' geometry); the copies go at the marker"), +]; + +fn copy(s: &mut Session, p: &Value) -> Result { + let cmd = "timeline.copy"; + let mut keys = string_list(p, "features"); + if let Some(Value::Array(a)) = p.get("features") { + keys.extend(a.iter().filter_map(Value::as_u64).map(|n| n.to_string())); + } + if keys.is_empty() || keys.len() > 1000 { + return Err(bad(cmd, "`features` must list 1…1000 features")); + } + let mut ids = Vec::new(); + for k in &keys { + ids.push(s.doc.find_feature(k).map(|f| f.id).ok_or_else(|| bad(cmd, format!("no feature `{k}`")))?); + } + // Keep timeline order so sketches come before the features that use them. + let copied: Vec = s.doc.features.iter().filter(|f| ids.contains(&f.id)).cloned().collect(); + let names: Vec = copied.iter().map(|f| f.name.clone()).collect(); + s.clipboard = copied; + Ok(json!({"copied": names})) +} + +/// Origin planes become explicit planes so a translation can move them. +fn explicit_planes(p: &mut PlaneRef) { + match p { + PlaneRef::Origin { name } => { + if let Some(plane) = solvecraft_geom::Plane::named(name) { + *p = PlaneRef::Custom { plane }; + } + } + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => explicit_planes(base), + _ => {} + } +} + +fn paste(s: &mut Session, p: &Value) -> Result { + let cmd = "timeline.paste"; + if s.clipboard.is_empty() { + return Err(bad(cmd, "nothing copied (timeline.copy first)")); + } + let translate = match p.get("translate") { + Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`translate` must be [x, y, z]"))?), + None => None, + }; + let feats = s.clipboard.clone(); + let comp = s.active_component; + let mut sketches: BTreeMap = BTreeMap::new(); + let mut names: BTreeMap = BTreeMap::new(); + let mut made = Vec::new(); + for f in feats { + let mut kind = f.kind.clone(); + kind.remap_refs(&sketches, &names); + let doc = s.doc_mut(); + if let FeatureKind::Sketch { sketch, .. } = &mut kind { + // Each dimension of the copy gets its own parameter, with the same expression. + for c in &mut sketch.constraints { + if let Some(pn) = c.param.clone() { + let (e, u) = doc.param(&pn).map(|x| (x.expr.clone(), x.unit.clone())).unwrap_or_else(|| (pn.clone(), "mm".into())); + c.param = Some(doc.new_model_param(&e, &u)); + } + } + } + if let Some(t) = translate { + if let FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } = &mut kind { + explicit_planes(plane); + } + let mut m = solvecraft_doc::IDENTITY; + m[3][0] = t.x; + m[3][1] = t.y; + m[3][2] = t.z; + kind.transform_geometry(&m); + } + let id = doc.add_feature(kind, None)?; + if let Some(nf) = doc.feature_mut(id) { + nf.component = comp; + nf.body_names = f.body_names.iter().map(|b| format!("{b} (copy)")).collect(); + } + let new_name = doc.feature(id).map(|x| x.name.clone()).unwrap_or_default(); + if matches!(f.kind, FeatureKind::Sketch { .. }) { + sketches.insert(f.id, id); + } + names.insert(f.name.clone(), new_name.clone()); + made.push(json!({"id": id, "name": new_name, "from": f.name})); + } + s.refresh(); + let errors: Vec = made + .iter() + .filter_map(|m| m["id"].as_u64()) + .filter_map(|id| s.model.result(id).and_then(|r| r.error.clone()).map(|e| json!({"feature": id, "error": e}))) + .collect(); + Ok(json!({"pasted": made, "errors": errors})) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + fn run(s: &mut Session, id: &str, p: serde_json::Value) -> serde_json::Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } + } + + fn volume(s: &mut Session) -> f64 { + run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN) + } + + #[test] + fn copy_paste_sketch_and_extrude() { + let mut s = Session::default(); + run(&mut s, "ChangeParameterCommand", json!({"name": "w", "expression": "10 mm"})); + run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "S"})); + run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 10]})); + run(&mut s, "ConstraintCoincident", json!({"a": "p1", "b": "origin"})); + run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": "w"})); + run(&mut s, "SketchDimension", json!({"entities": ["l2"], "value": 10})); + run(&mut s, "SketchStop", json!({})); + run(&mut s, "Extrude", json!({"sketch": "S", "distance": 5})); + assert!((volume(&mut s) - 500.0).abs() < 1e-6); + run(&mut s, "timeline.copy", json!({"features": ["S", "Extrude1"]})); + let r = run(&mut s, "timeline.paste", json!({"translate": [30, 0, 0]})); + assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}"); + assert_eq!(run(&mut s, "MeasureCommand", json!({}))["body_count"], 2); + assert!((volume(&mut s) - 1000.0).abs() < 1e-6); + // The copy's dimensions are its own, still driven by the user parameter. + run(&mut s, "ChangeParameterCommand", json!({"name": "w", "expression": "20 mm"})); + assert!((volume(&mut s) - 2000.0).abs() < 1e-6); + let pasted_extrude = r["pasted"][1]["name"].as_str().unwrap_or_default().to_string(); + let d = s.doc.find_feature(&pasted_extrude).and_then(|f| f.param_names.first().cloned()).unwrap_or_default(); + run(&mut s, "ChangeParameterCommand", json!({"name": d, "expression": "10"})); + assert!((volume(&mut s) - 3000.0).abs() < 1e-6, "only the copy got thicker"); + assert!(s.execute("timeline.paste", &json!({"translate": "x"})).is_err()); + let mut t = Session::default(); + assert!(t.execute("timeline.paste", &json!({})).is_err()); + } +} diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index d6ef2cff..7532a5e9 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -154,7 +154,7 @@ pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> { } } -fn operation(p: &Value, cmd: &str) -> Result { +pub(super) fn operation(p: &Value, cmd: &str) -> Result { match str_(p, "operation") { None => Ok(Operation::NewBody), Some(o) => Operation::parse(o).ok_or_else(|| bad(cmd, format!("unknown operation `{o}` (new, join, cut, intersect)"))), @@ -162,7 +162,7 @@ fn operation(p: &Value, cmd: &str) -> Result { } /// The sketch a feature uses: `sketch` param, the active sketch, or the last sketch. -fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result { +pub(super) fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result { if let Some(v) = p.get("sketch") { let key = match v { Value::Number(n) => n.to_string(), @@ -189,7 +189,7 @@ fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result { .ok_or_else(|| bad(cmd, "there is no sketch to use (create one first)")) } -fn profiles(p: &Value, cmd: &str) -> Result { +pub(super) fn profiles(p: &Value, cmd: &str) -> Result { let Some(v) = p.get("profiles") else { return Ok(ProfileSel::All) }; match v { Value::String(s) if s.eq_ignore_ascii_case("all") => Ok(ProfileSel::All), @@ -233,12 +233,12 @@ fn profiles(p: &Value, cmd: &str) -> Result { } } -fn check_expr(s: &Session, e: &str, kind: Kind, cmd: &str, what: &str) -> Result<()> { +pub(super) fn check_expr(s: &Session, e: &str, kind: Kind, cmd: &str, what: &str) -> Result<()> { s.doc.eval(e, kind).map(|_| ()).map_err(|err| bad(cmd, format!("{what}: {err}"))) } /// Add a feature, evaluate, and report its result (an evaluation error fails the command). -fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Result { +pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Result { let name = str_(p, "name"); // Commands speak world coordinates; the feature is authored in the active component. let mut kind = kind; @@ -779,7 +779,7 @@ fn draft(s: &mut Session, p: &Value) -> Result { add_feature(s, p, FeatureKind::Draft { faces, angle, neutral, pull, body: str_(p, "body").map(str::to_string) }) } -fn sketch_id(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result { +pub(super) fn sketch_id(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result { let key = match v { Some(Value::Number(n)) => n.to_string(), Some(Value::String(x)) => x.clone(), diff --git a/crates/engine/src/cmd/features_more.rs b/crates/engine/src/cmd/features_more.rs new file mode 100644 index 00000000..77992d3a --- /dev/null +++ b/crates/engine/src/cmd/features_more.rs @@ -0,0 +1,164 @@ +//! Rib, Web, Emboss, Replace Face, Align and Remove. + +use serde_json::Value; +use solvecraft_doc::FeatureKind; +use solvecraft_doc::expr::Kind; +use solvecraft_geom::Vec3; + +use super::CommandSpec; +use super::features::{add_feature, check_expr, feature_sketch, plane_param, profiles}; +use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3}; +use crate::{Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("FusionRibCommand", "Rib", rib) + .at("SOLID", "CREATE") + .icon("rib") + .params("sketch, curves: [open curve ids, in order]; thickness (across the sketch plane); depth? (default: to the next face); flip?: bool (fill to the other side)"), + CommandSpec::new("FusionWebCommand", "Web", web) + .at("SOLID", "CREATE") + .icon("rib") + .params("sketch, curves: [open curve ids] (one wall each); thickness; depth? (default: to the next face); flip?: bool"), + CommandSpec::new("EmbossCmd", "Emboss", emboss) + .at("SOLID", "CREATE") + .icon("emboss") + .params("sketch (on a planar face), profiles?; depth; mode?: emboss|deboss (default emboss); targets?"), + CommandSpec::new("FusionReplaceFaceCommand", "Replace Face", replace_face) + .at("SOLID", "MODIFY") + .icon("offset_face") + .params("faces: [[x,y,z] points on planar faces]; target: plane (XY|XZ|YZ|construction plane|{origin, normal}) | target_face: [x,y,z] on a parallel planar face; body?"), + CommandSpec::new("AlignCmd", "Align", align) + .at("SOLID", "MODIFY") + .icon("move") + .params("bodies: [names]; from: [x,y,z] | from_face: [x,y,z]; to: [x,y,z] | to_face: [x,y,z] (faces also turn to meet); flip?: bool"), + CommandSpec::new("SoftDeleteCommand", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"), +]; + +fn curves_param(p: &Value, cmd: &str) -> Result> { + let c = string_list(p, "curves"); + if c.is_empty() || c.len() > 1000 { + return Err(bad(cmd, "`curves` must list 1…1000 sketch curve ids")); + } + Ok(c) +} + +fn rib_like(s: &mut Session, p: &Value, cmd: &str, web: bool) -> Result { + let sketch = feature_sketch(s, p, cmd)?; + let curves = curves_param(p, cmd)?; + { + let st = s.model.state(); + let ss = st.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch is not evaluated"))?; + if let Some(c) = curves.iter().find(|c| ss.sketch.curve_index(c).is_none()) { + return Err(bad(cmd, format!("no curve `{c}` in the sketch"))); + } + } + let thickness = req_expr(cmd, p, "thickness")?; + check_expr(s, &thickness, Kind::Length, cmd, "thickness")?; + let depth = expr(p, "depth"); + if let Some(d) = &depth { + check_expr(s, d, Kind::Length, cmd, "depth")?; + } + let flip = bool_(p, "flip").unwrap_or(false); + add_feature(s, p, FeatureKind::Rib { sketch, curves, thickness, depth, flip, web }) +} + +fn rib(s: &mut Session, p: &Value) -> Result { + rib_like(s, p, "FusionRibCommand", false) +} + +fn web(s: &mut Session, p: &Value) -> Result { + rib_like(s, p, "FusionWebCommand", true) +} + +fn emboss(s: &mut Session, p: &Value) -> Result { + let cmd = "EmbossCmd"; + let sketch = feature_sketch(s, p, cmd)?; + let profiles = profiles(p, cmd)?; + let depth = req_expr(cmd, p, "depth")?; + check_expr(s, &depth, Kind::Length, cmd, "depth")?; + let deboss = match str_(p, "mode").map(str::to_ascii_lowercase).as_deref() { + None | Some("emboss") => false, + Some("deboss") => true, + Some(m) => return Err(bad(cmd, format!("unknown mode `{m}` (emboss or deboss)"))), + }; + if let Some(fp) = p.get("face").and_then(vec3) + && super::face::face_normal(s, fp).is_none() + { + return Err(bad(cmd, "not supported yet: embossing onto a curved face")); + } + add_feature(s, p, FeatureKind::Emboss { sketch, profiles, depth, deboss, targets: string_list(p, "targets") }) +} + +fn face_points(p: &Value, k: &str, cmd: &str) -> Result> { + let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{k}` must list points [x, y, z]")))?; + if a.is_empty() || a.len() > 1000 { + return Err(bad(cmd, format!("`{k}` must list 1…1000 points"))); + } + a.iter().map(|v| vec3(v).ok_or_else(|| bad(cmd, format!("`{k}` must contain [x, y, z] points")))).collect() +} + +fn replace_face(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionReplaceFaceCommand"; + let faces = face_points(p, "faces", cmd)?; + for f in &faces { + if super::face::face_normal(s, *f).is_none() { + return Err(bad(cmd, "not supported yet: replacing a face that is not planar")); + } + } + let target = match p.get("target_face").and_then(vec3) { + Some(tp) => { + let n = super::face::face_normal(s, tp).ok_or_else(|| bad(cmd, "the target face must be planar"))?; + let plane = solvecraft_geom::Plane::from_normal(tp, n).ok_or_else(|| bad(cmd, "the target face's plane"))?; + solvecraft_doc::PlaneRef::Custom { plane } + } + None => plane_param(s, p.get("target"), cmd)?, + }; + let body = str_(p, "body").map(str::to_string); + add_feature(s, p, FeatureKind::ReplaceFace { faces, target, body }) +} + +fn align(s: &mut Session, p: &Value) -> Result { + let cmd = "AlignCmd"; + let bodies = string_list(p, "bodies"); + if bodies.is_empty() { + return Err(bad(cmd, "`bodies` must list the bodies to move")); + } + { + let st = s.model.state(); + if let Some(b) = bodies.iter().find(|b| st.body(b).is_none()) { + return Err(bad(cmd, format!("no body `{b}`"))); + } + } + let pick = |s: &Session, pk: &str, fk: &str| -> Result<(Vec3, Option)> { + if let Some(fp) = p.get(fk).and_then(vec3) { + let n = super::face::face_normal(s, fp).ok_or_else(|| bad(cmd, format!("`{fk}` must be on a planar face")))?; + return Ok((fp, Some(n))); + } + p.get(pk).and_then(vec3).map(|v| (v, None)).ok_or_else(|| bad(cmd, format!("give `{pk}` or `{fk}` as [x, y, z]"))) + }; + let (from, from_normal) = pick(s, "from", "from_face")?; + let (to, to_normal) = pick(s, "to", "to_face")?; + let (from_normal, to_normal) = match (from_normal, to_normal) { + (Some(a), Some(b)) => (Some(a), Some(b)), + _ => (None, None), + }; + let flip = bool_(p, "flip").unwrap_or(false); + add_feature(s, p, FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip }) +} + +fn remove(s: &mut Session, p: &Value) -> Result { + let cmd = "SoftDeleteCommand"; + let bodies = string_list(p, "bodies"); + if bodies.is_empty() { + return Err(bad(cmd, "`bodies` must list bodies")); + } + let st = s.model.state(); + if let Some(b) = bodies.iter().find(|b| st.body(b).is_none()) { + return Err(bad(cmd, format!("no body `{b}`"))); + } + add_feature(s, p, FeatureKind::Remove { bodies }) +} + +#[cfg(test)] +#[path = "features_more_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs new file mode 100644 index 00000000..f0724d9d --- /dev/null +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -0,0 +1,110 @@ +use serde_json::{Value, json}; + +use crate::Session; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn measure(s: &mut Session) -> Value { + run(s, "MeasureCommand", json!({})) +} + +fn volume(s: &mut Session) -> f64 { + measure(s)["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN) +} + +fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs().max(1e-12) +} + +/// An L bracket: a 40 x 20 x 5 base and a 5 x 20 x 40 wall at x = 0..5. +fn bracket() -> Session { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 20, "height": 5, "corner": [0, 0, 0], "body_name": "L"})); + run(&mut s, "PrimitiveBox", json!({"length": 5, "width": 20, "height": 40, "corner": [0, 0, 0], "operation": "join"})); + assert!(rel(volume(&mut s), 4000.0 + 3500.0) < 1e-9); + // Mid-plane of the bracket (y = 10): sketch x = X, sketch y = Z. + run(&mut s, "SketchCreate", json!({"plane": {"origin": [0, 10, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "RibSketch"})); + run(&mut s, "DrawPolyline", json!({"points": [[5, 25], [25, 5]], "ids": ["l1"]})); + run(&mut s, "SketchStop", json!({})); + s +} + +#[test] +fn rib_to_the_next_face_fills_the_corner() { + let mut s = bracket(); + run(&mut s, "FusionRibCommand", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4})); + // The triangle (5,5)-(5,25)-(25,5) of area 200, 4 thick. + assert!(rel(volume(&mut s), 7500.0 + 800.0) < 1e-6, "{}", volume(&mut s)); + assert_eq!(measure(&mut s)["body_count"], 1); + // The thickness is a parameter. + let t = s.doc.features.last().and_then(|f| f.param_names.first().cloned()).unwrap_or_default(); + run(&mut s, "ChangeParameterCommand", json!({"name": t, "expression": "2"})); + assert!(rel(volume(&mut s), 7500.0 + 400.0) < 1e-6); +} + +#[test] +fn rib_with_a_depth_and_web() { + let mut s = bracket(); + run(&mut s, "FusionRibCommand", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4, "depth": 2})); + let v = volume(&mut s); + assert!(v > 7500.0 + 100.0 && v < 7500.0 + 800.0, "{v}"); + let mut s = bracket(); + run(&mut s, "FusionWebCommand", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4})); + assert!(rel(volume(&mut s), 8300.0) < 1e-6); + // Curves that face no body. + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10})); + run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Far"})); + run(&mut s, "DrawPolyline", json!({"points": [[50, 50], [60, 60]], "ids": ["l1"]})); + run(&mut s, "SketchStop", json!({})); + let e = s.execute("FusionRibCommand", &json!({"sketch": "Far", "curves": ["l1"], "thickness": 1})); + assert!(e.is_err_and(|e| e.to_string().contains("do not face a body"))); +} + +#[test] +fn emboss_and_deboss_on_a_face() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 10})); + run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 10]}, "name": "Logo"})); + run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [5, 5], "p1": [15, 15]})); + run(&mut s, "SketchStop", json!({})); + run(&mut s, "EmbossCmd", json!({"sketch": "Logo", "depth": 2})); + assert!(rel(volume(&mut s), 12000.0 + 200.0) < 1e-6, "{}", volume(&mut s)); + run(&mut s, "UndoCommand", json!({})); + run(&mut s, "EmbossCmd", json!({"sketch": "Logo", "depth": 3, "mode": "deboss"})); + assert!(rel(volume(&mut s), 12000.0 - 300.0) < 1e-6); + assert!(s.execute("EmbossCmd", &json!({"sketch": "Logo", "depth": 1, "mode": "sideways"})).is_err()); +} + +#[test] +fn replace_face_align_remove() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"})); + run(&mut s, "FusionReplaceFaceCommand", json!({"faces": [[5, 5, 10]], "target": {"origin": [0, 0, 15], "normal": [0, 0, 1]}})); + assert!(rel(volume(&mut s), 1500.0) < 1e-6); + let e = s.execute("FusionReplaceFaceCommand", &json!({"faces": [[5, 5, 15]], "target": {"origin": [0, 0, 15], "normal": [1, 0, 1]}})); + assert!(e.is_err() || s.model.results.last().is_some_and(|r| r.error.as_deref().unwrap_or("").contains("parallel"))); + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"})); + run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [50, 0, 0], "body_name": "B"})); + // B's top face onto A's top face: B turns over and sits on A. + run(&mut s, "AlignCmd", json!({"bodies": ["B"], "from_face": [55, 5, 10], "to_face": [5, 5, 10]})); + let m = measure(&mut s); + let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] == "B").unwrap().clone(); + let (lo, hi) = (b["bbox"]["min"].clone(), b["bbox"]["max"].clone()); + for (got, want) in [(&lo[0], 0.0), (&lo[2], 10.0), (&hi[0], 10.0), (&hi[2], 20.0)] { + assert!((got.as_f64().unwrap_or(f64::NAN) - want).abs() < 1e-6, "{b}"); + } + // Points only: a plain move. + run(&mut s, "AlignCmd", json!({"bodies": ["B"], "from": [0, 0, 10], "to": [0, 0, 30]})); + let m = measure(&mut s); + let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] == "B").unwrap().clone(); + assert!((b["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 30.0).abs() < 1e-6, "{b}"); + run(&mut s, "SoftDeleteCommand", json!({"bodies": ["B"]})); + assert_eq!(measure(&mut s)["body_count"], 1); +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f678a4be..183d9c8f 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -2,10 +2,12 @@ //! toolbar placement (`tab`, `panel`) follows Fusion's Design workspace so the registry doubles //! as the parity metric (`cargo xtask parity`). +mod clipboard; mod component; mod edit; mod face; mod features; +mod features_more; pub use features::auto_operation; mod file; mod inspect; @@ -120,8 +122,10 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(sketch_modify::COMMANDS.iter()); v.extend(sketch_project::COMMANDS.iter()); v.extend(features::COMMANDS.iter()); + v.extend(features_more::COMMANDS.iter()); v.extend(component::COMMANDS.iter()); v.extend(edit::COMMANDS.iter()); + v.extend(clipboard::COMMANDS.iter()); v.extend(parameters::COMMANDS.iter()); v.extend(file::COMMANDS.iter()); v.extend(inspect::COMMANDS.iter()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f349dee4..bbb1dc83 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -154,6 +154,8 @@ pub struct Session { /// Occurrence moves not captured yet (Capture Position keeps them, a recompute drops them). pub pending_moves: std::collections::BTreeMap, world_cache: std::sync::Mutex)>>, + /// Features copied with `timeline.copy` (pasted by `timeline.paste`). + pub clipboard: Vec, } const MAX_UNDO: usize = 200; @@ -185,6 +187,7 @@ impl Session { active_component: 0, pending_moves: Default::default(), world_cache: Default::default(), + clipboard: Vec::new(), } } @@ -310,6 +313,7 @@ impl Session { active_component: self.active_component, pending_moves: self.pending_moves.clone(), world_cache: Default::default(), + clipboard: self.clipboard.clone(), } }