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, 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}
76
77/// The scene's component set, keyed by owning feature id. `BTreeMap` so
78/// iteration (structure tree, BOM) is deterministic.
79pub type ComponentMap = BTreeMap<String, ComponentRecord>;
80
81// ===========================================================================
82// Namespacing
83// ===========================================================================
84
85/// Wrap one entity name with a component prefix: `{component_id}:{name}`.
86pub fn namespaced(component_id: &str, name: &str) -> String {
87    format!("{component_id}:{name}")
88}
89
90/// Prefix every NAMED face/edge of a member solid with the component id (the
91/// solid's own scene name is prefixed by the caller — solids carry their name in
92/// [`AddedSolid`], not on the BREP). Unnamed entities stay unnamed (they are not
93/// scene-resolvable either way).
94fn namespace_solid_names(solid: &mut BrepSolid, component_id: &str) {
95    for shell in &mut solid.shells {
96        for face in &mut shell.faces {
97            if let Some(name) = &face.name {
98                face.name = Some(namespaced(component_id, name));
99            }
100        }
101    }
102    for edge in &mut solid.edges {
103        if let Some(name) = &edge.name {
104            edge.name = Some(namespaced(component_id, name));
105        }
106    }
107}
108
109/// A component id must be a plain feature id: non-empty, no `:` (the namespace
110/// delimiter) and no `|` (the topology-vs-authored name discriminator).
111fn validate_component_id(id: &str) -> Result<(), String> {
112    if id.is_empty() {
113        return Err("component id must not be empty".into());
114    }
115    if id.contains(':') || id.contains('|') {
116        return Err(format!("component id '{id}' must not contain ':' or '|'"));
117    }
118    Ok(())
119}
120
121// ===========================================================================
122// Rigid-transform helpers (row-major 4x4, the AffineTransform layout)
123// ===========================================================================
124
125/// Require a RIGID map (orthonormal rotation rows, determinant +1). Components
126/// move as rigid bodies — a scaling/shearing/reflecting instance pose is a
127/// modeling error, rejected loudly (no fallback).
128fn require_rigid(transform: &AffineTransform) -> Result<(), String> {
129    let m = &transform.elements;
130    let rows = [[m[0], m[1], m[2]], [m[4], m[5], m[6]], [m[8], m[9], m[10]]];
131    for i in 0..3 {
132        for j in i..3 {
133            let dot: f64 = (0..3).map(|k| rows[i][k] * rows[j][k]).sum();
134            let expected = if i == j { 1.0 } else { 0.0 };
135            if (dot - expected).abs() > 1e-8 {
136                return Err("component transform must be rigid (rotation + translation)".into());
137            }
138        }
139    }
140    if (transform.determinant3() - 1.0).abs() > 1e-8 {
141        return Err("component transform must be rigid (no reflection/scale)".into());
142    }
143    Ok(())
144}
145
146/// Row-major product `a · b` (apply `b` first, then `a`).
147fn compose(a: &AffineTransform, b: &AffineTransform) -> Result<AffineTransform, String> {
148    let (ma, mb) = (&a.elements, &b.elements);
149    let mut out = [0.0f64; 16];
150    for row in 0..4 {
151        for col in 0..4 {
152            out[row * 4 + col] = (0..4)
153                .map(|k| ma[row * 4 + k] * mb[k * 4 + col])
154                .sum();
155        }
156    }
157    AffineTransform::new(out)
158}
159
160// ===========================================================================
161// Create / re-pose
162// ===========================================================================
163
164/// Build a component from a set of PART-LOCAL solids: rigid-pose each member
165/// with `transform`, namespace its entity names with `id`, and register it as a
166/// scene-resident solid. Returns the component record plus one [`AddedSolid`]
167/// per member — the owning ACOMP feature pushes both into its `FeatureResult`
168/// (`added` + `components`), and the record's handles are then owned by that
169/// feature's history-cache entry like any other feature output.
170///
171/// `members` are `(solid name, part-local BREP)` pairs exactly as the sub-part's
172/// history produced them (deterministic names final; see the module doc — the
173/// prefix wraps them, nothing is re-derived). Two-phase: every member is posed
174/// BEFORE anything registers, so a failure never leaks a half-built component.
175// Contract surface for the Wave-2 ACOMP feature; exercised by this module's tests.
176#[allow(dead_code)]
177pub fn create_component(
178    id: &str,
179    part_name: &str,
180    fixed: bool,
181    source: serde_json::Value,
182    transform: AffineTransform,
183    members: Vec<(String, BrepSolid)>,
184) -> Result<(ComponentRecord, Vec<AddedSolid>), String> {
185    validate_component_id(id)?;
186    require_rigid(&transform)?;
187
188    // Phase 1 (fallible): pose + namespace every member.
189    let mut posed: Vec<(String, BrepSolid)> = Vec::with_capacity(members.len());
190    for (member_name, solid) in &members {
191        let mut body = transform_brep(solid, transform, false)
192            .map_err(|error| format!("component '{id}': member '{member_name}': {error}"))?;
193        namespace_solid_names(&mut body, id);
194        posed.push((namespaced(id, member_name), body));
195    }
196
197    // Phase 2 (infallible): register.
198    let mut added = Vec::with_capacity(posed.len());
199    let mut solids = Vec::with_capacity(posed.len());
200    for (name, body) in posed {
201        let face_names = collect_face_names(&body);
202        let edge_names = collect_edge_names(&body);
203        let handle = crate::register_solid_value(body);
204        solids.push(name.clone());
205        added.push(AddedSolid {
206            handle,
207            name,
208            face_names,
209            edge_names,
210            ..AddedSolid::default()
211        });
212    }
213
214    Ok((
215        ComponentRecord {
216            id: id.to_string(),
217            part_name: part_name.to_string(),
218            transform,
219            fixed,
220            source,
221            solids,
222            ports: Vec::new(),
223        },
224        added,
225    ))
226}
227
228/// Pose a part-local port record by a rigid transform: the base point maps as
229/// a point, the direction by the rotation part alone.
230pub fn pose_port(record: &PortRecord, transform: &AffineTransform) -> PortRecord {
231    let point = transform.point(record.point);
232    let tip = transform.point(record.point.add(record.direction));
233    let direction = tip.sub(point).normalized().unwrap_or(record.direction);
234    PortRecord {
235        point,
236        direction,
237        ..record.clone()
238    }
239}
240
241/// Attach a part's connection points to a placed component: each point's
242/// ADDRESS is wrapped `{component}:{address}` and its record is posed by the
243/// component transform. The record's `port_name` / `point_name` / `purpose`
244/// stay PART-LOCAL — the occurrence chain lives in the address, so pin `VCC` of
245/// port `J1` reads the same however deep it is placed. The record lists the
246/// addresses so [`update_component_transform`] moves them with the members; the
247/// posed records are returned for the feature to publish.
248pub fn attach_component_ports(
249    record: &mut ComponentRecord,
250    ports: &BTreeMap<String, PortRecord>,
251) -> Vec<(String, PortRecord)> {
252    let mut posed = Vec::with_capacity(ports.len());
253    for (local_id, port) in ports {
254        let id = namespaced(&record.id, local_id);
255        let port = pose_port(port, &record.transform);
256        record.ports.push(id.clone());
257        posed.push((id, port));
258    }
259    posed
260}
261
262/// Re-pose a component to a NEW absolute rigid transform: applies the delta
263/// `new · old⁻¹` to every member solid IN PLACE (same handles — a rigid map
264/// keeps every topology id and name, so the scene's face/edge refs stay valid)
265/// and updates the record.
266///
267/// This is the INTERACTIVE lane (move-gizmo drag, solver preview). The
268/// AUTHORITATIVE pose lives on the owning ACOMP feature's `inputParams`
269/// transform: a commit must write it back there (the solver pose write-back),
270/// which dirties the feature's fingerprint so the next history run re-executes
271/// it from the snapshot at the committed pose. Without the write-back, a clean
272/// cache replay would serve the mutated geometry against a stale descriptor.
273///
274/// Two-phase: every member transform is computed before anything commits, so a
275/// failure never leaves the component half-posed.
276// Contract surface for the Wave-2/3 move + solver lanes; exercised by tests.
277#[allow(dead_code)]
278pub fn update_component_transform(
279    scene: &mut SceneMap,
280    id: &str,
281    new_transform: AffineTransform,
282) -> Result<(), String> {
283    require_rigid(&new_transform)?;
284    let record = scene
285        .components
286        .get(id)
287        .ok_or_else(|| format!("unknown component '{id}'"))?;
288    let delta = compose(&new_transform, &record.transform.rigid_inverse()?)?;
289    let port_ids = record.ports.clone();
290
291    // Phase 1 (fallible): resolve + re-pose every member.
292    let mut posed: Vec<(u32, BrepSolid)> = Vec::with_capacity(record.solids.len());
293    for name in &record.solids {
294        let handle = scene
295            .solids
296            .get(name)
297            .copied()
298            .ok_or_else(|| format!("component '{id}': member '{name}' is not scene-resident"))?;
299        let body = crate::with_registered_solid_str(handle, |solid| {
300            transform_brep(solid, delta, false)
301        })
302        .map_err(|error| format!("component '{id}': member '{name}': {error}"))?;
303        posed.push((handle, body));
304    }
305
306    // Phase 2: commit under the existing handles, then update the record.
307    for (handle, body) in posed {
308        crate::replace_registered_solid(handle, body)?;
309    }
310    scene
311        .components
312        .get_mut(id)
313        .expect("record fetched above")
314        .transform = new_transform;
315
316    // The component's ports (and their published entities) follow the members,
317    // so a solve that moves a part moves the harness network it carries.
318    for port_id in port_ids {
319        let Some(port) = scene.ports.get(&port_id) else {
320            continue;
321        };
322        let posed = pose_port(port, &delta);
323        crate::feature_pipeline::ports::place_scene_port(scene, &port_id, posed)?;
324    }
325    Ok(())
326}
327
328/// The feature fence (build-spec §3): fail with a clear message when any of
329/// `names` resolves to component-owned geometry. Modeling features that consume
330/// solids/faces/edges call this over their reference names before operating —
331/// cross-part feature linking is out of scope and must be rejected cleanly,
332/// never silently applied.
333pub fn reject_component_references<'a>(
334    scene: &SceneMap,
335    names: impl IntoIterator<Item = &'a str>,
336) -> Result<(), String> {
337    for name in names {
338        if let Some(record) = scene.owning_component(name) {
339            return Err(format!(
340                "'{name}' belongs to assembly component '{}' — modeling features cannot consume component geometry",
341                record.id
342            ));
343        }
344    }
345    Ok(())
346}
347
348/// The fence at the DISPATCH altitude — the one place every feature passes
349/// through (`execute_feature`), so no per-feature checks are scattered. A
350/// reference here is what the pipeline already defines it to be (the
351/// `scan_consumed` cache-dependency walk): any descriptor string that
352/// exact-matches a scene name. Any such string owned by a component fails the
353/// feature before it executes.
354///
355/// Exempt (the two ALLOWED in-context uses, build-spec §3 — attach + project):
356/// SKETCH (plane attach + edge projection), DATUM/PLANE (a datum derived from
357/// component geometry), ACOMP itself (the component's own feature, wired by
358/// the parts-library lane), and the two wire-harness construction features —
359/// WAYPOINT (its `directionRef` is a face on a placed component: the harness
360/// workbench's whole point) and SPLINE (its anchors attach to connection
361/// points).
362/// Assembly constraints are not history features, so they never reach this
363/// fence. The exemption is type-level, which is safe because none of the
364/// exempt types carries a `boolean` param or operand-solid references —
365/// re-examine if such a param is ever added to one of them.
366pub fn enforce_reference_fence(
367    feature_type: &str,
368    descriptor: &FeatureDescriptor,
369    scene: &SceneMap,
370) -> Result<(), String> {
371    // Componentless scenes skip the walk entirely — the byte-identical
372    // guarantee for every existing modeling document.
373    if scene.components.is_empty() {
374        return Ok(());
375    }
376    // Verbatim dispatch alias strings (the dispatch matches exact spellings;
377    // "ASSEMBLY COMPONENT" is ACOMP's long dispatch alias in assembly_component.rs).
378    if matches!(
379        feature_type,
380        "S" | "SKETCH"
381            | "D"
382            | "DATUM"
383            | "DATIUM"
384            | "P"
385            | "PLANE"
386            | "ACOMP"
387            | "ASSEMBLY COMPONENT"
388            | "WP"
389            | "WAYPOINT"
390            | "SP"
391            | "SPLINE"
392    ) {
393        return Ok(());
394    }
395    if let Some(name) = first_component_owned(&descriptor.input_params, scene)
396        .or_else(|| first_component_owned(&descriptor.persistent_data, scene))
397    {
398        return reject_component_references(scene, [name]);
399    }
400    Ok(())
401}
402
403/// The first string anywhere in `value` (recursively, both param sources — the
404/// `scan_consumed` walk shape) that names component-owned geometry.
405fn first_component_owned<'a>(
406    value: &'a serde_json::Value,
407    scene: &SceneMap,
408) -> Option<&'a str> {
409    match value {
410        serde_json::Value::String(text) => {
411            let trimmed = text.trim();
412            (!trimmed.is_empty() && scene.is_component_owned(trimmed)).then_some(trimmed)
413        }
414        serde_json::Value::Array(items) => {
415            items.iter().find_map(|item| first_component_owned(item, scene))
416        }
417        serde_json::Value::Object(map) => {
418            map.values().find_map(|item| first_component_owned(item, scene))
419        }
420        _ => None,
421    }
422}
423
424// ===========================================================================
425// SceneMap component surface
426// ===========================================================================
427
428impl SceneMap {
429    /// Resolve a component record by its owning feature id (exact match).
430    /// Contract surface for the Wave-2 constraint/selection lanes.
431    #[allow(dead_code)]
432    pub fn resolve_component(&self, id: &str) -> Option<&ComponentRecord> {
433        self.components.get(id)
434    }
435
436    /// The component owning an entity name, if any: the BARE component id itself
437    /// (a COMPONENT-type selection), or the name's OUTERMOST namespace prefix
438    /// (`ACOMP3:ACOMP1:X` is owned by `ACOMP3` at this scene level; `ACOMP1` is
439    /// the nested assembly's internal structure). Membership is decided against
440    /// the registered component set, never syntactically — authored names like
441    /// `S1:PROFILE` have a first segment that is a sketch id, not a component id.
442    pub fn owning_component(&self, name: &str) -> Option<&ComponentRecord> {
443        if let Some(record) = self.components.get(name) {
444            return Some(record);
445        }
446        let (prefix, _) = name.split_once(':')?;
447        self.components.get(prefix)
448    }
449
450    /// The feature-fence predicate: does this entity name belong to a component?
451    /// Cheap (one map probe + one prefix probe); the dispatch fence
452    /// ([`enforce_reference_fence`]) uses it to REJECT component geometry as
453    /// modeling-feature input (build-spec §3).
454    pub fn is_component_owned(&self, name: &str) -> bool {
455        self.owning_component(name).is_some()
456    }
457
458    /// Iterate the scene's components in deterministic (id) order — the
459    /// structure-tree / BOM projection surface.
460    #[allow(dead_code)]
461    pub fn iter_components(&self) -> impl Iterator<Item = &ComponentRecord> {
462        self.components.values()
463    }
464
465    /// A component's member solids as `(scene name, resident handle)`, in the
466    /// record's member order. A member missing from the solid map (never the
467    /// case for an intact scene) is skipped.
468    #[allow(dead_code)]
469    pub fn component_solids(&self, id: &str) -> Vec<(String, u32)> {
470        let Some(record) = self.components.get(id) else {
471            return Vec::new();
472        };
473        record
474            .solids
475            .iter()
476            .filter_map(|name| self.solids.get(name).map(|&handle| (name.clone(), handle)))
477            .collect()
478    }
479
480    /// A component's grounded flag (`None` for an unknown id).
481    #[allow(dead_code)]
482    pub fn component_fixed(&self, id: &str) -> Option<bool> {
483        self.components.get(id).map(|record| record.fixed)
484    }
485
486    /// Set a component's grounded flag. Scene-view state only — the
487    /// authoritative flag is the owning feature's `isFixed` (the Fixed
488    /// constraint / tree action writes THERE; this keeps the live scene in
489    /// sync between runs). Returns false for an unknown id.
490    #[allow(dead_code)]
491    pub fn set_component_fixed(&mut self, id: &str, fixed: bool) -> bool {
492        match self.components.get_mut(id) {
493            Some(record) => {
494                record.fixed = fixed;
495                true
496            }
497            None => false,
498        }
499    }
500}
501