From 4f2e087ccc265c47b657138cb317de28bd313772 Mon Sep 17 00:00:00 2001 From: Lenny Khazan Date: Wed, 14 Oct 2026 06:24:18 +0000 Subject: [PATCH 1/4] Expand geometric MAPPED_ITEMs as transformed instances A MAPPED_ITEM in a shape representation the traversal visits now queues its mapped_representation at mat * target * origin^-1, so tessellation, instance copies, colours and unit scaling reuse the existing path for both triangulate() and tessellate_step_bytes(). Presentation-only mapped items (draughting models, views) are still never reached. - origin/target: AXIS2_PLACEMENT_3D or CARTESIAN_TRANSFORMATION_OPERATOR_3D (ISO 10303-42 base_axis, uniform scale); anything else warns and skips. - Scaled instances keep unit normals; mirrored ones reverse winding. - Cycles through mapped items are detected per path and reported. - The mesh-every-solid-at-identity fallback no longer fires once mapped items were found, so mapped-only models are not stacked at the origin. - SHAPE_REPRESENTATION_RELATIONSHIPs to representations that hold only MAPPED_ITEMs are now followed. ENG-4019 Amp-Thread-ID: https://ampcode.com/threads/T-01a13904-410c-738a-9919-3b52df6a9383 Co-authored-by: Amp --- triangulate/src/triangulate.rs | 198 ++++++++++++++++++++++++++++---- triangulate/tests/examples.rs | 202 +++++++++++++++++++++++++++++++++ 2 files changed, 379 insertions(+), 21 deletions(-) diff --git a/triangulate/src/triangulate.rs b/triangulate/src/triangulate.rs index 1cf7c38..feb58db 100644 --- a/triangulate/src/triangulate.rs +++ b/triangulate/src/triangulate.rs @@ -59,21 +59,128 @@ struct ShapeGeometry { uncertainty: f64, } +/// Look up an entity that may be written either on its own or as one +/// component of a complex entity. +fn entity_or_component<'a, T: FromEntity<'a>>(e: &'a Entity<'a>) -> Option<&'a T> { + T::try_from_entity(e).or_else(|| match e { + Entity::ComplexEntity(subs) => subs.iter().find_map(T::try_from_entity), + _ => None, + }) +} + +/// The representation a geometric MAPPED_ITEM instances, and the transform +/// from that representation's coordinates into the coordinates of the +/// representation holding the item: `target * origin^-1`. +fn mapped_item_transform<'a>( + s: &'a StepFile, + m: &'a MappedItem_<'a>, +) -> Result<(Representation<'a>, DMat4), Error> { + let map = s + .0 + .get(m.mapping_source.0) + .and_then(entity_or_component::) + .ok_or(Error::InvalidStepEntity("MappedItem.mapping_source"))?; + let origin = mapping_placement(s, map.mapping_origin)? + .try_inverse() + .ok_or(Error::SingularTransform("mapped item origin"))?; + let target = mapping_placement(s, m.mapping_target)?; + Ok((map.mapped_representation, target * origin)) +} + +/// A MAPPED_ITEM's origin or target as a matrix. +fn mapping_placement(s: &StepFile, item: RepresentationItem) -> Result { + let e = s + .0 + .get(item.0) + .ok_or(Error::InvalidStepEntity("mapped item placement"))?; + if let Entity::Axis2Placement3d(_) = e { + let (location, axis, ref_direction) = axis2_placement_3d(s, item.cast())?; + Ok(Surface::make_affine_transform( + axis, + ref_direction, + axis.cross(&ref_direction), + location, + )) + } else if let Some(op) = entity_or_component::(e) { + cartesian_transformation_operator_3d(s, op) + } else { + Err(Error::InvalidStepEntity( + "mapped item placement (expected AXIS2_PLACEMENT_3D or CARTESIAN_TRANSFORMATION_OPERATOR_3D)", + )) + } +} + +/// ISO 10303-42 `cartesian_transformation_operator_3d`: the axes come from +/// `base_axis`, so they are orthonormal (possibly left-handed), and `scale` +/// is uniform. +fn cartesian_transformation_operator_3d( + s: &StepFile, + op: &CartesianTransformationOperator3d_, +) -> Result { + let optional = |d: Option| d.map(|d| direction(s, d)).transpose(); + let invalid = || Error::InvalidGeometry("degenerate cartesian transformation operator axes"); + let z = match optional(op.axis3)? { + Some(z) => z.try_normalize(0.0).ok_or_else(invalid)?, + None => DVec3::z(), + }; + // first_proj_axis + let v = match optional(op.axis1)? { + Some(v) => v, + None if z.x.abs() == 1.0 && z.y == 0.0 && z.z == 0.0 => DVec3::y(), + None => DVec3::x(), + }; + let x = (v - z * v.dot(&z)).try_normalize(0.0).ok_or_else(invalid)?; + // second_proj_axis + let v = optional(op.axis2)?.unwrap_or_else(DVec3::y); + let y = v - z * v.dot(&z); + let y = (y - x * y.dot(&x)).try_normalize(0.0).ok_or_else(invalid)?; + let scale = op.scale.unwrap_or(1.0); + if !(scale.is_finite() && scale > 0.0) { + return Err(Error::InvalidGeometry( + "cartesian transformation operator scale must be positive", + )); + } + let origin = cartesian_point(s, op.local_origin)?; + Ok(Surface::make_affine_transform( + z * scale, + x * scale, + y * scale, + origin, + )) +} + fn collect_shape_instances<'a>( s: &'a StepFile, rep_instances: &HashMap, Vec>, shape_rep_relationship: &HashMap, Vec>>, ) -> Vec<(RepresentationItem<'a>, ShapeGeometry)> { + // Each entry carries the representations it was reached through, so a + // MAPPED_ITEM that (indirectly) instances its own representation is + // reported instead of expanded forever. let mut todo: Vec<_> = rep_instances .iter() - .flat_map(|(rep, mats)| mats.iter().copied().map(move |mat| (*rep, mat))) + .flat_map(|(rep, mats)| mats.iter().copied().map(move |mat| (*rep, mat, Vec::new()))) .collect(); let mut to_mesh: HashMap, ShapeGeometry> = HashMap::new(); + let mut found_mapped_items = false; - while let Some((id, mat)) = todo.pop() { + while let Some((id, mat, path)) = todo.pop() { + if path.contains(&id) { + warn!( + "Skipping representation #{}: it instances itself through MAPPED_ITEMs (via {:?})", + id.0, + path.iter().map(|r: &Representation| r.0).collect::>() + ); + continue; + } + let child_path = || { + let mut p = path.clone(); + p.push(id); + p + }; if let Some(children) = shape_rep_relationship.get(&id) { for child in children { - todo.push((*child, mat)); + todo.push((*child, mat, child_path())); } } // Bind this transform to the RepresentationItem, which is @@ -90,7 +197,8 @@ fn collect_shape_instances<'a>( let uncertainty = representation_uncertainty(s, context); for m in items.iter() { - match &s[*m] { + let e = &s[*m]; + match e { Entity::ManifoldSolidBrep(_) | Entity::BrepWithVoids(_) | Entity::ShellBasedSurfaceModel(_) => { @@ -102,13 +210,32 @@ fn collect_shape_instances<'a>( shape.uncertainty = shape.uncertainty.min(uncertainty); shape.instances.push(mat); } - Entity::Axis2Placement3d(_) | Entity::MappedItem(_) => (), - e => warn!("Skipping {:?}", e), + Entity::Axis2Placement3d(_) => (), + // A MAPPED_ITEM in a shape representation is geometry: an + // instance of another representation. MAPPED_ITEMs in + // presentation models (e.g. saved views) are never reached. + _ => match entity_or_component::(e) { + Some(mapped) => { + found_mapped_items = true; + match mapped_item_transform(s, mapped) { + Ok((rep, t)) => todo.push((rep, mat * t, child_path())), + Err(err) => warn!( + "Skipping MAPPED_ITEM #{} in representation #{}: {}", + m.0, id.0, err + ), + } + } + None => warn!("Skipping {:?}", e), + }, } } } - if to_mesh.is_empty() { + // Meshing every solid in place would stack mapped instances at their + // source coordinates, so only fall back without any mapped geometry. + if to_mesh.is_empty() && found_mapped_items { + warn!("No MAPPED_ITEM in the shape representations could be resolved to geometry"); + } else if to_mesh.is_empty() { s.0.iter() .enumerate() .filter(|(_i, e)| match e { @@ -522,35 +649,41 @@ pub(crate) fn triangulate_with_materials(s: &StepFile) -> (Mesh, Stats, Vec<(usi // parent→child direction in practice: e.g. Inventor/PDElib files often // write `SHAPE_REPRESENTATION_RELATIONSHIP(...,#advanced_brep,#shape)`. // Build the traversal edge from the non-mesh representation to the - // mesh-bearing representation, regardless of attribute order. - let representation_has_mesh_items = |rep: Id<_>| -> bool { + // mesh-bearing representation, regardless of attribute order. A + // representation holding only MAPPED_ITEMs is geometry too, but ranks + // below one holding solids, so it never captures an edge to those. + let representation_mesh_rank = |rep: Id<_>| -> u8 { let items = match &s[rep] { Entity::AdvancedBrepShapeRepresentation(b) => &b.items, Entity::ShapeRepresentation(b) => &b.items, Entity::ManifoldSurfaceShapeRepresentation(b) => &b.items, - _ => return false, + _ => return 0, }; - items.iter().any(|m| { + let solids = items.iter().any(|m| { matches!( &s[*m], Entity::ManifoldSolidBrep(_) | Entity::BrepWithVoids(_) | Entity::ShellBasedSurfaceModel(_) ) - }) + }); + if solids { + 2 + } else if items.iter().any(|m| entity_or_component::(&s[*m]).is_some()) { + 1 + } else { + 0 + } }; let mut shape_rep_relationship: HashMap, Vec>> = HashMap::new(); for (r1, r2) in filter_entities(s, |_, e| { ShapeRepresentationRelationship_::try_from_entity(e).map(|e| (e.rep_1, e.rep_2)) }) { - match ( - representation_has_mesh_items(r1), - representation_has_mesh_items(r2), - ) { - (true, false) => shape_rep_relationship.entry(r2).or_default().push(r1), - (false, true) => shape_rep_relationship.entry(r1).or_default().push(r2), - _ => continue, + match representation_mesh_rank(r1).cmp(&representation_mesh_rank(r2)) { + std::cmp::Ordering::Greater => shape_rep_relationship.entry(r2).or_default().push(r1), + std::cmp::Ordering::Less => shape_rep_relationship.entry(r1).or_default().push(r2), + std::cmp::Ordering::Equal => continue, }; } @@ -629,13 +762,14 @@ pub(crate) fn triangulate_with_materials(s: &StepFile) -> (Mesh, Stats, Vec<(usi let m_end = materials.len(); crate::timing::time("shape:instance_copies", || { for mat in &mats[1..] { + let (normal_scale, mirrored) = instance_handedness(mat); for v in v_start..v_end { let p = mesh.verts[v].pos; let p_h = DVec4::new(p.x, p.y, p.z, 1.0); let pos = (mat * p_h).xyz(); let n = mesh.verts[v].norm; - let norm = (mat * glm::vec3_to_vec4(&n)).xyz(); + let norm = (mat * glm::vec3_to_vec4(&n)).xyz() * normal_scale; let color = mesh.verts[v].color; mesh.verts.push(mesh::Vertex { pos, norm, color }); @@ -644,6 +778,9 @@ pub(crate) fn triangulate_with_materials(s: &StepFile) -> (Mesh, Stats, Vec<(usi for t in t_start..t_end { let mut tri = mesh.triangles[t]; tri.verts.add_scalar_mut(offset as u32); + if mirrored { + tri.verts.swap_rows(1, 2); + } mesh.triangles.push(tri); } materials.extend_from_within(m_start..m_end); @@ -652,13 +789,19 @@ pub(crate) fn triangulate_with_materials(s: &StepFile) -> (Mesh, Stats, Vec<(usi // Now that we've built all of the other copies of the mesh, // re-use the original mesh and apply the first transform let mat = mats[0]; + let (normal_scale, mirrored) = instance_handedness(&mat); for v in v_start..v_end { let p = mesh.verts[v].pos; let p_h = DVec4::new(p.x, p.y, p.z, 1.0); mesh.verts[v].pos = (mat * p_h).xyz(); let n = mesh.verts[v].norm; - mesh.verts[v].norm = (mat * glm::vec3_to_vec4(&n)).xyz(); + mesh.verts[v].norm = (mat * glm::vec3_to_vec4(&n)).xyz() * normal_scale; + } + if mirrored { + for t in t_start..t_end { + mesh.triangles[t].verts.swap_rows(1, 2); + } } }); (mesh, stats, materials) @@ -705,6 +848,19 @@ pub(crate) fn triangulate_with_materials(s: &StepFile) -> (Mesh, Stats, Vec<(usi (mesh, stats, materials) } +/// Instance transforms are similarities: rotations and translations, plus +/// the uniform scale and reflection a CARTESIAN_TRANSFORMATION_OPERATOR can +/// add. Returns the factor that keeps transformed normals unit length, and +/// whether the instance is mirrored, which reverses its triangles' winding. +fn instance_handedness(mat: &DMat4) -> (f64, bool) { + let det = glm::mat4_to_mat3(mat).determinant(); + let scale = det.abs().cbrt(); + // Rigid transforms, including placements whose directions are unit + // only to print precision, keep their normals exactly as before. + let normal_scale = if (scale - 1.0).abs() > 1e-5 { 1.0 / scale } else { 1.0 }; + (normal_scale, det < 0.0) +} + fn item_defined_transformation( s: &StepFile, t: Id, diff --git a/triangulate/tests/examples.rs b/triangulate/tests/examples.rs index 9c2bbe3..9aeafa0 100644 --- a/triangulate/tests/examples.rs +++ b/triangulate/tests/examples.rs @@ -219,3 +219,205 @@ fn same_rgb_faces_retain_distinct_materials_and_override_the_solid() { diffuse_strength: Some(0.4), ..base })); } + +/// A right-handed AXIS2_PLACEMENT_3D frame in millimetres: location, then +/// its x (ref_direction), y = z × x, and z (axis) directions. +type Frame = [[f64; 3]; 4]; + +fn frame(location: [f64; 3], z: [f64; 3], x: [f64; 3]) -> Frame { + let y = [z[1] * x[2] - z[2] * x[1], z[2] * x[0] - z[0] * x[2], z[0] * x[1] - z[1] * x[0]]; + [location, x, y, z] +} + +fn dot(a: [f64; 3], b: [f64; 3]) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +/// Map a point given in `origin`'s world coordinates to the point with the +/// same local coordinates in `target`, which may also scale (CTO). +fn remap(p: [f64; 3], origin: &Frame, target: &Frame, scale: f64, is_point: bool) -> [f64; 3] { + let d = if is_point { std::array::from_fn(|i| p[i] - origin[0][i]) } else { p }; + let local = [dot(d, origin[1]), dot(d, origin[2]), dot(d, origin[3])]; + std::array::from_fn(|i| { + let t = if is_point { target[0][i] } else { 0. }; + t + scale * (local[0] * target[1][i] + local[1] * target[2][i] + local[2] * target[3][i]) + }) +} + +fn xyz(v: &triangulate::mesh::Vertex) -> ([f64; 3], [f64; 3]) { + ([v.pos.x, v.pos.y, v.pos.z], [v.norm.x, v.norm.y, v.norm.z]) +} + +fn close(a: [f64; 3], b: [f64; 3], tol: f64) -> bool { + (0..3).all(|i| (a[i] - b[i]).abs() <= tol) +} + +fn triangulate_text(text: &str) -> (triangulate::mesh::Mesh, triangulate::stats::Stats) { + let flat = StepFile::strip_flatten(text.as_bytes()).unwrap(); + let step = StepFile::parse(&flat).unwrap(); + triangulate(&step) +} + +/// Every vertex of `mesh[range]` is the matching cuboid vertex taken +/// through `f` (points) and `g` (normals), and its triangles face outward. +fn assert_instance( + mesh: &triangulate::mesh::Mesh, + plain: &triangulate::mesh::Mesh, + copy: usize, + f: &dyn Fn([f64; 3]) -> [f64; 3], + g: &dyn Fn([f64; 3]) -> [f64; 3], +) { + let n = plain.verts.len(); + for (i, v) in plain.verts.iter().enumerate() { + let (p, norm) = xyz(v); + let (q, qn) = xyz(&mesh.verts[copy * n + i]); + assert!(close(q, f(p), 1e-9), "vertex {}: {:?} != {:?}", i, q, f(p)); + assert!(close(qn, g(norm), 1e-12), "normal {}: {:?} != {:?}", i, qn, g(norm)); + } + let t = plain.triangles.len(); + for tri in &mesh.triangles[copy * t..(copy + 1) * t] { + let [a, b, c] = [tri.verts.x, tri.verts.y, tri.verts.z].map(|i| xyz(&mesh.verts[i as usize])); + let (u, w) = ([0, 1, 2].map(|k| b.0[k] - a.0[k]), [0, 1, 2].map(|k| c.0[k] - a.0[k])); + let cross = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]]; + assert!(dot(cross, a.1) > 0., "triangle winding disagrees with its normal"); + } +} + +/// The cuboid's solid, ABSR #19, reached only through MAPPED_ITEM #300 in +/// the product's SHAPE_REPRESENTATION #123. Origin and target are both +/// rotated and translated, so any other composition order lands elsewhere. +const MAPPED: &str = " + #300=MAPPED_ITEM('',#301,#310); + #301=REPRESENTATION_MAP(#302,#19); + #302=AXIS2_PLACEMENT_3D('',#303,#304,#305); + #303=CARTESIAN_POINT('',(0.01,0.,0.)); + #304=DIRECTION('',(0.,0.,1.)); + #305=DIRECTION('',(0.,1.,0.)); + #310=AXIS2_PLACEMENT_3D('',#311,#312,#313); + #311=CARTESIAN_POINT('',(0.,0.02,0.005)); + #312=DIRECTION('',(1.,0.,0.)); + #313=DIRECTION('',(0.,1.,0.)); +"; + +fn mapped_only(extra: &str) -> String { + let text = CUBOID + .replace("#18=SHAPE_REPRESENTATION_RELATIONSHIP('','',#123,#19);", "") + .replace("#123=SHAPE_REPRESENTATION('Part 1',(#124),#185);", + "#123=SHAPE_REPRESENTATION('Part 1',(#124,#300),#185);"); + add_entities(&text, &format!("{}{}", MAPPED, extra)) +} + +#[test] +fn mapped_items_instance_their_representation_at_target_times_inverse_origin() { + let (plain, _) = triangulate_text(CUBOID); + // The model is in metres; the mesh is in millimetres. + let origin = frame([10., 0., 0.], [0., 0., 1.], [0., 1., 0.]); + let target = frame([0., 20., 5.], [1., 0., 0.], [0., 1., 0.]); + let f = |p| remap(p, &origin, &target, 1., true); + let g = |n| remap(n, &origin, &target, 1., false); + + // Only mapped geometry: one instance at the target, none stacked at the origin. + let (mesh, stats) = triangulate_text(&mapped_only("")); + assert!(stats.is_complete() && stats.num_faces == 6); + assert_eq!(mesh.verts.len(), plain.verts.len()); + assert_instance(&mesh, &plain, 0, &f, &g); + + // The coloured path shares the traversal and keeps the solid's style. + let (colored, stats) = tessellate_step_bytes(mapped_only("").as_bytes()).unwrap(); + assert!(stats.is_complete() && stats.num_faces == 6); + let reference = tessellate(CUBOID); + assert_eq!(colored.submeshes.len(), 1); + assert_eq!(colored.submeshes[0].color, reference.submeshes[0].color); + for (q, p) in colored.submeshes[0].positions.iter().zip(&reference.submeshes[0].positions) { + let expected = f(p.map(f64::from)); + assert!(close(q.map(f64::from), expected, 1e-4), "{:?} != {:?}", q, expected); + } + + // Nested: #19 is reached through a second, inner MAPPED_ITEM whose target + // is a CARTESIAN_TRANSFORMATION_OPERATOR_3D that scales by 0.5 and is + // left-handed (axis3 = -(axis1 × axis2)), i.e. mirrored. + let nested = mapped_only(" + #320=SHAPE_REPRESENTATION('',(#321),#185); + #321=MAPPED_ITEM('',#322,#330); + #322=REPRESENTATION_MAP(#323,#19); + #323=AXIS2_PLACEMENT_3D('',#324,#313,#304); + #324=CARTESIAN_POINT('',(0.,0.,0.002)); + #330=CARTESIAN_TRANSFORMATION_OPERATOR_3D('','',$,#331,#332,#333,0.5,#334); + #331=DIRECTION('',(0.,0.,1.)); + #332=DIRECTION('',(1.,0.,0.)); + #333=CARTESIAN_POINT('',(0.003,-0.004,0.006)); + #334=DIRECTION('',(0.,-1.,0.)); + ").replace("#301=REPRESENTATION_MAP(#302,#19);", "#301=REPRESENTATION_MAP(#302,#320);"); + let inner_origin = frame([0., 0., 2.], [0., 1., 0.], [0., 0., 1.]); + let inner_target: Frame = [[3., -4., 6.], [0., 0., 1.], [1., 0., 0.], [0., -1., 0.]]; + let f2 = |p| f(remap(p, &inner_origin, &inner_target, 0.5, true)); + let g2 = |n| g(remap(n, &inner_origin, &inner_target, 1., false)); + let (mesh, stats) = triangulate_text(&nested); + assert!(stats.is_complete() && stats.num_faces == 6); + assert_eq!(mesh.verts.len(), plain.verts.len()); + assert_instance(&mesh, &plain, 0, &f2, &g2); + + // An unresolvable mapping is skipped, not replaced by the solid stacked + // at its untransformed position. + let unresolved = mapped_only("").replace("#300=MAPPED_ITEM('',#301,#310);", "#300=MAPPED_ITEM('',#301,#311);"); + let (mesh, stats) = triangulate_text(&unresolved); + assert_eq!((mesh.verts.len(), stats.num_faces), (0, 0)); +} + +#[test] +fn mapped_instances_add_to_direct_bodies_and_cycles_terminate() { + let (plain, _) = triangulate_text(CUBOID); + let origin = frame([10., 0., 0.], [0., 0., 1.], [0., 1., 0.]); + let target = frame([0., 20., 5.], [1., 0., 0.], [0., 1., 0.]); + let f = |p| remap(p, &origin, &target, 1., true); + let g = |n| remap(n, &origin, &target, 1., false); + + // #19, reached through the SHAPE_REPRESENTATION_RELATIONSHIP, holds the + // solid directly and maps another representation of the same solid. + let mixed = add_entities( + &CUBOID.replace("#19=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#121),#185);", + "#19=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#121,#300),#185);"), + &format!("{}#320=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#121),#185);", MAPPED) + .replace("#301=REPRESENTATION_MAP(#302,#19);", "#301=REPRESENTATION_MAP(#302,#320);"), + ); + // #320 also maps #19, which maps #320 again. + let cyclic = mixed + .replace("#320=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#121),#185);", + "#320=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#121,#340),#185); + #340=MAPPED_ITEM('',#341,#310);#341=REPRESENTATION_MAP(#302,#19);"); + for text in [mixed, cyclic] { + let (mesh, stats) = triangulate_text(&text); + assert!(stats.is_complete() && stats.num_faces == 6); + assert_eq!(mesh.verts.len(), 2 * plain.verts.len()); + // Instances are ordered by their matrices, not by discovery. + let identity = |p| p; + let mapped_first = close(xyz(&mesh.verts[0]).0, f(xyz(&plain.verts[0]).0), 1e-9); + let (first, second): (&dyn Fn(_) -> _, &dyn Fn(_) -> _) = + if mapped_first { (&f, &identity) } else { (&identity, &f) }; + let (first_n, second_n): (&dyn Fn(_) -> _, &dyn Fn(_) -> _) = + if mapped_first { (&g, &identity) } else { (&identity, &g) }; + assert_instance(&mesh, &plain, 0, first, first_n); + assert_instance(&mesh, &plain, 1, second, second_n); + } +} + +#[test] +fn presentation_mapped_items_do_not_change_the_mesh() { + // Creo writes saved views as MAPPED_ITEMs of the part's representation + // in draughting and presentation models; those are not geometry. + let views = add_entities(CUBOID, &format!("{} + #400=DRAUGHTING_MODEL('',(#300),#185); + #401=PRESENTATION_VIEW('',(#402),#185); + #402=MAPPED_ITEM('',#403,#310); + #403=REPRESENTATION_MAP(#302,#123); + #404=PRESENTATION_AREA('',(#405),#185); + #405=MAPPED_ITEM('',#406,#311); + #406=REPRESENTATION_MAP(#311,#401); + ", MAPPED)); + let (plain, _) = triangulate_text(CUBOID); + let (mesh, stats) = triangulate_text(&views); + assert!(stats.is_complete() && stats.num_faces == 6); + assert_eq!(mesh.verts.len(), plain.verts.len()); + assert_instance(&mesh, &plain, 0, &|p| p, &|n| n); + assert_eq!(geometry(&tessellate(&views)), geometry(&tessellate(CUBOID))); +} From e7c201bf478a22253ac0ab75b7ec715edf62c116 Mon Sep 17 00:00:00 2001 From: Lenny Khazan Date: Wed, 14 Oct 2026 06:36:51 +0000 Subject: [PATCH 2/4] Fix MAPPED_ITEM review findings - Identity fallback: leave out only solids instanced by MAPPED_ITEMs whose placement could not be read, instead of disabling the fallback whenever any MAPPED_ITEM was seen (mapped wireframes emptied the mesh). - CARTESIAN_TRANSFORMATION_OPERATOR_3D without axis2 completes a right-handed frame, as OpenCascade does, rather than mirroring (axis3 = -z) or failing (axis3 = x). - Scaled instances: mesh shared geometry to tolerance / largest scale. - Relationships between two MAPPED_ITEM-only representations are oriented from the product's representation, or warned about when ambiguous. - Test: check the coloured mesh's vertex count before comparing positions. ENG-4019 Amp-Thread-ID: https://ampcode.com/threads/T-01a13904-410c-738a-9919-3b52df6a9383 Co-authored-by: Amp --- triangulate/src/triangulate.rs | 205 ++++++++++++++++++++++----------- triangulate/tests/examples.rs | 74 +++++++++++- 2 files changed, 211 insertions(+), 68 deletions(-) diff --git a/triangulate/src/triangulate.rs b/triangulate/src/triangulate.rs index feb58db..eaef187 100644 --- a/triangulate/src/triangulate.rs +++ b/triangulate/src/triangulate.rs @@ -68,23 +68,77 @@ fn entity_or_component<'a, T: FromEntity<'a>>(e: &'a Entity<'a>) -> Option<&'a T }) } -/// The representation a geometric MAPPED_ITEM instances, and the transform -/// from that representation's coordinates into the coordinates of the -/// representation holding the item: `target * origin^-1`. -fn mapped_item_transform<'a>( +/// The items and context of a representation the traversal can mesh. +fn shape_representation_items<'a>( + s: &'a StepFile, + rep: Representation<'a>, +) -> Option<(&'a [RepresentationItem<'a>], RepresentationContext<'a>)> { + match s.0.get(rep.0)? { + Entity::AdvancedBrepShapeRepresentation(b) => Some((&b.items, b.context_of_items)), + Entity::ShapeRepresentation(b) => Some((&b.items, b.context_of_items)), + Entity::ManifoldSurfaceShapeRepresentation(b) => Some((&b.items, b.context_of_items)), + _ => None, + } +} + +fn is_solid(e: &Entity) -> bool { + matches!( + e, + Entity::ManifoldSolidBrep(_) | Entity::BrepWithVoids(_) | Entity::ShellBasedSurfaceModel(_) + ) +} + +fn mapped_item_source<'a>( s: &'a StepFile, m: &'a MappedItem_<'a>, -) -> Result<(Representation<'a>, DMat4), Error> { - let map = s - .0 - .get(m.mapping_source.0) +) -> Result<&'a RepresentationMap_<'a>, Error> { + s.0.get(m.mapping_source.0) .and_then(entity_or_component::) - .ok_or(Error::InvalidStepEntity("MappedItem.mapping_source"))?; + .ok_or(Error::InvalidStepEntity("MappedItem.mapping_source")) +} + +/// The transform from a MAPPED_ITEM's mapped representation's coordinates +/// into those of the representation holding the item: `target * origin^-1`. +fn mapped_item_transform( + s: &StepFile, + m: &MappedItem_, + map: &RepresentationMap_, +) -> Result { let origin = mapping_placement(s, map.mapping_origin)? .try_inverse() .ok_or(Error::SingularTransform("mapped item origin"))?; let target = mapping_placement(s, m.mapping_target)?; - Ok((map.mapped_representation, target * origin)) + Ok(target * origin) +} + +/// Solids in `roots`, and in the representations they relate to or map. +fn reachable_solids<'a>( + s: &'a StepFile, + roots: Vec>, + shape_rep_relationship: &HashMap, Vec>>, +) -> HashSet> { + let mut seen = HashSet::new(); + let mut solids = HashSet::new(); + let mut todo = roots; + while let Some(rep) = todo.pop() { + if !seen.insert(rep) { + continue; + } + todo.extend(shape_rep_relationship.get(&rep).into_iter().flatten().copied()); + let Some((items, _)) = shape_representation_items(s, rep) else { + continue; + }; + for m in items { + if is_solid(&s[*m]) { + solids.insert(*m); + } else if let Some(map) = entity_or_component::(&s[*m]) + .and_then(|mapped| mapped_item_source(s, mapped).ok()) + { + todo.push(map.mapped_representation); + } + } + } + solids } /// A MAPPED_ITEM's origin or target as a matrix. @@ -130,10 +184,17 @@ fn cartesian_transformation_operator_3d( None => DVec3::x(), }; let x = (v - z * v.dot(&z)).try_normalize(0.0).ok_or_else(invalid)?; - // second_proj_axis - let v = optional(op.axis2)?.unwrap_or_else(DVec3::y); - let y = v - z * v.dot(&z); - let y = (y - x * y.dot(&x)).try_normalize(0.0).ok_or_else(invalid)?; + // second_proj_axis. Without axis2, ISO 10303-42 projects (0, 1, 0), + // which mirrors (e.g. axis3 = -z) or degenerates (axis3 = x) for + // operators exporters mean as rotations. Complete a right-handed frame + // instead, as OpenCascade does; an explicit axis2 may still mirror. + let y = match optional(op.axis2)? { + Some(v) => { + let y = v - z * v.dot(&z); + (y - x * y.dot(&x)).try_normalize(0.0).ok_or_else(invalid)? + } + None => z.cross(&x), + }; let scale = op.scale.unwrap_or(1.0); if !(scale.is_finite() && scale > 0.0) { return Err(Error::InvalidGeometry( @@ -162,7 +223,7 @@ fn collect_shape_instances<'a>( .flat_map(|(rep, mats)| mats.iter().copied().map(move |mat| (*rep, mat, Vec::new()))) .collect(); let mut to_mesh: HashMap, ShapeGeometry> = HashMap::new(); - let mut found_mapped_items = false; + let mut unplaced = Vec::new(); while let Some((id, mat, path)) = todo.pop() { if path.contains(&id) { @@ -185,23 +246,16 @@ fn collect_shape_instances<'a>( } // Bind this transform to the RepresentationItem, which is // either a ManifoldSolidBrep or a ShellBasedSurfaceModel - let (items, context) = match &s[id] { - Entity::AdvancedBrepShapeRepresentation(b) => (&b.items, b.context_of_items), - Entity::ShapeRepresentation(b) => (&b.items, b.context_of_items), - Entity::ManifoldSurfaceShapeRepresentation(b) => (&b.items, b.context_of_items), - e => { - warn!("Skipping {:?} (not a supported representation)", e); - continue; - } + let Some((items, context)) = shape_representation_items(s, id) else { + warn!("Skipping {:?} (not a supported representation)", s[id]); + continue; }; let uncertainty = representation_uncertainty(s, context); for m in items.iter() { let e = &s[*m]; match e { - Entity::ManifoldSolidBrep(_) - | Entity::BrepWithVoids(_) - | Entity::ShellBasedSurfaceModel(_) => { + _ if is_solid(e) => { let shape = to_mesh.entry(*m).or_insert_with(|| ShapeGeometry { instances: Vec::new(), uncertainty, @@ -216,8 +270,15 @@ fn collect_shape_instances<'a>( // presentation models (e.g. saved views) are never reached. _ => match entity_or_component::(e) { Some(mapped) => { - found_mapped_items = true; - match mapped_item_transform(s, mapped) { + let placed = mapped_item_source(s, mapped).and_then(|map| { + mapped_item_transform(s, mapped, map) + .map(|t| (map.mapped_representation, t)) + .map_err(|err| { + unplaced.push(map.mapped_representation); + err + }) + }); + match placed { Ok((rep, t)) => todo.push((rep, mat * t, child_path())), Err(err) => warn!( "Skipping MAPPED_ITEM #{} in representation #{}: {}", @@ -231,19 +292,20 @@ fn collect_shape_instances<'a>( } } - // Meshing every solid in place would stack mapped instances at their - // source coordinates, so only fall back without any mapped geometry. - if to_mesh.is_empty() && found_mapped_items { - warn!("No MAPPED_ITEM in the shape representations could be resolved to geometry"); - } else if to_mesh.is_empty() { + if to_mesh.is_empty() { + // Solids that MAPPED_ITEMs instance at a placement we could not + // read have no known position. Meshing them in place would stack + // them at their source coordinates, so leave them out. + let unplaced = reachable_solids(s, unplaced, shape_rep_relationship); + if !unplaced.is_empty() { + warn!( + "Not meshing {} solids instanced only by unresolved MAPPED_ITEMs", + unplaced.len() + ); + } s.0.iter() .enumerate() - .filter(|(_i, e)| match e { - Entity::ManifoldSolidBrep(_) - | Entity::BrepWithVoids(_) - | Entity::ShellBasedSurfaceModel(_) => true, - _ => false, - }) + .filter(|(i, e)| is_solid(e) && !unplaced.contains(&Id::new(*i))) .map(|(i, _e)| Id::new(i)) .for_each(|i| { to_mesh @@ -653,21 +715,10 @@ pub(crate) fn triangulate_with_materials(s: &StepFile) -> (Mesh, Stats, Vec<(usi // representation holding only MAPPED_ITEMs is geometry too, but ranks // below one holding solids, so it never captures an edge to those. let representation_mesh_rank = |rep: Id<_>| -> u8 { - let items = match &s[rep] { - Entity::AdvancedBrepShapeRepresentation(b) => &b.items, - Entity::ShapeRepresentation(b) => &b.items, - Entity::ManifoldSurfaceShapeRepresentation(b) => &b.items, - _ => return 0, + let Some((items, _)) = shape_representation_items(s, rep) else { + return 0; }; - let solids = items.iter().any(|m| { - matches!( - &s[*m], - Entity::ManifoldSolidBrep(_) - | Entity::BrepWithVoids(_) - | Entity::ShellBasedSurfaceModel(_) - ) - }); - if solids { + if items.iter().any(|m| is_solid(&s[*m])) { 2 } else if items.iter().any(|m| entity_or_component::(&s[*m]).is_some()) { 1 @@ -676,23 +727,38 @@ pub(crate) fn triangulate_with_materials(s: &StepFile) -> (Mesh, Stats, Vec<(usi } }; + let rep_instances = collect_rep_instances(s); + if rep_instances.is_empty() { + warn!("No semantic representation instances found"); + } else { + info!("Semantic representation instances: {}", rep_instances.len()); + } + let mut shape_rep_relationship: HashMap, Vec>> = HashMap::new(); for (r1, r2) in filter_entities(s, |_, e| { ShapeRepresentationRelationship_::try_from_entity(e).map(|e| (e.rep_1, e.rep_2)) }) { - match representation_mesh_rank(r1).cmp(&representation_mesh_rank(r2)) { + let rank = representation_mesh_rank(r1); + match rank.cmp(&representation_mesh_rank(r2)) { std::cmp::Ordering::Greater => shape_rep_relationship.entry(r2).or_default().push(r1), std::cmp::Ordering::Less => shape_rep_relationship.entry(r1).or_default().push(r2), + // Two representations of MAPPED_ITEMs: the product's own + // representation leads to the other one. + std::cmp::Ordering::Equal if rank == 1 => { + match (rep_instances.contains_key(&r1), rep_instances.contains_key(&r2)) { + (true, false) => shape_rep_relationship.entry(r1).or_default().push(r2), + (false, true) => shape_rep_relationship.entry(r2).or_default().push(r1), + (true, true) => (), + (false, false) => warn!( + "Skipping SHAPE_REPRESENTATION_RELATIONSHIP between #{} and #{}: \ + both hold only MAPPED_ITEMs and neither is a product's representation", + r1.0, r2.0 + ), + } + } std::cmp::Ordering::Equal => continue, }; } - - let rep_instances = collect_rep_instances(s); - if rep_instances.is_empty() { - warn!("No semantic representation instances found"); - } else { - info!("Semantic representation instances: {}", rep_instances.len()); - } let to_mesh = collect_shape_instances(s, &rep_instances, &shape_rep_relationship); let (to_mesh_iter, empty) = { @@ -719,6 +785,9 @@ pub(crate) fn triangulate_with_materials(s: &StepFile) -> (Mesh, Stats, Vec<(usi let v_start = mesh.verts.len(); let t_start = mesh.triangles.len(); let m_start = materials.len(); + // Instances share one mesh in native coordinates; a scaled-up + // copy magnifies its deviation, so mesh for the largest one. + let tolerance = tolerance / mats.iter().map(instance_scale).fold(1.0, f64::max); let (default_color, default_material) = styles .get(&id.0) .copied() @@ -854,11 +923,15 @@ pub(crate) fn triangulate_with_materials(s: &StepFile) -> (Mesh, Stats, Vec<(usi /// whether the instance is mirrored, which reverses its triangles' winding. fn instance_handedness(mat: &DMat4) -> (f64, bool) { let det = glm::mat4_to_mat3(mat).determinant(); - let scale = det.abs().cbrt(); - // Rigid transforms, including placements whose directions are unit - // only to print precision, keep their normals exactly as before. - let normal_scale = if (scale - 1.0).abs() > 1e-5 { 1.0 / scale } else { 1.0 }; - (normal_scale, det < 0.0) + (1.0 / instance_scale(mat), det < 0.0) +} + +/// The uniform scale of an instance transform. Rigid transforms, including +/// placements whose directions are unit only to print precision, are +/// exactly 1 so their meshes and normals are unchanged. +fn instance_scale(mat: &DMat4) -> f64 { + let scale = glm::mat4_to_mat3(mat).determinant().abs().cbrt(); + if (scale - 1.0).abs() > 1e-5 { scale } else { 1.0 } } fn item_defined_transformation( diff --git a/triangulate/tests/examples.rs b/triangulate/tests/examples.rs index 9aeafa0..8958bd1 100644 --- a/triangulate/tests/examples.rs +++ b/triangulate/tests/examples.rs @@ -328,6 +328,7 @@ fn mapped_items_instance_their_representation_at_target_times_inverse_origin() { let reference = tessellate(CUBOID); assert_eq!(colored.submeshes.len(), 1); assert_eq!(colored.submeshes[0].color, reference.submeshes[0].color); + assert_eq!(colored.submeshes[0].positions.len(), reference.submeshes[0].positions.len()); for (q, p) in colored.submeshes[0].positions.iter().zip(&reference.submeshes[0].positions) { let expected = f(p.map(f64::from)); assert!(close(q.map(f64::from), expected, 1e-4), "{:?} != {:?}", q, expected); @@ -357,11 +358,54 @@ fn mapped_items_instance_their_representation_at_target_times_inverse_origin() { assert_eq!(mesh.verts.len(), plain.verts.len()); assert_instance(&mesh, &plain, 0, &f2, &g2); - // An unresolvable mapping is skipped, not replaced by the solid stacked - // at its untransformed position. + // Without axis2, an operator target completes a right-handed frame: + // these are rotations, not a mirror (axis3 = -z) or a degenerate frame + // (axis3 = x). + for (axes, z, x) in [ + ("#350,$,#311,$,#351", [0., 0., -1.], [1., 0., 0.]), + ("$,$,#311,$,#350", [1., 0., 0.], [0., 1., 0.]), + ] { + let text = mapped_only(&format!(" + #360=CARTESIAN_TRANSFORMATION_OPERATOR_3D('','',$,{}); + #350=DIRECTION('',(1.,0.,0.)); + #351=DIRECTION('',(0.,0.,-1.)); + ", axes)).replace("#300=MAPPED_ITEM('',#301,#310);", "#300=MAPPED_ITEM('',#301,#360);"); + let target = frame([0., 20., 5.], z, x); + let (mesh, stats) = triangulate_text(&text); + assert!(stats.is_complete() && stats.num_faces == 6); + assert_eq!(mesh.verts.len(), plain.verts.len()); + assert_instance(&mesh, &plain, 0, + &|p| remap(p, &origin, &target, 1., true), &|n| remap(n, &origin, &target, 1., false)); + } + + // A product representation leads to a related representation that also + // holds only MAPPED_ITEMs, whichever way the relationship is written. + let linked = mapped_only(" + #370=SHAPE_REPRESENTATION('',(#371),#185); + #371=MAPPED_ITEM('',#301,#310); + #372=SHAPE_REPRESENTATION_RELATIONSHIP('','',#370,#123); + "); + let (mesh, stats) = triangulate_text(&linked); + assert!(stats.is_complete() && stats.num_faces == 6); + assert_eq!(mesh.verts.len(), 2 * plain.verts.len()); + assert_instance(&mesh, &plain, 0, &f, &g); + assert_instance(&mesh, &plain, 1, &f, &g); + + // A solid instanced only at an unreadable placement is skipped, not + // stacked at its untransformed position. let unresolved = mapped_only("").replace("#300=MAPPED_ITEM('',#301,#310);", "#300=MAPPED_ITEM('',#301,#311);"); let (mesh, stats) = triangulate_text(&unresolved); assert_eq!((mesh.verts.len(), stats.num_faces), (0, 0)); + + // A MAPPED_ITEM of non-mesh geometry leaves the fallback for solids the + // product structure does not reach. + let wireframe = mapped_only(" + #380=GEOMETRICALLY_BOUNDED_WIREFRAME_SHAPE_REPRESENTATION('',(#302),#185); + ").replace("#301=REPRESENTATION_MAP(#302,#19);", "#301=REPRESENTATION_MAP(#302,#380);"); + let (mesh, stats) = triangulate_text(&wireframe); + assert!(stats.is_complete() && stats.num_faces == 6); + assert_eq!(mesh.verts.len(), plain.verts.len()); + assert_instance(&mesh, &plain, 0, &|p| p, &|n| n); } #[test] @@ -421,3 +465,29 @@ fn presentation_mapped_items_do_not_change_the_mesh() { assert_instance(&mesh, &plain, 0, &|p| p, &|n| n); assert_eq!(geometry(&tessellate(&views)), geometry(&tessellate(CUBOID))); } + +#[test] +fn scaled_mapped_instances_are_meshed_to_the_same_physical_tolerance() { + // cube_hole's solid has a cylindrical hole: chords that are within + // tolerance at scale 1 deviate ten times as far once scaled by 10. + let source = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples/cube_hole.step"), + ) + .unwrap(); + let triangles = |scale: &str| { + let text = source + .replace("#18=SHAPE_REPRESENTATION_RELATIONSHIP('','',#143,#19);", "") + .replace("#143=SHAPE_REPRESENTATION('Part 1',(#144),#219);", + "#143=SHAPE_REPRESENTATION('Part 1',(#144,#900),#219);"); + let text = add_entities(&text, &format!(" + #900=MAPPED_ITEM('',#901,#902); + #901=REPRESENTATION_MAP(#144,#19); + #902=CARTESIAN_TRANSFORMATION_OPERATOR_3D('','',$,$,$,#903,{},$); + #903=CARTESIAN_POINT('',(0.,0.,0.)); + ", scale)); + let (mesh, stats) = triangulate_text(&text); + assert!(stats.is_complete() && stats.num_faces > 0); + mesh.triangles.len() + }; + assert!(triangles("10.") > 2 * triangles("1.")); +} From f07d0df07577a220bec0c9d9356ce2c97a81c3f2 Mon Sep 17 00:00:00 2001 From: Lenny Khazan Date: Wed, 14 Oct 2026 06:45:41 +0000 Subject: [PATCH 3/4] Fix second round of MAPPED_ITEM review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A MAPPED_ITEM placement may omit its axis (ISO 10303-42 default +Z); previously the instance was dropped and its solid left out of the fallback, emptying meshes master drew at identity. - first_proj_axis treats directions within round-off of ±X as X, so printed noise no longer flips an operator's frame by 180 degrees. - Do not link a product's MAPPED_ITEM-only representation from another of its product representations; it is already traversed directly. ENG-4019 Amp-Thread-ID: https://ampcode.com/threads/T-01a13904-410c-738a-9919-3b52df6a9383 Co-authored-by: Amp --- triangulate/src/triangulate.rs | 67 +++++++++++++++++++++++----------- triangulate/tests/examples.rs | 29 +++++++++++++++ 2 files changed, 75 insertions(+), 21 deletions(-) diff --git a/triangulate/src/triangulate.rs b/triangulate/src/triangulate.rs index eaef187..802a2fd 100644 --- a/triangulate/src/triangulate.rs +++ b/triangulate/src/triangulate.rs @@ -147,8 +147,16 @@ fn mapping_placement(s: &StepFile, item: RepresentationItem) -> Result Result) -> Result { + let v = arg.unwrap_or_else(|| { + if z.cross(&DVec3::x()).norm() <= 1e-9 { DVec3::y() } else { DVec3::x() } + }); + (v - z * v.dot(&z)) + .try_normalize(0.0) + .ok_or(Error::InvalidGeometry("reference direction is parallel to the axis")) +} + /// ISO 10303-42 `cartesian_transformation_operator_3d`: the axes come from /// `base_axis`, so they are orthonormal (possibly left-handed), and `scale` /// is uniform. @@ -177,13 +199,7 @@ fn cartesian_transformation_operator_3d( Some(z) => z.try_normalize(0.0).ok_or_else(invalid)?, None => DVec3::z(), }; - // first_proj_axis - let v = match optional(op.axis1)? { - Some(v) => v, - None if z.x.abs() == 1.0 && z.y == 0.0 && z.z == 0.0 => DVec3::y(), - None => DVec3::x(), - }; - let x = (v - z * v.dot(&z)).try_normalize(0.0).ok_or_else(invalid)?; + let x = first_proj_axis(z, optional(op.axis1)?)?; // second_proj_axis. Without axis2, ISO 10303-42 projects (0, 1, 0), // which mirrors (e.g. axis3 = -z) or degenerates (axis3 = x) for // operators exporters mean as rotations. Complete a right-handed frame @@ -738,26 +754,35 @@ pub(crate) fn triangulate_with_materials(s: &StepFile) -> (Mesh, Stats, Vec<(usi for (r1, r2) in filter_entities(s, |_, e| { ShapeRepresentationRelationship_::try_from_entity(e).map(|e| (e.rep_1, e.rep_2)) }) { - let rank = representation_mesh_rank(r1); - match rank.cmp(&representation_mesh_rank(r2)) { - std::cmp::Ordering::Greater => shape_rep_relationship.entry(r2).or_default().push(r1), - std::cmp::Ordering::Less => shape_rep_relationship.entry(r1).or_default().push(r2), + let (rank1, rank2) = (representation_mesh_rank(r1), representation_mesh_rank(r2)); + let (product1, product2) = (rep_instances.contains_key(&r1), rep_instances.contains_key(&r2)); + let (from, to) = match rank1.cmp(&rank2) { + std::cmp::Ordering::Greater => (r2, r1), + std::cmp::Ordering::Less => (r1, r2), // Two representations of MAPPED_ITEMs: the product's own // representation leads to the other one. - std::cmp::Ordering::Equal if rank == 1 => { - match (rep_instances.contains_key(&r1), rep_instances.contains_key(&r2)) { - (true, false) => shape_rep_relationship.entry(r1).or_default().push(r2), - (false, true) => shape_rep_relationship.entry(r2).or_default().push(r1), - (true, true) => (), - (false, false) => warn!( + std::cmp::Ordering::Equal if rank1 == 1 => match (product1, product2) { + (true, false) => (r1, r2), + (false, true) => (r2, r1), + (true, true) => continue, + (false, false) => { + warn!( "Skipping SHAPE_REPRESENTATION_RELATIONSHIP between #{} and #{}: \ both hold only MAPPED_ITEMs and neither is a product's representation", r1.0, r2.0 - ), + ); + continue; } - } + }, std::cmp::Ordering::Equal => continue, }; + // A product's own representation of MAPPED_ITEMs is already + // traversed from that product; linking it too would instance it + // twice. (Links to solid-bearing representations are unchanged.) + if rank1.max(rank2) == 1 && product1 && product2 { + continue; + } + shape_rep_relationship.entry(from).or_default().push(to); } let to_mesh = collect_shape_instances(s, &rep_instances, &shape_rep_relationship); diff --git a/triangulate/tests/examples.rs b/triangulate/tests/examples.rs index 8958bd1..b015642 100644 --- a/triangulate/tests/examples.rs +++ b/triangulate/tests/examples.rs @@ -361,14 +361,18 @@ fn mapped_items_instance_their_representation_at_target_times_inverse_origin() { // Without axis2, an operator target completes a right-handed frame: // these are rotations, not a mirror (axis3 = -z) or a degenerate frame // (axis3 = x). + // An axis3 printed with round-off noise off X picks the same frame as + // exactly X, not one flipped by the sign of the noise. for (axes, z, x) in [ ("#350,$,#311,$,#351", [0., 0., -1.], [1., 0., 0.]), ("$,$,#311,$,#350", [1., 0., 0.], [0., 1., 0.]), + ("$,$,#311,$,#352", [1., 0., 0.], [0., 1., 0.]), ] { let text = mapped_only(&format!(" #360=CARTESIAN_TRANSFORMATION_OPERATOR_3D('','',$,{}); #350=DIRECTION('',(1.,0.,0.)); #351=DIRECTION('',(0.,0.,-1.)); + #352=DIRECTION('',(1.,1.E-15,0.)); ", axes)).replace("#300=MAPPED_ITEM('',#301,#310);", "#300=MAPPED_ITEM('',#301,#360);"); let target = frame([0., 20., 5.], z, x); let (mesh, stats) = triangulate_text(&text); @@ -378,6 +382,31 @@ fn mapped_items_instance_their_representation_at_target_times_inverse_origin() { &|p| remap(p, &origin, &target, 1., true), &|n| remap(n, &origin, &target, 1., false)); } + // A placement may omit its axis, which is then +Z (ISO 10303-42). + let no_axis = mapped_only("").replace("#302=AXIS2_PLACEMENT_3D('',#303,#304,#305);", + "#302=AXIS2_PLACEMENT_3D('',#303,$,$);"); + let identity_origin = frame([10., 0., 0.], [0., 0., 1.], [1., 0., 0.]); + let (mesh, stats) = triangulate_text(&no_axis); + assert!(stats.is_complete() && stats.num_faces == 6); + assert_eq!(mesh.verts.len(), plain.verts.len()); + assert_instance(&mesh, &plain, 0, &|p| remap(p, &identity_origin, &target, 1., true), + &|n| remap(n, &identity_origin, &target, 1., false)); + + // A relationship between two of the product's own representations does + // not instance its MAPPED_ITEMs a second time. + let two_reps = add_entities( + &CUBOID.replace("#18=SHAPE_REPRESENTATION_RELATIONSHIP('','',#123,#19);", ""), + &format!("{} + #390=SHAPE_REPRESENTATION('',(#300),#185); + #391=SHAPE_DEFINITION_REPRESENTATION(#190,#390); + #392=SHAPE_REPRESENTATION_RELATIONSHIP('','',#123,#390); + ", MAPPED), + ); + let (mesh, stats) = triangulate_text(&two_reps); + assert!(stats.is_complete() && stats.num_faces == 6); + assert_eq!(mesh.verts.len(), plain.verts.len()); + assert_instance(&mesh, &plain, 0, &f, &g); + // A product representation leads to a related representation that also // holds only MAPPED_ITEMs, whichever way the relationship is written. let linked = mapped_only(" From 11464323e5e7aec40f61ce48aa77d06127260363 Mon Sep 17 00:00:00 2001 From: Lenny Khazan Date: Wed, 14 Oct 2026 06:54:15 +0000 Subject: [PATCH 4/4] Build MAPPED_ITEM placement frames with ISO build_axes - An AXIS2_PLACEMENT_3D along X without ref_direction no longer collapses (axis2_placement_3d's (1,0,0) default made x = z, y = 0): mapping placements now take their reference direction from first_proj_axis, and a reference parallel to the axis is an error instead of a singular frame. - Treat axes within 1e-6 rad of X as along X when choosing the default reference, covering single-precision exporter noise. ENG-4019 Amp-Thread-ID: https://ampcode.com/threads/T-01a13904-410c-738a-9919-3b52df6a9383 Co-authored-by: Amp --- triangulate/src/triangulate.rs | 29 +++++++++++++++++------------ triangulate/tests/examples.rs | 28 ++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/triangulate/src/triangulate.rs b/triangulate/src/triangulate.rs index 802a2fd..86f7744 100644 --- a/triangulate/src/triangulate.rs +++ b/triangulate/src/triangulate.rs @@ -148,15 +148,19 @@ fn mapping_placement(s: &StepFile, item: RepresentationItem) -> Result direction(s, d)? + .try_normalize(0.0) + .ok_or(Error::InvalidGeometry("zero axis direction"))?, + None => DVec3::z(), }; + let ref_direction = a.ref_direction.map(|d| direction(s, d)).transpose()?; + let ref_direction = first_proj_axis(axis, ref_direction)?; + let location = cartesian_point(s, a.location)?; Ok(Surface::make_affine_transform( axis, ref_direction, @@ -174,12 +178,13 @@ fn mapping_placement(s: &StepFile, item: RepresentationItem) -> Result) -> Result { let v = arg.unwrap_or_else(|| { - if z.cross(&DVec3::x()).norm() <= 1e-9 { DVec3::y() } else { DVec3::x() } + if z.cross(&DVec3::x()).norm() <= 1e-6 { DVec3::y() } else { DVec3::x() } }); (v - z * v.dot(&z)) .try_normalize(0.0) diff --git a/triangulate/tests/examples.rs b/triangulate/tests/examples.rs index b015642..421ed9e 100644 --- a/triangulate/tests/examples.rs +++ b/triangulate/tests/examples.rs @@ -363,17 +363,24 @@ fn mapped_items_instance_their_representation_at_target_times_inverse_origin() { // (axis3 = x). // An axis3 printed with round-off noise off X picks the same frame as // exactly X, not one flipped by the sign of the noise. - for (axes, z, x) in [ + // `v` is the direction the operator's x axis is taken from: the tilted + // axis3 keeps +Y as its reference, like exactly X does. + for (axes, z, v) in [ ("#350,$,#311,$,#351", [0., 0., -1.], [1., 0., 0.]), ("$,$,#311,$,#350", [1., 0., 0.], [0., 1., 0.]), - ("$,$,#311,$,#352", [1., 0., 0.], [0., 1., 0.]), + ("$,$,#311,$,#352", [1., 1e-15, 0.], [0., 1., 0.]), + ("$,$,#311,$,#353", [1., 2e-8, 0.], [0., 1., 0.]), ] { let text = mapped_only(&format!(" #360=CARTESIAN_TRANSFORMATION_OPERATOR_3D('','',$,{}); #350=DIRECTION('',(1.,0.,0.)); #351=DIRECTION('',(0.,0.,-1.)); #352=DIRECTION('',(1.,1.E-15,0.)); + #353=DIRECTION('',(1.,2.E-8,0.)); ", axes)).replace("#300=MAPPED_ITEM('',#301,#310);", "#300=MAPPED_ITEM('',#301,#360);"); + let unit = |a: [f64; 3]| a.map(|c| c / dot(a, a).sqrt()); + let z = unit(z); + let x = unit(std::array::from_fn(|i| v[i] - z[i] * dot(v, z))); let target = frame([0., 20., 5.], z, x); let (mesh, stats) = triangulate_text(&text); assert!(stats.is_complete() && stats.num_faces == 6); @@ -392,6 +399,23 @@ fn mapped_items_instance_their_representation_at_target_times_inverse_origin() { assert_instance(&mesh, &plain, 0, &|p| remap(p, &identity_origin, &target, 1., true), &|n| remap(n, &identity_origin, &target, 1., false)); + // An axis along X without a reference direction still has a frame (ISO + // first_proj_axis: +Y), as an origin and as a target. + let along_x = frame([0., 20., 5.], [1., 0., 0.], [0., 1., 0.]); + let x_target = mapped_only("").replace("#310=AXIS2_PLACEMENT_3D('',#311,#312,#313);", + "#310=AXIS2_PLACEMENT_3D('',#311,#312,$);"); + let x_origin = mapped_only("").replace("#300=MAPPED_ITEM('',#301,#310);", "#300=MAPPED_ITEM('',#301,#302);") + .replace("#302=AXIS2_PLACEMENT_3D('',#303,#304,#305);", "#302=AXIS2_PLACEMENT_3D('',#311,#312,$);") + .replace("#301=REPRESENTATION_MAP(#302,#19);", "#301=REPRESENTATION_MAP(#302,#19);#306=AXIS2_PLACEMENT_3D('',#303,#304,#305);") + .replace("#300=MAPPED_ITEM('',#301,#302);", "#300=MAPPED_ITEM('',#301,#306);"); + for (text, origin, target) in [(x_target, &origin, &along_x), (x_origin, &along_x, &origin)] { + let (mesh, stats) = triangulate_text(&text); + assert!(stats.is_complete() && stats.num_faces == 6); + assert_eq!(mesh.verts.len(), plain.verts.len()); + assert_instance(&mesh, &plain, 0, &|p| remap(p, origin, target, 1., true), + &|n| remap(n, origin, target, 1., false)); + } + // A relationship between two of the product's own representations does // not instance its MAPPED_ITEMs a second time. let two_reps = add_entities(