cad-editor/src/scene/convert/solid3d_tess.rs
AI Assistant 4a61c54826 Fix the 3D-solids save/reopen bug: body_transform() dropped SAB integer tokens
Root cause of "reopen shows nothing, Zoom Extents finds nothing" for any
solid saved to DWG and reopened: SabWriter round-trips exact transform
values (0/1 in an identity matrix - the common case for a freshly drawn
solid) as compact SAB integer tokens (tag 0x04), not doubles.
body_transform()'s value-extraction loop only tried
coordinate_components() -> as_float() -> as_string(), never as_integer(),
so it silently parsed 0 of 13 values out of a perfectly valid transform
record. tessellate_sat() then hit its own (already-instrumented) "no
resolvable transform" bailout and aborted the whole document.

Reproduced directly against a real saved/reopened file (a standalone
harness against acadrust+cadkernel, bypassing the app's own broken native
desktop build - unrelated pre-existing issues, not touched here): the
transform record's tokens were confirmed to be Sab{tag:4,...}, and adding
the as_integer() fallback resolved them to the expected identity matrix.

Fix: fall back to token.as_integer() (cast to f64) alongside as_float()
in body_transform(). Added regression coverage with a synthetic
SAB-integer-encoded transform record.
2026-08-27 17:00:04 +10:00

350 lines
10 KiB
Rust

