diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs new file mode 100644 index 00000000..766d4dd3 --- /dev/null +++ b/crates/doc/src/appearance.rs @@ -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, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub faces: Vec, + /// By component id. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub components: BTreeMap, +} + +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 { + 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 { + 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 { + 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 { + 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"); + } +} diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index b21fc725..5a0f092e 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1070,8 +1070,8 @@ pub struct Document { #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub materials: std::collections::BTreeMap, /// 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, + #[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, @@ -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); } diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index f86c2914..be1c0078 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -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 { /// 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 = + 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 { @@ -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"); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 0e2202ed..ba929fe0 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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; diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index cdaa7bcc..347e2acf 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -278,8 +278,11 @@ fn rename_body(s: &mut Session, p: &Value) -> Result { 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); diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index a94a15cf..fa49ac30 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -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 { + use solvecraft_doc::appearance::{FaceLook, Look, library}; let cmd = "AppearanceCommand"; + let look: Option = 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 = 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)> = + 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::>(), + "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 { + let list: Vec = 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 { + let a = &s.doc.appearances; + let mut out = json!({ + "bodies": a.bodies.iter().map(|(b, l)| (b.clone(), look_json(l))).collect::>(), + "faces": a.faces.iter().map(|f| json!({"body": f.body, "point": f.point, "appearance": look_json(&f.look)})).collect::>(), + "components": a.components.iter().map(|(c, l)| json!({"component": c, "appearance": look_json(l)})).collect::>(), + }); + 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::>(), + }); + } + Ok(out) } fn material_list(_s: &mut Session, _p: &Value) -> Result { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8428da35..f459dd30 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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"); +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index d92a5d7a..8f85ebfd 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -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 { +fn body_color(doc: &solvecraft_engine::doc::Document, b: &solvecraft_engine::doc::ModelBody) -> Option { 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()) }