BREP_render 0.2.0

BREP Rust rendering engine: kernel-fed scene store + wgpu renderer (headless artifact, desktop window, and wasm canvas shells).
Documentation
use super::*;

// ============================================================================
// Assembly COMPONENT read surface (Wave 3, lane H) — the app-side view of the
// scene's component instances, DERIVED from what already crosses the runner
// seam: the engine-owned history (the ACOMP features are the pose/fixed truth
// per the pose-authority contract) plus the display scene's NAMESPACED solid
// names (`ACOMP2:Extrude1_top` — build-spec §3). No kernel `ComponentRecord`
// crosses the boundary: the kernel scene lives on the runner thread/worker, so
// the app derives the same projection locally from the two sources it owns.
// The namespace parse is the kernel's own (`brep_kernel::split_component_namespace`),
// so the app and the resolver can never disagree on what a component prefix is.
// ============================================================================

/// One assembly component instance as the APP sees it: the owning ACOMP feature
/// id, the parts-library part name, the authored rigid pose (`inputParams.
/// transform` — `{translate, rotateEulerDeg}`, intrinsic-XYZ degrees), the
/// grounded flag, and the member solid names currently in the display scene.
#[derive(Debug, Clone, PartialEq)]
pub struct ComponentInfo {
    /// The owning ACOMP feature id (`ACOMP<digits>`) — also the namespace prefix.
    pub id: String,
    /// The parts-library entry this instance places (`inputParams.partName`).
    pub part_name: String,
    /// `inputParams.transform.translate` (numbers-or-zero: an expression-valued
    /// slot reads 0 here, matching the transform gizmo's numeric read).
    pub translate: [f64; 3],
    /// `inputParams.transform.rotateEulerDeg` (degrees, intrinsic XYZ).
    pub rotate_deg: [f64; 3],
    /// Grounded: an explicit `isFixed` wins; ABSENT mirrors the kernel's
    /// auto-ground rule (grounded iff no ACOMP precedes it in the history).
    pub fixed: bool,
    /// Member solid names in display-scene order (`{id}:{part solid name}`).
    /// Empty when the instance is rolled back / failed to build.
    pub members: Vec<String>,
}

/// The ACOMP dispatch predicate, mirroring the kernel's `is_acomp_type` (the
/// two literals `execute_feature` matches on).
pub(crate) fn is_acomp_feature_type(feature_type: &str) -> bool {
    matches!(feature_type, "ACOMP" | "ASSEMBLY COMPONENT")
}

/// Read a `[x, y, z]` number array off a JSON value (missing / short / non-
/// numeric slots keep the per-index default) — the same lenient numeric read
/// the transform gizmo uses for pose vectors.
pub(super) fn vec3_or(value: Option<&serde_json::Value>, default: [f64; 3]) -> [f64; 3] {
    let mut out = default;
    if let Some(array) = value.and_then(|v| v.as_array()) {
        for (index, slot) in out.iter_mut().enumerate() {
            if let Some(number) = array.get(index).and_then(|v| v.as_f64()) {
                *slot = number;
            }
        }
    }
    out
}

impl EngineState {
    /// The OWNING component feature id of a scene solid — the OUTERMOST
    /// `ACOMP<digits>:` namespace segment of its name, verified against the
    /// history (the segment must be an ACOMP feature of THIS document; a nested
    /// chain's inner segments belong to the sub-assembly's own document).
    /// `None` for ordinary modeling solids (no prefix) and for sketch-child
    /// names (`S1:G20` — `S1` is not an ACOMP segment).
    pub fn component_of_solid(&self, solid_name: &str) -> Option<String> {
        let (chain, _local) = brep_kernel::split_component_namespace(solid_name);
        let head = *chain.first()?;
        let index = self.history.index_of(head)?;
        self.history
            .feature_type(index)
            .filter(|ty| is_acomp_feature_type(ty))
            .map(|_| head.to_string())
    }

    /// Every ACOMP feature id in history order (the structure tree's row order).
    pub fn component_ids(&self) -> Vec<String> {
        (0..self.history.len())
            .filter(|&i| {
                self.history
                    .feature_type(i)
                    .is_some_and(|ty| is_acomp_feature_type(&ty))
            })
            .filter_map(|i| self.history.feature_id(i))
            .collect()
    }

    /// The derived [`ComponentInfo`] for an ACOMP feature id (`None` when the id
    /// is missing or not an ACOMP feature).
    pub fn component_info(&self, feature_id: &str) -> Option<ComponentInfo> {
        let index = self.history.index_of(feature_id)?;
        self.history
            .feature_type(index)
            .filter(|ty| is_acomp_feature_type(ty))?;
        let params = self.history.feature_params(index).unwrap_or_default();
        let transform = params.get("transform");
        let translate = vec3_or(transform.and_then(|t| t.get("translate")), [0.0; 3]);
        let rotate_deg = vec3_or(transform.and_then(|t| t.get("rotateEulerDeg")), [0.0; 3]);
        // Grounded: explicit boolean wins; ABSENT (or null) mirrors the kernel's
        // auto-ground — the FIRST component of the document is grounded.
        let fixed = match params.get("isFixed") {
            Some(serde_json::Value::Bool(flag)) => *flag,
            _ => self
                .component_ids()
                .first()
                .is_some_and(|first| first == feature_id),
        };
        let members: Vec<String> = self
            .scene
            .solids()
            .iter()
            .filter(|solid| self.component_of_solid(&solid.name).as_deref() == Some(feature_id))
            .map(|solid| solid.name.clone())
            .collect();
        Some(ComponentInfo {
            id: feature_id.to_string(),
            part_name: params
                .get("partName")
                .and_then(|v| v.as_str())
                .unwrap_or_default()
                .to_string(),
            translate,
            rotate_deg,
            fixed,
            members,
        })
    }

