Appearances on faces, bodies and components (face > body > component > material > imported colour), with names and opacity and a built-in library of 20 (metals, ABS colours, glass, rubber, wood, paint); AppearanceCommand takes bodies, faces (points) or components, a library name or a colour with opacity, or nothing to clear; appearance.library, appearance.list (a body's resolved look and per-face colours); file format 3 turns the old body colour map into body appearances

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 11:37:33 +00:00
1 parent f413a86d3d
commit a283e424d9
8 files changed
+422 -30

No files matched your search

+201
View File
@@ -0,0 +1,201 @@
//! Appearances: how bodies, faces and components look (colour and opacity, with a name), kept
//! in the design. A face's appearance wins over its body's, a body's over its component's, and
//! a component's over the physical material's look; a body with none of these keeps the colour
//! it was imported with.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use solvecraft_geom::Vec3;
use crate::Document;
/// A look: a name (a library entry's, or "Custom"), sRGB colour and opacity (1 = opaque).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Look {
pub name: String,
pub color: [u8; 3],
#[serde(default = "opaque")]
pub opacity: f64,
}
fn opaque() -> f64 {
1.0
}
impl Look {
pub fn custom(color: [u8; 3], opacity: f64) -> Look {
Look { name: "Custom".into(), color, opacity: opacity.clamp(0.0, 1.0) }
}
/// Colour as 0–1 fractions.
pub fn rgb(&self) -> [f32; 3] {
self.color.map(|x| x as f32 / 255.0)
}
pub fn hex(&self) -> String {
format!("#{:02x}{:02x}{:02x}", self.color[0], self.color[1], self.color[2])
}
}
/// A face's appearance: the face of `body` through `point` (world, on the face).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FaceLook {
pub body: String,
pub point: Vec3,
pub look: Look,
}
/// The design's appearances.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Appearances {
/// By body name.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub bodies: BTreeMap<String, Look>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub faces: Vec<FaceLook>,
/// By component id.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub components: BTreeMap<u64, Look>,
}
impl Appearances {
pub fn is_empty(&self) -> bool {
self.bodies.is_empty() && self.faces.is_empty() && self.components.is_empty()
}
}
/// The built-in appearance library: (name, colour, opacity).
pub const LIBRARY: [(&str, [u8; 3], f64); 20] = [
("Aluminum - Satin", [204, 208, 214], 1.0),
("Aluminum - Anodized Black", [44, 46, 50], 1.0),
("Aluminum - Anodized Blue", [52, 92, 160], 1.0),
("Steel - Satin", [158, 164, 172], 1.0),
("Stainless Steel - Brushed", [186, 191, 198], 1.0),
("Cast Iron", [104, 104, 110], 1.0),
("Brass - Polished", [208, 172, 88], 1.0),
("Copper", [204, 122, 80], 1.0),
("Titanium", [150, 148, 158], 1.0),
("ABS - White", [236, 236, 230], 1.0),
("ABS - Black", [36, 36, 38], 1.0),
("ABS - Red", [196, 40, 40], 1.0),
("ABS - Blue", [40, 90, 186], 1.0),
("ABS - Yellow", [236, 196, 40], 1.0),
("ABS - Green", [52, 150, 80], 1.0),
("Rubber - Black", [28, 28, 28], 1.0),
("Glass - Clear", [190, 220, 228], 0.25),
("Glass - Smoked", [90, 96, 104], 0.45),
("Wood - Oak", [190, 146, 92], 1.0),
("Paint - Gloss White", [246, 246, 244], 1.0),
];
/// A library appearance by name (case-insensitive).
pub fn library(name: &str) -> Option<Look> {
LIBRARY.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim())).map(|(n, c, o)| Look { name: n.to_string(), color: *c, opacity: *o })
}
/// The look a physical material gives a body that has no appearance of its own.
pub fn material_look(material: &str) -> Option<Look> {
let color = crate::material_color(material)?;
let opacity = if material == "Glass" { 0.25 } else { 1.0 };
Some(Look { name: material.to_string(), color, opacity })
}
impl Document {
/// A body's look (without face appearances): its own, else its component's, else its
/// material's.
pub fn body_look(&self, name: &str, feature: u64) -> Option<Look> {
self.appearances
.bodies
.get(name)
.cloned()
.or_else(|| {
let c = self.body_component(name, feature);
(c != 0).then(|| self.appearances.components.get(&c).cloned()).flatten()
})
.or_else(|| self.materials.get(name).and_then(|m| material_look(m)))
}
/// Face appearances on a body: (point on the face, look).
pub fn face_looks(&self, body: &str) -> Vec<(Vec3, Look)> {
self.appearances.faces.iter().filter(|f| f.body == body).map(|f| (f.point, f.look.clone())).collect()
}
/// The faces of a body with appearances of their own: (B-rep face index, look). A later
/// assignment to the same face wins.
pub fn face_colors(&self, b: &crate::ModelBody) -> Vec<(usize, Look)> {
let mut out: Vec<(usize, Look)> = Vec::new();
for (p, look) in self.face_looks(&b.name) {
if let Some(i) = face_index_at(b, p) {
out.retain(|(j, _)| *j != i);
out.push((i, look));
}
}
out
}
}
/// The B-rep face of a body nearest a point (within 5% of the body's size).
pub fn face_index_at(b: &crate::ModelBody, p: Vec3) -> Option<usize> {
let m = b.mesh();
let mut best: Option<(f64, u32)> = None;
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let Some([a, bb, c]) = m.tri(t) else { continue };
let d = closest_on_triangle(p, a, bb, c).dist(p);
if best.is_none_or(|(x, _)| d < x) {
best = Some((d, *f));
}
}
let (d, f) = best?;
(d <= b.body.size() * 0.05 + 1e-6).then_some(f as usize)
}
/// The point of triangle abc nearest p.
fn closest_on_triangle(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
let (ab, ac, ap) = (b - a, c - a, p - a);
let (d1, d2) = (ab.dot(ap), ac.dot(ap));
if d1 <= 0.0 && d2 <= 0.0 {
return a;
}
let bp = p - b;
let (d3, d4) = (ab.dot(bp), ac.dot(bp));
if d3 >= 0.0 && d4 <= d3 {
return b;
}
let vc = d1 * d4 - d3 * d2;
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
return a + ab * (d1 / (d1 - d3));
}
let cp = p - c;
let (d5, d6) = (ab.dot(cp), ac.dot(cp));
if d6 >= 0.0 && d5 <= d6 {
return c;
}
let vb = d5 * d2 - d1 * d6;
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
return a + ac * (d2 / (d2 - d6));
}
let va = d3 * d6 - d5 * d4;
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)));
}
let den = va + vb + vc;
if den.abs() < 1e-300 {
return a;
}
a + ab * (vb / den) + ac * (vc / den)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn library_and_materials() {
assert_eq!(library("abs - red").map(|l| l.color), Some([196, 40, 40]));
assert!(library("Glass - Clear").is_some_and(|l| l.opacity < 0.5));
assert!(library("nope").is_none());
assert_eq!(material_look("Brass").map(|l| l.color), crate::material_color("Brass"));
assert!(material_look("Glass").is_some_and(|l| l.opacity < 1.0));
assert_eq!(Look::custom([1, 2, 3], 7.0).opacity, 1.0);
assert_eq!(Look::custom([255, 0, 16], 1.0).hex(), "#ff0010");
}
}
+8 -8
View File
@@ -1070,8 +1070,8 @@ pub struct Document {
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub materials: std::collections::BTreeMap<String, String>,
/// Appearance colour per body (by body name, sRGB); it overrides the material's look.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub appearances: std::collections::BTreeMap<String, [u8; 3]>,
#[serde(default, skip_serializing_if = "crate::appearance::Appearances::is_empty")]
pub appearances: crate::appearance::Appearances,
/// Bodies moved into another component than their feature's (by body name).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub body_components: std::collections::BTreeMap<String, u64>,
@@ -1152,18 +1152,18 @@ impl Document {
}
}
/// The colour a body is shown and exported in (sRGB as 0–1 fractions): its
/// appearance, else its material's look, else the colour it was imported with.
pub fn body_color(&self, name: &str, body: &solvecraft_kernel::Body) -> Option<[f32; 3]> {
let rgb = |c: [u8; 3]| c.map(|x| x as f32 / 255.0);
self.appearances.get(name).copied().or_else(|| self.materials.get(name).and_then(|m| material_color(m))).map(rgb).or_else(|| body.color())
/// The colour a body is shown and exported in (sRGB as 0–1 fractions): its appearance,
/// 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())
}
/// The model with each body carrying the colour it is shown in (for export).
pub fn painted(&self, state: &crate::ModelState) -> crate::ModelState {
let mut st = state.clone();
for b in &mut st.bodies {
let c = self.body_color(&b.name, &b.body);
let c = self.body_color(b);
if c != b.body.color() {
b.body = b.body.clone().with_color(c);
}
+26 -2
View File
@@ -10,13 +10,16 @@
//! have defaults; designs from before feature input names and before occurrences are filled
//! in on upgrade.
//! - 2: the same shape; files say 2 once they have been upgraded, so later readers can tell.
//! - 3: appearances per body, face and component with names and opacity
//! (`"appearances": {"bodies": {name: {name, color, opacity}}, "faces": […], "components": {…}}`);
//! before, `"appearances"` mapped body names to bare `[r, g, b]` colours.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 2;
pub const FORMAT: u32 = 3;
pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}")
@@ -29,7 +32,21 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 1] = [|_| Ok(())];
const JSON_STEPS: [JsonStep; 2] = [|_| Ok(()), v2_to_v3];
/// Body colours `{name: [r, g, b]}` become named, opaque body appearances.
fn v2_to_v3(v: &mut Value) -> Result<()> {
let Some(Value::Object(old)) = v.get("appearances") else { return Ok(()) };
if !old.values().all(Value::is_array) {
return Ok(());
}
let bodies: serde_json::Map<String, Value> =
old.iter().map(|(k, c)| (k.clone(), serde_json::json!({"name": "Custom", "color": c, "opacity": 1.0}))).collect();
if let Some(o) = v.as_object_mut() {
o.insert("appearances".into(), serde_json::json!({ "bodies": bodies }));
}
Ok(())
}
/// Read a design file's JSON, upgrading older formats.
pub fn read(s: &str) -> Result<Document> {
@@ -160,6 +177,13 @@ mod tests {
assert!(read(&v.to_string()).is_err(), "{bad}");
}
assert!(read("[]").is_err());
// Format 2 body colours become body appearances.
let mut old: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
old["format"] = Value::from("solvecraft/2");
old["appearances"] = serde_json::json!({"Body1": [200, 10, 20]});
let d = read(&old.to_string()).unwrap();
let l = d.appearances.bodies.get("Body1").unwrap();
assert_eq!((l.color, l.opacity, l.name.as_str()), ([200, 10, 20], 1.0, "Custom"));
let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
v["huge"] = Value::from(1e300);
assert!(read(&v.to_string()).is_err(), "out-of-range numbers anywhere");
+1
View File
@@ -5,6 +5,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod appearance;
mod assembly;
pub mod canvas;
mod clipboard;
+5 -2
View File
@@ -278,8 +278,11 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(m) = d.materials.remove(&old) {
d.materials.insert(name.to_string(), m);
}
if let Some(c) = d.appearances.remove(&old) {
d.appearances.insert(name.to_string(), c);
if let Some(c) = d.appearances.bodies.remove(&old) {
d.appearances.bodies.insert(name.to_string(), c);
}
for f in d.appearances.faces.iter_mut().filter(|f| f.body == old) {
f.body = name.to_string();
}
if let Some(c) = d.body_components.remove(&old) {
d.body_components.insert(name.to_string(), c);
+135 -14
View File
@@ -23,11 +23,17 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("material")
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …; `material.list`)"),
CommandSpec::new("AppearanceCommand", "Appearance", appearance)
.at("SOLID", "MODIFY")
.icon("appearance")
.key("A")
.params("bodies: [names]; color: \"#rrggbb\" or [r, g, b] (0–255), or null to follow the material"),
CommandSpec::new("AppearanceCommand", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params(
"bodies?: [names]; faces?: [[x,y,z] on faces] (body?: which body, default the nearest); components?: [names or ids, or occurrence names]; \
appearance?: library name (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \
neither (or color: null): clear, so the face follows its body, the body its component, the component its material",
),
CommandSpec::new("appearance.library", "Appearance Library", appearance_library)
.noundo()
.params("→ the built-in appearances (name, colour, opacity)"),
CommandSpec::new("appearance.list", "List Appearances", appearance_list)
.noundo()
.params("body?: name → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
CommandSpec::new("material.list", "List Materials", material_list).noundo(),
CommandSpec::new("engine.commands", "List Commands", commands).noundo(),
];
@@ -292,23 +298,138 @@ fn color_param(v: &Value) -> Option<[u8; 3]> {
}
fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
use solvecraft_doc::appearance::{FaceLook, Look, library};
let cmd = "AppearanceCommand";
let look: Option<Look> = match (str_(p, "appearance"), p.get("color")) {
(Some(n), _) => {
let mut l = library(n).ok_or_else(|| bad(cmd, format!("no appearance `{n}` in the library (appearance.library)")))?;
if let Some(o) = p.get("opacity").and_then(Value::as_f64).filter(|x| x.is_finite()) {
l.opacity = o.clamp(0.0, 1.0);
}
Some(l)
}
(None, None | Some(Value::Null)) => None,
(None, Some(v)) => {
let c = color_param(v).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?;
let o = match p.get("opacity") {
Some(o) => o.as_f64().filter(|x| x.is_finite() && (0.0..=1.0).contains(x)).ok_or_else(|| bad(cmd, "`opacity` must be 0…1"))?,
None => 1.0,
};
Some(Look::custom(c, o))
}
};
let bodies = string_list(p, "bodies");
let st = s.model.state();
if bodies.is_empty() || bodies.iter().any(|b| st.body(b).is_none()) {
if bodies.iter().any(|b| st.body(b).is_none()) {
return Err(bad(cmd, "`bodies` must list existing bodies"));
}
let color = match p.get("color") {
None | Some(Value::Null) => None,
Some(v) => Some(color_param(v).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?),
};
// Faces: a point on each, on the named body or the nearest one.
let mut faces: Vec<(String, solvecraft_geom::Vec3)> = Vec::new();
if let Some(list) = p.get("faces") {
let list = list.as_array().filter(|a| a.len() <= 10_000).ok_or_else(|| bad(cmd, "`faces` must list points [x, y, z]"))?;
for v in list {
let pt = crate::params::vec3(v)
.or_else(|| v.get("point").and_then(crate::params::vec3))
.ok_or_else(|| bad(cmd, "`faces` must list points [x, y, z]"))?;
let on = |b: &solvecraft_doc::ModelBody| solvecraft_doc::appearance::face_index_at(b, pt).is_some();
let body = match str_(p, "body") {
Some(n) => st.body(n).filter(|b| on(b)).map(|b| b.name.clone()),
None => st.bodies.iter().filter(|b| on(b)).min_by(|a, b| dist_to(a, pt).total_cmp(&dist_to(b, pt))).map(|b| b.name.clone()),
};
faces.push((body.ok_or_else(|| bad(cmd, format!("no face at {pt:?}")))?, pt));
}
}
let mut comps: Vec<u64> = Vec::new();
for c in string_list(p, "components") {
let id = s
.doc
.find_component(&c)
.or_else(|| s.doc.occurrences.iter().find(|o| o.name == c).map(|o| o.component))
.filter(|id| *id != 0)
.ok_or_else(|| bad(cmd, format!("no component `{c}`")))?;
comps.push(id);
}
if bodies.is_empty() && faces.is_empty() && comps.is_empty() {
return Err(bad(cmd, "give `bodies`, `faces` or `components`"));
}
let a = &mut s.doc_mut().appearances;
for b in &bodies {
match color {
Some(c) => s.doc_mut().appearances.insert(b.clone(), c),
None => s.doc_mut().appearances.remove(b),
match &look {
Some(l) => a.bodies.insert(b.clone(), l.clone()),
None => a.bodies.remove(b),
};
}
Ok(json!({"bodies": bodies, "color": color.map(|c| format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]))}))
for (body, pt) in &faces {
// One entry per face: a new one replaces any at the same spot.
a.faces.retain(|f| !(f.body == *body && f.point.dist(*pt) < 1e-9));
if let Some(l) = &look {
if a.faces.len() >= 100_000 {
return Err(bad(cmd, "too many face appearances"));
}
a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone() });
}
}
for c in &comps {
match &look {
Some(l) => a.components.insert(*c, l.clone()),
None => a.components.remove(c),
};
}
// Clearing a face: drop every entry on that face, not just at the same point.
if look.is_none() && !faces.is_empty() {
let st = s.model.state();
let doc = s.doc_mut();
let picked: Vec<(String, Option<usize>)> =
faces.iter().map(|(b, pt)| (b.clone(), st.body(b).and_then(|mb| solvecraft_doc::appearance::face_index_at(mb, *pt)))).collect();
doc.appearances.faces.retain(|f| {
let idx = st.body(&f.body).and_then(|mb| solvecraft_doc::appearance::face_index_at(mb, f.point));
!picked.iter().any(|(b, i)| *b == f.body && i.is_some() && *i == idx)
});
}
Ok(json!({
"bodies": bodies,
"faces": faces.iter().map(|(b, p)| json!({"body": b, "point": p})).collect::<Vec<_>>(),
"components": comps,
"appearance": look.as_ref().map(|l| json!({"name": l.name, "color": l.hex(), "opacity": l.opacity})),
}))
}
fn dist_to(b: &solvecraft_doc::ModelBody, p: solvecraft_geom::Vec3) -> f64 {
let bb = b.mesh().bounds();
let c = solvecraft_geom::Vec3::new(p.x.clamp(bb.min.x, bb.max.x), p.y.clamp(bb.min.y, bb.max.y), p.z.clamp(bb.min.z, bb.max.z));
c.dist(p)
}
fn look_json(l: &solvecraft_doc::appearance::Look) -> Value {
json!({"name": l.name, "color": l.hex(), "opacity": l.opacity})
}
fn appearance_library(_s: &mut Session, _p: &Value) -> Result<Value> {
let list: Vec<Value> = solvecraft_doc::appearance::LIBRARY
.iter()
.map(|(n, c, o)| json!({"name": n, "color": format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]), "opacity": o}))
.collect();
Ok(json!({"appearances": list}))
}
fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
let a = &s.doc.appearances;
let mut out = json!({
"bodies": a.bodies.iter().map(|(b, l)| (b.clone(), look_json(l))).collect::<serde_json::Map<_, _>>(),
"faces": a.faces.iter().map(|f| json!({"body": f.body, "point": f.point, "appearance": look_json(&f.look)})).collect::<Vec<_>>(),
"components": a.components.iter().map(|(c, l)| json!({"component": c, "appearance": look_json(l)})).collect::<Vec<_>>(),
});
if let Some(name) = str_(p, "body") {
let st = s.model.state();
let b = st.body(name).ok_or_else(|| bad("appearance.list", format!("no body `{name}`")))?;
out["body"] = json!({
"name": name,
"appearance": s.doc.body_look(&b.name, b.feature).map(|l| look_json(&l)),
"color": s.doc.body_color(b),
"faces": s.doc.face_colors(b).iter().map(|(i, l)| json!({"face": i, "appearance": look_json(l)})).collect::<Vec<_>>(),
});
}
Ok(out)
}
fn material_list(_s: &mut Session, _p: &Value) -> Result<Value> {
+42
View File
@@ -1032,3 +1032,45 @@ fn face_extrude_follows_edits() {
let want = 30.0 * 20.0 * 20.0 * PI / 2.0;
assert!(rel(added, want) < 1e-4, "{added} vs {want}");
}
/// Appearances on faces, bodies and components: face > body > component > material.
#[test]
fn appearances_by_face_body_and_component() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "body_name": "Root"}));
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "Case"}));
run(&mut s, "PrimitiveBox", json!({"corner": [20, 0, 0], "length": 10, "width": 10, "height": 10, "body_name": "Shell"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let lib = run(&mut s, "appearance.library", json!({}));
assert!(lib["appearances"].as_array().is_some_and(|a| a.len() >= 15 && a.iter().any(|x| x["name"] == "Glass - Clear")));
let look = |s: &mut Session, b: &str| run(s, "appearance.list", json!({"body": b}))["body"].clone();
// Material, then component, then body.
run(&mut s, "PhysicalMaterialCommand", json!({"bodies": ["Shell"], "material": "Brass"}));
assert_eq!(look(&mut s, "Shell")["appearance"]["name"], "Brass");
run(&mut s, "AppearanceCommand", json!({"components": ["Case"], "appearance": "ABS - Blue"}));
assert_eq!(look(&mut s, "Shell")["appearance"]["name"], "ABS - Blue");
assert!(look(&mut s, "Root")["appearance"].is_null(), "root bodies have no component look");
run(&mut s, "AppearanceCommand", json!({"bodies": ["Shell"], "color": [255, 0, 0], "opacity": 0.5}));
let l = look(&mut s, "Shell");
assert_eq!((l["appearance"]["color"].as_str(), l["appearance"]["opacity"].as_f64()), (Some("#ff0000"), Some(0.5)), "{l}");
// A face: the top of Shell.
run(&mut s, "AppearanceCommand", json!({"faces": [[25, 5, 10]], "appearance": "Glass - Clear"}));
let l = look(&mut s, "Shell");
let faces = l["faces"].as_array().cloned().unwrap_or_default();
assert_eq!(faces.len(), 1, "{l}");
assert_eq!(faces[0]["appearance"]["name"], "Glass - Clear");
// Clearing goes back down the chain.
run(&mut s, "AppearanceCommand", json!({"faces": [[26, 6, 10]]}));
assert_eq!(look(&mut s, "Shell")["faces"].as_array().map(Vec::len), Some(0), "cleared by another point on the same face");
run(&mut s, "AppearanceCommand", json!({"bodies": ["Shell"], "color": null}));
assert_eq!(look(&mut s, "Shell")["appearance"]["name"], "ABS - Blue");
run(&mut s, "AppearanceCommand", json!({"components": ["Case"]}));
assert_eq!(look(&mut s, "Shell")["appearance"]["name"], "Brass");
// Saved and opened again.
run(&mut s, "AppearanceCommand", json!({"faces": [[5, 5, 10]], "color": "#00ff00"}));
let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(back.appearances, s.doc.appearances);
assert!(s.execute("AppearanceCommand", &json!({"bodies": ["Shell"], "appearance": "Unobtainium"})).is_err());
assert!(s.execute("AppearanceCommand", &json!({"color": "#ffffff"})).is_err(), "no target");
assert!(s.execute("AppearanceCommand", &json!({"faces": [[500, 500, 500]], "color": "#ffffff"})).is_err(), "no face there");
}
+4 -4
View File
@@ -215,7 +215,7 @@ fn build_scene(app: &SolveApp) -> GpuScene {
}
// With a component active, bodies outside it are drawn faded.
let faded = s.active_component != 0 && !s.doc.component_within(s.doc.body_component(&b.name, b.feature), s.active_component);
let base = body_color(&s.doc, &b.name, &b.body).unwrap_or(tk.body);
let base = body_color(&s.doc, b).unwrap_or(tk.body);
let col = if faded { c4(base.gamma_multiply(0.35)) } else { c4(base) };
let m = b.mesh();
let (shaded, edges) = match app.ui.visual_style {
@@ -968,15 +968,15 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
}
/// A body's display colour (appearance, material or imported colour), if it has one.
fn body_color(doc: &solvecraft_engine::doc::Document, name: &str, body: &solvecraft_engine::kernel::Body) -> Option<Color32> {
fn body_color(doc: &solvecraft_engine::doc::Document, b: &solvecraft_engine::doc::ModelBody) -> Option<Color32> {
let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
doc.body_color(name, body).map(|[r, g, b]| Color32::from_rgb(to8(r), to8(g), to8(b)))
doc.body_color(b).map(|[r, g, b]| Color32::from_rgb(to8(r), to8(g), to8(b)))
}
/// A body's display colour (else the theme's) as RGB.
fn body_rgb(app: &SolveApp, name: &str) -> (u8, u8, u8) {
let st = app.session.model.state();
let c = st.body(name).and_then(|b| body_color(&app.session.doc, name, &b.body)).unwrap_or(Tokens::get().body);
let c = st.body(name).and_then(|b| body_color(&app.session.doc, b)).unwrap_or(Tokens::get().body);
(c.r(), c.g(), c.b())
}