Skip to main content

brep_kernel/feature_pipeline/
component.rs

1//! The scene COMPONENT concept — assemblies build-spec §3 / §10 item 2.
2//!
3//! A component is a rigid group of solids inserted by an ACOMP feature (one per
4//! placed instance). Its record carries the owning feature id, the library part
5//! name, the rigid instance pose, the fixed (grounded) flag, and an opaque source
6//! metadata slot; its member solids are ordinary scene-resident solids whose
7//! entity names are NAMESPACED with the owning feature id.
8//!
9//! # Namespacing — the prefix wraps, nothing else moves
10//!
11//! Every entity name of a member solid (the solid name, every `face.name`, every
12//! `edge.name` — the only named entities in this kernel; vertices carry no names)
13//! is prefixed with `{component_id}:` AT THE COMPONENT BOUNDARY, i.e. when the
14//! sub-part's solids enter the assembly scene:
15//!
16//! ```text
17//!   Extrude1_top            ->  ACOMP2:Extrude1_top
18//!   Extrude1|Extrude1_top[0]->  ACOMP2:Extrude1|Extrude1_top[0]
19//! ```
20//!
21//! INSIDE the namespace the deterministic-naming rules apply UNCHANGED: the
22//! names arrive final from the sub-part's own `register_added` passes
23//! (`ensure_unique_face_names` + `stamp_derived_edge_names`) and are NEVER
24//! re-derived here. Re-running the derived-edge pass after prefixing would
25//! rewrite `ACOMP2:A|B[0]` from the prefixed face names (different sort, embedded
26//! prefixes) — do not "fix" this by routing members through `register_added`.
27//! Two instances of the same part therefore never collide (distinct feature ids),
28//! and a nested assembly's already-namespaced members chain naturally:
29//! `ACOMP1:Extrude1_top` wrapped by `ACOMP3` becomes `ACOMP3:ACOMP1:Extrude1_top`.
30//!
31//! # The feature fence (build-spec §3)
32//!
33//! Solids from ordinary modeling features never belong to a component and behave
34//! exactly as before. Component geometry is valid input for constraints and
35//! sketch attach/project ONLY; modeling features must cheaply REJECT it —
36//! [`SceneMap::is_component_owned`] / [`reject_component_references`] are that
37//! predicate (Wave 2 wires the checks into feature resolution).
38
39use std::collections::BTreeMap;
40
41use crate::feature_pipeline::features::common::{collect_edge_names, collect_face_names};
42use crate::feature_pipeline::{AddedSolid, FeatureDescriptor, SceneMap};
43use crate::{transform_brep, AffineTransform, BrepSolid};
44
45// ===========================================================================
46// The component record
47// ===========================================================================
48
49/// One scene component: an ACOMP instance's identity, pose, and member solids.
50/// Rides the [`crate::feature_pipeline::FeatureResult`] `components` side-channel
51/// into [`SceneMap::apply`], exactly like profiles/frames — the scene's component
52/// set is rebuilt from feature results every history run (a projected view,
53/// never an owning data structure).
54#[derive(Debug, Clone)]
55pub struct ComponentRecord {
56    /// The owning ACOMP feature id — also the namespace prefix segment.
57    pub id: String,
58    /// The display / parts-library part name (`M4-bolt` in `M4-bolt (ACOMP3)`).
59    pub part_name: String,
60    /// The rigid instance pose (part-local snapshot space -> assembly space),
61    /// already baked into the member geometry. Kept so a pose UPDATE can apply
62    /// the delta `new · old⁻¹` and so the solver can read the current pose.
63    pub transform: AffineTransform,
64    /// Grounded flag (the feature's `isFixed`; the solver never moves it).
65    pub fixed: bool,
66    /// Opaque source metadata slot (`sourceKey` / `sourceSignature` / …) for the
67    /// parts-library + update-components lanes; the scene never interprets it.
68    pub source: serde_json::Value,
69    /// Member solid scene names, already namespaced (`{id}:{part solid name}`).
70    pub solids: Vec<String>,
71}
72
73/// The scene's component set, keyed by owning feature id. `BTreeMap` so
74/// iteration (structure tree, BOM) is deterministic.
75pub type ComponentMap = BTreeMap<String, ComponentRecord>;
76
77// ===========================================================================
78// Namespacing
79// ===========================================================================
80
81/// Wrap one entity name with a component prefix: `{component_id}:{name}`.
82pub fn namespaced(component_id: &str, name: &str) -> String {
83    format!("{component_id}:{name}")
84}
85
86/// Prefix every NAMED face/edge of a member solid with the component id (the
87/// solid's own scene name is prefixed by the caller — solids carry their name in
88/// [`AddedSolid`], not on the BREP). Unnamed entities stay unnamed (they are not
89/// scene-resolvable either way).
90fn namespace_solid_names(solid: &mut BrepSolid, component_id: &str) {
91    for shell in &mut solid.shells {
92        for face in &mut shell.faces {
93            if let Some(name) = &face.name {
94                face.name = Some(namespaced(component_id, name));
95            }
96        }
97    }
98    for edge in &mut solid.edges {
99        if let Some(name) = &edge.name {
100            edge.name = Some(namespaced(component_id, name));
101        }
102    }
103}
104
105/// A component id must be a plain feature id: non-empty, no `:` (the namespace
106/// delimiter) and no `|` (the topology-vs-authored name discriminator).
107fn validate_component_id(id: &str) -> Result<(), String> {
108    if id.is_empty() {
109        return Err("component id must not be empty".into());
110    }
111    if id.contains(':') || id.contains('|') {
112        return Err(format!("component id '{id}' must not contain ':' or '|'"));
113    }
114    Ok(())
115}
116
117// ===========================================================================
118// Rigid-transform helpers (row-major 4x4, the AffineTransform layout)
119// ===========================================================================
120
121/// Require a RIGID map (orthonormal rotation rows, determinant +1). Components
122/// move as rigid bodies — a scaling/shearing/reflecting instance pose is a
123/// modeling error, rejected loudly (no fallback).
124fn require_rigid(transform: &AffineTransform) -> Result<(), String> {
125    let m = &transform.elements;
126    let rows = [[m[0], m[1], m[2]], [m[4], m[5], m[6]], [m[8], m[9], m[10]]];
127    for i in 0..3 {
128        for j in i..3 {
129            let dot: f64 = (0..3).map(|k| rows[i][k] * rows[j][k]).sum();
130            let expected = if i == j { 1.0 } else { 0.0 };
131            if (dot - expected).abs() > 1e-8 {
132                return Err("component transform must be rigid (rotation + translation)".into());
133            }
134        }
135    }
136    if (transform.determinant3() - 1.0).abs() > 1e-8 {
137        return Err("component transform must be rigid (no reflection/scale)".into());
138    }
139    Ok(())
140}
141
142/// Row-major product `a · b` (apply `b` first, then `a`).
143fn compose(a: &AffineTransform, b: &AffineTransform) -> Result<AffineTransform, String> {
144    let (ma, mb) = (&a.elements, &b.elements);
145    let mut out = [0.0f64; 16];
146    for row in 0..4 {
147        for col in 0..4 {
148            out[row * 4 + col] = (0..4)
149                .map(|k| ma[row * 4 + k] * mb[k * 4 + col])
150                .sum();
151        }
152    }
153    AffineTransform::new(out)
154}
155
156/// Inverse of a RIGID map: `[Rᵀ | −Rᵀ·t]` (caller has verified rigidity).
157fn rigid_inverse(transform: &AffineTransform) -> Result<AffineTransform, String> {
158    let m = &transform.elements;
159    let r = [[m[0], m[1], m[2]], [m[4], m[5], m[6]], [m[8], m[9], m[10]]];
160    let t = [m[3], m[7], m[11]];
161    let mut out = [0.0f64; 16];
162    for row in 0..3 {
163        for col in 0..3 {
164            out[row * 4 + col] = r[col][row]; // Rᵀ
165        }
166        out[row * 4 + 3] = -(0..3).map(|k| r[k][row] * t[k]).sum::<f64>();
167    }
168    out[15] = 1.0;
169    AffineTransform::new(out)
170}
171
172// ===========================================================================
173// Create / re-pose
174// ===========================================================================
175
176/// Build a component from a set of PART-LOCAL solids: rigid-pose each member
177/// with `transform`, namespace its entity names with `id`, and register it as a
178/// scene-resident solid. Returns the component record plus one [`AddedSolid`]
179/// per member — the owning ACOMP feature pushes both into its `FeatureResult`
180/// (`added` + `components`), and the record's handles are then owned by that
181/// feature's history-cache entry like any other feature output.
182///
183/// `members` are `(solid name, part-local BREP)` pairs exactly as the sub-part's
184/// history produced them (deterministic names final; see the module doc — the
185/// prefix wraps them, nothing is re-derived). Two-phase: every member is posed
186/// BEFORE anything registers, so a failure never leaks a half-built component.
187// Contract surface for the Wave-2 ACOMP feature; exercised by this module's tests.
188#[allow(dead_code)]
189pub fn create_component(
190    id: &str,
191    part_name: &str,
192    fixed: bool,
193    source: serde_json::Value,
194    transform: AffineTransform,
195    members: Vec<(String, BrepSolid)>,
196) -> Result<(ComponentRecord, Vec<AddedSolid>), String> {
197    validate_component_id(id)?;
198    require_rigid(&transform)?;
199
200    // Phase 1 (fallible): pose + namespace every member.
201    let mut posed: Vec<(String, BrepSolid)> = Vec::with_capacity(members.len());
202    for (member_name, solid) in &members {
203        let mut body = transform_brep(solid, transform, false)
204            .map_err(|error| format!("component '{id}': member '{member_name}': {error}"))?;
205        namespace_solid_names(&mut body, id);
206        posed.push((namespaced(id, member_name), body));
207    }
208
209    // Phase 2 (infallible): register.
210    let mut added = Vec::with_capacity(posed.len());
211    let mut solids = Vec::with_capacity(posed.len());
212    for (name, body) in posed {
213        let face_names = collect_face_names(&body);
214        let edge_names = collect_edge_names(&body);
215        let handle = crate::register_solid_value(body);
216        solids.push(name.clone());
217        added.push(AddedSolid {
218            handle,
219            name,
220            face_names,
221            edge_names,
222            ..AddedSolid::default()
223        });
224    }
225
226    Ok((
227        ComponentRecord {
228            id: id.to_string(),
229            part_name: part_name.to_string(),
230            transform,
231            fixed,
232            source,
233            solids,
234        },
235        added,
236    ))
237}
238
239/// Re-pose a component to a NEW absolute rigid transform: applies the delta
240/// `new · old⁻¹` to every member solid IN PLACE (same handles — a rigid map
241/// keeps every topology id and name, so the scene's face/edge refs stay valid)
242/// and updates the record.
243///
244/// This is the INTERACTIVE lane (move-gizmo drag, solver preview). The
245/// AUTHORITATIVE pose lives on the owning ACOMP feature's `inputParams`
246/// transform: a commit must write it back there (the solver pose write-back),
247/// which dirties the feature's fingerprint so the next history run re-executes
248/// it from the snapshot at the committed pose. Without the write-back, a clean
249/// cache replay would serve the mutated geometry against a stale descriptor.
250///
251/// Two-phase: every member transform is computed before anything commits, so a
252/// failure never leaves the component half-posed.
253// Contract surface for the Wave-2/3 move + solver lanes; exercised by tests.
254#[allow(dead_code)]
255pub fn update_component_transform(
256    scene: &mut SceneMap,
257    id: &str,
258    new_transform: AffineTransform,
259) -> Result<(), String> {
260    require_rigid(&new_transform)?;
261    let record = scene
262        .components
263        .get(id)
264        .ok_or_else(|| format!("unknown component '{id}'"))?;
265    let delta = compose(&new_transform, &rigid_inverse(&record.transform)?)?;
266
267    // Phase 1 (fallible): resolve + re-pose every member.
268    let mut posed: Vec<(u32, BrepSolid)> = Vec::with_capacity(record.solids.len());
269    for name in &record.solids {
270        let handle = scene
271            .solids
272            .get(name)
273            .copied()
274            .ok_or_else(|| format!("component '{id}': member '{name}' is not scene-resident"))?;
275        let body = crate::with_registered_solid_str(handle, |solid| {
276            transform_brep(solid, delta, false)
277        })
278        .map_err(|error| format!("component '{id}': member '{name}': {error}"))?;
279        posed.push((handle, body));
280    }
281
282    // Phase 2: commit under the existing handles, then update the record.
283    for (handle, body) in posed {
284        crate::replace_registered_solid(handle, body)?;
285    }
286    scene
287        .components
288        .get_mut(id)
289        .expect("record fetched above")
290        .transform = new_transform;
291    Ok(())
292}
293
294/// The feature fence (build-spec §3): fail with a clear message when any of
295/// `names` resolves to component-owned geometry. Modeling features that consume
296/// solids/faces/edges call this over their reference names before operating —
297/// cross-part feature linking is out of scope and must be rejected cleanly,
298/// never silently applied.
299pub fn reject_component_references<'a>(
300    scene: &SceneMap,
301    names: impl IntoIterator<Item = &'a str>,
302) -> Result<(), String> {
303    for name in names {
304        if let Some(record) = scene.owning_component(name) {
305            return Err(format!(
306                "'{name}' belongs to assembly component '{}' — modeling features cannot consume component geometry",
307                record.id
308            ));
309        }
310    }
311    Ok(())
312}
313
314/// The fence at the DISPATCH altitude — the one place every feature passes
315/// through (`execute_feature`), so no per-feature checks are scattered. A
316/// reference here is what the pipeline already defines it to be (the
317/// `scan_consumed` cache-dependency walk): any descriptor string that
318/// exact-matches a scene name. Any such string owned by a component fails the
319/// feature before it executes.
320///
321/// Exempt (the two ALLOWED in-context uses, build-spec §3 — attach + project):
322/// SKETCH (plane attach + edge projection), DATUM/PLANE (a datum derived from
323/// component geometry), and ACOMP itself (the component's own feature, wired by
324/// the parts-library lane). Assembly constraints are not history features, so
325/// they never reach this fence. The exemption is type-level, which is safe
326/// because none of the exempt types carries a `boolean` param or operand-solid
327/// references — re-examine if such a param is ever added to one of them.
328pub fn enforce_reference_fence(
329    feature_type: &str,
330    descriptor: &FeatureDescriptor,
331    scene: &SceneMap,
332) -> Result<(), String> {
333    // Componentless scenes skip the walk entirely — the byte-identical
334    // guarantee for every existing modeling document.
335    if scene.components.is_empty() {
336        return Ok(());
337    }
338    // Verbatim dispatch alias strings (the dispatch matches exact spellings;
339    // "ASSEMBLY COMPONENT" is ACOMP's long dispatch alias in assembly_component.rs).
340    if matches!(
341        feature_type,
342        "S" | "SKETCH" | "D" | "DATUM" | "DATIUM" | "P" | "PLANE" | "ACOMP" | "ASSEMBLY COMPONENT"
343    ) {
344        return Ok(());
345    }
346    if let Some(name) = first_component_owned(&descriptor.input_params, scene)
347        .or_else(|| first_component_owned(&descriptor.persistent_data, scene))
348    {
349        return reject_component_references(scene, [name]);
350    }
351    Ok(())
352}
353
354/// The first string anywhere in `value` (recursively, both param sources — the
355/// `scan_consumed` walk shape) that names component-owned geometry.
356fn first_component_owned<'a>(
357    value: &'a serde_json::Value,
358    scene: &SceneMap,
359) -> Option<&'a str> {
360    match value {
361        serde_json::Value::String(text) => {
362            let trimmed = text.trim();
363            (!trimmed.is_empty() && scene.is_component_owned(trimmed)).then_some(trimmed)
364        }
365        serde_json::Value::Array(items) => {
366            items.iter().find_map(|item| first_component_owned(item, scene))
367        }
368        serde_json::Value::Object(map) => {
369            map.values().find_map(|item| first_component_owned(item, scene))
370        }
371        _ => None,
372    }
373}
374
375// ===========================================================================
376// SceneMap component surface
377// ===========================================================================
378
379impl SceneMap {
380    /// Resolve a component record by its owning feature id (exact match).
381    /// Contract surface for the Wave-2 constraint/selection lanes.
382    #[allow(dead_code)]
383    pub fn resolve_component(&self, id: &str) -> Option<&ComponentRecord> {
384        self.components.get(id)
385    }
386
387    /// The component owning an entity name, if any: the BARE component id itself
388    /// (a COMPONENT-type selection), or the name's OUTERMOST namespace prefix
389    /// (`ACOMP3:ACOMP1:X` is owned by `ACOMP3` at this scene level; `ACOMP1` is
390    /// the nested assembly's internal structure). Membership is decided against
391    /// the registered component set, never syntactically — authored names like
392    /// `S1:PROFILE` have a first segment that is a sketch id, not a component id.
393    pub fn owning_component(&self, name: &str) -> Option<&ComponentRecord> {
394        if let Some(record) = self.components.get(name) {
395            return Some(record);
396        }
397        let (prefix, _) = name.split_once(':')?;
398        self.components.get(prefix)
399    }
400
401    /// The feature-fence predicate: does this entity name belong to a component?
402    /// Cheap (one map probe + one prefix probe); the dispatch fence
403    /// ([`enforce_reference_fence`]) uses it to REJECT component geometry as
404    /// modeling-feature input (build-spec §3).
405    pub fn is_component_owned(&self, name: &str) -> bool {
406        self.owning_component(name).is_some()
407    }
408
409    /// Iterate the scene's components in deterministic (id) order — the
410    /// structure-tree / BOM projection surface.
411    #[allow(dead_code)]
412    pub fn iter_components(&self) -> impl Iterator<Item = &ComponentRecord> {
413        self.components.values()
414    }
415
416    /// A component's member solids as `(scene name, resident handle)`, in the
417    /// record's member order. A member missing from the solid map (never the
418    /// case for an intact scene) is skipped.
419    #[allow(dead_code)]
420    pub fn component_solids(&self, id: &str) -> Vec<(String, u32)> {
421        let Some(record) = self.components.get(id) else {
422            return Vec::new();
423        };
424        record
425            .solids
426            .iter()
427            .filter_map(|name| self.solids.get(name).map(|&handle| (name.clone(), handle)))
428            .collect()
429    }
430
431    /// A component's grounded flag (`None` for an unknown id).
432    #[allow(dead_code)]
433    pub fn component_fixed(&self, id: &str) -> Option<bool> {
434        self.components.get(id).map(|record| record.fixed)
435    }
436
437    /// Set a component's grounded flag. Scene-view state only — the
438    /// authoritative flag is the owning feature's `isFixed` (the Fixed
439    /// constraint / tree action writes THERE; this keeps the live scene in
440    /// sync between runs). Returns false for an unknown id.
441    #[allow(dead_code)]
442    pub fn set_component_fixed(&mut self, id: &str, fixed: bool) -> bool {
443        match self.components.get_mut(id) {
444            Some(record) => {
445                record.fixed = fixed;
446                true
447            }
448            None => false,
449        }
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use crate::feature_pipeline::{
457        clear_history_cache, execute_history, FeatureResult, HistoryRequest,
458    };
459
460    fn run(request: serde_json::Value) -> crate::feature_pipeline::HistoryResult {
461        let request: HistoryRequest =
462            serde_json::from_value(request).expect("request deserializes");
463        clear_history_cache();
464        execute_history(&request)
465    }
466
467    fn cube_descriptor(id: &str, sx: f64, sy: f64, sz: f64) -> serde_json::Value {
468        serde_json::json!({
469            "type": "P.CU",
470            "inputParams": { "id": id, "sizeX": sx, "sizeY": sy, "sizeZ": sz },
471            "persistentData": {}
472        })
473    }
474
475    /// A PART-LOCAL source solid with REAL deterministic names: run a one-cube
476    /// history (so `register_added` stamped unique faces + derived edges), clone
477    /// the resident solid out, then clear the cache (frees its handle).
478    fn source_part(id: &str) -> BrepSolid {
479        let history = run(serde_json::json!({
480            "features": [ cube_descriptor(id, 2.0, 3.0, 4.0) ]
481        }));
482        let handle = history.results[0].added[0].handle;
483        let solid = crate::with_registered_solid_str(handle, |solid| Ok(solid.clone()))
484            .expect("resident source solid");
485        clear_history_cache();
486        solid
487    }
488
489    fn identity() -> AffineTransform {
490        AffineTransform::new([
491            1.0, 0.0, 0.0, 0.0, //
492            0.0, 1.0, 0.0, 0.0, //
493            0.0, 0.0, 1.0, 0.0, //
494            0.0, 0.0, 0.0, 1.0,
495        ])
496        .unwrap()
497    }
498
499    fn translation(x: f64, y: f64, z: f64) -> AffineTransform {
500        AffineTransform::new([
501            1.0, 0.0, 0.0, x, //
502            0.0, 1.0, 0.0, y, //
503            0.0, 0.0, 1.0, z, //
504            0.0, 0.0, 0.0, 1.0,
505        ])
506        .unwrap()
507    }
508
509    /// Every scene name of an added-solid list: solid + face + edge names.
510    fn all_names(added: &[AddedSolid]) -> Vec<String> {
511        let mut names = Vec::new();
512        for solid in added {
513            names.push(solid.name.clone());
514            names.extend(solid.face_names.iter().map(|(_, name)| name.clone()));
515            names.extend(solid.edge_names.iter().map(|(_, name)| name.clone()));
516        }
517        names
518    }
519
520    /// Apply a component into a scene the way an ACOMP feature result would.
521    fn apply_component(scene: &mut SceneMap, record: ComponentRecord, added: Vec<AddedSolid>) {
522        let mut result = FeatureResult::empty(record.id.clone(), "ACOMP");
523        result.added = added;
524        result.components = vec![record];
525        scene.apply(&result);
526    }
527
528    fn free_added(added: &[AddedSolid]) {
529        for solid in added {
530            crate::free_registered_solid(solid.handle);
531        }
532    }
533
534    /// The vertex AABB of a resident solid.
535    fn bbox(handle: u32) -> ([f64; 3], [f64; 3]) {
536        crate::with_registered_solid_str(handle, |solid| {
537            let mut lo = [f64::INFINITY; 3];
538            let mut hi = [f64::NEG_INFINITY; 3];
539            for vertex in &solid.vertices {
540                let p = [vertex.point.x, vertex.point.y, vertex.point.z];
541                for k in 0..3 {
542                    lo[k] = lo[k].min(p[k]);
543                    hi[k] = hi[k].max(p[k]);
544                }
545            }
546            Ok((lo, hi))
547        })
548        .expect("resident solid bbox")
549    }
550
551    // Two components from the SAME source part: zero name collisions, prefixes
552    // wrap the unchanged part-local names, and a rebuild with identical inputs
553    // reproduces byte-identical name sets.
554    #[test]
555    fn two_instances_of_one_part_never_collide_and_names_are_stable() {
556        let start = crate::registered_solid_count();
557        let part = source_part("Part");
558
559        let (record1, added1) = create_component(
560            "ACOMP1",
561            "bracket",
562            true,
563            serde_json::json!({ "sourceKey": "bracket" }),
564            identity(),
565            vec![("Part".into(), part.clone())],
566        )
567        .expect("component 1");
568        let (record2, added2) = create_component(
569            "ACOMP2",
570            "bracket",
571            false,
572            serde_json::json!({ "sourceKey": "bracket" }),
573            translation(10.0, 0.0, 0.0),
574            vec![("Part".into(), part.clone())],
575        )
576        .expect("component 2");
577
578        // The prefix wraps the part-local names, nothing else moves.
579        assert_eq!(added1[0].name, "ACOMP1:Part");
580        assert_eq!(added2[0].name, "ACOMP2:Part");
581        let faces1: Vec<&str> = added1[0].face_names.iter().map(|(_, n)| n.as_str()).collect();
582        assert_eq!(
583            faces1,
584            vec![
585                "ACOMP1:Part_NZ", "ACOMP1:Part_PZ", "ACOMP1:Part_NY",
586                "ACOMP1:Part_PY", "ACOMP1:Part_NX", "ACOMP1:Part_PX",
587            ],
588        );
589        // Derived edge names keep their part-local `{A}|{B}[n]` form under ONE
590        // prefix (embedded face names untouched — the spec's example shape).
591        assert!(
592            added1[0]
593                .edge_names
594                .iter()
595                .all(|(_, name)| name.starts_with("ACOMP1:") && name.contains('|')),
596            "edge names: {:?}",
597            added1[0].edge_names
598        );
599        assert_eq!(added1[0].edge_names.len(), 12, "cube edge count");
600
601        // Zero collisions across the two instances.
602        let names1 = all_names(&added1);
603        let names2 = all_names(&added2);
604        assert!(
605            names1.iter().all(|name| !names2.contains(name)),
606            "instances must not share any name"
607        );
608
609        // Stability: an identical rebuild reproduces the exact same names.
610        let (record1b, added1b) = create_component(
611            "ACOMP1",
612            "bracket",
613            true,
614            serde_json::json!({ "sourceKey": "bracket" }),
615            identity(),
616            vec![("Part".into(), part.clone())],
617        )
618        .expect("component 1 rebuild");
619        assert_eq!(all_names(&added1b), names1, "names stable across rebuilds");
620        assert_eq!(record1b.solids, record1.solids);
621
622        assert_eq!(record1.id, "ACOMP1");
623        assert_eq!(record1.part_name, "bracket");
624        assert!(record1.fixed);
625        assert!(!record2.fixed);
626
627        free_added(&added1);
628        free_added(&added2);
629        free_added(&added1b);
630        assert_eq!(crate::registered_solid_count(), start, "no handle leak");
631    }
632
633    // Scene ingestion + the whole lookup surface: owning-component parse, the
634    // fence predicate, member iteration, fixed access, and component removal.
635    #[test]
636    fn scene_ingestion_owning_lookup_fence_and_removal() {
637        let start = crate::registered_solid_count();
638        let part = source_part("Part");
639        let (record1, added1) = create_component(
640            "ACOMP1", "bracket", false, serde_json::Value::Null,
641            identity(), vec![("Part".into(), part.clone())],
642        )
643        .expect("component 1");
644        let (record2, added2) = create_component(
645            "ACOMP2", "bracket", true, serde_json::Value::Null,
646            translation(10.0, 0.0, 0.0), vec![("Part".into(), part.clone())],
647        )
648        .expect("component 2");
649        let handle1 = added1[0].handle;
650        let handle2 = added2[0].handle;
651
652        let mut scene = SceneMap::default();
653        apply_component(&mut scene, record1, added1);
654        apply_component(&mut scene, record2, added2);
655
656        // Members resolve like ordinary scene solids/faces.
657        assert_eq!(scene.resolve_solid("ACOMP1:Part"), Some(handle1));
658        assert!(scene.resolve_face("ACOMP2:Part_PZ").is_some());
659
660        // Owning-component lookup: bare id, solid, face, and edge names.
661        assert_eq!(scene.owning_component("ACOMP1").unwrap().id, "ACOMP1");
662        assert_eq!(scene.owning_component("ACOMP1:Part").unwrap().id, "ACOMP1");
663        assert_eq!(scene.owning_component("ACOMP2:Part_PZ").unwrap().id, "ACOMP2");
664        let edge_name = scene
665            .resolve_solid("ACOMP1:Part")
666            .and_then(|_| {
667                scene.edges.keys().find(|name| name.starts_with("ACOMP1:")).cloned()
668            })
669            .expect("a namespaced edge");
670        assert_eq!(scene.owning_component(&edge_name).unwrap().id, "ACOMP1");
671
672        // The fence predicate: component-owned vs plain names.
673        assert!(scene.is_component_owned("ACOMP1"));
674        assert!(scene.is_component_owned("ACOMP2:Part_NX"));
675        assert!(!scene.is_component_owned("Part_NX"));
676        assert!(!scene.is_component_owned("S1:PROFILE"));
677        assert!(reject_component_references(&scene, ["Part_NX"]).is_ok());
678        let rejected = reject_component_references(&scene, ["Part_NX", "ACOMP2:Part_NX"]);
679        assert!(
680            rejected.as_ref().is_err_and(|error| error.contains("ACOMP2")),
681            "fence must name the owning component: {rejected:?}"
682        );
683
684        // Deterministic iteration + member handles + fixed access.
685        let ids: Vec<&str> = scene.iter_components().map(|r| r.id.as_str()).collect();
686        assert_eq!(ids, vec!["ACOMP1", "ACOMP2"]);
687        assert_eq!(
688            scene.component_solids("ACOMP1"),
689            vec![("ACOMP1:Part".to_string(), handle1)]
690        );
691        assert_eq!(scene.component_fixed("ACOMP1"), Some(false));
692        assert_eq!(scene.component_fixed("ACOMP2"), Some(true));
693        assert!(scene.set_component_fixed("ACOMP1", true));
694        assert_eq!(scene.component_fixed("ACOMP1"), Some(true));
695        assert!(!scene.set_component_fixed("ghost", true));
696
697        // Removing a component id (a replace/delete result) unmaps the record
698        // AND its members; the other component is untouched.
699        let mut removal = FeatureResult::empty("ACOMP1", "ACOMP");
700        removal.removed = vec!["ACOMP1".to_string()];
701        scene.apply(&removal);
702        assert!(scene.resolve_component("ACOMP1").is_none());
703        assert!(scene.resolve_solid("ACOMP1:Part").is_none());
704        assert!(scene.resolve_face("ACOMP1:Part_PZ").is_none());
705        assert!(!scene.is_component_owned("ACOMP1:Part"));
706        assert_eq!(scene.resolve_solid("ACOMP2:Part"), Some(handle2));
707
708        crate::free_registered_solid(handle1);
709        crate::free_registered_solid(handle2);
710        assert_eq!(crate::registered_solid_count(), start, "no handle leak");
711    }
712
713    // A transform update re-poses member GEOMETRY in place (same handle, moved
714    // vertices) and is ABSOLUTE: successive updates never accumulate.
715    #[test]
716    fn transform_update_reposes_member_geometry_absolutely() {
717        let start = crate::registered_solid_count();
718        let part = source_part("Part"); // spans 0..2, 0..3, 0..4
719        let (record, added) = create_component(
720            "ACOMP1", "bracket", false, serde_json::Value::Null,
721            identity(), vec![("Part".into(), part)],
722        )
723        .expect("component");
724        let handle = added[0].handle;
725        let pz_face = added[0]
726            .face_names
727            .iter()
728            .find(|(_, name)| name == "ACOMP1:Part_PZ")
729            .map(|(id, _)| *id)
730            .expect("_PZ face");
731
732        let mut scene = SceneMap::default();
733        apply_component(&mut scene, record, added);
734
735        update_component_transform(&mut scene, "ACOMP1", translation(5.0, 0.0, 0.0))
736            .expect("re-pose");
737        let (lo, hi) = bbox(handle);
738        assert!((lo[0] - 5.0).abs() < 1e-9 && (hi[0] - 7.0).abs() < 1e-9, "x {lo:?}..{hi:?}");
739
740        // Absolute pose semantics: a second update REPLACES, never accumulates.
741        update_component_transform(&mut scene, "ACOMP1", translation(2.0, 0.0, 1.0))
742            .expect("second re-pose");
743        let (lo, hi) = bbox(handle);
744        assert!((lo[0] - 2.0).abs() < 1e-9 && (hi[0] - 4.0).abs() < 1e-9, "x {lo:?}..{hi:?}");
745        assert!((lo[2] - 1.0).abs() < 1e-9 && (hi[2] - 5.0).abs() < 1e-9, "z {lo:?}..{hi:?}");
746
747        // Same handle, same names: the scene's face ref still resolves and the
748        // record carries the committed pose.
749        let face = scene.resolve_face("ACOMP1:Part_PZ").expect("face still mapped");
750        assert_eq!(face.handle, handle);
751        assert_eq!(face.face_id, pz_face);
752        let posed = scene.resolve_component("ACOMP1").unwrap().transform.elements;
753        assert_eq!([posed[3], posed[7], posed[11]], [2.0, 0.0, 1.0]);
754
755        // Unknown component / non-rigid transform are loud errors.
756        assert!(update_component_transform(&mut scene, "ghost", identity()).is_err());
757        let scaled = AffineTransform::new([
758            2.0, 0.0, 0.0, 0.0, //
759            0.0, 2.0, 0.0, 0.0, //
760            0.0, 0.0, 2.0, 0.0, //
761            0.0, 0.0, 0.0, 1.0,
762        ])
763        .unwrap();
764        assert!(update_component_transform(&mut scene, "ACOMP1", scaled).is_err());
765
766        crate::free_registered_solid(handle);
767        assert_eq!(crate::registered_solid_count(), start, "no handle leak");
768    }
769
770    // Nested assemblies chain prefixes: wrapping an already-namespaced member
771    // yields `ACOMP3:ACOMP1:...`, owned by the OUTER component at this level.
772    #[test]
773    fn nested_component_prefixes_chain() {
774        let start = crate::registered_solid_count();
775        let part = source_part("Part");
776        // The inner assembly's instance (as its own document built it).
777        let (_, inner_added) = create_component(
778            "ACOMP1", "bracket", false, serde_json::Value::Null,
779            translation(1.0, 0.0, 0.0), vec![("Part".into(), part)],
780        )
781        .expect("inner component");
782        let inner_solid =
783            crate::with_registered_solid_str(inner_added[0].handle, |solid| Ok(solid.clone()))
784                .expect("inner resident");
785        let inner_name = inner_added[0].name.clone();
786        free_added(&inner_added);
787
788        // Inserting that assembly into a parent wraps ONE more prefix.
789        let (record, added) = create_component(
790            "ACOMP3", "sub-assembly", false, serde_json::Value::Null,
791            identity(), vec![(inner_name, inner_solid)],
792        )
793        .expect("outer component");
794        assert_eq!(added[0].name, "ACOMP3:ACOMP1:Part");
795        assert!(added[0]
796            .face_names
797            .iter()
798            .any(|(_, name)| name == "ACOMP3:ACOMP1:Part_PZ"));
799
800        // At the parent scene level the owner is the OUTERMOST component.
801        let mut scene = SceneMap::default();
802        let handle = added[0].handle;
803        apply_component(&mut scene, record, added);
804        assert_eq!(
805            scene.owning_component("ACOMP3:ACOMP1:Part_PZ").unwrap().id,
806            "ACOMP3"
807        );
808        assert!(scene.resolve_component("ACOMP1").is_none(), "inner id is not a scene component");
809
810        crate::free_registered_solid(handle);
811        assert_eq!(crate::registered_solid_count(), start, "no handle leak");
812    }
813
814    // Invalid inputs are loud errors, and nothing leaks on the error path.
815    #[test]
816    fn create_component_rejects_bad_ids_and_non_rigid_transforms() {
817        let start = crate::registered_solid_count();
818        let part = source_part("Part");
819        for bad_id in ["", "A:B", "A|B"] {
820            assert!(
821                create_component(
822                    bad_id, "x", false, serde_json::Value::Null,
823                    identity(), vec![("Part".into(), part.clone())],
824                )
825                .is_err(),
826                "id '{bad_id}' must be rejected"
827            );
828        }
829        let scaled = AffineTransform::new([
830            1.0, 0.0, 0.0, 0.0, //
831            0.0, 3.0, 0.0, 0.0, //
832            0.0, 0.0, 1.0, 0.0, //
833            0.0, 0.0, 0.0, 1.0,
834        ])
835        .unwrap();
836        assert!(
837            create_component(
838                "ACOMP1", "x", false, serde_json::Value::Null,
839                scaled, vec![("Part".into(), part)],
840            )
841            .is_err(),
842            "non-rigid transform must be rejected"
843        );
844        assert_eq!(crate::registered_solid_count(), start, "error paths leak nothing");
845    }
846
847    // The componentless path is BYTE-IDENTICAL to today: a plain modeling
848    // fixture (cube + subtract, exercising derived `{A}|{B}[n]` edge names)
849    // produces exactly the established names, none of them component-owned.
850    #[test]
851    fn componentless_scenes_keep_todays_names() {
852        let start = crate::registered_solid_count();
853        // Fixture 1: a lone cube — the full pinned name set.
854        let history = run(serde_json::json!({
855            "features": [ cube_descriptor("cube1", 2.0, 3.0, 4.0) ]
856        }));
857        let added = &history.results[0].added[0];
858        let mut scene = SceneMap::default();
859        for result in &history.results {
860            scene.apply(result);
861        }
862        assert!(scene.components.is_empty(), "no components in a modeling scene");
863        let faces: Vec<&str> = added.face_names.iter().map(|(_, n)| n.as_str()).collect();
864        assert_eq!(
865            faces,
866            vec!["cube1_NZ", "cube1_PZ", "cube1_NY", "cube1_PY", "cube1_NX", "cube1_PX"],
867        );
868        let edges: Vec<&str> = added.edge_names.iter().map(|(_, n)| n.as_str()).collect();
869        assert_eq!(
870            edges,
871            vec![
872                "cube1_NX|cube1_NZ[0]", "cube1_NZ|cube1_PY[0]", "cube1_NZ|cube1_PX[0]",
873                "cube1_NY|cube1_NZ[0]", "cube1_NY|cube1_PZ[0]", "cube1_PX|cube1_PZ[0]",
874                "cube1_PY|cube1_PZ[0]", "cube1_NX|cube1_PZ[0]", "cube1_NY|cube1_PX[0]",
875                "cube1_NX|cube1_NY[0]", "cube1_NX|cube1_PY[0]", "cube1_PX|cube1_PY[0]",
876            ],
877        );
878        for name in all_names(std::slice::from_ref(added)) {
879            assert!(!scene.is_component_owned(&name), "'{name}' must be componentless");
880        }
881
882        // Fixture 2: cube-subtract-cube (boolean-propagated + derived names).
883        let mut tool = cube_descriptor("B", 4.0, 4.0, 4.0);
884        tool["inputParams"]["boolean"] = serde_json::json!({
885            "operation": "SUBTRACT", "targets": ["A"], "mergeCoplanarFaces": true });
886        let history = run(serde_json::json!({
887            "features": [ cube_descriptor("A", 10.0, 10.0, 10.0), tool ]
888        }));
889        let cut = &history.results[1].added[0];
890        assert_eq!(cut.name, "A");
891        let mut cut_names = all_names(std::slice::from_ref(cut));
892        cut_names.sort();
893        assert_eq!(
894            cut_names,
895            vec![
896                "A", "A_NX", "A_NX|A_NY[0]", "A_NX|A_NZ[0]", "A_NX|A_PY[0]", "A_NX|A_PZ[0]",
897                "A_NX|B_PY[0]", "A_NX|B_PZ[0]", "A_NY", "A_NY|A_NZ[0]", "A_NY|A_PX[0]",
898                "A_NY|A_PZ[0]", "A_NY|B_PX[0]", "A_NY|B_PZ[0]", "A_NZ", "A_NZ|A_PX[0]",
899                "A_NZ|A_PY[0]", "A_NZ|B_PX[0]", "A_NZ|B_PY[0]", "A_PX", "A_PX|A_PY[0]",
900                "A_PX|A_PZ[0]", "A_PY", "A_PY|A_PZ[0]", "A_PZ", "B_PX", "B_PX|B_PY[0]",
901                "B_PX|B_PZ[0]", "B_PY", "B_PY|B_PZ[0]", "B_PZ",
902            ],
903        );
904
905        let mut scene = SceneMap::default();
906        for result in &history.results {
907            scene.apply(result);
908        }
909        for name in all_names(std::slice::from_ref(cut)) {
910            assert!(!scene.is_component_owned(&name), "'{name}' must be componentless");
911        }
912        clear_history_cache();
913        assert_eq!(crate::registered_solid_count(), start, "no handle leak");
914    }
915}