    /// The world bbox CENTER of a component's member solids — the Move gizmo's
    /// attach point (build-spec §8.5). `None` when the component has no resident
    /// members (rolled back / failed build).
    pub fn component_bbox_center(&self, feature_id: &str) -> Option<[f64; 3]> {
        let mut bbox = crate::camera::Aabb::empty();
        for solid in self.scene.solids() {
            if self.component_of_solid(&solid.name).as_deref() == Some(feature_id) {
                bbox.union(&solid.bbox);
            }
        }
        (!bbox.is_empty()).then(|| bbox.center())
    }
}

#[cfg(test)]
pub(crate) mod component_fixtures {
    /// A two-instance assembly document over ONE parts-library entry `widget`
    /// (a 10 mm cube part named `Part`): `ACOMP1` fixed at the origin, `ACOMP2`
    /// (isFixed ABSENT) at `translate [20,0,0]`. The entry ships with an EMPTY
    /// snapshot, so building it exercises the kernel's self-heal lane (document
    /// re-execution → snapshot rewrite) — the same lane the app-side
    /// edit-in-place refresh relies on.
    pub fn two_instance_assembly_json() -> String {
        serde_json::json!({
            "expressions": "",
            "configurator": {},
            "features": [
                {
                    "type": "ACOMP",
                    "inputParams": {
                        "id": "ACOMP1",
                        "partName": "widget",
                        "transform": { "translate": [0.0, 0.0, 0.0], "rotateEulerDeg": [0.0, 0.0, 0.0] },
                        "isFixed": true
                    },
                    "persistentData": {}
                },
                {
                    "type": "ACOMP",
                    "inputParams": {
                        "id": "ACOMP2",
                        "partName": "widget",
                        "transform": { "translate": [20.0, 0.0, 0.0], "rotateEulerDeg": [0.0, 0.0, 0.0] }
                    },
                    "persistentData": {}
                }
            ],
            "partsLibrary": {
                "widget": {
                    "sourceKey": "widget",
                    "sourceSignature": "sig-1",
                    "document": cube_part_document(10.0),
                    "snapshot": ""
                }
            }
        })
        .to_string()
    }

    /// A standalone cube part document (`P.CU` id `Part`, side `size`).
    pub fn cube_part_document(size: f64) -> serde_json::Value {
        serde_json::json!({
            "expressions": "",
            "configurator": {},
            "features": [{
                "type": "P.CU",
                "inputParams": {
                    "id": "Part",
                    "sizeX": size, "sizeY": size, "sizeZ": size,
                    "transform": {
                        "position": [0.0, 0.0, 0.0],
                        "rotationEuler": [0.0, 0.0, 0.0],
                        "scale": [1.0, 1.0, 1.0]
                    },
                    "boolean": { "targets": [], "operation": "NONE" }
                },
                "persistentData": {}
            }]
        })
    }
}

#[cfg(test)]
mod component_read_tests {
    use super::component_fixtures::two_instance_assembly_json;
    use super::*;

    fn assembly_engine() -> EngineState {
        let mut engine = EngineState::new();
        let report = engine
            .set_history_json(&two_instance_assembly_json())
            .expect("assembly document loads");
        let report: serde_json::Value = serde_json::from_str(&report).unwrap();
        assert!(
            report["featureErrors"].as_array().is_none_or(|e| e.is_empty()),
            "assembly built clean: {report}"
        );
        engine
    }

    #[test]
    fn acomp_instances_build_namespaced_members_via_self_heal() {
        // The fixture's library entry has an EMPTY snapshot: building it proves
        // the self-heal lane (document re-execution) feeds the instances.
        let engine = assembly_engine();
        let names: Vec<&str> = engine
            .scene
            .solids()
            .iter()
            .map(|s| s.name.as_str())
            .collect();
        assert!(names.contains(&"ACOMP1:Part"), "{names:?}");
        assert!(names.contains(&"ACOMP2:Part"), "{names:?}");
    }

