* Hole matrix: placement, types, extents, edits and 0.9.0 designs, with today's failures listed Each case checks the drill goes into the material, the bore sits on its axis, a blind hole stops where it should, the removed volume against its analytic value, the face count and that every body is still one closed valid solid. Failing cases are listed in tests/hole_matrix/known_failures.txt as a ratchet. Co-Authored-By: Claude Opus 5.5 <[email protected]> * A hole on a face drills into the material, also in older designs (#950) Without a direction, solid.hole took the direction from any face whose infinite plane passed within ten tolerances of the point, the last one found winning: the far side of a thin plate or a face of another body at the same height could set it, and the wrong direction was saved. It now uses the face nearest the point, against its outward normal (exact for planes, cylinders and spheres, so holes on curved faces work without a direction). When a design is evaluated, a face hole whose saved direction leaves the face it starts on is mirrored into it, so designs saved with the wrong direction drill into the part. Fixes #950. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Hole at a point on no face (a planar face's centroid outside it) keeps the plane it lies in Co-Authored-By: Claude Opus 5.5 <[email protected]> * Record the hole direction fix in the goldens and the action matrix baseline (#950) Seeds 7, 21, 81 and 85 drill their holes straight into their faces now. In the action matrix, holes on four faces that used to go the wrong way now build, and two whose correct direction meets a cut the kernel refuses are marked failing. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Goldens: seeds 7, 21 and 85 drill their holes straight in (#950) Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
297 lines
11 KiB
Rust
297 lines
11 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 solidcraft_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).
|
||
#[path = "../../../assets/appearances/library.rs"]
|
||
mod defaults;
|
||
pub use defaults::LIBRARY;
|
||
|
||
/// 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| self.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| self.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 (d, f) = nearest_face(b, p)?;
|
||
(d <= pick_tolerance(b)).then_some(f)
|
||
}
|
||
|
||
/// The face of `b` nearest `p` and how far it is, however far.
|
||
pub fn nearest_face(b: &crate::ModelBody, p: Vec3) -> Option<(f64, 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));
|
||
}
|
||
}
|
||
best.map(|(d, f)| (d, f as usize))
|
||
}
|
||
|
||
/// How far from a face of `b` a picked point may lie and still pick it (`face_index_at`).
|
||
pub fn pick_tolerance(b: &crate::ModelBody) -> f64 {
|
||
b.body.size() * 0.05 + 1e-6
|
||
}
|
||
|
||
/// The point of triangle abc nearest p.
|
||
pub(crate) 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");
|
||
}
|
||
}
|