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 <noreply@anthropic.com>
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;