    #[test]
    fn component_of_solid_parses_the_outermost_acomp_segment() {
        let engine = assembly_engine();
        assert_eq!(
            engine.component_of_solid("ACOMP1:Part").as_deref(),
            Some("ACOMP1")
        );
        // A NESTED chain resolves to the OUTERMOST segment (the instance of
        // THIS document); the inner ids belong to the sub-assembly's document.
        assert_eq!(
            engine.component_of_solid("ACOMP2:ACOMP7:Extrude1_top").as_deref(),
            Some("ACOMP2")
        );
        // Ordinary solids, sketch-child names, and whole-component ids are not
        // component members.
        assert_eq!(engine.component_of_solid("Box"), None);
        assert_eq!(engine.component_of_solid("S1:G20"), None);
        assert_eq!(engine.component_of_solid("ACOMP1"), None);
        // An ACOMP-shaped prefix with NO matching history feature is rejected.
        assert_eq!(engine.component_of_solid("ACOMP9:Part"), None);
    }

    #[test]
    fn component_info_reads_pose_fixed_and_members() {
        let engine = assembly_engine();
        assert_eq!(engine.component_ids(), ["ACOMP1", "ACOMP2"]);

        let first = engine.component_info("ACOMP1").expect("ACOMP1 info");
        assert_eq!(first.part_name, "widget");
        assert_eq!(first.translate, [0.0, 0.0, 0.0]);
        assert!(first.fixed, "explicit isFixed:true is honored");
        assert_eq!(first.members, ["ACOMP1:Part"]);

        let second = engine.component_info("ACOMP2").expect("ACOMP2 info");
        assert_eq!(second.translate, [20.0, 0.0, 0.0]);
        assert!(
            !second.fixed,
            "isFixed ABSENT on a NON-first component mirrors the kernel auto-ground: free"
        );
        assert_eq!(second.members, ["ACOMP2:Part"]);

        // Not a component: the id exists nowhere / not an ACOMP.
        assert!(engine.component_info("Box").is_none());
    }

    #[test]
    fn absent_is_fixed_grounds_only_the_first_component() {
        // A single-instance document with NO isFixed at all: the sole (first)
        // component reads grounded, mirroring the kernel's auto-ground.
        let mut doc: serde_json::Value =
            serde_json::from_str(&two_instance_assembly_json()).unwrap();
        let features = doc["features"].as_array_mut().unwrap();
        features.truncate(1);
        features[0]["inputParams"]
            .as_object_mut()
            .unwrap()
            .remove("isFixed");
        let mut engine = EngineState::new();
        engine.set_history_json(&doc.to_string()).unwrap();
        let info = engine.component_info("ACOMP1").expect("sole component");
        assert!(info.fixed, "first component auto-grounds when isFixed is absent");
    }

    /// The ENGINE-ALTITUDE pose-authority fold: loading a constrained assembly
    /// solves at the run tail and the solved pose lands in the owning ACOMP's
    /// `inputParams.transform` (by id) WITHOUT minting an undo entry — so the
    /// document the app persists / re-runs already carries the solved pose, and
    /// a follow-up run is a settled no-motion solve (request stable).
    #[test]
    fn solved_poses_fold_into_acomp_params_without_undo() {
        let mut doc: serde_json::Value =
            serde_json::from_str(&two_instance_assembly_json()).unwrap();
        doc["assembly"] = serde_json::json!({
            "constraints": [{
                "type": "coincident",
                "inputParams": { "id": "COIN1", "elements": ["ACOMP1", "ACOMP2"] },
                "persistentData": {},
                "enabled": true,
                "open": false
            }],
            "idCounter": 2
        });
        let mut engine = EngineState::new();
        engine.set_history_json(&doc.to_string()).unwrap();

        // The fold adopted the solved pose: ACOMP2's authored [20,0,0] moved
        // onto ACOMP1 (whole-component coincident pulls the anchors together).
        let info = engine.component_info("ACOMP2").expect("ACOMP2");
        assert!(
            info.translate[0] < 15.0,
            "solved translate folded into inputParams: {:?}",
            info.translate
        );
        let c1 = engine.component_bbox_center("ACOMP1").unwrap();
        let c2 = engine.component_bbox_center("ACOMP2").unwrap();
        let gap = ((c1[0] - c2[0]).powi(2) + (c1[1] - c2[1]).powi(2) + (c1[2] - c2[2]).powi(2)).sqrt();
        assert!(gap < 1e-4, "displayed members coincide: {c1:?} vs {c2:?}");

        // The fold is NOT an undo entry (a solve write-back is not a user edit).
        assert!(!engine.history.can_undo(), "no undo entry from the fold");

        // Settled: re-running the folded document changes nothing (no pose
        // churn — the no-motion solve emits no write-backs).
        let before = engine.history_request_json();
        engine.roll_to(engine.history_len() - 1);
        assert_eq!(before, engine.history_request_json(), "request stable across a settled re-run");
    }

    #[test]
    fn bbox_center_unions_the_member_solids() {
        let engine = assembly_engine();
        // ACOMP2 is the 10 mm cube translated +20 X → bbox [20..30, 0..10, 0..10].
        let center = engine.component_bbox_center("ACOMP2").expect("center");
        assert!((center[0] - 25.0).abs() < 1e-6, "{center:?}");
        assert!((center[1] - 5.0).abs() < 1e-6, "{center:?}");
        assert!((center[2] - 5.0).abs() < 1e-6, "{center:?}");
        // No members → no center.
        assert!(engine.component_bbox_center("ACOMP9").is_none());
    }
}