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})"))