From fce855789963c1254769c1839135eba8b8da0c17 Mon Sep 17 00:00:00 2001 From: Bruce Mitchener Date: Thu, 27 Aug 2026 13:02:29 +0700 Subject: [PATCH] feat(cambium): retain Addressable inspection sessions Keep the panel_trio assembly behind one Wasm InspectionSession with an explicit runtime space and revision. Project repeatable provenance snapshots from that live host while preserving the one-shot recipe scenario API. --- .tickets/cwb-ddf7.md | 12 ++- Cargo.lock | 1 + apps/cambium_web_bridge/Cargo.toml | 1 + apps/cambium_web_bridge/README.md | 6 ++ apps/cambium_web_bridge/src/inspect.rs | 111 +++++++++++++++++++++- apps/cambium_web_viewer/README.md | 4 + apps/cambium_web_viewer/src/inspector.ts | 24 ++++- apps/cambium_web_viewer/src/wasm_pkg.d.ts | 7 ++ 8 files changed, 158 insertions(+), 8 deletions(-) diff --git a/.tickets/cwb-ddf7.md b/.tickets/cwb-ddf7.md index d53783b..a382108 100644 --- a/.tickets/cwb-ddf7.md +++ b/.tickets/cwb-ddf7.md @@ -1,6 +1,6 @@ --- id: cwb-ddf7 -status: open +status: in_progress deps: [] links: [] type: feature @@ -20,6 +20,14 @@ The web bridge and viewer own deterministic inspection payloads and presentation; they do not own example geometry, kernel semantics, or a generic plugin system. +## Addressable session extension + +Retain the `panel_trio` assembly behind a Wasm `InspectionSession`, then let a +picked face identify its exact instance address and constructive material slot. +The bridge owns this session and its JSON presentation. Exedra continues to own +assembly state, material policy, and typed addressed operations; this work does +not extract a generic tooling schema. + ## Acceptance - Dependency direction is documented before implementation. @@ -28,3 +36,5 @@ plugin system. - Group order, instance identity, and coordinate convention remain stable. - Missing provenance is displayed as missing. - Existing scenarios remain byte-deterministic. +- Repeated session snapshots observe one explicit runtime space and revision. +- A picked material-bearing face yields an instance-address/material-slot pair. diff --git a/Cargo.lock b/Cargo.lock index dcb1c8d..acac2dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,6 +140,7 @@ dependencies = [ name = "cambium_web_bridge" version = "0.1.0" dependencies = [ + "addressable", "cambium", "exedra", "exedra_assembly", diff --git a/apps/cambium_web_bridge/Cargo.toml b/apps/cambium_web_bridge/Cargo.toml index fd59638..5c0a50c 100644 --- a/apps/cambium_web_bridge/Cargo.toml +++ b/apps/cambium_web_bridge/Cargo.toml @@ -14,6 +14,7 @@ categories = ["graphics", "wasm"] crate-type = ["cdylib", "rlib"] [dependencies] +addressable.workspace = true cambium = { features = ["libm"], workspace = true } exedra = { features = ["libm"], workspace = true } exedra_assembly.workspace = true diff --git a/apps/cambium_web_bridge/README.md b/apps/cambium_web_bridge/README.md index d110e1f..36702d6 100644 --- a/apps/cambium_web_bridge/README.md +++ b/apps/cambium_web_bridge/README.md @@ -31,6 +31,12 @@ Inspection scenarios (`list_inspection_scenarios_json()`): - `panel_trio`: the multi-instance assembly scene; three placements share one provenance-attributed body. +`InspectionSession::new("panel_trio", space)` retains that assembly as one +Addressable runtime across calls. Its `snapshot_json()` method projects the +current host through the same `cambium-inspect-v1` payload, while `space` and +`revision` expose the observation context. The one-shot function remains +available for recipe-only scenarios and compatibility. + Current scenarios: - `stepped_tower` - `pedestal` diff --git a/apps/cambium_web_bridge/src/inspect.rs b/apps/cambium_web_bridge/src/inspect.rs index 02b1b60..dc49507 100644 --- a/apps/cambium_web_bridge/src/inspect.rs +++ b/apps/cambium_web_bridge/src/inspect.rs @@ -26,6 +26,7 @@ use alloc::format; use alloc::string::{String, ToString}; use alloc::vec::Vec; +use addressable::SpaceId; use exedra_constructive::evaluate::{Evaluation, Fidelity, Severity, evaluate}; use exedra_constructive::ir::{ CapMode, CsgOp, NodeId, NodeKind, Placement3, Recipe, RecipeBuilder, @@ -36,6 +37,80 @@ use wasm_bindgen::prelude::*; use crate::{MeshBuffers, extract_mesh_buffers, matrix16, panel_trio_assembly}; +/// A live Addressable assembly behind the Cambium provenance inspector. +/// +/// The first stateful scenario is `panel_trio`. Constructing a session assigns +/// that assembly a caller-supplied runtime space identity; repeated snapshots +/// then observe the same host and revision instead of rebuilding an unrelated +/// assembly for every bridge call. +/// +/// Recipe-only inspection scenarios remain available through +/// [`run_inspection_scenario_json`]. +#[wasm_bindgen] +#[derive(Debug)] +pub struct InspectionSession { + space: exedra_assembly::AddressableAssembly, +} + +#[wasm_bindgen] +impl InspectionSession { + /// Starts a live inspection session for one named assembly scenario. + /// + /// # Errors + /// + /// Returns a JavaScript error when `name` is not a stateful assembly + /// scenario or its assembly cannot be constructed. + #[wasm_bindgen(constructor)] + pub fn new(name: &str, space: u64) -> Result { + Self::build(name, space).map_err(|error| JsValue::from_str(&error)) + } + + /// Returns the caller-assigned runtime Addressable space identity. + #[wasm_bindgen(getter)] + #[must_use] + pub fn space(&self) -> u64 { + self.space.id().get() + } + + /// Returns the current space-local Addressable revision. + #[wasm_bindgen(getter)] + #[must_use] + pub fn revision(&self) -> u64 { + self.space.revision().get() + } + + /// Projects the current assembly as a `cambium-inspect-v1` snapshot. + /// + /// # Errors + /// + /// Returns a JavaScript error when compilation, evaluation, or + /// serialization fails. + pub fn snapshot_json(&self) -> Result { + let response = self.snapshot().map_err(|error| JsValue::from_str(&error))?; + serde_json::to_string(&response).map_err(|error| { + JsValue::from_str(&format!("failed to serialize inspection response: {error}")) + }) + } +} + +impl InspectionSession { + fn build(name: &str, space: u64) -> Result { + if name != "panel_trio" { + return Err(format!( + "inspection scenario `{name}` does not own an Addressable assembly" + )); + } + let assembly = panel_trio_assembly()?; + Ok(Self { + space: assembly.into_addressable(SpaceId::new(space)), + }) + } + + fn snapshot(&self) -> Result { + inspect_panel_trio_assembly(self.space.assembly()) + } +} + /// The inspection payload format identifier. pub const INSPECT_FORMAT: &str = "cambium-inspect-v1"; @@ -333,14 +408,20 @@ fn inspect_recipe(name: &str, recipe: &Recipe) -> Result Result { + let assembly = panel_trio_assembly()?; + inspect_panel_trio_assembly(&assembly) +} + +fn inspect_panel_trio_assembly( + assembly: &exedra_assembly::Assembly, +) -> Result { use exedra_assembly::{PartCompiler, PartSource, flatten}; - let asm = panel_trio_assembly()?; let mut compiler = PartCompiler::new(); let compiled = compiler - .compile_parts(&asm, &EvalPolicy::default()) + .compile_parts(assembly, &EvalPolicy::default()) .map_err(|e| format!("{e}"))?; - let list = flatten(&asm, &compiled); + let list = flatten(assembly, &compiled); let mut response = empty_response("panel_trio"); // Evaluate each distinct part's recipe once for provenance; the body @@ -354,7 +435,7 @@ fn inspect_panel_trio() -> Result { let body = match body_lookup.get(&key).copied() { Some(body) => body, None => { - let def = asm + let def = assembly .part(item.part) .ok_or_else(|| "flatten referenced an unknown part".to_string())?; let part_key = def.key().to_string(); @@ -730,4 +811,26 @@ mod tests { assert_eq!(json(name), json(name), "{name}: byte-identical reruns"); } } + + #[test] + fn panel_trio_session_retains_space_revision_and_snapshot_identity() { + let session = InspectionSession::build("panel_trio", 41).expect("session starts"); + assert_eq!(session.space(), 41); + assert_eq!(session.revision(), 0); + + let first = serde_json::to_string(&session.snapshot().expect("first snapshot")) + .expect("first snapshot serializes"); + let second = serde_json::to_string(&session.snapshot().expect("second snapshot")) + .expect("second snapshot serializes"); + assert_eq!(first, second); + assert_eq!(session.space(), 41); + assert_eq!(session.revision(), 0); + } + + #[test] + fn session_rejects_recipe_only_scenarios() { + let error = InspectionSession::build("drilled_block", 41) + .expect_err("recipe scenario has no live assembly"); + assert!(error.contains("does not own an Addressable assembly")); + } } diff --git a/apps/cambium_web_viewer/README.md b/apps/cambium_web_viewer/README.md index 28a6b66..c2dece5 100644 --- a/apps/cambium_web_viewer/README.md +++ b/apps/cambium_web_viewer/README.md @@ -50,6 +50,10 @@ a summary readout and the exact diagnostics ledger. The selected face highlights in the accent color. Reloading a scenario re-renders the byte-identical payload. +The `panel_trio` inspector retains an `InspectionSession` backed by one +Addressable assembly. Recipe-only scenarios continue to use immutable +snapshots; reloading `panel_trio` intentionally starts a new runtime space. + ## Viewer Controls - Scenario picker: choose and rerun a named flow. diff --git a/apps/cambium_web_viewer/src/inspector.ts b/apps/cambium_web_viewer/src/inspector.ts index 2d61228..f0e0659 100644 --- a/apps/cambium_web_viewer/src/inspector.ts +++ b/apps/cambium_web_viewer/src/inspector.ts @@ -120,6 +120,14 @@ type InspectionResponse = { type WasmApi = { list_inspection_scenarios_json: () => string; run_inspection_scenario_json: (name: string) => string; + InspectionSession: new (name: string, space: bigint) => WasmInspectionSession; +}; + +type WasmInspectionSession = { + readonly space: bigint; + readonly revision: bigint; + snapshot_json: () => string; + free: () => void; }; // --- DOM --- @@ -549,6 +557,8 @@ async function loadWasmApi(): Promise { async function bootstrap(): Promise { const wasm = await loadWasmApi(); + let currentSession: WasmInspectionSession | null = null; + let nextSpace = 1n; const scenarios = JSON.parse(wasm.list_inspection_scenarios_json()) as string[]; for (const name of scenarios) { const option = document.createElement("option"); @@ -560,9 +570,17 @@ async function bootstrap(): Promise { const runScenario = (): void => { const selectedName = scenarioSelect.value; try { - const response = JSON.parse( - wasm.run_inspection_scenario_json(selectedName), - ) as InspectionResponse; + currentSession?.free(); + currentSession = null; + let responseJson: string; + if (selectedName === "panel_trio") { + currentSession = new wasm.InspectionSession(selectedName, nextSpace); + nextSpace += 1n; + responseJson = currentSession.snapshot_json(); + } else { + responseJson = wasm.run_inspection_scenario_json(selectedName); + } + const response = JSON.parse(responseJson) as InspectionResponse; currentResponse = response; formatLabel.textContent = response.format; buildScene(response); diff --git a/apps/cambium_web_viewer/src/wasm_pkg.d.ts b/apps/cambium_web_viewer/src/wasm_pkg.d.ts index aecec92..0e3d41d 100644 --- a/apps/cambium_web_viewer/src/wasm_pkg.d.ts +++ b/apps/cambium_web_viewer/src/wasm_pkg.d.ts @@ -4,4 +4,11 @@ declare module "./wasm_pkg/cambium_web_bridge" { export function run_scenario_json(name: string, optionsJson: string): string; export function list_inspection_scenarios_json(): string; export function run_inspection_scenario_json(name: string): string; + export class InspectionSession { + constructor(name: string, space: bigint); + readonly space: bigint; + readonly revision: bigint; + snapshot_json(): string; + free(): void; + } }