Skip to main content

brep_kernel/io/
snapshot.rs

1//! Native serialized exact-BREP snapshot — the parts-library fast lane.
2//!
3//! Serializes a set of EVALUATED solids (full topology, exact NURBS geometry,
4//! every deterministic face/edge name, and the scene-metadata records stamped
5//! against those names) into one base64 string, and restores them exactly.
6//! This is the `partsLibrary.snapshot` payload of the assemblies build spec
7//! (§2.1/§10.1): inserting a component decodes this straight into resident
8//! kernel structs — no sub-part history execution, no STEP-import-style
9//! reconstruction or validation gate.
10//!
11//! **Format.** A byte container framed over the existing [`encode_solid`] flat
12//! `f64` codec, base64-wrapped for JSON embedding. Reusing the flat codec
13//! (rather than a serde-derive binary layer) keeps the dependency allowlist of
14//! the published crate unchanged and pins an EXPLICIT wire layout: a struct
15//! field reorder cannot silently change the format, and every `f64` round-trips
16//! bit-exact through `to_le_bytes`. Layout (integers u32 little-endian):
17//!
18//! ```text
19//! magic "BREPSNAP" | format version | solid count
20//! per solid: name (len+utf8) | SolidNames JSON (len+utf8) | f64 count | f64s (LE)
21//! metadata record count
22//! per record: entity name (len+utf8) | record JSON (len+utf8)
23//! trailer: FNV-1a 64 checksum of every preceding byte
24//! ```
25//!
26//! The checksum makes ANY byte corruption detectable — without it a bit-flip
27//! inside a control-point coordinate would decode into silently different
28//! geometry instead of the clean `Err` the self-heal lane keys off.
29//!
30//! **Failure frame.** The snapshot is a CACHE; the embedded part document is
31//! the durable source. The format does not survive kernel refactors — it fails
32//! DETECTABLY instead: any unreadable, truncated, corrupted, or
33//! version-mismatched payload returns a clean `Err` (never a panic), which the
34//! ACOMP self-heal lane answers by re-executing the embedded document and
35//! re-snapshotting.
36//!
37//! **…except where the payload IS the document — a DURABILITY commitment.** A
38//! natively-imported part (`IMPORT3D` with `inputParams.nativeBrep`, kernel-plan
39//! `step-assembly-import.md` §3.2/§6) has NO parametric history behind it and
40//! does not retain the source file it was read from: this container holds the
41//! only copy of that geometry. There is nothing to self-heal FROM, so an
42//! unreadable payload there is LOST GEOMETRY, not a slow rebuild. This container
43//! and [`crate::SOLID_CODEC_VERSION`] are therefore a **durable format**, not a
44//! disposable cache: a version bump must ship a READER for the previous version
45//! (or a migration that rewrites old payloads), and a layout change that cannot
46//! be read forward is a breaking change to saved user documents. Cheap insurance
47//! while the format is at version 1; expensive to retrofit after the first field
48//! file. The clean-`Err` contract above still holds — it is the detection
49//! mechanism, no longer the whole answer.
50//!
51//! **Metadata seam.** [`restore_solids`] RETURNS the captured metadata records
52//! without stamping them into the scene-metadata store: the ACOMP lane must
53//! namespace entity names (`ACOMP2:…`) before calling
54//! `scene_metadata::merge_record`, and only it knows the instance prefix.
55
56use crate::topology::BrepSolid;
57use crate::{decode_solid, encode_solid, SolidNames};
58use std::collections::BTreeMap;
59
60/// Bumped whenever the container layout changes. A mismatch is always a clean
61/// `Err` — the self-heal signal for a snapshot whose part document can be
62/// re-executed. For a payload that IS the document (`nativeBrep`) that `Err` is
63/// unrecoverable, so per the durability commitment in the module doc a bump MUST
64/// ship a reader for the previous version, or a migration.
65pub const SNAPSHOT_FORMAT_VERSION: u32 = 1;
66
67const MAGIC: &[u8; 8] = b"BREPSNAP";
68
69/// One restored solid: its scene name plus the exact topology/geometry.
70#[derive(Debug, Clone)]
71pub struct RestoredSolid {
72    pub name: String,
73    pub solid: BrepSolid,
74}
75
76/// The output of [`restore_solids`]: solids in snapshot order plus the
77/// captured scene-metadata records (`entity name -> own record`), NOT stamped
78/// into the store — see the metadata seam in the module doc.
79#[derive(Debug, Clone)]
80pub struct RestoredSnapshot {
81    pub solids: Vec<RestoredSolid>,
82    pub metadata: Vec<(String, serde_json::Map<String, serde_json::Value>)>,
83}
84
85// ---------------------------------------------------------------------------
86// Snapshot (encode)
87// ---------------------------------------------------------------------------
88
89/// Serialize `(name, solid)` pairs into one base64 snapshot payload. Captures
90/// each entity's OWN scene-metadata record (solid name + every face/edge name)
91/// alongside the geometry, so a stamped part restores with its metadata. The
92/// payload is byte-deterministic for identical input (solids in caller order,
93/// metadata sorted by name).
94pub fn snapshot_solids(solids: &[(&str, &BrepSolid)]) -> Result<String, String> {
95    let mut out = Vec::with_capacity(1024);
96    out.extend_from_slice(MAGIC);
97    push_u32(&mut out, SNAPSHOT_FORMAT_VERSION);
98    push_count(&mut out, solids.len())?;
99    let mut metadata = BTreeMap::<String, String>::new();
100    let mut capture = |name: &str| -> Result<(), String> {
101        if let Some(record) = crate::feature_pipeline::scene_metadata::own_record(name) {
102            let json = serde_json::to_string(&record)
103                .map_err(|error| format!("snapshot: metadata record for '{name}': {error}"))?;
104            metadata.insert(name.to_string(), json);
105        }
106        Ok(())
107    };
108    for (name, solid) in solids {
109        let (data, names) = encode_solid(solid)?;
110        let names_json = serde_json::to_string(&names)
111            .map_err(|error| format!("snapshot: names for '{name}': {error}"))?;
112        push_str(&mut out, name)?;
113        push_str(&mut out, &names_json)?;
114        push_count(&mut out, data.len())?;
115        for value in &data {
116            out.extend_from_slice(&value.to_le_bytes());
117        }
118        capture(name)?;
119        for face_name in names.faces.values() {
120            capture(face_name)?;
121        }
122        for edge_name in names.edges.values() {
123            capture(edge_name)?;
124        }
125    }
126    push_count(&mut out, metadata.len())?;
127    for (name, record_json) in &metadata {
128        push_str(&mut out, name)?;
129        push_str(&mut out, record_json)?;
130    }
131    Ok(seal(out))
132}
133
134/// Append the container checksum and base64-wrap — the single encode exit.
135fn seal(mut container: Vec<u8>) -> String {
136    let digest = fnv1a(&container);
137    container.extend_from_slice(&digest.to_le_bytes());
138    base64_encode(&container)
139}
140
141/// FNV-1a 64 over the container bytes — cheap, dependency-free corruption
142/// detection (not cryptographic; the payload is a local cache, not an input
143/// trust boundary).
144fn fnv1a(bytes: &[u8]) -> u64 {
145    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
146    for &byte in bytes {
147        hash ^= byte as u64;
148        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
149    }
150    hash
151}
152
153/// [`snapshot_solids`] over RESIDENT solids — the shape the parts-library lane
154/// holds after running a sub-part history (`AddedSolid.name` + handle).
155pub fn snapshot_resident_solids(named_handles: &[(String, u32)]) -> Result<String, String> {
156    let mut owned = Vec::with_capacity(named_handles.len());
157    for (name, handle) in named_handles {
158        let solid = crate::with_registered_solid_str(*handle, |solid| Ok(solid.clone()))?;
159        owned.push((name.as_str(), solid));
160    }
161    let borrowed: Vec<(&str, &BrepSolid)> = owned
162        .iter()
163        .map(|(name, solid)| (*name, solid))
164        .collect();
165    snapshot_solids(&borrowed)
166}
167
168// ---------------------------------------------------------------------------
169// Restore (decode) — O(payload), straight into kernel structs
170// ---------------------------------------------------------------------------
171
172/// Restore a snapshot payload exactly. Every failure mode (bad base64, wrong
173/// magic, version mismatch, truncation, corrupted geometry) is a clean `Err`.
174pub fn restore_solids(payload: &str) -> Result<RestoredSnapshot, String> {
175    let bytes = base64_decode(payload)?;
176    // Checksum first: any corruption anywhere fails here with one clear error.
177    if bytes.len() < MAGIC.len() + 8 {
178        return Err("snapshot: payload too short".into());
179    }
180    let (body, trailer) = bytes.split_at(bytes.len() - 8);
181    let stored = u64::from_le_bytes(trailer.try_into().expect("8-byte trailer"));
182    if fnv1a(body) != stored {
183        return Err("snapshot: payload checksum mismatch (corrupted)".into());
184    }
185    let mut reader = ByteReader {
186        data: body,
187        cursor: 0,
188    };
189    if reader.take(MAGIC.len())? != MAGIC {
190        return Err("snapshot: not a BREP snapshot payload (bad magic)".into());
191    }
192    let version = reader.u32()?;
193    if version != SNAPSHOT_FORMAT_VERSION {
194        return Err(format!(
195            "snapshot: unsupported format version {version} (expected {SNAPSHOT_FORMAT_VERSION})"
196        ));
197    }
198    let solid_count = reader.u32()? as usize;
199    let mut solids = Vec::with_capacity(solid_count.min(1024));
200    for _ in 0..solid_count {
201        let name = reader.string()?;
202        let names_json = reader.string()?;
203        let names: SolidNames = serde_json::from_str(&names_json)
204            .map_err(|error| format!("snapshot: names for '{name}': {error}"))?;
205        let data = reader.f64_vec()?;
206        let solid = decode_solid(&data, &names)
207            .map_err(|error| format!("snapshot: solid '{name}': {error}"))?;
208        solids.push(RestoredSolid { name, solid });
209    }
210    let record_count = reader.u32()? as usize;
211    let mut metadata = Vec::with_capacity(record_count.min(4096));
212    for _ in 0..record_count {
213        let name = reader.string()?;
214        let record_json = reader.string()?;
215        let record: serde_json::Map<String, serde_json::Value> =
216            serde_json::from_str(&record_json)
217                .map_err(|error| format!("snapshot: metadata record for '{name}': {error}"))?;
218        metadata.push((name, record));
219    }
220    if reader.cursor != body.len() {
221        return Err("snapshot: trailing bytes after payload".into());
222    }
223    Ok(RestoredSnapshot { solids, metadata })
224}
225
226// ---------------------------------------------------------------------------
227// Byte framing
228// ---------------------------------------------------------------------------
229
230fn push_u32(out: &mut Vec<u8>, value: u32) {
231    out.extend_from_slice(&value.to_le_bytes());
232}
233
234fn push_count(out: &mut Vec<u8>, count: usize) -> Result<(), String> {
235    let value: u32 = count
236        .try_into()
237        .map_err(|_| format!("snapshot: count {count} exceeds u32"))?;
238    push_u32(out, value);
239    Ok(())
240}
241
242fn push_str(out: &mut Vec<u8>, text: &str) -> Result<(), String> {
243    push_count(out, text.len())?;
244    out.extend_from_slice(text.as_bytes());
245    Ok(())
246}
247
248struct ByteReader<'a> {
249    data: &'a [u8],
250    cursor: usize,
251}
252
253impl<'a> ByteReader<'a> {
254    fn take(&mut self, count: usize) -> Result<&'a [u8], String> {
255        let end = self
256            .cursor
257            .checked_add(count)
258            .ok_or("snapshot: truncated payload")?;
259        let slice = self
260            .data
261            .get(self.cursor..end)
262            .ok_or("snapshot: truncated payload")?;
263        self.cursor = end;
264        Ok(slice)
265    }
266
267    fn u32(&mut self) -> Result<u32, String> {
268        let raw = self.take(4)?;
269        Ok(u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]))
270    }
271
272    fn string(&mut self) -> Result<String, String> {
273        let length = self.u32()? as usize;
274        let raw = self.take(length)?;
275        String::from_utf8(raw.to_vec()).map_err(|_| "snapshot: invalid UTF-8 string".into())
276    }
277
278    fn f64_vec(&mut self) -> Result<Vec<f64>, String> {
279        let count = self.u32()? as usize;
280        let raw = self.take(
281            count
282                .checked_mul(8)
283                .ok_or("snapshot: truncated payload")?,
284        )?;
285        Ok(raw
286            .chunks_exact(8)
287            .map(|chunk| {
288                f64::from_le_bytes([
289                    chunk[0], chunk[1], chunk[2], chunk[3], chunk[4], chunk[5], chunk[6], chunk[7],
290                ])
291            })
292            .collect())
293    }
294}
295
296// ---------------------------------------------------------------------------
297// Base64 (standard alphabet, padded) — kept local: the published crate's
298// dependency allowlist stays unchanged, and the decoder must be strict
299// (reject stray characters / bad length / misplaced padding, never panic).
300// ---------------------------------------------------------------------------
301
302const B64_ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
303
304fn base64_encode(bytes: &[u8]) -> String {
305    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
306    for chunk in bytes.chunks(3) {
307        let b0 = chunk[0] as u32;
308        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
309        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
310        let triple = (b0 << 16) | (b1 << 8) | b2;
311        out.push(B64_ALPHABET[(triple >> 18) as usize & 63] as char);
312        out.push(B64_ALPHABET[(triple >> 12) as usize & 63] as char);
313        out.push(if chunk.len() > 1 {
314            B64_ALPHABET[(triple >> 6) as usize & 63] as char
315        } else {
316            '='
317        });
318        out.push(if chunk.len() > 2 {
319            B64_ALPHABET[triple as usize & 63] as char
320        } else {
321            '='
322        });
323    }
324    out
325}
326
327fn base64_value(byte: u8) -> Result<u32, String> {
328    match byte {
329        b'A'..=b'Z' => Ok((byte - b'A') as u32),
330        b'a'..=b'z' => Ok((byte - b'a') as u32 + 26),
331        b'0'..=b'9' => Ok((byte - b'0') as u32 + 52),
332        b'+' => Ok(62),
333        b'/' => Ok(63),
334        _ => Err(format!(
335            "snapshot: invalid base64 character 0x{byte:02x}"
336        )),
337    }
338}
339
340fn base64_decode(text: &str) -> Result<Vec<u8>, String> {
341    let bytes = text.as_bytes();
342    if bytes.len() % 4 != 0 {
343        return Err("snapshot: base64 length must be a multiple of 4".into());
344    }
345    let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
346    for (index, quad) in bytes.chunks_exact(4).enumerate() {
347        let is_last = (index + 1) * 4 == bytes.len();
348        // Padding is legal only as the final one or two characters.
349        let padding = match (quad[2], quad[3]) {
350            (b'=', b'=') if is_last => 2,
351            (_, b'=') if is_last && quad[2] != b'=' => 1,
352            (b'=', _) => return Err("snapshot: misplaced base64 padding".into()),
353            _ => 0,
354        };
355        if quad[0] == b'=' || quad[1] == b'=' {
356            return Err("snapshot: misplaced base64 padding".into());
357        }
358        let mut triple = base64_value(quad[0])? << 18 | base64_value(quad[1])? << 12;
359        if padding < 2 {
360            triple |= base64_value(quad[2])? << 6;
361        }
362        if padding < 1 {
363            triple |= base64_value(quad[3])?;
364        }
365        out.push((triple >> 16) as u8);
366        if padding < 2 {
367            out.push((triple >> 8) as u8);
368        }
369        if padding < 1 {
370            out.push(triple as u8);
371        }
372    }
373    Ok(out)
374}
375
376// BREP private tests: 8ae5ebbbd093dea7