use std::collections::BTreeMap;
use std::io::{Cursor, Read, SeekFrom};
use cadmpeg_ir::codec::{CodecError, ContainerEntry, ContainerSummary, ReadSeek};
use cadmpeg_ir::hash::sha256_hex;
use zip::CompressionMethod;
use crate::asm_header;
pub mod role {
pub const BREP_SMBH: &str = "brep-smbh";
pub const BREP_SMB: &str = "brep-smb";
pub const PROTEIN: &str = "protein-assets";
pub const BULKSTREAM: &str = "bulkstream";
pub const METASTREAM: &str = "metastream";
pub const MANIFEST: &str = "manifest";
pub const PREVIEW: &str = "preview";
pub const IMAGE: &str = "image";
pub const PARAMESH: &str = "paramesh";
pub const DESIGN_CONFIG: &str = "design-config";
pub const PROPERTIES: &str = "properties";
pub const DIRECTORY: &str = "directory";
pub const OTHER: &str = "other";
}
pub const DETECT_MARKERS: &[&[u8]] = &[
b"Breps.BlobParts",
b"FusionAssetName",
b"FusionDocType",
b".smbh",
];
pub fn classify(name: &str) -> &'static str {
if name.ends_with('/') {
return role::DIRECTORY;
}
let base = name.rsplit('/').next().unwrap_or(name);
if std::path::Path::new(name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("smbh"))
{
role::BREP_SMBH
} else if std::path::Path::new(name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("smb"))
{
role::BREP_SMB
} else if name.ends_with(".protein") {
role::PROTEIN
} else if name.ends_with(".paramesh") {
role::PARAMESH
} else if name.ends_with(".dsgcfg") || name.ends_with(".dsgcfgrule") {
role::DESIGN_CONFIG
} else if base == "Manifest.dat" {
role::MANIFEST
} else if base == "MetaStream.dat" {
role::METASTREAM
} else if base == "BulkStream.dat" {
role::BULKSTREAM
} else if base == "Properties.dat" {
role::PROPERTIES
} else if name.contains("Previews/") {
role::PREVIEW
} else if name.contains("Images.BlobParts") {
role::IMAGE
} else {
role::OTHER
}
}
fn compression_label(method: CompressionMethod) -> String {
match method {
CompressionMethod::Stored => "stored".to_string(),
CompressionMethod::Deflated => "deflate".to_string(),
CompressionMethod::Zstd => "zstd".to_string(),
other => format!("{other:?}").to_lowercase(),
}
}
#[derive(Debug, Clone)]
pub struct BrepFacts {
pub name: String,
pub is_smbh: bool,
pub uncompressed_len: u64,
pub header: Option<asm_header::AsmHeader>,
pub delta_state_offset: Option<usize>,
pub sha256: String,
}
pub struct ContainerScan {
pub source_image: Vec<u8>,
pub entries: Vec<ContainerEntry>,
pub breps: Vec<BrepFacts>,
pub asset_folder: Option<String>,
inflated_entries: BTreeMap<String, Vec<u8>>,
}
impl ContainerScan {
pub fn entry_bytes(&self, name: &str) -> Result<&[u8], CodecError> {
self.inflated_entries
.get(name)
.map(Vec::as_slice)
.ok_or_else(|| CodecError::Malformed(format!("entry {name} not found")))
}
}
pub fn scan(reader: &mut dyn ReadSeek) -> Result<ContainerScan, CodecError> {
reader.seek(SeekFrom::Start(0))?;
let mut source_image = Vec::new();
reader.read_to_end(&mut source_image)?;
reader.seek(SeekFrom::Start(0))?;
let mut archive = zip::ZipArchive::new(Cursor::new(&source_image))
.map_err(|e| CodecError::Malformed(format!("not a readable ZIP: {e}")))?;
let mut entries = Vec::with_capacity(archive.len());
let mut breps = Vec::new();
let mut asset_folder = None;
let mut inflated_entries = BTreeMap::new();
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| CodecError::Malformed(format!("bad ZIP entry {i}: {e}")))?;
let name = file.name().to_string();
let role = classify(&name);
let mut attributes = BTreeMap::new();
let is_brep = role == role::BREP_SMBH || role == role::BREP_SMB;
let mut buf = Vec::with_capacity(file.size() as usize);
file.read_to_end(&mut buf)
.map_err(|e| CodecError::Malformed(format!("cannot read {name}: {e}")))?;
if is_brep {
if asset_folder.is_none() {
if let Some((folder, _)) = name.split_once("/Breps.BlobParts") {
asset_folder = Some(folder.to_string());
}
}
let header = asm_header::parse(&buf);
let delta = asm_header::first_delta_state_offset(&buf);
let sha = sha256_hex(&buf);
attributes.insert("asm_magic".to_string(), asm_magic_label(&buf));
if let Some(h) = &header {
attributes.insert("asm_width".to_string(), h.width.to_string());
if let Some(v) = h.release {
attributes.insert("asm_release".to_string(), v.to_string());
}
if let Some(v) = h.record_count {
attributes.insert("asm_record_count".to_string(), v.to_string());
}
if let Some(v) = h.entity_count {
attributes.insert("asm_entity_count".to_string(), v.to_string());
}
if let Some(v) = h.flags {
attributes.insert("asm_flags".to_string(), v.to_string());
}
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(s) = h.scale {
attributes.insert("scale".to_string(), format!("{s}"));
}
if let Some(r) = h.linear {
attributes.insert("resabs".to_string(), format!("{r}"));
}
if let Some(r) = h.angular {
attributes.insert("resnor".to_string(), format!("{r}"));
}
}
match delta {
Some(off) => {
attributes.insert("delta_state_first_offset".to_string(), off.to_string());
attributes.insert("active_slice_len".to_string(), off.to_string());
}
None => {
attributes.insert("delta_state_first_offset".to_string(), "none".to_string());
}
}
attributes.insert("sha256".to_string(), sha.clone());
breps.push(BrepFacts {
name: name.clone(),
is_smbh: role == role::BREP_SMBH,
uncompressed_len: file.size(),
header,
delta_state_offset: delta,
sha256: sha,
});
}
entries.push(ContainerEntry {
name: name.clone(),
role: role.to_string(),
compression: compression_label(file.compression()),
compressed_size: file.compressed_size(),
uncompressed_size: file.size(),
attributes,
});
inflated_entries.insert(name, buf);
}
Ok(ContainerScan {
source_image,
entries,
breps,
asset_folder,
inflated_entries,
})
}
pub fn summarize(scan: &ContainerScan) -> ContainerSummary {
let mut notes = Vec::new();
if let Some(folder) = &scan.asset_folder {
notes.push(format!("asset folder (from entry paths): {folder}"));
}
match select_active_brep(scan) {
Some(b) => notes.push(format!(
"active BREP candidate: {} ({} bytes uncompressed, {})",
b.name,
b.uncompressed_len,
if b.is_smbh {
"authoritative .smbh"
} else {
"no .smbh present; .smb is a construction snapshot"
}
)),
None => notes.push("no ASM BREP stream found".to_string()),
}
notes.push(
"container-level inspection only; run `decode` to build the B-rep graph and analytic \
geometry from the active BREP's SAB record stream"
.to_string(),
);
ContainerSummary {
format: "f3d".to_string(),
container_kind: "zip".to_string(),
entries: scan.entries.clone(),
notes,
}
}
pub fn select_active_brep(scan: &ContainerScan) -> Option<&BrepFacts> {
scan.breps
.iter()
.find(|b| b.is_smbh)
.or_else(|| scan.breps.first())
}
fn asm_magic_label(bytes: &[u8]) -> String {
if asm_header::has_asm_magic(bytes) {
String::from_utf8_lossy(&bytes[..15]).to_string()
} else {
"absent".to_string()
}
}