fix(acis): place a lifted solid where its body transform says

Solids in a BIM export collapsed onto the origin — every one the right shape,
all in the same place.

ACIS keeps a body's geometry in its own local frame and records where it sits
in a separate `transform` record. Both paths that drew ACIS before this
applied it; the kernel path I put in their place did not, so every solid drew
at its local origin. A file authored in absolute coordinates carries no
transform and looked fine, which is why it took a placed one to show.

The mesh now goes through `finalize_mesh`, which already applied the placement
for the fallback sampler and already split positions into the coarse and fine
pair. That fixes the fault and deletes the hand-rolled copy of the splitting
next to it.

The convention is the part worth testing and the part that invites a mistake:
ACIS treats points as row vectors, `p' = scale·(p·M) + T`, so the stored 3×3
is indexed transposed from a column-vector multiply and the translation is not
scaled. Getting the first wrong mirrors a solid instead of moving it; the
second puts it at twice its offset, which reads as a plausible position and is
the wrong one. Both are covered.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5 committed 2026-08-10 01:41:11 +03:00
1 parent 273f482aee
commit c668b2f665
1 file changed
+81 -23
+81 -23
View File
@@ -21,7 +21,8 @@ use acadrust::acis::lift;
use acadrust::entities::acis::SatDocument;
use acadrust::kernel::brep;
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
use crate::scene::convert::solid3d_tess::{body_transform, finalize_mesh};
use crate::scene::model::mesh_model::MeshLodSet;
/// How far a triangle may sit from the surface it lies on.
const SAG: f64 = 0.05;
@@ -46,6 +47,8 @@ pub fn tessellate_sat(
}
let sag = if sag > 0.0 { sag } else { SAG };
// Positions stay f64 until `finalize_mesh` splits them into the coarse
// and fine pair, so a solid at survey coordinates keeps its millimetres.
let mut positions: Vec<[f64; 3]> = Vec::new();
let mut normals: Vec<[f32; 3]> = Vec::new();
let mut indices: Vec<u32> = Vec::new();
@@ -77,32 +80,20 @@ pub fn tessellate_sat(
return None;
}
// The renderer holds each position as a coarse float plus a fine
// correction, so a solid at survey coordinates keeps its last millimetres
// instead of losing them to f32.
let mut verts = Vec::with_capacity(positions.len());
let mut verts_low = Vec::with_capacity(positions.len());
for point in &positions {
let high = [point[0] as f32, point[1] as f32, point[2] as f32];
verts.push(high);
verts_low.push([
(point[0] - high[0] as f64) as f32,
(point[1] - high[1] as f64) as f32,
(point[2] - high[2] as f64) as f32,
]);
}
let mut set = MeshLodSet::from_single(MeshModel {
// ACIS keeps a body's geometry in its own local frame and records where it
// sits in a separate `transform` record. Skipping that leaves every solid
// stacked at the origin — which is what a BIM file looks like when each
// component is placed rather than authored in world coordinates.
let mut set = MeshLodSet::from_single(finalize_mesh(
name,
verts,
verts_low,
positions,
normals,
indices,
triangle_material_handles: Vec::new(),
triangle_colors: Vec::new(),
Vec::new(),
Vec::new(),
color,
selected: false,
});
body_transform(document),
));
set.complete = loss.is_empty() && undrawn == 0;
Some(set)
}
@@ -121,8 +112,75 @@ pub fn tessellate_sat(
pub fn edge_polylines(document: &SatDocument, sag: f64) -> Vec<Vec<[f64; 3]>> {
let (bodies, _) = lift(document);
let sag = if sag > 0.0 { sag } else { SAG };
let placement = body_transform(document);
bodies
.iter()
.flat_map(|body| brep::edge_polylines(body, sag))
.map(|polyline| {
polyline
.into_iter()
.map(|point| placed(point, placement))
.collect()
})
.collect()
}
/// A body-local point moved to where the body sits.
///
/// ACIS treats points as row vectors — `p' = scale·(p·M) + T` — so the stored
/// 3×3 is indexed transposed from a column-vector multiply. Getting that the
/// wrong way round mirrors a placed solid rather than moving it.
fn placed(point: [f64; 3], xform: Option<([f64; 9], [f64; 3], f64)>) -> [f64; 3] {
let Some((m, translation, scale)) = xform else {
return point;
};
let [x, y, z] = point;
[
scale * (x * m[0] + y * m[3] + z * m[6]) + translation[0],
scale * (x * m[1] + y * m[4] + z * m[7]) + translation[1],
scale * (x * m[2] + y * m[5] + z * m[8]) + translation[2],
]
}
#[cfg(test)]
mod tests {
use super::*;
/// A quarter turn about z, written the way ACIS writes it: row-major, and
/// applied to points as row vectors.
fn quarter_turn() -> Option<([f64; 9], [f64; 3], f64)> {
Some((
[0.0, 1.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, 1.0],
[10.0, 20.0, 30.0],
2.0,
))
}
#[test]
fn a_placement_turns_the_way_acis_means_it_to() {
// Deliberately asymmetric: a transposed multiply turns the other way,
// so this catches the one mistake the convention invites.
let moved = placed([1.0, 0.0, 0.0], quarter_turn());
assert!((moved[0] - 10.0).abs() < 1e-12, "{moved:?}");
assert!((moved[1] - 22.0).abs() < 1e-12, "{moved:?}");
assert!((moved[2] - 30.0).abs() < 1e-12, "{moved:?}");
}
#[test]
fn a_body_with_no_transform_stays_where_it_is() {
// Many solids store absolute geometry and carry no transform record.
// Treating that as anything but identity moves them off their own
// coordinates.
let point = [3.0, -4.0, 5.0];
assert_eq!(placed(point, None), point);
}
#[test]
fn the_scale_reaches_the_translation_only_once() {
// `p' = scale·(p·M) + T`: the translation is not scaled. Folding the
// scale into it as well puts a placed solid at twice its offset,
// which reads as a plausible position and is the wrong one.
let moved = placed([0.0, 0.0, 0.0], quarter_turn());
assert_eq!(moved, [10.0, 20.0, 30.0]);
}
}