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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 00:42:26 +00:00
1 parent 075808e7ee
commit b5051b2744
13 files changed
+864 -6

No files matched your search

+17
View File
@@ -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 { .. } => {}
}
+73
View File
@@ -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<String, String>) {
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<u64, u64>, names: &BTreeMap<String, String>) {
let sk = |id: &mut u64| {
if let Some(n) = sketches.get(id) {
*id = *n;
}
};
let feats = |list: &mut Vec<String>| {
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),
_ => {}
}
}
}
+70
View File
@@ -333,6 +333,56 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<String>,
},
/// 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<String>,
},
/// 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<String>,
thickness: String,
/// Depth from the curves; `None` = to the next face of the body.
#[serde(default, skip_serializing_if = "Option::is_none")]
depth: Option<String>,
/// 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<Vec3>,
target: PlaneRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// Move bodies so that `from` lands on `to` (and, with normals, the faces meet face to face).
Align {
bodies: Vec<String>,
from: Vec3,
to: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
from_normal: Option<Vec3>,
#[serde(default, skip_serializing_if = "Option::is_none")]
to_normal: Option<Vec3>,
/// 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<String>,
},
Move {
bodies: Vec<String>,
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),
_ => {}
}
}
+8
View File
@@ -1305,8 +1305,16 @@ fn replay(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut
Ok(())
}
#[path = "eval_more.rs"]
mod more;
fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> 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();
+241
View File
@@ -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<String, Value>, 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, &regions, 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<Vec<String>> = 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<Vec3> {
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<Vec<Vec2>> {
let mut segs: Vec<Seg2> = 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<f64>, flip: bool) -> Result<Body> {
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<Vec2> = (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()
}
+1
View File
@@ -6,6 +6,7 @@
#![forbid(unsafe_code)]
mod assembly;
mod clipboard;
mod document;
mod eval;
pub mod expr;
+10
View File
@@ -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 { .. }
+156
View File
@@ -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<Value> {
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<solvecraft_doc::Feature> = s.doc.features.iter().filter(|f| ids.contains(&f.id)).cloned().collect();
let names: Vec<String> = 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<Value> {
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<u64, u64> = BTreeMap::new();
let mut names: BTreeMap<String, String> = 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<Value> = 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());
}
}
+6 -6
View File
@@ -154,7 +154,7 @@ pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
}
}
fn operation(p: &Value, cmd: &str) -> Result<Operation> {
pub(super) fn operation(p: &Value, cmd: &str) -> Result<Operation> {
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<Operation> {
}
/// The sketch a feature uses: `sketch` param, the active sketch, or the last sketch.
fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
pub(super) fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
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<u64> {
.ok_or_else(|| bad(cmd, "there is no sketch to use (create one first)"))
}
fn profiles(p: &Value, cmd: &str) -> Result<ProfileSel> {
pub(super) fn profiles(p: &Value, cmd: &str) -> Result<ProfileSel> {
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<ProfileSel> {
}
}
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<Value> {
pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Result<Value> {
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<Value> {
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<u64> {
pub(super) fn sketch_id(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result<u64> {
let key = match v {
Some(Value::Number(n)) => n.to_string(),
Some(Value::String(x)) => x.clone(),
+164
View File
@@ -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<Vec<String>> {
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<Value> {
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<Value> {
rib_like(s, p, "FusionRibCommand", false)
}
fn web(s: &mut Session, p: &Value) -> Result<Value> {
rib_like(s, p, "FusionWebCommand", true)
}
fn emboss(s: &mut Session, p: &Value) -> Result<Value> {
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<Vec<Vec3>> {
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<Value> {
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<Value> {
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<Vec3>)> {
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<Value> {
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;
@@ -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);
}
+4
View File
@@ -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());
+4
View File
@@ -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<u64, solvecraft_doc::Mat>,
world_cache: std::sync::Mutex<Option<(u64, Arc<solvecraft_doc::ModelState>)>>,
/// Features copied with `timeline.copy` (pasted by `timeline.paste`).
pub clipboard: Vec<solvecraft_doc::Feature>,
}
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(),
}
}