Why: every reference a user makes to a face or edge (fillet and chamfer edges, sketches on faces, shell/draft/offset faces, joints, appearances) is stored as a name and found again after any upstream edit. Those names were re-derived by sampling points of each face and voting, and pieces of a split face were numbered by where their centres lay. After an upstream edit that splits, merges, mirrors or shrinks a face, a fillet or sketch could silently re-attach to the wrong face, and nothing told the user. The kernel now reports exact history for its operations, so names follow it and fail loudly instead of guessing. - crates/doc/src/lineage.rs: a feature's kernel histories composed into one lineage over nameable operands (bodies before the feature, moved copies, shell cavities, tools named by role: sketch curve, cap, box side, imported face). Instrumented: extrude/revolve/sweep/loft tools, join/cut/intersect, lumps after a cut, fillet/chamfer, shell, draft, split face/body, pattern/mirror (bodies and features), combine, boundary fill, move/scale, offset/delete face, thread, import. - crates/doc/src/history_names.rs: names from lineage; split pieces told apart by the faces only they touch (F2:top#[F2:side:l4,F4:side:l4]), merged faces answer to every name (aliases), lump bodies ordered by their faces' names instead of shell order. - crates/doc/src/name_lookup.rs, piece_names.rs: references resolve by exact name, alias or bounding faces, else nearest piece with a warning; names worked out from geometry (operations without history) are reported when only geometry decided. - Format 14: names stored by older formats get a legacy mark and are found as the build that saved them found them (some geometric names name a face history names differently: a tube's outer wall was named after its inner circle). New corpus file saved by 9a979cf. - kernel: Body::lumps carries each piece's history over the whole. - Adversarial tests: split, merge, mirrored twins, shrink to a sliver, moving cut with body pieces, history-less bodies, certified sketch. - docs/book/src/dev/naming.md describes the real mechanism and why. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
306 lines
12 KiB
Rust
306 lines
12 KiB
Rust
//! 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 face's persistent name (found first).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub name: Option<String>,
|
||
/// Strict semantic assignments never transfer to a nearby replacement face.
|
||
#[serde(default, skip_serializing_if = "is_false")]
|
||
pub strict_name: bool,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub certificate: Option<crate::config::FaceCertificate>,
|
||
}
|
||
|
||
fn is_false(value: &bool) -> bool {
|
||
!*value
|
||
}
|
||
|
||
/// 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 {
|
||
/// 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 {
|
||
/// Shared definition appearance for a computed occurrence body.
|
||
pub fn model_body_look(&self, body: &crate::ModelBody) -> Option<Look> {
|
||
let name = body.definition_name();
|
||
self.appearances
|
||
.bodies
|
||
.get(name)
|
||
.cloned()
|
||
.or_else(|| self.appearances.components.get(&self.model_body_component(body)).cloned())
|
||
.or_else(|| self.materials.get(name).and_then(|material| material_look(material)))
|
||
}
|
||
/// Effective body opacity, including paint carried from an imported body.
|
||
pub fn model_body_opacity(&self, body: &crate::ModelBody) -> f64 {
|
||
self.model_body_look(body)
|
||
.map(|look| look.opacity)
|
||
.or_else(|| body.body.paint().map(|paint| paint.opacity as f64))
|
||
.or_else(|| body.imported.as_ref().and_then(|paint| paint.opacity.map(f64::from)))
|
||
.unwrap_or(1.0)
|
||
}
|
||
/// 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)> {
|
||
// Face colours the body was imported with, under the design's own.
|
||
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.definition_name()) {
|
||
let (p, look) = (crate::apply_point(&b.source_transform, f.point), f.look.clone());
|
||
if f.strict_name {
|
||
let Some(name) = f.name.as_ref() else { continue };
|
||
let Some(origin_feature) = name.strip_prefix('F').and_then(|name| name.split_once(':')).and_then(|(id, _)| id.parse().ok()) else {
|
||
continue;
|
||
};
|
||
let target = crate::config::ConfigFaceTarget {
|
||
body: f.body.clone(),
|
||
origin_feature,
|
||
name: crate::naming::strip_piece(name).into(),
|
||
point: f.point,
|
||
certificate: f.certificate.clone(),
|
||
};
|
||
let Ok(indices) = self.configuration_face_indices(b, &target) else { continue };
|
||
for index in indices {
|
||
out.retain(|(old, _)| *old != index);
|
||
out.push((index, look.clone()));
|
||
}
|
||
continue;
|
||
}
|
||
// By name (every piece of a split face), else by the point.
|
||
if let Some(n) = &f.name {
|
||
let hits = crate::naming::faces_with_stem(b, n);
|
||
if !hits.is_empty() {
|
||
for i in hits {
|
||
out.retain(|(j, _)| *j != i);
|
||
out.push((i, look.clone()));
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
if !f.strict_name
|
||
&& 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");
|
||
}
|
||
}
|