Imported looks outlive later features: a body's imported colour, opacity and face colours (kernel paint, dropped by operations) are carried to the next version of the body, its faces by persistent name (split faces keep the colour, new faces take the body's); assigned face appearances already follow their faces by name
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
2ea9955621
commit
9ee8ba5dd7
12 files changed
+120
-45
No files matched your search
@@ -47,6 +47,33 @@ pub struct FaceLook {
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// The look a body was imported with, carried through later features (which build new kernel
|
||||
/// bodies without it): its colour and opacity, and its faces' colours by persistent face name.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ImportedLook {
|
||||
pub color: Option<[f32; 3]>,
|
||||
pub opacity: Option<f32>,
|
||||
pub faces: BTreeMap<String, ([f32; 3], f32)>,
|
||||
}
|
||||
|
||||
impl ImportedLook {
|
||||
/// A body's imported look: its own kernel paint, else what it carries.
|
||||
pub fn of(b: &crate::ModelBody) -> Option<std::sync::Arc<ImportedLook>> {
|
||||
if b.body.paint().is_none() && b.body.color().is_none() {
|
||||
return b.imported.clone();
|
||||
}
|
||||
let names = crate::naming::face_names(b);
|
||||
let faces = b
|
||||
.body
|
||||
.paint()
|
||||
.map(|p| {
|
||||
p.faces.iter().filter_map(|f| names.get(f.face).map(|n| (crate::naming::strip_piece(n).to_string(), (f.color, f.opacity)))).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Some(std::sync::Arc::new(ImportedLook { color: b.body.color(), opacity: b.body.paint().map(|p| p.opacity), faces }))
|
||||
}
|
||||
}
|
||||
|
||||
/// The design's appearances.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Appearances {
|
||||
@@ -126,15 +153,20 @@ impl Document {
|
||||
/// assignment to the same face wins.
|
||||
pub fn face_colors(&self, b: &crate::ModelBody) -> Vec<(usize, Look)> {
|
||||
// Face colours the body was imported with, under the design's own.
|
||||
let mut out: Vec<(usize, Look)> = b
|
||||
.body
|
||||
.paint()
|
||||
.map(|p| {
|
||||
let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
p.faces.iter().map(|f| (f.face, Look::custom(f.color.map(to8), f.opacity as f64))).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let names = crate::naming::face_names(b);
|
||||
let mut out: Vec<(usize, Look)> = match b.body.paint() {
|
||||
Some(p) => p.faces.iter().map(|f| (f.face, Look::custom(f.color.map(to8), f.opacity as f64))).collect(),
|
||||
// Carried from an earlier version of the body: by face name.
|
||||
None => match &b.imported {
|
||||
Some(imp) => names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, n)| imp.faces.get(crate::naming::strip_piece(n)).map(|(c, o)| (i, Look::custom(c.map(to8), *o as f64))))
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
},
|
||||
};
|
||||
for f in self.appearances.faces.iter().filter(|f| f.body == b.name) {
|
||||
let (p, look) = (f.point, f.look.clone());
|
||||
// By name (every piece of a split face), else by the point.
|
||||
|
||||
@@ -1329,7 +1329,7 @@ impl Document {
|
||||
/// else its component's, else its material's look, else the colour it was imported with.
|
||||
/// (Faces may have their own: `face_looks`.)
|
||||
pub fn body_color(&self, b: &crate::ModelBody) -> Option<[f32; 3]> {
|
||||
self.body_look(&b.name, b.feature).map(|l| l.rgb()).or_else(|| b.body.color())
|
||||
self.body_look(&b.name, b.feature).map(|l| l.rgb()).or_else(|| b.body.color()).or_else(|| b.imported.as_ref().and_then(|i| i.color))
|
||||
}
|
||||
|
||||
/// The model with each body carrying the colour, opacity and face colours it is shown with
|
||||
@@ -1344,7 +1344,11 @@ impl Document {
|
||||
.into_iter()
|
||||
.map(|(face, l)| solvecraft_kernel::FacePaint { face, color: l.rgb(), opacity: l.opacity as f32 })
|
||||
.collect();
|
||||
let opacity = look.map(|l| l.opacity as f32).or_else(|| b.body.paint().map(|p| p.opacity)).unwrap_or(1.0);
|
||||
let opacity = look
|
||||
.map(|l| l.opacity as f32)
|
||||
.or_else(|| b.body.paint().map(|p| p.opacity))
|
||||
.or_else(|| b.imported.as_ref().and_then(|i| i.opacity))
|
||||
.unwrap_or(1.0);
|
||||
let paint = solvecraft_kernel::Paint { opacity, faces };
|
||||
if c != b.body.color() || b.body.paint() != Some(&paint) {
|
||||
b.body = b.body.clone().with_color(c).with_paint(Some(paint));
|
||||
|
||||
+10
-1
@@ -27,11 +27,13 @@ pub struct ModelBody {
|
||||
pub(crate) names: crate::naming::NameCell,
|
||||
/// The component the body belongs to (set after each feature).
|
||||
pub component: u64,
|
||||
/// The look it was imported with, carried through later features.
|
||||
pub imported: Option<Arc<crate::appearance::ImportedLook>>,
|
||||
}
|
||||
|
||||
impl ModelBody {
|
||||
pub fn new(name: String, body: Body, feature: u64) -> Self {
|
||||
ModelBody { name, body, feature, mesh: OnceLock::new(), key: 0, names: Default::default(), component: 0 }
|
||||
ModelBody { name, body, feature, mesh: OnceLock::new(), key: 0, names: Default::default(), component: 0, imported: None }
|
||||
}
|
||||
/// Display mesh (chord tolerance 1/1000 of the body size), computed once.
|
||||
pub fn mesh(&self) -> Arc<Mesh> {
|
||||
@@ -236,6 +238,13 @@ impl ModelState {
|
||||
(key, &b.name).hash(&mut h);
|
||||
b.key = h.finish();
|
||||
b.names = crate::naming::NameCell::new(Some(origin.clone()));
|
||||
// An imported look outlives the operations on the body.
|
||||
if b.body.paint().is_none()
|
||||
&& b.body.color().is_none()
|
||||
&& let Some(old) = before.bodies.iter().find(|x| x.name == b.name)
|
||||
{
|
||||
b.imported = crate::appearance::ImportedLook::of(old);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,8 @@ fn contact_sets_stop_a_driven_joint_at_touching() {
|
||||
#[test]
|
||||
fn motion_study_plays_and_exports() {
|
||||
let mut s = rail();
|
||||
let r = run(&mut s, "motion.study", json!({"name": "Push", "steps": 100, "keys": [{"joint": "Rail", "points": [[0, 10], [100, -5]]}]}));
|
||||
let r =
|
||||
run(&mut s, "motion.study", json!({"name": "Push", "steps": 100, "keys": [{"joint": "Rail", "points": [[0, 10], [100, -5]]}]}));
|
||||
assert_eq!(r["keys"], 1);
|
||||
run(&mut s, "motion.play", json!({"study": "Push", "step": 50}));
|
||||
assert!((b_x(&s) - 12.5).abs() < 1e-6, "{}", b_x(&s));
|
||||
|
||||
@@ -136,9 +136,7 @@ fn patterns_of_a_blind_hole_drill_every_copy() {
|
||||
};
|
||||
let r = patterned(("solid.pattern.rectangular", json!({"features": ["Hole1"], "dir1": [1, 0, 0], "count1": 3, "spacing1": "15 mm"})));
|
||||
assert!((r - 3.0 * one).abs() < 1e-3 * one, "{r} vs 3 × {one}");
|
||||
let c = patterned((
|
||||
"solid.pattern.circular",
|
||||
json!({"features": ["Hole1"], "axis": {"origin": [25, 12, 0], "dir": [0, 0, 1]}, "count": 2, "angle": 360}),
|
||||
));
|
||||
let c =
|
||||
patterned(("solid.pattern.circular", json!({"features": ["Hole1"], "axis": {"origin": [25, 12, 0], "dir": [0, 0, 1]}, "count": 2, "angle": 360})));
|
||||
assert!((c - 2.0 * one).abs() < 1e-3 * one, "{c} vs 2 × {one}");
|
||||
}
|
||||
@@ -1076,24 +1076,16 @@ fn c_equal(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
constrain(s, p, "sketch.constraint.equal", |sk| two_curves(sk, p, "sketch.constraint.equal").map(|(a, b)| ConstraintKind::Equal { a, b }))
|
||||
}
|
||||
fn c_parallel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
constrain(s, p, "sketch.constraint.parallel", |sk| {
|
||||
two_curves(sk, p, "sketch.constraint.parallel").map(|(a, b)| ConstraintKind::Parallel { a, b })
|
||||
})
|
||||
constrain(s, p, "sketch.constraint.parallel", |sk| two_curves(sk, p, "sketch.constraint.parallel").map(|(a, b)| ConstraintKind::Parallel { a, b }))
|
||||
}
|
||||
fn c_perpendicular(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
constrain(s, p, "sketch.constraint.perpendicular", |sk| {
|
||||
two_curves(sk, p, "sketch.constraint.perpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b })
|
||||
})
|
||||
constrain(s, p, "sketch.constraint.perpendicular", |sk| two_curves(sk, p, "sketch.constraint.perpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b }))
|
||||
}
|
||||
fn c_concentric(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
constrain(s, p, "sketch.constraint.concentric", |sk| {
|
||||
two_curves(sk, p, "sketch.constraint.concentric").map(|(a, b)| ConstraintKind::Concentric { a, b })
|
||||
})
|
||||
constrain(s, p, "sketch.constraint.concentric", |sk| two_curves(sk, p, "sketch.constraint.concentric").map(|(a, b)| ConstraintKind::Concentric { a, b }))
|
||||
}
|
||||
fn c_collinear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
constrain(s, p, "sketch.constraint.collinear", |sk| {
|
||||
two_curves(sk, p, "sketch.constraint.collinear").map(|(a, b)| ConstraintKind::Collinear { a, b })
|
||||
})
|
||||
constrain(s, p, "sketch.constraint.collinear", |sk| two_curves(sk, p, "sketch.constraint.collinear").map(|(a, b)| ConstraintKind::Collinear { a, b }))
|
||||
}
|
||||
fn c_midpoint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.constraint.midpoint";
|
||||
|
||||
@@ -54,8 +54,7 @@ fn curvature_polygon_arc_length_linear_diameter() {
|
||||
// A horizontal line ending where a control-point spline starts: G2 keeps the spline's
|
||||
// start straight (zero curvature) and tangent.
|
||||
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[-10, 0], [0, 0]], "infer": true}))["curves"])[0].clone();
|
||||
let sp =
|
||||
ids(&run(&mut s, "sketch.spline.control_point", json!({"points": [format!("{l}.end"), [5, 2], [10, 6], [15, 15]]}))["curves"])[0].clone();
|
||||
let sp = ids(&run(&mut s, "sketch.spline.control_point", json!({"points": [format!("{l}.end"), [5, 2], [10, 6], [15, 15]]}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.constraint.curvature", json!({"a": l, "b": sp}));
|
||||
let sk = sketch(&s);
|
||||
let pts: Vec<Vec2> = sk.curves[sk.curve_index(&sp).unwrap()].kind.point_ids().iter().map(|i| sk.point(*i).unwrap()).collect();
|
||||
|
||||
@@ -348,9 +348,7 @@ fn degree_five_and_cut_free_form_profiles_extrude_exactly() {
|
||||
let vol = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
// A degree-5 control spline closed by a line: one B-spline side face.
|
||||
let mut s = new_sketch();
|
||||
let sp =
|
||||
ids(&run(&mut s, "sketch.spline.control_point_5", json!({"points": [[0, 0], [4, 10], [10, 12], [16, 12], [22, 10], [26, 0]]}))["curves"])[0]
|
||||
.clone();
|
||||
let sp = ids(&run(&mut s, "sketch.spline.control_point_5", json!({"points": [[0, 0], [4, 10], [10, 12], [16, 12], [22, 10], [26, 0]]}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.line", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
|
||||
let a = profiles(&s)[0];
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
@@ -429,8 +427,7 @@ fn fillet_a_line_and_an_arc() {
|
||||
let mut s = new_sketch();
|
||||
// A line into a quarter arc meeting at a sharp corner.
|
||||
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
|
||||
let a =
|
||||
ids(&run(&mut s, "sketch.arc.center_point", json!({"center": [30, 0], "start": format!("{l}.end"), "end": [30, -10]}))["curves"])[0].clone();
|
||||
let a = ids(&run(&mut s, "sketch.arc.center_point", json!({"center": [30, 0], "start": format!("{l}.end"), "end": [30, -10]}))["curves"])[0].clone();
|
||||
let _ = a;
|
||||
let f = run(&mut s, "sketch.fillet", json!({"point": format!("{l}.end"), "radius": 2}));
|
||||
let arc = ids(&f["curves"])[0].clone();
|
||||
|
||||
@@ -146,7 +146,11 @@ mod tests {
|
||||
assert!((v - 240.0).abs() < 1e-9, "{v}");
|
||||
// Stitch the patch of a box's open top back on.
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cup"}));
|
||||
run(&mut s, "surface.patch", json!({"edges": [[25, 0, 10], [30, 5, 10], [25, 10, 10], [20, 5, 10]], "body": "Cup", "body_name": "Lid"}));
|
||||
run(
|
||||
&mut s,
|
||||
"surface.patch",
|
||||
json!({"edges": [[25, 0, 10], [30, 5, 10], [25, 10, 10], [20, 5, 10]], "body": "Cup", "body_name": "Lid"}),
|
||||
);
|
||||
assert!(s.world_state().body("Lid").unwrap().body.is_surface());
|
||||
assert!(s.execute("surface.thicken", &json!({"bodies": ["Nope"], "thickness": 1})).is_err());
|
||||
assert!(s.execute("surface.trim", &json!({"body": "Sheet"})).is_err());
|
||||
|
||||
@@ -69,7 +69,9 @@ fn draft(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn accessibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dir = match p.get("direction") {
|
||||
Some(v) => vec3(v).and_then(Vec3::normalized).ok_or_else(|| bad("inspect.accessibility", "`direction` must be a non-zero [x,y,z]"))?,
|
||||
Some(v) => {
|
||||
vec3(v).and_then(Vec3::normalized).ok_or_else(|| bad("inspect.accessibility", "`direction` must be a non-zero [x,y,z]"))?
|
||||
}
|
||||
None => Vec3::Z,
|
||||
};
|
||||
set(s, p, SurfaceAnalysis::Access { dir })
|
||||
|
||||
@@ -61,7 +61,9 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
|
||||
"sketch.intersect" => &[("refs", Picks)],
|
||||
"sketch.project_to_surface" | "sketch.isoparametric_curve" => &[("face", Pick), ("body", Body)],
|
||||
"sketch.intersection_curve" => &[("a", Picks), ("b", Picks)],
|
||||
"inspect.curvature_comb" | "inspect.minimum_radius" | "inspect.isocurve" => &[("edges", Picks), ("faces", Picks)],
|
||||
"inspect.curvature_comb" | "inspect.minimum_radius" | "inspect.isocurve" => {
|
||||
&[("edges", Picks), ("faces", Picks)]
|
||||
}
|
||||
"parts.insert" | "parts.fastener" => &[("at", Pick), ("point", Make), ("direction", Dir)],
|
||||
"appearance.assign" => &[("faces", Picks), ("bodies", Body), ("body", Body)],
|
||||
"solid.move" => &[("bodies", Body), ("translate", Dir), ("axis", Dir), ("origin", Make)],
|
||||
|
||||
@@ -299,7 +299,10 @@ fn hostile_params_never_panic() {
|
||||
];
|
||||
let mut internal = Vec::new();
|
||||
for spec in command_specs() {
|
||||
if matches!(spec.id, "doc.open" | "file.save" | "file.save_as" | "file.export" | "file.save_mesh" | "sketch.export_dxf") {
|
||||
if matches!(
|
||||
spec.id,
|
||||
"doc.open" | "file.save" | "file.save_as" | "file.export" | "file.save_mesh" | "sketch.export_dxf"
|
||||
) {
|
||||
continue; // file system side effects are covered by their own tests
|
||||
}
|
||||
for v in hostile_values() {
|
||||
@@ -402,7 +405,11 @@ fn timeline_edits_reresolve_references() {
|
||||
assert_eq!(names, ["Base", "Bore", "Round"]);
|
||||
assert!(rel(volume(&mut s), expect(60.0, 25.0)) < 1e-3);
|
||||
// Redefine the fillet in place with a new radius; it keeps its name and position.
|
||||
run(&mut s, "timeline.redefine", json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[30, 0, 25]], "radius": 4}}));
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[30, 0, 25]], "radius": 4}}),
|
||||
);
|
||||
let f4 = (16.0 - 16.0 * PI / 4.0) * 60.0;
|
||||
assert!(rel(volume(&mut s), 60.0 * 30.0 * 25.0 - f4 - PI * 9.0 * 25.0) < 1e-3);
|
||||
assert_eq!(s.doc.features.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["Base", "Bore", "Round"]);
|
||||
@@ -1110,11 +1117,7 @@ fn face_extrude_follows_edits() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
|
||||
let before = volume(&mut s);
|
||||
run(
|
||||
&mut s,
|
||||
"solid.revolve",
|
||||
json!({"face": [40, 15, 10], "axis": {"origin": [40, 0, 30], "dir": [0, 1, 0]}, "angle": "90 deg", "operation": "new"}),
|
||||
);
|
||||
run(&mut s, "solid.revolve", json!({"face": [40, 15, 10], "axis": {"origin": [40, 0, 30], "dir": [0, 1, 0]}, "angle": "90 deg", "operation": "new"}));
|
||||
let added = volume(&mut s) - before;
|
||||
let want = 30.0 * 20.0 * 20.0 * PI / 2.0;
|
||||
assert!(rel(added, want) < 1e-4, "{added} vs {want}");
|
||||
@@ -1619,4 +1622,36 @@ fn hole_as_deep_as_the_plate() {
|
||||
assert!(r.is_ok(), "{i} {p}: {r:?}");
|
||||
assert!(volume(&mut s) < before - 1.0, "{i} {p}");
|
||||
}
|
||||
/// Appearances outlive later features: a coloured face keeps its colour after a fillet on a
|
||||
/// neighbouring edge; an imported STEP's body and face colours survive a hole.
|
||||
#[test]
|
||||
fn appearances_survive_later_features() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "B"}));
|
||||
run(&mut s, "appearance.assign", json!({"faces": [[20, 15, 20]], "color": "#ff0000"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[40, 15, 0]], "radius": 3}));
|
||||
let red = |s: &mut Session, body: &str| {
|
||||
let l = run(s, "appearance.list", json!({ "body": body }));
|
||||
l["body"]["faces"].as_array().into_iter().flatten().any(|f| f["appearance"]["color"] == "#ff0000")
|
||||
};
|
||||
assert!(red(&mut s, "B"), "the top face is still red after the fillet");
|
||||
// Exported with a body colour and the red face, then opened: the colours come in as the
|
||||
// body's own; a hole afterwards keeps them.
|
||||
run(&mut s, "appearance.assign", json!({"bodies": ["B"], "color": "#2050c0"}));
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-paint-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let step = dir.join("painted.step");
|
||||
run(&mut s, "file.export", json!({"path": step.to_string_lossy()}));
|
||||
let mut t = Session::default();
|
||||
run(&mut t, "doc.open", json!({"path": step.to_string_lossy()}));
|
||||
let body = t.model.state().bodies[0].name.clone();
|
||||
let before = t.doc.body_color(&t.model.state().bodies[0]);
|
||||
assert!(before.is_some());
|
||||
run(&mut t, "solid.hole", json!({"position": [10, 10, 20], "diameter": 4}));
|
||||
let st = t.model.state();
|
||||
let b = st.body(&body).unwrap();
|
||||
assert!(b.body.paint().is_none(), "the hole made a new kernel body");
|
||||
assert_eq!(t.doc.body_color(b), before, "the imported body colour carries on");
|
||||
assert!(red(&mut t, &body), "the imported red face carries on");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
Reference in new issue
Block a user