use acadrust::entities::acis::{SabReader, SatBody, SatDocument};
use acadrust::entities::{Body, Region, Solid3D};
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
fn tessellate_acis(
sat: &SatDocument,
name: String,
color: [f32; 4],
facet_res: f64,
chordal_deflection: Option<f64>,
isolines: usize,
) -> Option<MeshLodSet> {
crate::scene::convert::acis_kernel::tessellate_sat(
sat,
name,
color,
facet_res,
chordal_deflection,
isolines,
)
}
pub(crate) fn body_transform(
sat: &SatDocument,
body_record: usize,
) -> Result<Option<([f64; 9], [f64; 3], f64)>, ()> {
let body = SatBody::from_record(sat.record(body_record).ok_or(())?).ok_or(())?;
let Some(transform_record) = body.transform().index() else {
return Ok(None);
};
let transform = sat.record(transform_record).ok_or(())?;
if transform.entity_type != "transform" {
return Err(());
}
let mut values = Vec::with_capacity(13);
for token in &transform.tokens {
if values.len() >= 13 {
break;
}
if let Some((components, len)) = token.coordinate_components() {
values.extend_from_slice(&components[..len]);
} else if let Some(value) = token.as_float() {
values.push(value);
} else if let Some(value) = token.as_integer() {
// A SAB-encoded transform's exact values (0/1 in an identity
// matrix - the common case for a freshly drawn solid) round-trip
// as compact SAB integer tokens (tag 0x04), not doubles. Missing
// this fallback silently parsed 0 of 13 values on any such
// transform, which read as "no resolvable transform" and aborted
// tessellation of the whole document (CMMS incident, 2026-08-27:
// reproduced on a solid saved and reopened, body_transform()
// parsed a valid identity transform record down to zero values).
values.push(value as f64);
} else if let Some(text) = token.as_string() {
for word in text.split_ascii_whitespace() {
let Ok(value) = word.parse::<f64>() else {
break;
};
values.push(value);
if values.len() >= 13 {
break;
}
}
}
}
(values.len() >= 13
&& values[..13].iter().all(|value| value.is_finite())
&& values[12] > 0.0)
.then(|| {
(
[
values[0], values[1], values[2], values[3], values[4], values[5], values[6],
values[7], values[8],
],
[values[9], values[10], values[11]],
values[12],
)
})
.map(Some)
.ok_or(())
}
#[cfg(test)]
mod tests {
use super::*;
use acadrust::entities::acis::{SatPointer, SatRecord, SatToken};
fn body_record(index: i32, transform_index: i32) -> SatRecord {
let mut r = SatRecord::new(index, "body");
r.tokens = vec![
SatToken::Pointer(SatPointer::NULL),
SatToken::Pointer(SatPointer::NULL),
SatToken::Pointer(SatPointer::NULL),
SatToken::Pointer(SatPointer::new(transform_index)),
];
r
}
/// A SAB-encoded transform round-trip through save/reopen writes its
/// exact 0/1 identity values as compact SAB integer tokens (tag 0x04),
/// not doubles - reproduces the "no resolvable transform" bug that
/// aborted tessellation of an entire document after any solid was saved
/// and reopened (CMMS incident, 2026-08-27).
fn sab_int_transform_record(index: i32) -> SatRecord {
let mut r = SatRecord::new(index, "transform");
r.tokens.push(SatToken::Pointer(SatPointer::NULL));
let values: [i32; 13] = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1];
for v in values {
r.tokens.push(SatToken::Sab {
tag: 0x04,
data: v.to_le_bytes().to_vec(),
});
}
r
}
#[test]
fn resolves_a_sab_integer_encoded_identity_transform() {
let mut sat = SatDocument::new();
sat.records.push(body_record(0, 1));
sat.records.push(sab_int_transform_record(1));
let result = body_transform(&sat, 0).expect("should resolve, not error");
let (matrix, translation, scale) = result.expect("should be Some, not identity-as-None");
assert_eq!(matrix, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
assert_eq!(translation, [0.0, 0.0, 0.0]);
assert_eq!(scale, 1.0);
}
#[test]
fn a_body_with_a_null_transform_pointer_has_no_transform() {
let mut sat = SatDocument::new();
sat.records.push(body_record(0, -1));
assert_eq!(body_transform(&sat, 0), Ok(None));
}
}
pub(crate) fn finalize_mesh(
name: String,
verts: Vec<[f64; 3]>,
normals: Vec<[f32; 3]>,
indices: Vec<u32>,
triangle_material_handles: Vec<Option<acadrust::Handle>>,
triangle_colors: Vec<Option<[f32; 4]>>,
color: [f32; 4],
xform: Option<([f64; 9], [f64; 3], f64)>,
) -> MeshModel {
let mut verts_high = Vec::with_capacity(verts.len());
let mut verts_low = Vec::with_capacity(verts.len());
for [x, y, z] in verts {
let [x, y, z] = match xform {
Some((matrix, translation, scale)) => [
scale * (x * matrix[0] + y * matrix[3] + z * matrix[6]) + translation[0],
scale * (x * matrix[1] + y * matrix[4] + z * matrix[7]) + translation[1],
scale * (x * matrix[2] + y * matrix[5] + z * matrix[8]) + translation[2],
],
None => [x, y, z],
};
let high = [x as f32, y as f32, z as f32];
verts_high.push(high);
verts_low.push([
(x - high[0] as f64) as f32,
(y - high[1] as f64) as f32,
(z - high[2] as f64) as f32,
]);
}
let normals = match xform {
Some((matrix, _, _)) => normals
.iter()
.map(|normal| {
let [x, y, z] = [normal[0] as f64, normal[1] as f64, normal[2] as f64];
let transformed = [
x * matrix[0] + y * matrix[3] + z * matrix[6],
x * matrix[1] + y * matrix[4] + z * matrix[7],
x * matrix[2] + y * matrix[5] + z * matrix[8],
];
let length = (transformed[0] * transformed[0]
+ transformed[1] * transformed[1]
+ transformed[2] * transformed[2])
.sqrt();
if length > 1e-9 {
[
(transformed[0] / length) as f32,
(transformed[1] / length) as f32,
(transformed[2] / length) as f32,
]
} else {
*normal
}
})
.collect(),
None => normals,
};
MeshModel {
name,
verts: verts_high,
verts_low,
normals,
indices,
triangle_material_handles,
triangle_colors,
color,
selected: false,
}
}
fn parse_acis(
sat_fn: impl FnOnce() -> Option<SatDocument>,
is_binary: bool,
sab_data: &[u8],
) -> Option<SatDocument> {
sat_fn().or_else(|| {
(is_binary && !sab_data.is_empty())
.then(|| SabReader::read(sab_data).ok())
.flatten()
})
}
fn remap_acis_material_bindings(
set: &mut MeshLodSet,
acis: &acadrust::entities::AcisData,
) {
for lod in &mut set.lods {
for handle in lod.triangle_material_handles.iter_mut().flatten() {
let reference = handle.value() as i32;
if let Some(material_handle) = acis.materials.iter().find_map(|binding| {
(binding.absolute_reference == reference || binding.array_index == reference)
.then_some(binding.material_handle)
.flatten()
}) {
*handle = material_handle;
}
}
}
}
fn finish(
sat: SatDocument,
name: String,
color: [f32; 4],
facet_res: f64,
chordal_deflection: Option<f64>,
isolines: usize,
acis: &acadrust::entities::AcisData,
) -> Option<MeshLodSet> {
let mut set = tessellate_acis(
&sat,
name,
color,
facet_res,
chordal_deflection,
isolines,
)?;
remap_acis_material_bindings(&mut set, acis);
Some(set)
}
pub fn tessellate_region(
region: &Region,
color: [f32; 4],
facet_res: f64,
chordal_deflection: Option<f64>,
isolines: usize,
) -> Option<MeshLodSet> {
let sat = parse_acis(
|| region.parse_sat(),
region.acis_data.is_binary,
&region.acis_data.sab_data,
)?;
finish(
sat,
region.common.handle.value().to_string(),
color,
facet_res,
chordal_deflection,
isolines,
&region.acis_data,
)
}
pub fn tessellate_body(
body: &Body,
color: [f32; 4],
facet_res: f64,
chordal_deflection: Option<f64>,
isolines: usize,
) -> Option<MeshLodSet> {
let sat = parse_acis(
|| body.parse_sat(),
body.acis_data.is_binary,
&body.acis_data.sab_data,
)?;
finish(
sat,
body.common.handle.value().to_string(),
color,
facet_res,
chordal_deflection,
isolines,
&body.acis_data,
)
}
pub fn tessellate_surface(
surface: &acadrust::entities::Surface,
color: [f32; 4],
facet_res: f64,
chordal_deflection: Option<f64>,
isolines: usize,
) -> Option<MeshLodSet> {
let sat = parse_acis(
|| surface.parse_sat(),
surface.acis_data.is_binary,
&surface.acis_data.sab_data,
)?;
finish(
sat,
surface.common.handle.value().to_string(),
color,
facet_res,
chordal_deflection,
isolines,
&surface.acis_data,
)
}
pub fn tessellate_solid3d(
solid: &Solid3D,
color: [f32; 4],
facet_res: f64,
chordal_deflection: Option<f64>,
isolines: usize,
) -> Option<MeshLodSet> {
let sat = parse_acis(
|| solid.parse_sat(),
solid.acis_data.is_binary,
&solid.acis_data.sab_data,
)?;
finish(
sat,
solid.common.handle.value().to_string(),
color,
facet_res,
chordal_deflection,
isolines,
&solid.acis_data,
)
}