Skip to main content

brep_kernel/feature_pipeline/
component.rs

1//! The scene COMPONENT concept.
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
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, PortRecord, 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    /// The part's harness PORT ids, namespaced (`{id}:{part port id}`) — the
72    /// wire-harness entities a placed part carries. Their records live in
73    /// [`SceneMap::ports`]; the re-pose lane moves them with the members.
74    pub ports: Vec<String>,
75    /// A sub-assembly's own constraint statuses (and those of every level
76    /// below it), namespaced with this component's id — solved inside the
77    /// sub-assembly's run, reported by the parent's (see `NestedConstraint`).
78    pub nested_constraints: Vec<NestedConstraint>,
79    /// A sub-assembly's SOLVED occurrence poses: occurrence path below this
80    /// component (`ACOMP2`, `ACOMP2:ACOMP1`, …) → its pose in this component's
81    /// part-local frame. Its own solve can move an occurrence away from the
82    /// transform its document authors, so anything that needs where a nested
83    /// occurrence actually sits composes `transform` with this, never the
84    /// authored transform. Empty for a part (and for a sub-assembly built from
85    /// a snapshot before its first solve this session).
86    pub nested_poses: BTreeMap<String, AffineTransform>,
87    /// An instance of a Pattern Component (`APAT`): which seed it follows and
88    /// how. `None` for every ACOMP-placed component.
89    pub pattern: Option<PatternFollow>,
90    /// Set on a FLEXIBLE sub-assembly instance (`inputParams.flexible`): what
91    /// the parent's solve needs to move its occurrences individually. `None`
92    /// = rigid, the default.
93    pub flexible: Option<FlexibleSpec>,
94}
95
96/// How a pattern instance follows its seed: its pose is `delta · seed pose`
97/// (`T · delta · T⁻¹ · seed pose` with `T` the driver's pose, in HOLES mode).
98#[derive(Debug, Clone)]
99pub struct PatternFollow {
100    /// The `APAT` feature that emitted it.
101    pub pattern_id: String,
102    /// The seed component it follows.
103    pub seed: String,
104    /// The instance's offset from the seed, in assembly space.
105    pub delta: AffineTransform,
106    /// Its place in the pattern (the seed is 1), or its hole's key.
107    pub key: i64,
108    /// HOLES mode: the component whose hole it follows. `delta` is then in
109    /// that component's frame and applies through its solved pose.
110    pub driver: Option<String>,
111}
112
113/// A flexible sub-assembly instance, as the parent's constraint run reads it
114/// (`assembly::flexible`): the child document's own constraints (raw, its
115/// namespace), which of its occurrences stay with the instance's frame, where
116/// the child authors each occurrence, and the per-instance solved poses the
117/// placing feature persists. All poses are part-local (the instance frame).
118/// `nested` holds the same for each occurrence the child itself places
119/// flexible (keyed by its id in the child), at any depth; only the top spec
120/// carries seeds and `fixed`, keyed by the full occurrence path
121/// (`ACOMP1:ACOMP2`). `fixed` is this instance's own Fix/Float of an
122/// occurrence (`inputParams.flexibleFixed`): it wins over the child's
123/// grounding, and a fixed occurrence is a grounded body where it stands.
124#[derive(Debug, Clone, Default)]
125pub struct FlexibleSpec {
126    pub constraints: Vec<serde_json::Value>,
127    pub grounded: std::collections::BTreeSet<String>,
128    pub authored: BTreeMap<String, AffineTransform>,
129    pub seeds: BTreeMap<String, AffineTransform>,
130    pub fixed: BTreeMap<String, bool>,
131    pub nested: BTreeMap<String, FlexibleSpec>,
132}
133
134/// One constraint of a NESTED sub-assembly as its parent reports it.
135///
136/// A sub-assembly is rigid: its own constraints solve inside its own run (the
137/// isolated document run that makes its snapshot), and the parent's mates then
138/// place it as one body. The parent never re-solves these, but it must still
139/// say how they ended, so the row rides the component record. `id` carries the
140/// namespace of every level it passed (`ACOMP1:ACOMP2:CONC1`).
141#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
142pub struct NestedConstraint {
143    pub id: String,
144    #[serde(rename = "type")]
145    pub constraint_type: String,
146    pub enabled: bool,
147    pub status: String,
148    pub message: String,
149    pub satisfied: bool,
150}
151
152impl NestedConstraint {
153    /// The row for one constraint the run just settled.
154    pub fn from_entry(entry: &crate::feature_pipeline::assembly::ConstraintEntry) -> Self {
155        Self {
156            id: entry.id().to_string(),
157            constraint_type: entry.constraint_type.clone(),
158            enabled: entry.enabled,
159            status: entry.status().to_string(),
160            message: entry
161                .persistent("message")
162                .and_then(|value| value.as_str())
163                .unwrap_or("")
164                .to_string(),
165            satisfied: entry
166                .persistent("satisfied")
167                .and_then(|value| value.as_bool())
168                .unwrap_or(false),
169        }
170    }
171
172    /// The same row one level up, under the placing component's id.
173    pub fn namespaced(&self, component_id: &str) -> Self {
174        Self {
175            id: namespaced(component_id, &self.id),
176            ..self.clone()
177        }
178    }
179
180    /// Whether the parent should be told: enabled and not satisfied.
181    pub fn is_failure(&self) -> bool {
182        self.enabled && !self.satisfied
183    }
184}
185
186/// The scene's component set, keyed by owning feature id. `BTreeMap` so
187/// iteration (structure tree, BOM) is deterministic.
188pub type ComponentMap = BTreeMap<String, ComponentRecord>;
189
190// ===========================================================================
191// Namespacing
192// ===========================================================================
193
194/// Wrap one entity name with a component prefix: `{component_id}:{name}`.
195pub fn namespaced(component_id: &str, name: &str) -> String {
196    format!("{component_id}:{name}")
197}
198
199/// Prefix every NAMED face/edge of a member solid with the component id (the
200/// solid's own scene name is prefixed by the caller — solids carry their name in
201/// [`AddedSolid`], not on the BREP). Unnamed entities stay unnamed (they are not
202/// scene-resolvable either way).
203fn namespace_solid_names(solid: &mut BrepSolid, component_id: &str) {
204    for shell in &mut solid.shells {
205        for face in &mut shell.faces {
206            if let Some(name) = &face.name {
207                face.name = Some(namespaced(component_id, name));
208            }
209        }
210    }
211    for edge in &mut solid.edges {
212        if let Some(name) = &edge.name {
213            edge.name = Some(namespaced(component_id, name));
214        }
215    }
216}
217
218/// A component id must be a plain feature id: non-empty, no `:` (the namespace
219/// delimiter) and no `|` (the topology-vs-authored name discriminator).
220fn validate_component_id(id: &str) -> Result<(), String> {
221    if id.is_empty() {
222        return Err("component id must not be empty".into());
223    }
224    if id.contains(':') || id.contains('|') {
225        return Err(format!("component id '{id}' must not contain ':' or '|'"));
226    }
227    Ok(())
228}
229
230// ===========================================================================
231// Rigid-transform helpers (row-major 4x4, the AffineTransform layout)
232// ===========================================================================
233
234/// Require a RIGID map (orthonormal rotation rows, determinant +1). Components
235/// move as rigid bodies — a scaling/shearing/reflecting instance pose is a
236/// modeling error, rejected loudly (no fallback).
237fn require_rigid(transform: &AffineTransform) -> Result<(), String> {
238    let m = &transform.elements;
239    let rows = [[m[0], m[1], m[2]], [m[4], m[5], m[6]], [m[8], m[9], m[10]]];
240    for i in 0..3 {
241        for j in i..3 {
242            let dot: f64 = (0..3).map(|k| rows[i][k] * rows[j][k]).sum();
243            let expected = if i == j { 1.0 } else { 0.0 };
244            if (dot - expected).abs() > 1e-8 {
245                return Err("component transform must be rigid (rotation + translation)".into());
246            }
247        }
248    }
249    if (transform.determinant3() - 1.0).abs() > 1e-8 {
250        return Err("component transform must be rigid (no reflection/scale)".into());
251    }
252    Ok(())
253}
254
255/// Row-major product `a · b` (apply `b` first, then `a`).
256pub(crate) fn compose(a: &AffineTransform, b: &AffineTransform) -> Result<AffineTransform, String> {
257    let (ma, mb) = (&a.elements, &b.elements);
258    let mut out = [0.0f64; 16];
259    for row in 0..4 {
260        for col in 0..4 {
261            out[row * 4 + col] = (0..4)
262                .map(|k| ma[row * 4 + k] * mb[k * 4 + col])
263                .sum();
264        }
265    }
266    AffineTransform::new(out)
267}
268
269// ===========================================================================
270// Create / re-pose
271// ===========================================================================
272
273/// Build a component from a set of PART-LOCAL solids: rigid-pose each member
274/// with `transform`, namespace its entity names with `id`, and register it as a
275/// scene-resident solid. Returns the component record plus one [`AddedSolid`]
276/// per member — the owning ACOMP feature pushes both into its `FeatureResult`
277/// (`added` + `components`), and the record's handles are then owned by that
278/// feature's history-cache entry like any other feature output.
279///
280/// `members` are `(solid name, part-local BREP)` pairs exactly as the sub-part's
281/// history produced them (deterministic names final; see the module doc — the
282/// prefix wraps them, nothing is re-derived). Two-phase: every member is posed
283/// BEFORE anything registers, so a failure never leaks a half-built component.
284// Contract surface for the Wave-2 ACOMP feature; exercised by this module's tests.
285#[allow(dead_code)]
286pub fn create_component(
287    id: &str,
288    part_name: &str,
289    fixed: bool,
290    source: serde_json::Value,
291    transform: AffineTransform,
292    members: Vec<(String, BrepSolid)>,
293) -> Result<(ComponentRecord, Vec<AddedSolid>), String> {
294    validate_component_id(id)?;
295    require_rigid(&transform)?;
296
297    // Phase 1 (fallible): pose + namespace every member.
298    let mut posed: Vec<(String, BrepSolid)> = Vec::with_capacity(members.len());
299    for (member_name, solid) in &members {
300        let mut body = transform_brep(solid, transform, false)
301            .map_err(|error| format!("component '{id}': member '{member_name}': {error}"))?;
302        namespace_solid_names(&mut body, id);
303        posed.push((namespaced(id, member_name), body));
304    }
305
306    // Phase 2 (infallible): register.
307    let mut added = Vec::with_capacity(posed.len());
308    let mut solids = Vec::with_capacity(posed.len());
309    for (name, body) in posed {
310        let face_names = collect_face_names(&body);
311        let edge_names = collect_edge_names(&body);
312        let handle = crate::register_solid_value(body);
313        solids.push(name.clone());
314        added.push(AddedSolid {
315            handle,
316            name,
317            face_names,
318            edge_names,
319            ..AddedSolid::default()
320        });
321    }
322
323    Ok((
324        ComponentRecord {
325            id: id.to_string(),
326            part_name: part_name.to_string(),
327            transform,
328            fixed,
329            source,
330            solids,
331            ports: Vec::new(),
332            nested_constraints: Vec::new(),
333            nested_poses: BTreeMap::new(),
334            pattern: None,
335            flexible: None,
336        },
337        added,
338    ))
339}
340
341/// Pose a part-local port record by a rigid transform: the base point maps as
342/// a point, the direction by the rotation part alone.
343pub fn pose_port(record: &PortRecord, transform: &AffineTransform) -> PortRecord {
344    let point = transform.point(record.point);
345    let tip = transform.point(record.point.add(record.direction));
346    let direction = tip.sub(point).normalized().unwrap_or(record.direction);
347    PortRecord {
348        point,
349        direction,
350        ..record.clone()
351    }
352}
353
354/// Attach a part's connection points to a placed component: each point's
355/// ADDRESS is wrapped `{component}:{address}` and its record is posed by the
356/// component transform. The record's `port_name` / `point_name` / `purpose`
357/// stay PART-LOCAL — the occurrence chain lives in the address, so pin `VCC` of
358/// port `J1` reads the same however deep it is placed. The record lists the
359/// addresses so [`update_component_transform`] moves them with the members; the
360/// posed records are returned for the feature to publish.
361pub fn attach_component_ports(
362    record: &mut ComponentRecord,
363    ports: &BTreeMap<String, PortRecord>,
364) -> Vec<(String, PortRecord)> {
365    let mut posed = Vec::with_capacity(ports.len());
366    for (local_id, port) in ports {
367        let id = namespaced(&record.id, local_id);
368        let port = pose_port(port, &record.transform);
369        record.ports.push(id.clone());
370        posed.push((id, port));
371    }
372    posed
373}
374
375/// Re-pose a component to a NEW absolute rigid transform: applies the delta
376/// `new · old⁻¹` to every member solid IN PLACE (same handles — a rigid map
377/// keeps every topology id and name, so the scene's face/edge refs stay valid)
378/// and updates the record.
379///
380/// This is the INTERACTIVE lane (move-gizmo drag, solver preview). The
381/// AUTHORITATIVE pose lives on the owning ACOMP feature's `inputParams`
382/// transform: a commit must write it back there (the solver pose write-back),
383/// which dirties the feature's fingerprint so the next history run re-executes
384/// it from the snapshot at the committed pose. Without the write-back, a clean
385/// cache replay would serve the mutated geometry against a stale descriptor.
386///
387/// Two-phase: every member transform is computed before anything commits, so a
388/// failure never leaves the component half-posed.
389// Contract surface for the Wave-2/3 move + solver lanes; exercised by tests.
390#[allow(dead_code)]
391pub fn update_component_transform(
392    scene: &mut SceneMap,
393    id: &str,
394    new_transform: AffineTransform,
395) -> Result<(), String> {
396    require_rigid(&new_transform)?;
397    let record = scene
398        .resolve_component(id)
399        .ok_or_else(|| format!("unknown component '{id}'"))?;
400    let delta = compose(&new_transform, &record.transform.rigid_inverse()?)?;
401    let port_ids = record.ports.clone();
402
403    // Phase 1 (fallible): resolve + re-pose every member.
404    let mut posed: Vec<(u32, BrepSolid)> = Vec::with_capacity(record.solids.len());
405    for name in &record.solids {
406        let handle = scene
407            .solids
408            .get(name)
409            .copied()
410            .ok_or_else(|| format!("component '{id}': member '{name}' is not scene-resident"))?;
411        let body = crate::with_registered_solid_str(handle, |solid| {
412            transform_brep(solid, delta, false)
413        })
414        .map_err(|error| format!("component '{id}': member '{name}': {error}"))?;
415        posed.push((handle, body));
416    }
417
418    // Phase 2: commit under the existing handles, then update the record.
419    for (handle, body) in posed {
420        crate::replace_registered_solid(handle, body)?;
421    }
422    if let Some(record) = scene.components.get_mut(id) {
423        record.transform = new_transform;
424        // A flexible instance's occurrences are among its members: they moved
425        // with it, so their frames follow.
426        let prefix = format!("{id}:");
427        for occurrence in scene.occurrences.values_mut().filter(|o| o.id.starts_with(&prefix)) {
428            occurrence.transform = compose(&delta, &occurrence.transform)?;
429        }
430        super::mark_component_cache_posed(id);
431    } else if scene.occurrences.contains_key(id) {
432        // A flexible occurrence placed inside a flexible occurrence moved
433        // with it too.
434        let prefix = format!("{id}:");
435        for occurrence in scene.occurrences.values_mut() {
436            if occurrence.id == id {
437                occurrence.transform = new_transform;
438            } else if occurrence.id.starts_with(&prefix) {
439                occurrence.transform = compose(&delta, &occurrence.transform)?;
440            }
441        }
442        // The placing feature's cache entry owns the occurrence's solids.
443        let owner = id.split(':').next().unwrap_or(id).to_string();
444        super::mark_component_cache_posed(&owner);
445    }
446
447    // The component's ports (and their published entities) follow the members,
448    // so a solve that moves a part moves the harness network it carries.
449    for port_id in port_ids {
450        let Some(port) = scene.ports.get(&port_id) else {
451            continue;
452        };
453        let posed = pose_port(port, &delta);
454        crate::feature_pipeline::ports::place_scene_port(scene, &port_id, posed)?;
455    }
456    Ok(())
457}
458
459/// The feature fence: fail with a clear message when any of `names` resolves to
460/// component-owned geometry. Modeling features that consume solids/faces/edges
461/// call this over their reference names before operating — cross-part feature
462/// linking is out of scope and must be rejected cleanly, never silently
463/// applied.
464pub fn reject_component_references<'a>(
465    scene: &SceneMap,
466    names: impl IntoIterator<Item = &'a str>,
467) -> Result<(), String> {
468    for name in names {
469        if let Some(record) = scene.owning_component(name) {
470            return Err(format!(
471                "'{name}' belongs to assembly component '{}' — modeling features cannot consume component geometry",
472                record.id
473            ));
474        }
475    }
476    Ok(())
477}
478
479/// The fence at the DISPATCH altitude — the one place every feature passes
480/// through (`execute_feature`), so no per-feature checks are scattered. A
481/// reference here is what the pipeline already defines it to be (the
482/// `scan_consumed` cache-dependency walk): any descriptor string that
483/// exact-matches a scene name. Any such string owned by a component fails the
484/// feature before it executes.
485///
486/// Exempt (the two ALLOWED in-context uses, attach + project): SKETCH (plane
487/// attach + edge projection), DATUM/PLANE (a datum derived from component
488/// geometry), ACOMP itself (the component's own feature, wired by the
489/// parts-library lane), and the two wire-harness construction features —
490/// WAYPOINT (its `directionRef` is a face on a placed component: the harness
491/// workbench's whole point) and SPLINE (its anchors attach to connection
492/// points). Assembly constraints are not history features, so they never
493/// reach this fence. The exemption is type-level, which is safe because none
494/// of the exempt types carries a `boolean` param or operand-solid references
495/// — re-examine if such a param is ever added to one of them.
496pub fn enforce_reference_fence(
497    feature_type: &str,
498    descriptor: &FeatureDescriptor,
499    scene: &SceneMap,
500) -> Result<(), String> {
501    // Componentless scenes skip the walk entirely — the byte-identical
502    // guarantee for every existing modeling document.
503    if scene.components.is_empty() {
504        return Ok(());
505    }
506    // Verbatim dispatch alias strings (the dispatch matches exact spellings;
507    // "ASSEMBLY COMPONENT" is ACOMP's long dispatch alias in assembly_component.rs).
508    if matches!(
509        feature_type,
510        "S" | "SKETCH"
511            | "S3D"
512            | "SKETCH3D"
513            | "3D SKETCH"
514            | "D"
515            | "DATUM"
516            | "DATIUM"
517            | "P"
518            | "PLANE"
519            | "ACOMP"
520            | "ASSEMBLY COMPONENT"
521            | "APAT"
522            | "PATTERN COMPONENT"
523            | "WP"
524            | "WAYPOINT"
525            | "SP"
526            | "SPLINE"
527    ) {
528        return Ok(());
529    }
530    if let Some(name) = first_component_owned(&descriptor.input_params, scene)
531        .or_else(|| first_component_owned(&descriptor.persistent_data, scene))
532    {
533        return reject_component_references(scene, [name]);
534    }
535    Ok(())
536}
537
538/// The first string anywhere in `value` (recursively, both param sources — the
539/// `scan_consumed` walk shape) that names component-owned geometry.
540fn first_component_owned<'a>(
541    value: &'a serde_json::Value,
542    scene: &SceneMap,
543) -> Option<&'a str> {
544    match value {
545        serde_json::Value::String(text) => {
546            let trimmed = text.trim();
547            (!trimmed.is_empty() && scene.is_component_owned(trimmed)).then_some(trimmed)
548        }
549        serde_json::Value::Array(items) => {
550            items.iter().find_map(|item| first_component_owned(item, scene))
551        }
552        serde_json::Value::Object(map) => {
553            map.values().find_map(|item| first_component_owned(item, scene))
554        }
555        _ => None,
556    }
557}
558
559// ===========================================================================
560// SceneMap component surface
561// ===========================================================================
562
563impl SceneMap {
564    /// Resolve a component record by its owning feature id (exact match).
565    /// Contract surface for the Wave-2 constraint/selection lanes.
566    #[allow(dead_code)]
567    pub fn resolve_component(&self, id: &str) -> Option<&ComponentRecord> {
568        self.components.get(id).or_else(|| self.occurrences.get(id))
569    }
570
571    /// The component owning an entity name, if any: the BARE component id itself
572    /// (a COMPONENT-type selection), or the name's OUTERMOST namespace prefix
573    /// (`ACOMP3:ACOMP1:X` is owned by `ACOMP3` at this scene level; `ACOMP1` is
574    /// the nested assembly's internal structure). Membership is decided against
575    /// the registered component set, never syntactically — authored names like
576    /// `S1:PROFILE` have a first segment that is a sketch id, not a component id.
577    pub fn owning_component(&self, name: &str) -> Option<&ComponentRecord> {
578        if let Some(record) = self.resolve_component(name) {
579            return Some(record);
580        }
581        // A flexible instance's occurrence owns its own geometry (the deepest
582        // registered prefix wins over the instance).
583        if let Some(record) = self
584            .occurrences
585            .values()
586            .filter(|record| name.starts_with(&format!("{}:", record.id)))
587            .max_by_key(|record| record.id.len())
588        {
589            return Some(record);
590        }
591        let (prefix, _) = name.split_once(':')?;
592        self.components.get(prefix)
593    }
594
595    /// The feature-fence predicate: does this entity name belong to a component?
596    /// Cheap (one map probe + one prefix probe); the dispatch fence
597    /// (`enforce_reference_fence`) uses it to REJECT component geometry as
598    /// modeling-feature input.
599    pub fn is_component_owned(&self, name: &str) -> bool {
600        self.owning_component(name).is_some()
601    }
602
603    /// Iterate the scene's components in deterministic (id) order — the
604    /// structure-tree / BOM projection surface.
605    #[allow(dead_code)]
606    pub fn iter_components(&self) -> impl Iterator<Item = &ComponentRecord> {
607        self.components.values()
608    }
609
610    /// A component's member solids as `(scene name, resident handle)`, in the
611    /// record's member order. A member missing from the solid map (never the
612    /// case for an intact scene) is skipped.
613    #[allow(dead_code)]
614    pub fn component_solids(&self, id: &str) -> Vec<(String, u32)> {
615        let Some(record) = self.resolve_component(id) else {
616            return Vec::new();
617        };
618        record
619            .solids
620            .iter()
621            .filter_map(|name| self.solids.get(name).map(|&handle| (name.clone(), handle)))
622            .collect()
623    }
624
625    /// The member solids of an occurrence path: a component's (or a flexible
626    /// occurrence's) own record, else every solid named under the path, which
627    /// is how a placement inside a rigid sub-assembly is reached
628    /// (`ACOMP1:ACOMP2` → `ACOMP1:ACOMP2:Pin`), in name order.
629    pub fn occurrence_solids(&self, path: &str) -> Vec<(String, u32)> {
630        if self.resolve_component(path).is_some() {
631            return self.component_solids(path);
632        }
633        let prefix = format!("{path}:");
634        let mut solids: Vec<(String, u32)> = self
635            .solids
636            .iter()
637            .filter(|(name, _)| name.starts_with(&prefix))
638            .map(|(name, &handle)| (name.clone(), handle))
639            .collect();
640        solids.sort();
641        solids
642    }
643
644    /// A component's grounded flag (`None` for an unknown id).
645    #[allow(dead_code)]
646    pub fn component_fixed(&self, id: &str) -> Option<bool> {
647        self.resolve_component(id).map(|record| record.fixed)
648    }
649
650    /// Set a component's grounded flag. Scene-view state only — the
651    /// authoritative flag is the owning feature's `isFixed` (the Fixed
652    /// constraint / tree action writes THERE; this keeps the live scene in
653    /// sync between runs). Returns false for an unknown id.
654    #[allow(dead_code)]
655    pub fn set_component_fixed(&mut self, id: &str, fixed: bool) -> bool {
656        match self.components.get_mut(id).or(self.occurrences.get_mut(id)) {
657            Some(record) => {
658                record.fixed = fixed;
659                true
660            }
661            None => false,
662        }
663    }
664}
665