From 294597cc96b85bcb38b5708d18ab7e480d8a5803 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 13:44:56 +0000 Subject: [PATCH] Face colours and opacity in STEP and 3MF export (and STEP import) - Bodies carry a Paint for export (opacity, per-face colours), set by Document::painted from the appearance model. The paint comes from a face appearance, a body/component appearance's opacity or a material look (Glass), and falls back to the paint the body was imported with. - STEP: faces with colours of their own get a STYLED_ITEM on their ADVANCED_FACE. See-through looks add SURFACE_STYLE_TRANSPARENT. Differently coloured pieces are not merged into one face. - STEP import reads face styles and transparency. The reader tracks the file face of each kernel face (bands split in two keep it), so imported face colours land on the right faces; the design shows them through face_colors. - 3MF: per-triangle colours (pid/p1 into the base materials, carried through welding) and the object's alpha (#RRGGBBAA). - Tests: a blue half-transparent cube with a red top exports, re-imports with its opacity and the red top face, and keeps it when opened and exported again. 3MF writes the alpha and the face entry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/appearance.rs | 10 +++- crates/doc/src/document.rs | 15 ++++- crates/engine/src/tests.rs | 29 +++++++++ crates/io/src/lib.rs | 17 +++++- crates/io/src/threemf.rs | 95 ++++++++++++++++++++++++------ crates/kernel/src/body.rs | 45 +++++++++++++- crates/kernel/src/lib.rs | 2 +- crates/kernel/src/meshbody.rs | 4 +- crates/kernel/src/step_in/brep.rs | 23 +++++--- crates/kernel/src/step_in/mod.rs | 55 +++++++++++++++-- crates/kernel/src/step_in/split.rs | 5 +- crates/kernel/src/step_out.rs | 62 ++++++++++++++++--- 12 files changed, 313 insertions(+), 49 deletions(-) diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index f9a10238..6d377b2a 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -125,7 +125,15 @@ impl Document { /// 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(); + // Face colours the body was imported with, under the design's own. + let mut out: Vec<(usize, Look)> = b + .body + .paint() + .map(|p| { + let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8; + p.faces.iter().map(|f| (f.face, Look::custom(f.color.map(to8), f.opacity as f64))).collect() + }) + .unwrap_or_default(); let names = crate::naming::face_names(b); for f in self.appearances.faces.iter().filter(|f| f.body == b.name) { let (p, look) = (f.point, f.look.clone()); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 290b7222..7a8138a9 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1197,13 +1197,22 @@ impl Document { 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). + /// The model with each body carrying the colour, opacity and face colours it is shown with + /// (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); - if c != b.body.color() { - b.body = b.body.clone().with_color(c); + let look = self.body_look(&b.name, b.feature); + let faces: Vec = self + .face_colors(b) + .into_iter() + .map(|(face, l)| solvecraft_kernel::FacePaint { face, color: l.rgb(), opacity: l.opacity as f32 }) + .collect(); + let opacity = look.map(|l| l.opacity as f32).or_else(|| b.body.paint().map(|p| p.opacity)).unwrap_or(1.0); + let paint = solvecraft_kernel::Paint { opacity, faces }; + if c != b.body.color() || b.body.paint() != Some(&paint) { + b.body = b.body.clone().with_color(c).with_paint(Some(paint)); } } st diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 129f616b..7451bbb0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -987,6 +987,35 @@ fn material_and_appearance_colours_export_to_step() { check(&step_round_trip(&mut s2, "colours2")); } +/// Face appearances and see-through bodies export: STEP styles on the faces with a +/// transparency, read back as face colours. +#[test] +fn face_colours_and_opacity_export() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "body_name": "Cube"})); + run(&mut s, "AppearanceCommand", json!({"bodies": ["Cube"], "color": "#2040c0", "opacity": 0.5})); + run(&mut s, "AppearanceCommand", json!({"faces": [[5, 5, 10]], "color": "#ff0000"})); + let imp = step_round_trip(&mut s, "facecol"); + let b = &imp.bodies[0]; + let paint = b.body.paint().expect("paint read back"); + assert!((paint.opacity - 0.5).abs() < 1e-3, "{paint:?}"); + let faces = b.body.faces(0.01).unwrap(); + let top = faces.iter().position(|f| (f.centroid.z - 10.0).abs() < 1e-6).unwrap(); + assert_eq!(paint.faces.len(), 1, "{paint:?}"); + assert_eq!(paint.faces[0].face, top); + assert!((paint.faces[0].color[0] - 1.0).abs() < 1e-3 && paint.faces[0].color[1].abs() < 1e-3, "{paint:?}"); + // Opened again, the face keeps its colour (shown and exported). + let p = std::env::temp_dir().join(format!("solvecraft-facecol-{}.step", std::process::id())); + run(&mut s, "ExportCommand", json!({"path": p.to_string_lossy()})); + let mut s2 = Session::default(); + run(&mut s2, "doc.open", json!({"path": p.to_string_lossy()})); + let _ = std::fs::remove_file(&p); + let st = s2.world_state(); + let looks = s2.doc.face_colors(&st.bodies[0]); + assert_eq!(looks.len(), 1); + assert_eq!(looks[0].1.color, [255, 0, 0]); +} + /// Components export as a STEP assembly: products, occurrences, placements. #[test] fn components_export_as_step_assembly() { diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index ab408005..e69e02f8 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -119,7 +119,7 @@ pub fn mesh_import_feature(bytes: &[u8], file: &str) -> Result { let positions: Vec = tris.iter().flatten().map(|v| Vec3::new(v[0] as f64, v[1] as f64, v[2] as f64)).collect(); let n = u32::try_from(tris.len()).map_err(|_| IoError::Invalid("STL too large".into()))?; let triangles = (0..n).map(|i| [i * 3, i * 3 + 1, i * 3 + 2]).collect(); - (vec![MeshObject { name: stem.clone(), positions, triangles, color: None }], Vec::new()) + (vec![MeshObject { name: stem.clone(), positions, triangles, ..Default::default() }], Vec::new()) } else { read_3mf(bytes)? }; @@ -269,8 +269,19 @@ pub fn export(state: &ModelState, bodies: &[String], format: Format, name: &str) .map(|b| { let m = export_mesh(&b.body)?; let eps = (b.body.size() * 1e-6).max(1e-7); - let (positions, triangles) = threemf::weld(&m.positions, &m.triangles, eps); - Ok(MeshObject { name: b.name.clone(), positions, triangles, color: b.body.color() }) + // Faces with colours of their own tag their triangles. + let paint = b.body.paint(); + let mut looks = Vec::new(); + let mut by_face = std::collections::HashMap::new(); + for f in paint.map(|p| p.faces.as_slice()).unwrap_or_default() { + looks.push((f.color, 1.0 - f.opacity)); + by_face.insert(f.face, looks.len() as u32); + } + let tags: Vec = m.tri_face.iter().map(|f| by_face.get(&(*f as usize)).copied().unwrap_or(0)).collect(); + let (positions, triangles, tri_looks) = threemf::weld_tagged(&m.positions, &m.triangles, &tags, eps); + let tri_looks = if looks.is_empty() { Vec::new() } else { tri_looks }; + let transparency = 1.0 - paint.map(|p| p.opacity).unwrap_or(1.0); + Ok(MeshObject { name: b.name.clone(), positions, triangles, color: b.body.color(), transparency, looks, tri_looks }) }) .collect::>()?; write_3mf(&objs)? diff --git a/crates/io/src/threemf.rs b/crates/io/src/threemf.rs index 5b007abd..1578a135 100644 --- a/crates/io/src/threemf.rs +++ b/crates/io/src/threemf.rs @@ -36,6 +36,13 @@ pub struct MeshObject { pub positions: Vec, pub triangles: Vec<[u32; 3]>, pub color: Option<[f32; 3]>, + /// 0 = opaque, 1 = invisible (written as the colour's alpha). + pub transparency: f32, + /// Colours of their own for some triangles (faces with appearances): colour 0..1 and + /// transparency; `tri_looks` gives each triangle's entry + 1 (0: the object's own), and is + /// empty when no triangle has one. + pub looks: Vec<([f32; 3], f32)>, + pub tri_looks: Vec, } fn esc(s: &str) -> String { @@ -55,7 +62,7 @@ fn esc(s: &str) -> String { } /// Shared vertices (merged at single precision, as written) and non-degenerate triangles. -fn indexed(m: &MeshObject) -> (Vec<[f32; 3]>, Vec<[u32; 3]>) { +fn indexed(m: &MeshObject) -> (Vec<[f32; 3]>, Vec<([u32; 3], u32)>) { let mut map: HashMap<[u32; 3], u32> = HashMap::new(); let mut verts: Vec<[f32; 3]> = Vec::new(); let mut remap = Vec::with_capacity(m.positions.len()); @@ -72,9 +79,10 @@ fn indexed(m: &MeshObject) -> (Vec<[f32; 3]>, Vec<[u32; 3]>) { let tris = m .triangles .iter() - .filter_map(|t| { + .enumerate() + .filter_map(|(k, t)| { let (a, b, c) = (*remap.get(t[0] as usize)?, *remap.get(t[1] as usize)?, *remap.get(t[2] as usize)?); - (a != b && b != c && c != a).then_some([a, b, c]) + (a != b && b != c && c != a).then_some(([a, b, c], m.tri_looks.get(k).copied().unwrap_or(0))) }) .collect(); (verts, tris) @@ -83,6 +91,13 @@ fn indexed(m: &MeshObject) -> (Vec<[f32; 3]>, Vec<[u32; 3]>) { /// Make a tessellation watertight: merge vertices closer than `eps` and split triangles at /// vertices lying on their open sides (T-junctions between separately meshed faces). pub fn weld(positions: &[Vec3], triangles: &[[u32; 3]], eps: f64) -> (Vec, Vec<[u32; 3]>) { + let (v, t, _) = weld_tagged(positions, triangles, &[], eps); + (v, t) +} + +/// [`weld`] keeping a tag per triangle (pieces of a split triangle keep its tag; missing tags +/// are 0). +pub fn weld_tagged(positions: &[Vec3], triangles: &[[u32; 3]], tags: &[u32], eps: f64) -> (Vec, Vec<[u32; 3]>, Vec) { // Merge on a grid of cell `eps` (a vertex joins one already in its own or a neighbour cell). let cell = |p: Vec3| [(p.x / eps).round() as i64, (p.y / eps).round() as i64, (p.z / eps).round() as i64]; let mut grid: HashMap<[i64; 3], Vec> = HashMap::new(); @@ -114,13 +129,14 @@ pub fn weld(positions: &[Vec3], triangles: &[[u32; 3]], eps: f64) -> (Vec, }; remap.push(i); } - let mut tris: Vec<[u32; 3]> = triangles + let (mut tris, mut tags): (Vec<[u32; 3]>, Vec) = triangles .iter() - .filter_map(|t| { + .enumerate() + .filter_map(|(k, t)| { let (a, b, c) = (*remap.get(t[0] as usize)?, *remap.get(t[1] as usize)?, *remap.get(t[2] as usize)?); - (a != b && b != c && c != a).then_some([a, b, c]) + (a != b && b != c && c != a).then_some(([a, b, c], tags.get(k).copied().unwrap_or(0))) }) - .collect(); + .unzip(); // T-junctions: split open sides at open-side vertices lying on them. for _ in 0..8 { let mut half: HashMap<(u32, u32), usize> = HashMap::new(); @@ -163,7 +179,8 @@ pub fn weld(positions: &[Vec3], triangles: &[[u32; 3]], eps: f64) -> (Vec, break; } let mut next = Vec::with_capacity(tris.len() + splits.len() * 2); - for t in &tris { + let mut next_tags = Vec::with_capacity(next.capacity()); + for (t, tag) in tris.iter().zip(&tags) { // Rotate so the split side (if any) is (t0, t1); split one side per pass. let rot = [[t[0], t[1], t[2]], [t[1], t[2], t[0]], [t[2], t[0], t[1]]]; match rot.iter().find_map(|r| splits.get(&(r[0], r[1])).map(|s| (r, s))) { @@ -171,16 +188,22 @@ pub fn weld(positions: &[Vec3], triangles: &[[u32; 3]], eps: f64) -> (Vec, let mut prev = r[0]; for (_, v) in s { next.push([prev, *v, r[2]]); + next_tags.push(*tag); prev = *v; } next.push([prev, r[1], r[2]]); + next_tags.push(*tag); + } + None => { + next.push(*t); + next_tags.push(*tag); } - None => next.push(*t), } } tris = next; + tags = next_tags; } - (verts, tris) + (verts, tris, tags) } /// The model part's XML. @@ -189,19 +212,34 @@ pub fn model_xml(objects: &[MeshObject]) -> String { x.push_str("\n"); let _ = writeln!(x, ""); x.push_str(" SolveCraft\n \n"); - let colored = objects.iter().any(|o| o.color.is_some()); - // Base material group id 1; objects from 2. + let colored = objects.iter().any(|o| o.color.is_some() || o.transparency > 0.0 || !o.looks.is_empty()); + // Base material group id 1 (each object's own colour, then its faces' colours); objects + // from 2. A see-through colour carries its alpha (#RRGGBBAA). + let display = |c: [f32; 3], transparency: f32| { + let c = c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + let alpha = if transparency > 0.0 { format!("{:02X}", ((1.0 - transparency.clamp(0.0, 1.0)) * 255.0).round() as u8) } else { String::new() }; + format!("#{:02X}{:02X}{:02X}{alpha}", c[0], c[1], c[2]) + }; + let mut first = Vec::with_capacity(objects.len()); if colored { x.push_str(" \n"); + let mut n = 0usize; for o in objects { - let c = o.color.unwrap_or([0.7, 0.7, 0.7]).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); - let _ = writeln!(x, " ", esc(&o.name), c[0], c[1], c[2]); + first.push(n); + let _ = + writeln!(x, " ", esc(&o.name), display(o.color.unwrap_or([0.7, 0.7, 0.7]), o.transparency)); + n += 1; + for (i, (c, t)) in o.looks.iter().enumerate() { + let _ = writeln!(x, " ", esc(&o.name), i + 1, display(*c, *t)); + n += 1; + } } x.push_str(" \n"); } for (k, o) in objects.iter().enumerate() { let id = k + 2; - let mat = if colored { format!(" pid=\"1\" pindex=\"{k}\"") } else { String::new() }; + let own = first.get(k).copied().unwrap_or(0); + let mat = if colored { format!(" pid=\"1\" pindex=\"{own}\"") } else { String::new() }; let _ = writeln!(x, " ", esc(&o.name)); x.push_str(" \n \n"); let (verts, tris) = indexed(o); @@ -209,8 +247,15 @@ pub fn model_xml(objects: &[MeshObject]) -> String { let _ = writeln!(x, " ", v[0], v[1], v[2]); } x.push_str(" \n \n"); - for t in &tris { - let _ = writeln!(x, " ", t[0], t[1], t[2]); + for (t, look) in &tris { + match look { + l if colored && *l > 0 && (*l as usize) <= o.looks.len() => { + let _ = writeln!(x, " ", t[0], t[1], t[2], own + *l as usize); + } + _ => { + let _ = writeln!(x, " ", t[0], t[1], t[2]); + } + } } x.push_str(" \n \n \n"); } @@ -573,9 +618,25 @@ mod tests { positions: vec![Vec3::ZERO, Vec3::new(10.0, 0.0, 0.0), Vec3::new(0.0, 10.0, 0.0), Vec3::new(0.0, 0.0, 10.0)], triangles: vec![[0, 2, 1], [0, 1, 3], [1, 2, 3], [0, 3, 2]], color: None, + ..Default::default() } } + /// A see-through object writes its alpha; triangles of faces with colours of their own + /// point at their entries. + #[test] + fn face_colours_and_alpha() { + let mut t = tetra("T"); + t.color = Some([0.125, 0.25, 0.75]); + t.transparency = 0.5; + t.looks = vec![([1.0, 0.0, 0.0], 0.0)]; + t.tri_looks = vec![0, 1, 0, 0]; + let model = model_xml(&[t]); + assert!(model.contains("displaycolor=\"#2040BF80\""), "{model}"); + assert!(model.contains("displaycolor=\"#FF0000\""), "{model}"); + assert_eq!(model.matches("p1=\"1\"").count(), 1, "{model}"); + } + fn package(model: &str, rels: Option<&str>) -> Vec { let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); let o = zip::write::SimpleFileOptions::default(); diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 72bbeae4..2c45fc4a 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -17,6 +17,24 @@ pub struct Body { pub(crate) mesh: Option>, /// Display colour (linear RGB 0..1) from an imported file; operations drop it. pub(crate) color: Option<[f32; 3]>, + /// Opacity and face colours for export (set by the document; operations drop it). + pub(crate) paint: Option>, +} + +/// How a body looks beyond its colour, for export: its opacity and its faces' own colours. +#[derive(Clone, Debug, PartialEq)] +pub struct Paint { + /// 1 = opaque. + pub opacity: f32, + pub faces: Vec, +} + +/// A face's own colour: the B-rep face index (`face_iter` order), sRGB as 0..1, opacity. +#[derive(Clone, Debug, PartialEq)] +pub struct FacePaint { + pub face: usize, + pub color: [f32; 3], + pub opacity: f32, } /// A B-rep edge as seen by the rest of the application. @@ -259,6 +277,7 @@ impl Body { _ => false, } && self.color == other.color + && self.paint == other.paint } pub(crate) fn new(solid: Solid) -> Result { @@ -283,7 +302,7 @@ impl Body { if v < 0.0 { solid.not(); } - Ok(Body { solid: std::sync::Arc::new(solid), mesh: None, color: None }) + Ok(Body { solid: std::sync::Arc::new(solid), mesh: None, color: None, paint: None }) } /// A deep copy of the solid that can be mutated without affecting other bodies. @@ -316,6 +335,30 @@ impl Body { self } + /// Opacity and face colours for export, if any. + pub fn paint(&self) -> Option<&Paint> { + self.paint.as_deref() + } + + /// The same body with opacity and face colours for export (`None` or opaque with no face + /// colours clears them). + pub fn with_paint(mut self, p: Option) -> Body { + let clean = |x: f32| if x.is_finite() { x.clamp(0.0, 1.0) } else { 1.0 }; + self.paint = p + .map(|mut p| { + p.opacity = clean(p.opacity); + p.faces.retain(|f| f.color.iter().all(|x| x.is_finite())); + for f in &mut p.faces { + f.color = f.color.map(|x| x.clamp(0.0, 1.0)); + f.opacity = clean(f.opacity); + } + p + }) + .filter(|p| p.opacity < 1.0 || !p.faces.is_empty()) + .map(std::sync::Arc::new); + self + } + /// Characteristic size (bounding box diagonal) for tolerances. pub fn size(&self) -> f64 { let mut b = solvecraft_geom::Aabb3::EMPTY; diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 8a4cc563..cfecb0c8 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -36,7 +36,7 @@ mod sweep_path; mod topo; pub use blend::{chamfer, fillet}; -pub use body::{Body, EdgeInfo, FaceInfo}; +pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, Paint}; pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus}; pub use helix::{loft_to_point, sweep_helix}; pub use measure::{BodyMeasure, measure}; diff --git a/crates/kernel/src/meshbody.rs b/crates/kernel/src/meshbody.rs index 787fe8bf..65d1ad0d 100644 --- a/crates/kernel/src/meshbody.rs +++ b/crates/kernel/src/meshbody.rs @@ -100,7 +100,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result { t.swap(1, 2); } } - Ok(Body { solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())), mesh: Some(Arc::new(out)), color: None }) + Ok(Body { solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())), mesh: Some(Arc::new(out)), color: None, paint: None }) } /// Every triangle side is shared by exactly two triangles in opposite directions. @@ -142,6 +142,6 @@ impl Body { t.swap(1, 2); } } - Some(Body { solid: self.solid.clone(), mesh: Some(Arc::new(out)), color: self.color }) + Some(Body { solid: self.solid.clone(), mesh: Some(Arc::new(out)), color: self.color, paint: self.paint.clone() }) } } diff --git a/crates/kernel/src/step_in/brep.rs b/crates/kernel/src/step_in/brep.rs index 0ac94ad7..56a9a1c3 100644 --- a/crates/kernel/src/step_in/brep.rs +++ b/crates/kernel/src/step_in/brep.rs @@ -26,6 +26,8 @@ pub(crate) struct BrepOut { pub closed: bool, /// Faces read from the file (before band faces are split for the kernel). pub file_faces: usize, + /// The file face of each kernel face, in face order. + pub face_origin: Vec, } #[derive(Default)] @@ -280,7 +282,7 @@ impl Builder { } } - fn shell(&mut self, cx: &Ctx, id: u64, failed: &mut Vec) -> R<(mt::Shell, bool, usize)> { + fn shell(&mut self, cx: &Ctx, id: u64, failed: &mut Vec) -> R<(mt::Shell, bool, usize, Vec)> { let e = entity(cx, id)?; let p = e.params(); let (faces, flip, closed) = match e.name() { @@ -296,6 +298,7 @@ impl Builder { return Err(format!("shell with {} faces", faces.len())); } let mut out = Vec::with_capacity(faces.len()); + let mut origin = Vec::with_capacity(faces.len()); for f in faces { match self.face(cx, f, 0) { Ok(mut face) => { @@ -303,6 +306,7 @@ impl Builder { face.invert(); } out.push(face); + origin.push(f); } Err(err) => { if self.faces > MAX_FACES { @@ -316,8 +320,8 @@ impl Builder { return Err("no face could be imported".into()); } let read = out.len(); - let out = super::split::split_bands(out, failed); - Ok((out.into(), closed, read)) + let out = super::split::split_bands(out, &mut origin, failed); + Ok((out.into(), closed, read, origin)) } } @@ -355,19 +359,23 @@ pub(crate) fn read_item(cx: &Ctx, id: u64) -> R { let mut shells = Vec::new(); let mut closed = true; let mut file_faces = 0; + // The file face each kernel face comes from (in face order). + let mut face_origin = Vec::new(); match e.name() { "MANIFOLD_SOLID_BREP" | "FACETED_BREP" | "BREP_WITH_VOIDS" => { - let (s, c, n) = b.shell(cx, rid(p.get(1), "brep outer shell")?, &mut failed)?; + let (s, c, n, o) = b.shell(cx, rid(p.get(1), "brep outer shell")?, &mut failed)?; closed &= c; file_faces += n; shells.push(s); + face_origin.extend(o); if e.name() == "BREP_WITH_VOIDS" { for v in refs(p.get(2)) { match b.shell(cx, v, &mut failed) { - Ok((s, c, n)) => { + Ok((s, c, n, o)) => { closed &= c; file_faces += n; shells.push(s); + face_origin.extend(o); } Err(err) => failed.push(format!("void shell #{v}: {err}")), } @@ -377,10 +385,11 @@ pub(crate) fn read_item(cx: &Ctx, id: u64) -> R { "SHELL_BASED_SURFACE_MODEL" => { for s in refs(p.get(1)) { match b.shell(cx, s, &mut failed) { - Ok((s, c, n)) => { + Ok((s, c, n, o)) => { closed &= c; file_faces += n; shells.push(s); + face_origin.extend(o); } Err(err) => failed.push(format!("shell #{s}: {err}")), } @@ -398,5 +407,5 @@ pub(crate) fn read_item(cx: &Ctx, id: u64) -> R { mt::Solid::new_unchecked(shells) } }; - Ok(BrepOut { solid, failed, closed, file_faces }) + Ok(BrepOut { solid, failed, closed, file_faces, face_origin }) } diff --git a/crates/kernel/src/step_in/mod.rs b/crates/kernel/src/step_in/mod.rs index 24a98018..6f89fd40 100644 --- a/crates/kernel/src/step_in/mod.rs +++ b/crates/kernel/src/step_in/mod.rs @@ -227,8 +227,10 @@ struct Reader<'a> { placements: HashMap, /// Styled item → colour. colors: HashMap, + /// Opacity of styled items with a transparency (1 = opaque). + opacity: HashMap, /// Converted items (brep id → solid, or the error). - solids: HashMap>, + solids: HashMap), String>>, used_items: HashSet, /// Items already instanced for the product being read. node_items: HashSet, @@ -291,6 +293,9 @@ impl<'a> Reader<'a> { && let Some(c) = self.find_colour(p.get(1)) { self.colors.entry(item).or_insert(c); + if let Some(t) = self.find_transparency(p.get(1)) { + self.opacity.entry(item).or_insert((1.0 - t).clamp(0.0, 1.0)); + } } } _ => {} @@ -314,6 +319,34 @@ impl<'a> Reader<'a> { } /// First colour reachable from a style list (bounded search). + /// SURFACE_STYLE_TRANSPARENT under a style list (0 = opaque, 1 = invisible). + fn find_transparency(&self, styles: Option<&Param>) -> Option { + let mut queue: Vec = styles?.as_list()?.iter().filter_map(Param::as_ref_id).collect(); + let mut seen = HashSet::new(); + while let Some(id) = queue.pop() { + if seen.len() > 64 || !seen.insert(id) { + continue; + } + let e = self.ex.get(id)?; + if e.name() == "SURFACE_STYLE_TRANSPARENT" { + return e.params().first().and_then(Param::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0) as f32); + } + if e.name() == "COLOUR_RGB" { + continue; + } + for r in &e.records { + for p in &r.params { + match p { + Param::Ref(x) => queue.push(*x), + Param::List(v) => queue.extend(v.iter().filter_map(Param::as_ref_id)), + _ => {} + } + } + } + } + None + } + fn find_colour(&self, styles: Option<&Param>) -> Option<[f32; 3]> { let mut queue: Vec = styles?.as_list()?.iter().filter_map(Param::as_ref_id).collect(); let mut seen = HashSet::new(); @@ -387,7 +420,7 @@ impl<'a> Reader<'a> { seen } - fn item_solid(&mut self, item: u64, u: Units) -> Option<(mt::Solid, bool, usize)> { + fn item_solid(&mut self, item: u64, u: Units) -> Option<(mt::Solid, bool, usize, Vec)> { if let Some(r) = self.solids.get(&item) { return r.as_ref().ok().cloned(); } @@ -401,7 +434,7 @@ impl<'a> Reader<'a> { if !out.closed { self.warn(format!("{label}: imported as an open body (the shell is not closed)")); } - Ok((out.solid, out.closed, out.file_faces)) + Ok((out.solid, out.closed, out.file_faces, out.face_origin)) } Err(e) => { let label = self.label(item); @@ -444,7 +477,7 @@ impl<'a> Reader<'a> { self.warn(format!("more than {MAX_BODIES} bodies: the rest are left out")); return; } - let Some((solid, closed, file_faces)) = self.item_solid(item, u) else { continue }; + let Some((solid, closed, file_faces, face_origin)) = self.item_solid(item, u) else { continue }; let identity = *m == mt::Matrix4::from_scale(1.0); let placed = if identity { Ok(solid) @@ -466,7 +499,18 @@ impl<'a> Reader<'a> { } out.push(self.bodies.len()); let color = self.colors.get(&item).copied(); - self.bodies.push(ImportedBody { name, body: body.with_color(color), color, path: path.to_vec(), closed, file_faces }); + // See-through bodies and faces with colours of their own. + let faces = face_origin + .iter() + .enumerate() + .filter_map(|(i, f)| { + let c = self.colors.get(f)?; + Some(crate::FacePaint { face: i, color: *c, opacity: self.opacity.get(f).copied().unwrap_or(1.0) }) + }) + .collect(); + let paint = crate::Paint { opacity: self.opacity.get(&item).copied().unwrap_or(1.0), faces }; + let body = body.with_color(color).with_paint(Some(paint)); + self.bodies.push(ImportedBody { name, body, color, path: path.to_vec(), closed, file_faces }); } Err(err) => { let label = self.label(item); @@ -775,6 +819,7 @@ pub fn step_import(text: &str) -> Result { children: HashMap::new(), placements: HashMap::new(), colors: HashMap::new(), + opacity: HashMap::new(), solids: HashMap::new(), used_items: HashSet::new(), node_items: HashSet::new(), diff --git a/crates/kernel/src/step_in/split.rs b/crates/kernel/src/step_in/split.rs index 985746fa..a72d2520 100644 --- a/crates/kernel/src/step_in/split.rs +++ b/crates/kernel/src/step_in/split.rs @@ -388,7 +388,7 @@ fn recut(face: &mt::Face, cuts: &[(mt::Edge, (mt::Edge, mt::Edge))]) -> mt::Face /// Split every band face of a shell (see the module docs). Faces that cannot be split stay as /// they are and are reported. -pub(crate) fn split_bands(mut faces: Vec, warnings: &mut Vec) -> Vec { +pub(crate) fn split_bands(mut faces: Vec, origin: &mut Vec, warnings: &mut Vec) -> Vec { let n = faces.len(); for i in 0..n { let Some(face) = faces.get(i).cloned() else { continue }; @@ -399,6 +399,9 @@ pub(crate) fn split_bands(mut faces: Vec, warnings: &mut Vec) *slot = p.faces.0; } faces.push(p.faces.1); + // The second piece comes from the same file face. + let o = origin.get(i).copied().unwrap_or(0); + origin.push(o); } Ok(None) => {} Err(e) => warnings diff --git a/crates/kernel/src/step_out.rs b/crates/kernel/src/step_out.rs index 83ae6e59..beab8d60 100644 --- a/crates/kernel/src/step_out.rs +++ b/crates/kernel/src/step_out.rs @@ -152,6 +152,8 @@ struct Out { /// B-spline cylinder sheets of the body being copied under merged faces, with the direction /// of the cylinder's parameter seam. merged: HashMap, + /// Faces of the body being copied: exchange id → written id. + faces_out: HashMap, } impl Out { @@ -332,6 +334,9 @@ impl Out { let topo = TOPOLOGY.contains(&e.name()); let new = if topo { self.add(text) } else { self.add_shared(text) }; map.insert(id, new); + if matches!(e.name(), "FACE_SURFACE" | "ADVANCED_FACE") { + self.faces_out.insert(id, new); + } } map.get(&root).copied().ok_or_else(|| "solid not copied".into()) } @@ -827,7 +832,7 @@ fn face_edges(ex: &p21::Exchange, face: &p21::Entity) -> Option HashMap { +fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap) -> HashMap { let mut merged_surfaces = std::collections::HashSet::new(); let mut next = ex.entities.keys().max().copied().unwrap_or(0) + 1; let shells: Vec = ex.entities.iter().filter(|(_, e)| matches!(e.name(), "CLOSED_SHELL" | "OPEN_SHELL")).map(|(i, _)| *i).collect(); @@ -908,6 +913,10 @@ fn merge_split_faces(ex: &mut p21::Exchange) -> HashMap { continue; } let (Some(&fa), Some(&fb)) = (faces.get(i), faces.get(j)) else { continue }; + // Faces of different colours stay apart. + if paint.get(&fa) != paint.get(&fb) { + continue; + } let (Some(ea), Some(eb)) = (ex.get(fa).and_then(|f| face_edges(ex, f)), ex.get(fb).and_then(|f| face_edges(ex, f))) else { continue; }; @@ -1100,6 +1109,10 @@ fn merge_split_faces(ex: &mut p21::Exchange) -> HashMap { merged_surfaces.insert(sid); } ex.entities.insert(nid, p21::Entity { records: vec![p21::Record { name: old.name().to_string(), params }] }); + if let Some(look) = paint.remove(&fa) { + paint.remove(&fb); + paint.insert(nid, look); + } let new_list: Vec = faces.iter().filter(|f| **f != fb).map(|f| Param::Ref(if *f == fa { nid } else { *f })).collect(); if let Some(she) = ex.entities.get_mut(&sh) && let Some(r) = she.records.first_mut() @@ -1168,14 +1181,31 @@ fn merge_split_faces(ex: &mut p21::Exchange) -> HashMap { /// The B-rep entities of one body as truck writes them, parsed, and the surfaces of faces /// merged from pieces. -fn brep_exchange(b: &Body, merge: bool) -> Result<(p21::Exchange, HashMap)> { +fn brep_exchange(b: &Body, merge: bool) -> Result<(p21::Exchange, HashMap, HashMap)> { let text = crate::step::truck_step(&[b], "SolveCraft")?; let mut ex = p21::parse(&text).map_err(|e| KernelError::Failed(format!("STEP export: {e}")))?; collapse_short_edges(&mut ex); - let merged = if merge { merge_split_faces(&mut ex) } else { Default::default() }; - Ok((ex, merged)) + // Face colours by exchange face: truck writes each shell's faces in the body's face order. + let mut paint = HashMap::new(); + if let Some(p) = b.paint() + && !p.faces.is_empty() + { + let mut shells: Vec = ex.entities.iter().filter(|(_, e)| matches!(e.name(), "CLOSED_SHELL" | "OPEN_SHELL")).map(|(i, _)| *i).collect(); + shells.sort(); + let faces: Vec = shells.iter().filter_map(|s| ex.get(*s)?.params().get(1)?.as_list()).flatten().filter_map(Param::as_ref_id).collect(); + for f in &p.faces { + if let Some(id) = faces.get(f.face) { + paint.insert(*id, (f.color, f.opacity)); + } + } + } + let merged = if merge { merge_split_faces(&mut ex, &mut paint) } else { Default::default() }; + Ok((ex, merged, paint)) } +/// A face's own colour (0..1) and opacity. +type FaceLook = ([f32; 3], f32); + fn rigid_frame(m: &[[f64; 4]; 4]) -> Result<([f64; 3], [f64; 3], [f64; 3])> { let x = [m[0][0], m[0][1], m[0][2]]; let y = [m[1][0], m[1][1], m[1][2]]; @@ -1308,18 +1338,27 @@ fn products_text(products: &[ExportProduct], root: usize, header: &StepHeader, m if !p.bodies.is_empty() { let mut solids = Vec::new(); for b in &p.bodies { - let (ex, merged) = brep_exchange(b.body, merge)?; + let (ex, merged, face_paint) = brep_exchange(b.body, merge)?; o.merged = merged; + o.faces_out.clear(); let mut roots: Vec = ex.entities.iter().filter(|(_, e)| matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS")).map(|(i, _)| *i).collect(); roots.sort(); for r in roots { let id = o.copy_solid(&ex, r, &b.name).map_err(|e| KernelError::Failed(format!("STEP export: {e}")))?; solids.push(id); + let opacity = b.body.paint().map(|p| p.opacity).unwrap_or(1.0); if let Some(c) = b.color { - styled.push(style(&mut o, id, c)); + styled.push(style(&mut o, id, c, opacity)); } } + // Faces with colours of their own. + let mut painted: Vec<(u64, ([f32; 3], f32))> = + face_paint.into_iter().filter_map(|(f, look)| Some((*o.faces_out.get(&f)?, look))).collect(); + painted.sort_by_key(|(f, _)| *f); + for (f, (c, op)) in painted { + styled.push(style(&mut o, f, c, op)); + } } let list = solids.iter().chain(std::iter::once(&origin)).map(|i| format!("#{i}")).collect::>().join(","); let absr = o.add(format!("ADVANCED_BREP_SHAPE_REPRESENTATION({},({list}),#{ctx})", string(&name))); @@ -1380,12 +1419,19 @@ fn products_text(products: &[ExportProduct], root: usize, header: &StepHeader, m } /// A surface colour for a solid (STYLED_ITEM chain). -fn style(o: &mut Out, item: u64, c: [f32; 3]) -> u64 { +fn style(o: &mut Out, item: u64, c: [f32; 3], opacity: f32) -> u64 { let rgb = o.add_shared(format!("COLOUR_RGB('',{},{},{})", real(c[0] as f64), real(c[1] as f64), real(c[2] as f64))); let fasc = o.add_shared(format!("FILL_AREA_STYLE_COLOUR('',#{rgb})")); let fas = o.add_shared(format!("FILL_AREA_STYLE('',(#{fasc}))")); let ssfa = o.add_shared(format!("SURFACE_STYLE_FILL_AREA(#{fas})")); - let sss = o.add_shared(format!("SURFACE_SIDE_STYLE('',(#{ssfa}))")); + // See-through looks add a transparency (1 = invisible) to the side style. + let sides = if opacity < 1.0 { + let tr = o.add_shared(format!("SURFACE_STYLE_TRANSPARENT({})", real((1.0 - opacity as f64).clamp(0.0, 1.0)))); + format!("#{ssfa},#{tr}") + } else { + format!("#{ssfa}") + }; + let sss = o.add_shared(format!("SURFACE_SIDE_STYLE('',({sides}))")); let ssu = o.add_shared(format!("SURFACE_STYLE_USAGE(.BOTH.,#{sss})")); let psa = o.add_shared(format!("PRESENTATION_STYLE_ASSIGNMENT((#{ssu}))")); o.add(format!("STYLED_ITEM('color',(#{psa}),#{item})"))