use cadmpeg_ir::annotations::AnnotationBuilder;
use cadmpeg_ir::codec::{CodecError, DecodeOptions, DecodeResult, ReadSeek};
use cadmpeg_ir::document::{CadIr, SourceMeta};
use cadmpeg_ir::ids::UnknownId;
use cadmpeg_ir::native::F3dNative;
use cadmpeg_ir::report::{DecodeReport, LossCategory, LossNote, Severity};
use cadmpeg_ir::units::{Tolerances, Units};
use cadmpeg_ir::unknown::UnknownRecord;
use crate::brep::{self, Brep};
use crate::container::{self, BrepFacts, ContainerScan};
use crate::{asm_header, materials, sab};
pub fn decode(
reader: &mut dyn ReadSeek,
options: &DecodeOptions,
) -> Result<DecodeResult, CodecError> {
let scan = container::scan(reader)?;
if options.container_only {
let mut ir = build_metadata_ir(&scan);
populate_annotations(&mut ir, &scan, None);
preserve_source_image(&scan, &mut ir);
let report = build_container_report(&scan, true);
return Ok(DecodeResult::new(ir, report));
}
if let Some(active) = container::select_active_brep(&scan).cloned() {
if let Some((mut brep, mut report)) = try_decode_brep(reader, &scan, &active)? {
let decoded_materials = materials::decode_with_bodies(reader, &scan, &brep.body_keys)?;
let annotation_records = std::mem::take(&mut brep.annotation_records);
let mut ir = build_geometry_ir(&scan, &active, brep);
if let Some(history) = decode_asm_history(reader, &active)? {
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.asm_histories
.push(history);
}
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.construction_recipes = crate::design::decode_recipes(reader, &scan)?;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.persistent_references =
crate::design::decode_persistent_references(reader, &scan)?;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.lost_edge_references = crate::design::decode_lost_edge_references(reader, &scan)?;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.design_material_assignments =
crate::materials::decode_design_assignments(reader, &scan)?;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.design_objects = crate::design::decode_objects(reader, &scan)?;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.design_entity_headers = crate::design::decode_entity_headers(reader, &scan)?;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.design_record_headers = crate::design::decode_record_headers(
reader,
&scan,
&ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.design_entity_headers,
)?;
let (record_headers, entity_headers) = {
let native = ir.native.f3d.get_or_insert_with(F3dNative::default);
(
native.design_record_headers.clone(),
native.design_entity_headers.clone(),
)
};
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.sketch_relations = crate::design::decode_sketch_relations(
reader,
&scan,
&record_headers,
&entity_headers,
)?;
extend_related_design_records(reader, &scan, &mut ir)?;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.sketch_points = crate::design::decode_sketch_points(reader, &scan)?;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.sketch_curve_identities =
crate::design::decode_sketch_curve_identities(reader, &scan)?;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.design_body_members = crate::design::decode_body_members(reader, &scan)?;
let act = crate::act::decode(reader, &scan)?;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.act_entities = act.entities;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.act_guids = act.guids;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.act_root_components = act.root_components;
if !ir
.native
.f3d
.get_or_insert_with(F3dNative::default)
.lost_edge_references
.is_empty()
{
report.losses.push(LossNote {
category: LossCategory::Attribute,
severity: Severity::Warning,
message: format!(
"{} source parametric edge reference(s) were marked EDGE_REFERENCE_LOST and cannot be replayed without repair.",
ir.native.f3d.get_or_insert_with(F3dNative::default).lost_edge_references.len()
),
provenance: None,
});
}
ir.model.appearances = decoded_materials.appearances;
ir.model.appearance_bindings = decoded_materials.bindings;
if !ir.model.appearances.is_empty() {
if ir.model.appearance_bindings.is_empty() {
if let Some(loss) = report
.losses
.iter_mut()
.find(|loss| loss.category == LossCategory::Material)
{
loss.message = format!(
"{} Protein appearance asset(s) were decoded, but no topology assignment was resolved.",
ir.model.appearances.len()
);
}
} else {
report
.losses
.retain(|loss| loss.category != LossCategory::Material);
}
}
populate_annotations(&mut ir, &scan, Some((&active.name, &annotation_records)));
preserve_source_image(&scan, &mut ir);
return Ok(DecodeResult::new(ir, report));
}
}
let mut ir = build_metadata_ir(&scan);
if let Some(active) = container::select_active_brep(&scan) {
if let Some(history) = decode_asm_history(reader, active)? {
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.asm_histories
.push(history);
}
}
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.construction_recipes = crate::design::decode_recipes(reader, &scan)?;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.persistent_references = crate::design::decode_persistent_references(reader, &scan)?;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.lost_edge_references = crate::design::decode_lost_edge_references(reader, &scan)?;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.design_material_assignments = crate::materials::decode_design_assignments(reader, &scan)?;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.design_objects = crate::design::decode_objects(reader, &scan)?;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.design_entity_headers = crate::design::decode_entity_headers(reader, &scan)?;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.design_record_headers = crate::design::decode_record_headers(
reader,
&scan,
&ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.design_entity_headers,
)?;
let (record_headers, entity_headers) = {
let native = ir.native.f3d.get_or_insert_with(F3dNative::default);
(
native.design_record_headers.clone(),
native.design_entity_headers.clone(),
)
};
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.sketch_relations =
crate::design::decode_sketch_relations(reader, &scan, &record_headers, &entity_headers)?;
extend_related_design_records(reader, &scan, &mut ir)?;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.sketch_points = crate::design::decode_sketch_points(reader, &scan)?;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.sketch_curve_identities = crate::design::decode_sketch_curve_identities(reader, &scan)?;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.design_body_members = crate::design::decode_body_members(reader, &scan)?;
let act = crate::act::decode(reader, &scan)?;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.act_entities = act.entities;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.act_guids = act.guids;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.act_root_components = act.root_components;
let decoded_materials = materials::decode(reader, &scan)?;
ir.model.appearances = decoded_materials.appearances;
ir.model.appearance_bindings = decoded_materials.bindings;
populate_annotations(&mut ir, &scan, None);
preserve_source_image(&scan, &mut ir);
let report = build_container_report(&scan, false);
Ok(DecodeResult::new(ir, report))
}
fn preserve_source_image(scan: &ContainerScan, ir: &mut CadIr) {
let id = "f3d:file:source-image#0";
ir.unknowns.retain(|record| record.id.0 != id);
ir.unknowns.push(UnknownRecord {
id: UnknownId(id.into()),
offset: 0,
byte_len: scan.source_image.len() as u64,
sha256: sha256_hex(&scan.source_image),
data: Some(scan.source_image.clone()),
links: Vec::new(),
});
let hash = semantic_hash(ir);
if let Some(source) = &mut ir.source {
source.attributes.insert("semantic_sha256".into(), hash);
}
}
pub(crate) fn semantic_hash(ir: &CadIr) -> String {
let normalized = CadIr {
ir_version: ir.ir_version.clone(),
source: ir.source.as_ref().map(|source| {
let mut source = source.clone();
source.attributes.remove("semantic_sha256");
source
}),
units: ir.units.clone(),
tolerances: ir.tolerances,
model: ir.model.clone(),
annotations: ir.annotations.clone(),
native: ir.native.clone(),
unknowns: ir
.unknowns
.iter()
.filter(|record| record.id.0 != "f3d:file:source-image#0")
.cloned()
.collect(),
};
sha256_hex(
normalized
.to_canonical_json()
.expect("CadIr serialization")
.as_bytes(),
)
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
Sha256::digest(bytes)
.iter()
.fold(String::new(), |mut output, byte| {
use std::fmt::Write as _;
let _ = write!(output, "{byte:02x}");
output
})
}
fn populate_annotations(
ir: &mut CadIr,
scan: &ContainerScan,
brep: Option<(&str, &[brep::AnnotationRecord])>,
) {
let mut annotations = AnnotationBuilder::new();
if let Some((stream_name, records)) = brep {
let stream = annotations.stream(format!("f3d:{stream_name}"));
for record in records {
annotations
.note(&record.id, stream, record.offset)
.tag(&record.tag);
for field in &record.derived_fields {
annotations.derived(&record.id, *field);
}
}
}
let native_stream = annotations.stream("f3d:native");
let mut note = |id: &str, tag: &str| {
let offset = trailing_offset(id);
annotations.note(id, native_stream, offset).tag(tag);
};
if let Some(native) = &ir.native.f3d {
for entity in &native.construction_recipes {
note(&entity.id, "construction_recipe");
}
for entity in &native.persistent_references {
note(&entity.id, "persistent_reference");
}
for entity in &native.lost_edge_references {
note(&entity.id, "EDGE_REFERENCE_LOST");
}
for entity in &native.design_objects {
note(&entity.id, "design_object");
}
for entity in &native.design_entity_headers {
note(&entity.id, "design_entity_header");
}
for entity in &native.design_record_headers {
note(&entity.id, "design_record_header");
}
for entity in &native.design_body_members {
note(&entity.id, "BodiesRoot");
}
for entity in &native.design_material_assignments {
note(&entity.id, "material_assignment");
}
for entity in &native.sketch_relations {
note(&entity.id, "sketch_relation");
}
for entity in &native.sketch_points {
note(&entity.id, "sketch_point");
}
for entity in &native.sketch_curve_identities {
note(&entity.id, "sketch_curve");
}
for entity in &native.sketch_curve_links {
note(&entity.id, "sketch_curve_link");
}
for entity in &native.persistent_design_links {
note(&entity.id, "persistent_design_link");
}
for entity in &native.act_entities {
note(&entity.id, "ACTEntity");
}
for entity in &native.act_guids {
note(&entity.id, "ACTGuid");
}
for entity in &native.act_root_components {
note(&entity.id, "ACTRootComponent");
}
for history in &native.asm_histories {
note(&history.id, "history_stream");
for state in &history.states {
note(&state.id, "delta_state");
for board in &state.bulletin_boards {
note(&board.id, "BulletinBoard");
for change in &board.changes {
note(&change.id, "entity_change");
}
}
for record in &state.records {
note(&record.id, &record.name);
}
}
}
}
let appearance_stream = scan
.entries
.iter()
.find(|entry| entry.role == container::role::PROTEIN)
.map(|entry| annotations.stream(format!("f3d:{}", entry.name)));
if let Some(stream) = appearance_stream {
for appearance in &ir.model.appearances {
annotations
.note(&appearance.id.0, stream, 0)
.tag(appearance.schema.as_deref().unwrap_or("appearance"));
}
}
for binding in &ir.model.appearance_bindings {
let id = format!("{:?}:{}", binding.target, binding.appearance.0);
annotations
.note(id, native_stream, 0)
.tag("appearance_binding");
}
if brep.is_none() {
if let Some(active) = container::select_active_brep(scan) {
let stream = annotations.stream(format!("f3d:{}", active.name));
for unknown in &ir.unknowns {
annotations
.note(&unknown.id.0, stream, unknown.offset)
.tag("opaque_brep");
}
}
}
ir.annotations = annotations.build();
}
fn trailing_offset(id: &str) -> u64 {
id.rsplit(':')
.find_map(|part| part.parse::<u64>().ok())
.unwrap_or(0)
}
fn decode_asm_history(
reader: &mut dyn ReadSeek,
active: &BrepFacts,
) -> Result<Option<cadmpeg_ir::history::AsmHistory>, CodecError> {
let width = active.header.as_ref().map_or(8, |h| usize::from(h.width));
let bytes = container::decompress_entry(reader, &active.name)?;
Ok(crate::history::decode(&bytes, &active.name, width))
}
fn extend_related_design_records(
reader: &mut dyn ReadSeek,
scan: &ContainerScan,
ir: &mut CadIr,
) -> Result<(), CodecError> {
let indices = ir
.native
.f3d
.get_or_insert_with(F3dNative::default)
.sketch_relations
.iter()
.flat_map(|relation| relation.members.iter().chain(&relation.return_members))
.copied()
.collect::<Vec<_>>();
let existing = ir
.native
.f3d
.get_or_insert_with(F3dNative::default)
.design_record_headers
.iter()
.map(|record| record.record_index)
.collect::<std::collections::HashSet<_>>();
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.design_record_headers
.extend(
crate::design::decode_related_record_headers(reader, scan, &indices)?
.into_iter()
.filter(|record| !existing.contains(&record.record_index)),
);
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.design_record_headers
.sort_by_key(|record| record.record_index);
Ok(())
}
fn try_decode_brep(
reader: &mut dyn ReadSeek,
scan: &ContainerScan,
active: &BrepFacts,
) -> Result<Option<(Brep, DecodeReport)>, CodecError> {
let width = active.header.as_ref().map_or(0, |h| h.width);
if width != 4 && width != 8 {
return Ok(None);
}
let bytes = container::decompress_entry(reader, &active.name)?;
let Some(start) = asm_header::record_stream_start(&bytes) else {
return Ok(None);
};
let limit = active.delta_state_offset.unwrap_or(bytes.len());
let records = match sab::frame(&bytes, start, limit, usize::from(width)) {
Ok(r) if !r.is_empty() => r,
_ => return Ok(None),
};
let decoded = brep::decode(&records, &bytes, &active.name);
if decoded.surfaces.is_empty() && decoded.points.is_empty() && decoded.faces.is_empty() {
return Ok(None);
}
let report = build_geometry_report(scan, &decoded);
Ok(Some((decoded, report)))
}
fn build_geometry_ir(scan: &ContainerScan, active: &BrepFacts, brep: Brep) -> CadIr {
let mut ir = CadIr::empty(Units::default());
let (source, tolerances) = source_and_tolerances(scan, active);
ir.source = Some(source);
ir.tolerances = tolerances;
ir.model.bodies = brep.bodies;
ir.model.regions = brep.regions;
ir.model.shells = brep.shells;
ir.model.faces = brep.faces;
ir.model.loops = brep.loops;
ir.model.coedges = brep.coedges;
ir.model.edges = brep.edges;
ir.model.vertices = brep.vertices;
ir.model.points = brep.points;
ir.model.surfaces = brep.surfaces;
ir.model.curves = brep.curves;
ir.model.pcurves = brep.pcurves;
ir.model.procedural_surfaces = brep.procedural_surfaces;
ir.model.procedural_curves = brep.procedural_curves;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.sketch_curve_links = brep.sketch_curve_links;
ir.native
.f3d
.get_or_insert_with(F3dNative::default)
.persistent_design_links = brep.persistent_design_links;
ir.model.attributes = brep.attributes;
ir.unknowns = brep.unknowns;
ir
}
fn source_and_tolerances(scan: &ContainerScan, active: &BrepFacts) -> (SourceMeta, Tolerances) {
let mut attributes = std::collections::BTreeMap::new();
if let Some(folder) = &scan.asset_folder {
attributes.insert("asset_folder".to_string(), folder.clone());
}
attributes.insert(
"zip_entry_count".to_string(),
scan.entries.len().to_string(),
);
attributes.insert("active_brep".to_string(), active.name.clone());
attributes.insert("active_brep_sha256".to_string(), active.sha256.clone());
if let Some(off) = active.delta_state_offset {
attributes.insert("active_slice_len".to_string(), off.to_string());
}
let mut tolerances = Tolerances::default();
if let Some(h) = &active.header {
if let Some(pf) = &h.product_family {
attributes.insert("product_family".to_string(), pf.clone());
}
if let Some(pv) = &h.product_version {
attributes.insert("product_version".to_string(), pv.clone());
}
if let Some(sd) = &h.save_date {
attributes.insert("save_date".to_string(), sd.clone());
}
if let (Some(resabs), Some(resnor)) = (h.linear, h.angular) {
tolerances = Tolerances {
linear: resabs,
angular: resnor,
};
}
}
(
SourceMeta {
format: "f3d".to_string(),
attributes,
},
tolerances,
)
}
fn build_geometry_report(scan: &ContainerScan, decoded: &Brep) -> DecodeReport {
let s = &decoded.stats;
let mut losses = Vec::new();
if s.nurbs_surfaces > 0 {
losses.push(LossNote {
category: LossCategory::Geometry,
severity: Severity::Info,
message: format!(
"{} spline surface record(s) were decoded into NURBS carriers from their inline \
cached B-spline block.",
s.nurbs_surfaces
),
provenance: None,
});
}
if s.nurbs_curves > 0 {
losses.push(LossNote {
category: LossCategory::Geometry,
severity: Severity::Info,
message: format!(
"{} procedural curve record(s) were decoded into NURBS carriers from their inline \
cached 3D B-spline block.",
s.nurbs_curves
),
provenance: None,
});
}
if s.unknown_surface_faces > 0 {
losses.push(LossNote {
category: LossCategory::Geometry,
severity: Severity::Warning,
message: format!(
"{} face(s) rest on spline/procedural surfaces whose shape was not decoded into a \
typed carrier (no inline cached B-spline block — the cache is reached through a \
subtype reference, or the record is a procedural form this codec does not \
evaluate); the face, its loops, and trims are emitted with an unknown-geometry \
surface linking to the preserved record bytes. Topology is transferred; the \
underlying surface shape is not.",
s.unknown_surface_faces
),
provenance: None,
});
}
if s.procedural_curve_edges > 0 {
losses.push(LossNote {
category: LossCategory::Geometry,
severity: Severity::Warning,
message: format!(
"{} edge(s) reference a procedural intcurve/spline 3D curve with no decodable inline \
B-spline cache; the edge was emitted with its vertices and parameter range but no \
attributed curve carrier.",
s.procedural_curve_edges
),
provenance: None,
});
}
if s.undecoded_pcurve_refs > 0 {
losses.push(LossNote {
category: LossCategory::Geometry,
severity: Severity::Warning,
message: format!(
"{} coedge(s) carry an explicit UV pcurve reference whose carrier could not be \
decoded; those coedges were emitted without a pcurve.",
s.undecoded_pcurve_refs
),
provenance: None,
});
}
if s.partial_procedural_supports > 0 {
losses.push(LossNote {
category: LossCategory::Geometry,
severity: Severity::Warning,
message: format!(
"{} rolling-ball blend definition(s) retain their signed radius and solved cache, but only one of two native supports resolved.",
s.partial_procedural_supports
),
provenance: None,
});
}
if s.other_records > 0 {
losses.push(LossNote {
category: LossCategory::Attribute,
severity: Severity::Warning,
message: format!(
"{} active-slice application/refinement record(s) were not transferred: {}.",
s.other_records,
s.other_record_kinds
.iter()
.map(|(name, count)| format!("{name}={count}"))
.collect::<Vec<_>>()
.join(", ")
),
provenance: None,
});
}
losses.push(LossNote {
category: LossCategory::Material,
severity: Severity::Warning,
message: "Materials/appearances (.protein assets, ACT/design assignments) were not \
transferred."
.to_string(),
provenance: None,
});
DecodeReport {
format: "f3d".to_string(),
container_only: false,
geometry_transferred: true,
losses,
notes: container::summarize(scan)
.notes
.into_iter()
.filter(|note| !note.starts_with("container-level inspection only"))
.collect(),
}
}
fn build_metadata_ir(scan: &ContainerScan) -> CadIr {
let mut ir = CadIr::empty(Units::default());
let mut attributes = std::collections::BTreeMap::new();
if let Some(folder) = &scan.asset_folder {
attributes.insert("asset_folder".to_string(), folder.clone());
}
attributes.insert(
"zip_entry_count".to_string(),
scan.entries.len().to_string(),
);
if let Some(brep) = container::select_active_brep(scan) {
attributes.insert("active_brep".to_string(), brep.name.clone());
attributes.insert("active_brep_sha256".to_string(), brep.sha256.clone());
if let Some(off) = brep.delta_state_offset {
attributes.insert("active_slice_len".to_string(), off.to_string());
}
if let Some(h) = &brep.header {
if let Some(pf) = &h.product_family {
attributes.insert("product_family".to_string(), pf.clone());
}
if let Some(pv) = &h.product_version {
attributes.insert("product_version".to_string(), pv.clone());
}
if let Some(sd) = &h.save_date {
attributes.insert("save_date".to_string(), sd.clone());
}
if let (Some(resabs), Some(resnor)) = (h.linear, h.angular) {
ir.tolerances = Tolerances {
linear: resabs,
angular: resnor,
};
}
}
ir.unknowns.push(UnknownRecord {
id: UnknownId(format!("f3d:{}:unknown#0", brep.name)),
offset: 0,
byte_len: brep.uncompressed_len,
sha256: brep.sha256.clone(),
data: None,
links: Vec::new(),
});
}
ir.source = Some(SourceMeta {
format: "f3d".to_string(),
attributes,
});
ir
}
fn build_container_report(scan: &ContainerScan, container_only: bool) -> DecodeReport {
let summary = container::summarize(scan);
let brep_count = scan.breps.len();
let mut losses = vec![
LossNote {
category: LossCategory::Geometry,
severity: Severity::Blocking,
message: format!(
"ASM BREP geometry was not transferred: the active stream is not a decodable \
BinaryFile4/BinaryFile8 SAB (or its framing failed). {brep_count} BREP stream(s) \
were located, but no surfaces, curves, or points were produced."
),
provenance: None,
},
LossNote {
category: LossCategory::Topology,
severity: Severity::Blocking,
message:
"B-rep topology graph (body/region/shell/face/loop/coedge/edge/vertex) was not \
built for this stream."
.to_string(),
provenance: None,
},
LossNote {
category: LossCategory::Material,
severity: Severity::Warning,
message: "Materials/appearances (.protein assets, ACT/design assignments) were not \
transferred."
.to_string(),
provenance: None,
},
];
if container::select_active_brep(scan).is_none() {
losses.push(LossNote {
category: LossCategory::Geometry,
severity: Severity::Error,
message: "no ASM BREP stream (.smb/.smbh) was found in the container".to_string(),
provenance: None,
});
}
DecodeReport {
format: "f3d".to_string(),
container_only,
geometry_transferred: false,
losses,
notes: summary.notes,
}
}