Skip to main content

brep_kernel/feature_pipeline/
mod.rs

1//! Feature-history execution with exact name resolution and stable output names.
2//!
3//! [`execute_history`] updates its live [`SceneMap`] after every feature. Reference
4//! parameters resolve by exact name; misses produce structured `unresolved`
5//! entries in [`FeatureResult`]. Output names must preserve saved-document naming
6//! conventions because later features store those names as references.
7//!
8//! Feature descriptors retain `{type, inputParams, persistentData, timestamp}`.
9//! Numeric parameters accept literals or expressions evaluated against [`Env`].
10//!
11//! Registry borrows stay local to each operation and never span feature execution.
12//! Features call Rust operations directly, preserving names on resident solids.
13//! Removed scene solids and consumed intermediates must release their handles.
14
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use wasm_bindgen::prelude::*;
18
19use crate::{KernelRefusal, NurbsCurve, Vec3};
20
21// The scene component concept (assemblies): component records + per-instance
22// entity-name namespacing at the component boundary. See component.rs.
23pub(crate) mod component;
24pub use component::ComponentRecord;
25// The per-document parts library (assemblies): unique part payloads the ACOMP
26// instance feature references by name; kernel-resident, request-seeded,
27// GC'd at the end of every history run. See parts_library.rs.
28pub(crate) mod parts_library;
29pub use parts_library::{
30    add_part_to_library, add_part_to_library_impl, install_parts_library, missing_library_parts,
31    parts_library_json, parts_library_map, parts_library_revision, refresh_library_entry,
32    refresh_library_entry_impl, same_build,
33    PartsLibraryEntry, PartsLibraryMap, stable_json_hash,
34};
35// Assembly constraint state + lifecycle: the `assembly` history block, the
36// ten constraint schemas, the mate-mapping + solve tail, and the exported
37// constraint-mutation ABI. See assembly.rs.
38pub mod assembly;
39/// Wire-harness routing: the document's `wireHarness` block, the sided port
40/// graph, per-connection routes and the bundle solids — solved at the tail of
41/// every run like `assembly` (see `wire_harness.rs`).
42pub mod wire_harness;
43/// A part's symbol pins ARE its harness ports, bound by label (eCAD plan
44/// decision 8): the pin/port report, following an edit from one side to the
45/// other, and resolving a placed part's pin to its port id (see `part_pins.rs`).
46pub mod part_pins;
47pub mod pmi;
48pub mod ports;
49pub(crate) use features::import3d::imported_solid_names;
50pub use assembly::{AssemblyState, ConstraintEntry};
51mod expression;
52#[path = "features/mod.rs"]
53pub(crate) mod features;
54pub use features::common::{first_reference_name, reference_names};
55pub(crate) use features::transform::bbox_center as transform_bbox_center;
56// Transform Face's default pivot over a replayed prefix: the value the app
57// stores into `pivot` when a face selection is committed, computed by the same
58// resolution and centre the feature falls back to for a null pivot.
59pub use features::transform_face::face_transform_pivot;
60// The native IMPORT3D payload encoder: finished solids → an `io/snapshot`
61// payload carrying IMPORT3D's own names, ready to become an
62// `inputParams.nativeBrep` part document. Lives WITH the feature that reads it
63// back (one naming convention, one implementation); exported for the import
64// lanes that build part documents.
65pub use features::import3d::{native_import_payload, native_import_payload_with_appearance};
66pub(crate) mod scene_metadata;
67// The scene-metadata isolation bracket, for out-of-crate producers that encode a
68// payload against a LIVE document (the STEP-assembly import lane).
69pub use scene_metadata::IsolatedSceneMetadata;
70// The colour-only read of the name-keyed store: THREAD-LOCAL, so a caller whose
71// history runs off-thread must read it runner-side (`io/appearance.rs`).
72pub use scene_metadata::scene_metadata_colors_json;
73mod schema;
74// The kernel-owned feature-schema catalogue, reachable by NATIVE Rust consumers
75// (the engine-native egui UI in brep-app, via a brep-render re-export) — the same
76// definitions the wasm `feature_schemas_json` export serves the caller's feature registry.
77pub use schema::feature_schema_catalogue;
78pub use schema::feature_hidden_params;
79pub mod context_offer;
80// Selection-context applicability: the per-feature show/no-show predicates the
81// app's context bar runs against the current selection (schema.rs's sibling —
82// definitions live with each feature, this only aggregates).
83pub use context_offer::{feature_context_applicable, SelectionProbe};
84#[path = "sheet_metal/mod.rs"]
85mod sheet_metal;
86// Flat-pattern (unfold) → 2D vector export, for the native engine's export lane.
87// Keyed by resident handle so `SheetTree` stays private to the pipeline.
88pub use sheet_metal::{flat_pattern_dxf, flat_pattern_svg, is_sheet_metal_handle};
89
90pub use expression::Env;
91// The sketch loop-id write-back (the editor calls it on commit) — the
92// persistence half of the per-loop face-naming scheme. See
93// `features/sketch/loop_ids.rs`.
94pub use features::sketch::assign_sketch_loop_ids;
95
96// ===========================================================================
97// Descriptor — the serialized `{ type, inputParams, persistentData, timestamp }`
98// ===========================================================================
99
100/// One feature as serialized by `PartHistory.toSerializable`. Permissive: unknown
101/// fields are ignored, so an entire saved part file's `features[]` deserializes
102/// as-is. `input_params` stays a `serde_json::Value` (an object) so features read
103/// their own params with their own type knowledge (which params are numeric).
104// `persistent_data` / `timestamp` are contract pass-throughs (round-tripped for
105// features that need them, e.g. hole/pattern persistent state); not read yet.
106#[allow(dead_code)]
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct FeatureDescriptor {
109    #[serde(rename = "type", default)]
110    pub feature_type: String,
111    #[serde(rename = "inputParams", default)]
112    pub input_params: serde_json::Value,
113    #[serde(rename = "persistentData", default)]
114    pub persistent_data: serde_json::Value,
115    /// Opaque pass-through (any shape) so one odd saved file cannot fail the parse.
116    #[serde(default)]
117    pub timestamp: Option<serde_json::Value>,
118}
119
120/// The `execute_history_json` request: the expression source, the configurator
121/// state, and the ordered feature list. Permissive — a whole saved part file
122/// parses as a request (extra top-level fields ignored).
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct HistoryRequest {
125    #[serde(default, deserialize_with = "de_string_lenient")]
126    pub expressions: String,
127    #[serde(default)]
128    pub configurator: serde_json::Value,
129    #[serde(default)]
130    pub features: Vec<FeatureDescriptor>,
131    /// Stop AFTER executing the feature with this id (the editor's "stop at the
132    /// expanded feature"). The features past the stop stay in the request so the
133    /// incremental cache RETAINS their entries — expanding/collapsing a panel
134    /// must not thrash the cache (marshal-side truncation would free them).
135    #[serde(default, rename = "stopAtId")]
136    pub stop_at_id: Option<String>,
137    /// Stop BEFORE executing the feature with this id.
138    #[serde(default, rename = "stopBeforeId")]
139    pub stop_before_id: Option<String>,
140    /// The assembly constraint block: `{ constraints, idCounter }`. Absent on
141    /// every non-assembly document (and omitted on re-serialize, so
142    /// componentless files persist byte-identically). Solved at the tail of
143    /// every run; see `assembly.rs`.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub assembly: Option<assembly::AssemblyState>,
146    /// The wire-harness block: `{ connections, idCounter, buildBundles }`.
147    /// Absent on every document without harness connections (and omitted on
148    /// re-serialize). Routed at the tail of every run; see `wire_harness.rs`.
149    #[serde(default, rename = "wireHarness", skip_serializing_if = "Option::is_none")]
150    pub wire_harness: Option<wire_harness::WireHarnessState>,
151    /// The PMI block: `{ views: [{ id, name, camera, display, annotations }],
152    /// idCounter }`. Absent on every document without PMI (and omitted on
153    /// re-serialize). Resolved at the tail of every run; see `pmi.rs`.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub pmi: Option<pmi::PmiState>,
156    /// Render level-of-detail factor for DISPLAY tessellation (the app's "LOD
157    /// factor" setting). `execute_history` ignores it — it rides on the request
158    /// only because the request is the serialized run boundary the display runner
159    /// receives, and the runner scales its per-solid chord tolerance by it
160    /// (higher = coarser mesh). Defaults to `1.0` (the "Normal" preset) so a saved
161    /// file / seed request with no `displayLod` tessellates exactly as before.
162    #[serde(default = "default_display_lod", rename = "displayLod")]
163    pub display_lod: f64,
164    /// The parts library block: unique part payloads the ACOMP instance
165    /// features reference by name. Ingested into the kernel-resident library
166    /// at the start of every run (this is how a LOADED document seeds it);
167    /// SAVE serializes `parts_library_json()` back out — never this loaded
168    /// block (see parts_library.rs).
169    #[serde(default, rename = "partsLibrary")]
170    pub parts_library: parts_library::PartsLibraryMap,
171    /// The declared-ports block: the part's connection points as DATA, a
172    /// sibling of `features` (see `ports.rs`). Absent on every document that
173    /// declares none (and omitted on re-serialize). Resolved at the tail of
174    /// every run, against the finished scene.
175    #[serde(default, skip_serializing_if = "Vec::is_empty")]
176    pub ports: Vec<ports::PortDeclaration>,
177    /// The EXPLICIT encapsulation mark: `true` hides this document's children
178    /// behind its declared ports, `false` keeps them reachable. Absent means
179    /// derive it — see [`ports::document_is_boundary`].
180    #[serde(default, rename = "portBoundary", skip_serializing_if = "Option::is_none")]
181    pub port_boundary: Option<bool>,
182    /// Whether the document carries a `pcb` block, which makes it a board and
183    /// therefore a port boundary by construction. Presence ONLY: the block
184    /// itself is eCAD's and the kernel neither reads nor rewrites it, so this
185    /// never serializes back out.
186    #[serde(
187        default,
188        rename = "pcb",
189        deserialize_with = "de_present",
190        skip_serializing
191    )]
192    pub pcb_present: bool,
193}
194
195/// Deserialize any value as "it was there" - see [`HistoryRequest::pcb_present`].
196fn de_present<'de, D>(deserializer: D) -> Result<bool, D::Error>
197where
198    D: serde::Deserializer<'de>,
199{
200    serde::Deserialize::deserialize(deserializer).map(|_: serde::de::IgnoredAny| true)
201}
202
203/// The "Normal" render LOD — the [`HistoryRequest::display_lod`] serde default.
204fn default_display_lod() -> f64 {
205    1.0
206}
207
208/// Accept `null`/missing as `""` for a string field (saved files sometimes store
209/// `expressions: null`).
210fn de_string_lenient<'de, D>(deserializer: D) -> Result<String, D::Error>
211where
212    D: serde::Deserializer<'de>,
213{
214    let value = Option::<String>::deserialize(deserializer)?;
215    Ok(value.unwrap_or_default())
216}
217
218// ===========================================================================
219// Result — the per-feature output contract the caller consumes
220// ===========================================================================
221
222/// A solid produced by a feature: its resident handle, its solid name, and its
223/// named faces/edges (`(topology_id, name)`). the caller tessellates/pulls names via the
224/// handle; the scene-map registers the names for downstream reference resolution.
225#[derive(Debug, Clone, Default, Serialize)]
226pub struct AddedSolid {
227    pub handle: u32,
228    pub name: String,
229    pub face_names: Vec<(u64, String)>,
230    /// Edge names propagated/authored on the solid. Present so edge-selecting
231    /// features (fillet/chamfer) can resolve "the edge of the extrude above"
232    /// against the scene-map — the pinned example in contract rule 1.
233    pub edge_names: Vec<(u64, String)>,
234    /// CONTAINER groups: `(container_name, member face NAMES)`. A profile-swept
235    /// feature registers the un-keyed name of each of its per-loop face families
236    /// here (`{cap_base}_START` -> every per-loop START cap), so a reference
237    /// stored before per-loop naming still resolves. See [`SceneMap::face_groups`].
238    ///
239    /// Members are NAMES, not `(handle, id)` refs, and are resolved at lookup
240    /// time. Name fidelity carries a face through a later boolean or dressup, so
241    /// a group registered by the extrude keeps naming the right faces after the
242    /// solid is consumed and re-registered by something downstream — which a
243    /// captured handle would not survive.
244    #[serde(default)]
245    pub face_groups: Vec<(String, Vec<String>)>,
246    /// The same, for the derived edge names that embed those face names.
247    #[serde(default)]
248    pub edge_groups: Vec<(String, Vec<String>)>,
249}
250
251/// The output of one feature execution.
252/// A named world point a feature published (a SKETCH publishes every solved
253/// point as `{sketchId}:P{pid}`; a spline its anchors, a helix its ends).
254/// `construction` carries the sketch point's own flag: a construction point (the
255/// sketcher's ◐ toggle, or a materialized external reference) still resolves by
256/// name — a helix may start at one — but it is NOT model geometry, so a placement
257/// consumer (the hole) skips it exactly as the profile skips construction lines.
258/// Serialized because the render-side build report carries it across the worker
259/// boundary.
260#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
261pub struct ScenePoint {
262    pub position: Vec3,
263    pub construction: bool,
264}
265
266impl ScenePoint {
267    /// A model point (the default for every non-sketch publisher).
268    pub fn model(position: Vec3) -> Self {
269        Self { position, construction: false }
270    }
271}
272
273#[derive(Debug, Clone, Serialize)]
274pub struct FeatureResult {
275    pub id: String,
276    pub feature_type: String,
277    pub added: Vec<AddedSolid>,
278    /// Names of prior solids this feature consumed/superseded (removed from the
279    /// scene-map; their handles are freed by the loop).
280    pub removed: Vec<String>,
281    /// Set on a hard failure. `execute_history` records it and HALTS the
282    /// remaining features (mirrors the abort-on-error loop). Never a panic.
283    pub error: Option<String>,
284    /// The TYPED refusal behind `error`, when the producer that refused minted
285    /// one (`KernelRefusal`: a closed class, the stage, the same text). `error`
286    /// is always that refusal's message verbatim, so a consumer that only
287    /// reads text sees nothing new; one that dispatches on the class reads it
288    /// here. `None` on success and on a refusal from a producer that still
289    /// returns text.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub refusal: Option<KernelRefusal>,
292    /// What a SUCCESSFUL feature did with what it was asked, when that is not
293    /// "everything, exactly": the references it applied and the ones it
294    /// rejected, each with why. `None` on a refusal and on a feature that did
295    /// everything it was asked. A feature that proceeds on the subset of its
296    /// selection that resolved says so HERE, typed, instead of only in
297    /// `unresolved` (which stays populated for the rename hint). This is a
298    /// success stating what it did, not a refusal carrying a partial result —
299    /// the payload the 2026-09-11 survey rejected.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub fulfilment: Option<Fulfilment>,
302    /// Which named motion STEP of a feature refused: Transform Face records
303    /// `"rotation"` or `"translation"` (`features::transform_face::REFUSED_STEP_*`)
304    /// for a refusal from either motion, alone or in rotate-then-translate.
305    /// `None` for every other result, including a Transform Face refused before
306    /// any motion ran. The refusal's class is the step's own; this says which
307    /// step it was, which no class can. Additive: absent when `None`.
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub refused_step: Option<String>,
310    /// `reference_selection` names this feature could not resolve against the
311    /// scene-map. NOT an error/halt — the caller runs a snapshot-repair pass and
312    /// re-dispatches (migration-plan Stage 5 gate).
313    pub unresolved: Vec<String>,
314    /// What the kernel REPAIRED while building this feature, in the words of the
315    /// repair that made it. A repair changes the answer the lane returns — the
316    /// self-crossing fixer drops material and re-trims its neighbours — so it is
317    /// never silent: the feature that ran it says so here, and the case gate
318    /// records it per case. Empty on every feature that repaired nothing, which
319    /// is nearly all of them.
320    #[serde(default, skip_serializing_if = "Vec::is_empty")]
321    pub notes: Vec<String>,
322    /// True when this result was REPLAYED from the incremental history cache (the
323    /// feature and everything it references are unchanged since the last run).
324    /// The handles are the same resident solids — the caller can skip re-tessellation.
325    pub reused: bool,
326    /// Sketch profiles this feature produced (a SKETCH feature emits one under
327    /// its name; solid features emit none). INTERNAL side-channel: `#[serde(skip)]`
328    /// so it never crosses the wasm boundary to the caller — [`SceneMap::apply`] ingests it
329    /// into `scene.profiles` for downstream profile-consumers (extrude/revolve/…).
330    #[serde(skip)]
331    pub profiles: Vec<(String, SketchProfile)>,
332    /// Named plane frames this feature produced (DATUM emits three, PLANE one).
333    /// Same INTERNAL `#[serde(skip)]` side-channel — ingested into `scene.frames`
334    /// so a later SKETCH can resolve its plane by name, fully headless.
335    #[serde(skip)]
336    pub frames: Vec<(String, Frame)>,
337    /// Named axis lines this feature produced (a SKETCH emits one per line
338    /// geometry). Same INTERNAL `#[serde(skip)]` side-channel — ingested into
339    /// `scene.axes` so a later revolve/sweep can resolve its axis by name.
340    #[serde(skip)]
341    pub axes: Vec<(String, Axis)>,
342    /// Named path chains this feature produced (a SKETCH publishes its ordered
343    /// open/closed geometry chain). Same INTERNAL `#[serde(skip)]` side-channel —
344    /// ingested into `scene.paths` for sweep/path_sweep/rib trajectory resolution.
345    #[serde(skip)]
346    pub paths: Vec<(String, Vec<NurbsCurve>)>,
347    /// The SOURCE NAME of each curve in a published path chain, parallel to the
348    /// matching [`Self::paths`] entry (`{sketchId}:G{gid}` for a sketch segment,
349    /// `None` for a curve with no name behind it). Same INTERNAL `#[serde(skip)]`
350    /// side-channel — ingested into `scene.path_segment_names` so a multi-segment
351    /// consumer (the SWEEP) can name the geometry each PATH SEGMENT built after
352    /// that segment, instead of after its position in the chain.
353    #[serde(skip)]
354    pub path_segment_names: Vec<(String, Vec<Option<String>>)>,
355    /// The SCREW AXIS of each curve in a published path chain, parallel to the
356    /// matching [`Self::paths`] entry: `Some` for a curve its builder KNOWS is a
357    /// helix about that axis (the HELIX feature), `None` for everything else.
358    /// Same INTERNAL `#[serde(skip)]` side-channel — ingested into
359    /// `scene.path_segment_axes` so the SWEEP's `pathAlign` can carry a profile by
360    /// the helix's own screw motion rather than by a rotation-minimizing frame,
361    /// which rolls away from it by the helix's integrated torsion.
362    #[serde(skip)]
363    pub path_segment_axes: Vec<(String, Vec<Option<Axis>>)>,
364    /// Named world points this feature produced (a SKETCH publishes every point
365    /// as `{sketchId}:P{pid}`, construction flag included — see [`ScenePoint`]).
366    /// Same side-channel — ingested into `scene.points` for hole-center /
367    /// placement resolution and drawn by the committed-sketch display.
368    #[serde(skip)]
369    pub points: Vec<(String, ScenePoint)>,
370    /// Harness ports this feature produced (a PORT feature emits one under its
371    /// id). Same INTERNAL `#[serde(skip)]` side-channel — ingested into
372    /// `scene.ports` for spline attachments and the wire-harness tail.
373    #[serde(skip)]
374    pub ports: Vec<(String, PortRecord)>,
375    /// Component records this feature produced (an ACOMP feature emits one per
376    /// instance; its members ride `added` like any solids). Same INTERNAL
377    /// `#[serde(skip)]` side-channel — ingested into `scene.components` so the
378    /// component surface (`component.rs`) resolves against the live scene.
379    #[serde(skip)]
380    pub components: Vec<ComponentRecord>,
381}
382
383impl FeatureResult {
384    /// An empty successful result (no solids, no error) — the shape a non-solid
385    /// pass-through feature returns.
386    pub fn empty(id: impl Into<String>, feature_type: impl Into<String>) -> Self {
387        Self {
388            id: id.into(),
389            feature_type: feature_type.into(),
390            added: Vec::new(),
391            removed: Vec::new(),
392            error: None,
393            refusal: None,
394            fulfilment: None,
395            refused_step: None,
396            unresolved: Vec::new(),
397            notes: Vec::new(),
398            reused: false,
399            profiles: Vec::new(),
400            frames: Vec::new(),
401            axes: Vec::new(),
402            paths: Vec::new(),
403            path_segment_names: Vec::new(),
404            path_segment_axes: Vec::new(),
405            points: Vec::new(),
406            ports: Vec::new(),
407            components: Vec::new(),
408        }
409    }
410
411    /// A hard failure. Halts the history loop.
412    pub fn error(
413        id: impl Into<String>,
414        feature_type: impl Into<String>,
415        message: impl Into<String>,
416    ) -> Self {
417        Self::refuse(id, feature_type, message.into())
418    }
419
420    /// The feature proceeded on the references that resolved and skipped the
421    /// ones in `unresolved`: record that as a typed partial fulfilment.
422    /// `requested` is the selection as the document names it; `applied` is
423    /// what remains after the misses. Nothing is recorded when every reference
424    /// resolved. Call it at the point the feature DECIDES to proceed, not at
425    /// resolution: a feature that no-ops or refuses on a miss has no partial.
426    pub fn note_partial_resolution(&mut self, requested: &[String]) {
427        if self.unresolved.is_empty() {
428            return;
429        }
430        let applied: Vec<String> = requested
431            .iter()
432            .filter(|name| !self.unresolved.contains(name))
433            .cloned()
434            .collect();
435        let rejected = self
436            .unresolved
437            .iter()
438            .map(|name| Rejected {
439                name: name.clone(),
440                kind: RejectedKind::Unresolved,
441                reason: "resolves to nothing on this model".into(),
442            })
443            .collect();
444        self.fulfilment = Some(Fulfilment {
445            requested: requested.to_vec(),
446            applied,
447            rejected,
448        });
449    }
450
451    /// A hard failure from either kind of producer: `error` carries the text
452    /// (the refusal's own message, verbatim, when it is typed) and `refusal`
453    /// the class and stage when there is one.
454    pub fn refuse(
455        id: impl Into<String>,
456        feature_type: impl Into<String>,
457        refusal: impl Into<FeatureRefusal>,
458    ) -> Self {
459        let mut result = Self::empty(id, feature_type);
460        match refusal.into() {
461            FeatureRefusal::Text(message) => result.error = Some(message),
462            FeatureRefusal::Typed(refusal) => {
463                result.error = Some(refusal.message.clone());
464                result.refusal = Some(refusal);
465            }
466        }
467        result
468    }
469
470    /// A non-solid construction feature (datum/plane/sketch/spline/port/helix):
471    /// a descriptor pass-through with no resident handle. The loop continues.
472    pub fn pass_through(id: impl Into<String>, feature_type: impl Into<String>) -> Self {
473        Self::empty(id, feature_type)
474    }
475
476    /// A solid-producing (or no-kernel) feature not yet ported to Rust. Halts the
477    /// loop with a clear message — replaced when a fan-out agent implements the
478    /// feature's `features/<feat>.rs`.
479    pub fn not_yet_migrated(id: impl Into<String>, feature_type: impl Into<String>) -> Self {
480        let feature_type = feature_type.into();
481        let message = format!("feature type '{feature_type}' is not yet migrated to the Rust pipeline");
482        Self::error(id, feature_type, message)
483    }
484}
485
486/// The whole-history output.
487#[derive(Debug, Clone, Serialize)]
488pub struct HistoryResult {
489    pub results: Vec<FeatureResult>,
490    /// Per-feature wall-clock execution time `(feature id, milliseconds)` in run
491    /// order — the lightweight timing surface the engine-native history tree's
492    /// "N ms" readout consumes. A REUSED (cache-replayed) feature records `0.0`
493    /// (its geometry was not re-executed this run). INTERNAL side-channel:
494    /// `#[serde(skip)]` so it never changes the caller-facing `execute_history_json`
495    /// shape; brep-render reads it natively via the re-exported struct.
496    #[serde(skip)]
497    pub timings: Vec<(String, f64)>,
498    /// The wire-harness tail's routing report (`None` when the request carries
499    /// no `wireHarness` block). INTERNAL side-channel like `timings`: never on
500    /// the JSON ABI; brep-render ships it to the harness panel.
501    #[serde(skip)]
502    pub wire_harness: Option<wire_harness::WireHarnessReport>,
503    /// The PMI tail's resolution of every view's annotations (`None` when the
504    /// request carries no `pmi` block). INTERNAL side-channel like `timings`.
505    #[serde(skip)]
506    pub pmi: Option<pmi::PmiReport>,
507    /// The ports tail's resolution of the declared-ports block (`None` when the
508    /// document declares none). INTERNAL side-channel like `timings`: the tail
509    /// is not a feature, so this is where its unresolved references and refused
510    /// names surface.
511    #[serde(skip)]
512    pub ports: Option<ports::PortsReport>,
513}
514
515// ===========================================================================
516// SceneMap — the live name -> handle/topology index maintained across the loop
517// ===========================================================================
518
519// `handle` + `face_id`/`edge_id` are the contract the caller (and edge-selecting
520// fan-out features) read to resolve a selection to a resident topology entity.
521#[allow(dead_code)]
522#[derive(Debug, Clone, Copy)]
523pub struct FaceRef {
524    pub handle: u32,
525    pub face_id: u64,
526}
527
528#[allow(dead_code)]
529#[derive(Debug, Clone, Copy)]
530pub struct EdgeRef {
531    pub handle: u32,
532    pub edge_id: u64,
533}
534
535/// A solved sketch's extracted profile, placed in 3D. Produced by the SKETCH
536/// pipeline feature and consumed BY NAME by the profile-consumers (extrude,
537/// revolve, loft, sweep, rib, sheet-metal tab/contour-flange) — the exact-BREP
538/// replacement for the old "marshal the resolved curves into `inputParams`".
539///
540/// The closed loops are grouped into disjoint REGIONS by even-odd containment
541/// depth: a depth-EVEN loop is a region's OUTER boundary, a depth-ODD loop is a
542/// hole of the region whose outer directly contains it (so an island inside a
543/// hole is its own region). Each region is `[outer, holes…]`; consumers build
544/// one solid per region and UNION them — the established multi-region behavior.
545/// The plane frame (`origin` + orthonormal `x_axis`/`y_axis`/`z_axis`) is resolved
546/// LIVE in Rust from the sketch's plane reference (DATUM/PLANE feature frame or a
547/// resident face), falling back to the persisted `basis`; the loop curves are
548/// emitted in WORLD space via that frame, so `z_axis` is the natural extrude
549/// direction / a revolve-plane hint.
550#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
551pub struct SketchProfile {
552    pub origin: Vec3,
553    pub x_axis: Vec3,
554    pub y_axis: Vec3,
555    pub z_axis: Vec3,
556    /// Disjoint regions, each `[outer, holes…]` (containment-classified).
557    pub regions: Vec<Vec<ProfileLoop>>,
558}
559
560/// One closed boundary of a [`SketchProfile`]: head-to-tail world-space curves plus
561/// the per-curve source edge name (`{sketchId}:G{gid}`, parallel to `curves`). The
562/// profile-consumers stamp sidewall names from `edge_names` (extrude → `{name}_E`).
563#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
564pub struct ProfileLoop {
565    pub curves: Vec<NurbsCurve>,
566    pub edge_names: Vec<Option<String>>,
567    /// The loop's STABLE identity within its source sketch (see the sketch
568    /// feature's `loop_ids`), persisted per edge in `persistentData.sketch` and
569    /// resilient to adding/removing edges. The profile-swept features embed it in
570    /// their per-loop cap and hole-wall names, so those names no longer move when
571    /// a DIFFERENT loop is added or removed. `None` for a loop with no numeric
572    /// geometry id behind it (a FACE profile, or a hand-built test profile) —
573    /// [`ProfileLoop::key`] then falls back to the loop's own edge names.
574    #[serde(default)]
575    pub loop_id: Option<u64>,
576}
577
578impl ProfileLoop {
579    /// This loop's naming KEY — the token the profile-swept features embed in the
580    /// faces they build from it (`{cap_base}:{key}_START`, `{id}:HOLE:{key}`).
581    ///
582    /// `L{loop_id}` for a sketch loop. `None` when the loop has no identity to key
583    /// on, and the caller keeps its previous un-keyed spelling: a FACE profile
584    /// (always a single region — nothing to disambiguate, and its faces keep the
585    /// names every saved model already stores) or a hand-built profile with no
586    /// numeric geometry ids behind it.
587    pub fn key(&self) -> Option<String> {
588        self.loop_id.map(|id| format!("L{id}"))
589    }
590}
591
592/// Annotate an UNRESOLVED reference with the name that replaced it, when the miss
593/// looks like the per-loop naming change.
594///
595/// Per-loop cap/hole naming keyed names that used to be positional
596/// (`{cap_base}_START` -> `{cap_base}:L4_START`, `{id}:HOLE:1` -> `{id}:HOLE:L9`).
597/// Live models mostly do not need this — the CONTAINER groups keep the old
598/// spelling resolvable — but a reference the containers cannot cover (a positional
599/// `X[1]` disambiguation from before the change, or a `{id}:HOLE:{n}` written
600/// against a since-edited sketch) still misses, and a bare "unresolved" says
601/// nothing about why. This turns the miss into the fix:
602///
603/// ```text
604/// E4:S3:PROFILE_START[1] (renamed: E4:S3:PROFILE:L9_START)
605/// ```
606///
607/// The candidates come from the live scene, so the hint is what the model
608/// ACTUALLY built, not a guess.
609pub fn annotate_rename(scene: &SceneMap, name: &str) -> String {
610    let candidates = rename_candidates(scene, name);
611    if candidates.is_empty() {
612        return name.to_string();
613    }
614    format!("{name} (renamed: {})", candidates.join(", "))
615}
616
617/// The keyed names that plausibly replaced `name`. A stale reference differs from
618/// its replacement only by the inserted loop key, so a candidate is any live face
619/// name that matches once the key is accounted for — `{stem}:{key}_START` for a
620/// cap, `{id}:HOLE:{key}` for a hole wall. A trailing positional `[n]` (the old
621/// duplicate-name disambiguation, the very thing this change removes) is stripped
622/// before matching.
623fn rename_candidates(scene: &SceneMap, name: &str) -> Vec<String> {
624    let stale = name.rsplit_once('[').map_or(name, |(head, tail)| {
625        if tail.ends_with(']') && tail[..tail.len() - 1].chars().all(|c| c.is_ascii_digit()) {
626            head
627        } else {
628            name
629        }
630    });
631    // Cap: `{stem}_START` / `{stem}_END` -> `{stem}:{key}_START` / `_END`.
632    let cap = ["_START", "_END"]
633        .into_iter()
634        .find_map(|suffix| stale.strip_suffix(suffix).map(|stem| (stem.to_string(), suffix)));
635    // Hole wall: `{id}:HOLE:{anything}` -> `{id}:HOLE:{key}`.
636    let hole = stale
637        .rsplit_once(":HOLE:")
638        .map(|(owner, _)| format!("{owner}:HOLE:"));
639
640    let mut candidates: Vec<String> = scene
641        .faces
642        .keys()
643        .filter(|live| {
644            if let Some((stem, suffix)) = &cap {
645                if let Some(rest) = live.strip_prefix(stem.as_str()) {
646                    // Exactly `:{key}` was inserted before the suffix.
647                    return rest.starts_with(':')
648                        && rest.ends_with(suffix)
649                        && rest.len() > suffix.len() + 1
650                        && !rest[1..rest.len() - suffix.len()].contains(':');
651                }
652            }
653            if let Some(prefix) = &hole {
654                return live.starts_with(prefix.as_str()) && live.as_str() != stale;
655            }
656            false
657        })
658        .cloned()
659        .collect();
660    candidates.sort();
661    candidates
662}
663
664/// An orthonormal placement frame — a named plane (DATUM/PLANE feature) or a
665/// resolved sketch plane. `z_axis` is the plane normal.
666#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
667pub struct Frame {
668    pub origin: Vec3,
669    pub x_axis: Vec3,
670    pub y_axis: Vec3,
671    pub z_axis: Vec3,
672}
673
674impl Frame {
675    /// Derive an orthonormal frame from an origin + plane normal via the worldUp
676    /// convention, the
677    /// SINGLE source of truth for how a sketch/plane reference becomes in-plane
678    /// axes: `refUp = |n·(0,1,0)| > 0.9 ? (1,0,0) : (0,1,0)`;
679    /// `x = norm(refUp × n)`; `y = norm(n × x)`; `z = n`. The referenced object's
680    /// own x/y are intentionally ignored — only its (origin, normal) matter.
681    pub fn from_origin_normal(origin: Vec3, normal: Vec3) -> Result<Self, String> {
682        let z_axis = normal.normalized()?;
683        let world_up = Vec3::new(0.0, 1.0, 0.0);
684        let ref_up = if z_axis.dot(world_up).abs() > 0.9 {
685            Vec3::new(1.0, 0.0, 0.0)
686        } else {
687            world_up
688        };
689        let x_axis = ref_up.cross(z_axis).normalized()?;
690        let y_axis = z_axis.cross(x_axis).normalized()?;
691        Ok(Frame {
692            origin,
693            x_axis,
694            y_axis,
695            z_axis,
696        })
697    }
698
699    /// Derive an orthonormal frame whose **x_axis** is `direction` — the frame a
700    /// connection point's placement is an offset in (its seat, `ports::seat_of`).
701    ///
702    /// A connection point's direction is its rotation applied to **+X** (the
703    /// convention every feature transform reads), so the frame that seats one
704    /// must carry the direction on X, not on Z. Rather than invent a second
705    /// worldUp rule this is the CYCLIC RELABEL of [`Self::from_origin_normal`]'s
706    /// triad — x = z', y = x', z = y' — which is still right-handed and keeps
707    /// ONE convention for how a direction becomes a basis. A sketch on the
708    /// point's published frame and the point's own offset axes therefore agree
709    /// by construction: they are the same three vectors, named round.
710    pub fn from_origin_direction(origin: Vec3, direction: Vec3) -> Result<Self, String> {
711        let normal = Self::from_origin_normal(origin, direction)?;
712        Ok(Frame {
713            origin,
714            x_axis: normal.z_axis,
715            y_axis: normal.x_axis,
716            z_axis: normal.y_axis,
717        })
718    }
719
720    /// Map a plane-local `(u, v)` into world space: `origin + u·x + v·y`.
721    pub fn to_3d(&self, u: f64, v: f64) -> Vec3 {
722        self.origin
723            .add(self.x_axis.scale(u))
724            .add(self.y_axis.scale(v))
725    }
726}
727
728/// A named world-space line — a revolve/sweep axis. Published by SKETCH features
729/// (every sketch line geometry, construction included) and resolvable from a
730/// resident solid edge, so an `axis`/path reference resolves fully headless.
731#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
732pub struct Axis {
733    pub point: Vec3,
734    pub direction: Vec3,
735}
736
737/// What a connection point is to the harness router: a cable END, or a
738/// pass-through that joins spline paths into a network. Not a user param any
739/// more - it is decided by WHICH producer made the record (a declared port
740/// point is a termination, a WAYPOINT feature is a waypoint).
741#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
742#[serde(rename_all = "lowercase")]
743pub enum PortKind {
744    Termination,
745    Waypoint,
746}
747
748/// One resolved connection point, keyed in [`SceneMap::ports`] by its ADDRESS
749/// (`J1.VCC`, `ACOMP3:J1.VCC` once placed - see `ports.rs`). Published by the
750/// ports tail, by the WAYPOINT feature (under its own feature id) and by an
751/// ACOMP re-publishing a placed part's points.
752///
753/// `direction` is the unit vector a wire leaves along on side **A**; side **B**
754/// is its negation. `extension` is the straight run a wire keeps from `point`
755/// before it may bend (a spline anchor attached here takes it as its
756/// forward/backward distance) and `display_length` the length of the drawn
757/// line.
758///
759/// `port_name` / `point_name` / `purpose` stay PART-LOCAL through every
760/// namespacing: a placed part's point is still point `VCC` of port `J1`
761/// whatever occurrence chain its address carries. That is what a pin binds to.
762/// A WAYPOINT carries an empty `port_name` and `purpose`, and its own name as
763/// `point_name`.
764#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
765pub struct PortRecord {
766    pub point: Vec3,
767    pub direction: Vec3,
768    pub kind: PortKind,
769    pub extension: f64,
770    pub display_length: f64,
771    #[serde(default, rename = "portName")]
772    pub port_name: String,
773    #[serde(default, rename = "pointName")]
774    pub point_name: String,
775    #[serde(default)]
776    pub purpose: String,
777}
778
779impl PortRecord {
780    /// The PART-LOCAL address of this point (`J1.VCC`), or just the name for a
781    /// WAYPOINT, which belongs to no port. The occurrence chain is the map key's
782    /// business, not the record's.
783    pub fn local_address(&self) -> String {
784        if self.port_name.is_empty() {
785            self.point_name.clone()
786        } else {
787            ports::address(&self.port_name, &self.point_name)
788        }
789    }
790
791    /// The direction a wire leaves this port along on `side` (`A` = the port
792    /// direction, `B` = its negation).
793    pub fn side_direction(&self, side: wire_harness::PortSide) -> Vec3 {
794        match side {
795            wire_harness::PortSide::A => self.direction,
796            wire_harness::PortSide::B => self.direction.scale(-1.0),
797        }
798    }
799}
800
801/// The live scene-map. Exact-match resolution only (contract rule 1).
802#[derive(Debug, Clone, Default)]
803pub struct SceneMap {
804    pub solids: HashMap<String, u32>,
805    pub faces: HashMap<String, FaceRef>,
806    pub edges: HashMap<String, EdgeRef>,
807    /// CONTAINER name -> the faces it stands for. A profile-swept feature names
808    /// each of its per-loop caps individually (`{cap_base}:L{loopId}_START`) and
809    /// registers the un-keyed `{cap_base}_START` here as the name of the whole
810    /// sketch container — every cap the feature made. So the name a model saved
811    /// before per-loop naming still resolves, and still means the right thing:
812    /// the one cap on a single-loop sketch, all of them on a multi-loop one.
813    pub face_groups: HashMap<String, Vec<String>>,
814    /// CONTAINER name -> the edges it stands for. The derived edge-name
815    /// convention embeds face names (`{faceA}|{faceB}[n]`), so keying the caps
816    /// renamed every cap-adjacent edge too; this holds the container-name
817    /// spelling of each such edge for the same reason [`Self::face_groups`] does.
818    pub edge_groups: HashMap<String, Vec<String>>,
819    /// Sketch name -> its extracted profile. Populated by SKETCH features; read by
820    /// profile-consumers via [`SceneMap::resolve_profile`].
821    pub profiles: HashMap<String, SketchProfile>,
822    /// Plane name -> its frame. Populated by DATUM/PLANE features; read by SKETCH
823    /// (and PLANE datum-refs) via [`SceneMap::resolve_frame`] for headless plane
824    /// resolution (fully headless).
825    pub frames: HashMap<String, Frame>,
826    /// Axis name -> its world line. Populated by SKETCH line geometries; read by
827    /// revolve/sweep via [`SceneMap::resolve_axis`].
828    pub axes: HashMap<String, Axis>,
829    /// Path name -> its ordered world curve chain (open or closed). Populated by
830    /// SKETCH features; read by sweep/path_sweep/rib via [`SceneMap::resolve_path`].
831    pub paths: HashMap<String, Vec<NurbsCurve>>,
832    /// Path name -> the SOURCE NAME of each curve in its chain, parallel to the
833    /// [`Self::paths`] entry of the same name. Populated by SKETCH features; read
834    /// by the SWEEP via [`SceneMap::resolve_path_segment_names`] so the faces one
835    /// path SEGMENT builds carry that segment's name rather than its position.
836    pub path_segment_names: HashMap<String, Vec<Option<String>>>,
837    /// Path name -> the SCREW AXIS of each curve in its chain (`Some` for a
838    /// helix), parallel to the [`Self::paths`] entry of the same name. Populated
839    /// by HELIX features; read by the SWEEP via
840    /// [`SceneMap::resolve_path_segment_axes`].
841    pub path_segment_axes: HashMap<String, Vec<Option<Axis>>>,
842    /// Point name -> its world position + construction flag. Populated by SKETCH
843    /// features (`{sketchId}:P{pid}`), splines and helices; read by hole placement
844    /// via [`SceneMap::model_points_with_prefix`] and by name via
845    /// [`SceneMap::resolve_point`].
846    pub points: HashMap<String, ScenePoint>,
847    /// Connection-point ADDRESS -> its record. Populated by the ports tail
848    /// (the document's declared points), by WAYPOINT features and by ACOMP
849    /// (a placed part's points, namespaced and posed); read by SPLINE
850    /// attachments ([`SceneMap::resolve_port`]) and by the wire-harness tail,
851    /// which builds its sided routing graph from it.
852    pub ports: HashMap<String, PortRecord>,
853    /// Owning feature id -> component record (assemblies). Populated by ACOMP
854    /// features via the `components` side-channel; the whole component API
855    /// surface (`resolve_component` / `owning_component` / the fence predicate)
856    /// lives in component.rs. Empty in every componentless scene — nothing in
857    /// the ordinary modeling path reads or writes it.
858    pub components: component::ComponentMap,
859    /// Naming-contract guard (accumulated across [`SceneMap::apply`] calls): one
860    /// message per detected cross-solid name COLLISION — an added solid's face /
861    /// edge name that already maps to a DIFFERENT still-resident solid. Two live
862    /// solids must never share a face/edge name (the SceneMap is a global
863    /// name → one-solid map). `execute_history` surfaces any collision introduced
864    /// by a freshly-run feature as that feature's error, so the offending
865    /// operation trips its own tests + shows an error in the app.
866    pub name_collisions: Vec<String>,
867}
868
869impl SceneMap {
870    /// Resolve a solid name to its resident handle (exact match).
871    pub fn resolve_solid(&self, name: &str) -> Option<u32> {
872        self.solids.get(name).copied()
873    }
874
875    /// Resolve a face name to its `(handle, face_id)` (exact match).
876    pub fn resolve_face(&self, name: &str) -> Option<FaceRef> {
877        if let Some(face) = self.faces.get(name) {
878            return Some(*face);
879        }
880        // A CONTAINER name holding exactly ONE face resolves to it. Not a scoring
881        // heuristic (contract rule 1 stands): the container is a name the feature
882        // registered deliberately, and a single member leaves nothing to choose
883        // between. This is what keeps every reference in a model saved before
884        // per-loop cap naming resolving unchanged — a single-loop sketch has one
885        // cap, so its container has one member. A container with SEVERAL members
886        // is ambiguous for a single-face consumer and stays unresolved; the
887        // multi-face consumers take it through [`Self::resolve_face_group`].
888        match self.resolve_face_group(name).as_slice() {
889            [only] => Some(*only),
890            _ => None,
891        }
892    }
893
894    /// Every face a name stands for: the one exact match, else the members of the
895    /// container by that name (possibly several). The multi-face resolution the
896    /// selection-list consumers use — [`Self::resolve_face`] is its single-face
897    /// sibling.
898    pub fn resolve_face_group(&self, name: &str) -> Vec<FaceRef> {
899        if let Some(face) = self.faces.get(name) {
900            return vec![*face];
901        }
902        // Members are resolved NOW, so a member a later operation consumed simply
903        // drops out instead of leaving a stale handle behind.
904        self.face_groups
905            .get(name)
906            .map(|members| {
907                members
908                    .iter()
909                    .filter_map(|member| self.faces.get(member).copied())
910                    .collect()
911            })
912            .unwrap_or_default()
913    }
914
915    /// Every edge a name stands for — [`Self::resolve_face_group`] for edges.
916    pub fn resolve_edge_group(&self, name: &str) -> Vec<EdgeRef> {
917        if let Some(edge) = self.edges.get(name) {
918            return vec![*edge];
919        }
920        self.edge_groups
921            .get(name)
922            .map(|members| {
923                members
924                    .iter()
925                    .filter_map(|member| self.edges.get(member).copied())
926                    .collect()
927            })
928            .unwrap_or_default()
929    }
930
931    /// Resolve an edge name to its `(handle, edge_id)` (exact match). Contract
932    /// surface for edge-selecting fan-out features (fillet/chamfer); no
933    /// implemented feature consumes it yet.
934    #[allow(dead_code)]
935    pub fn resolve_edge(&self, name: &str) -> Option<EdgeRef> {
936        if let Some(edge) = self.edges.get(name) {
937            return Some(*edge);
938        }
939        // The [`Self::resolve_face`] rule for edges: derived edge names embed face
940        // names, so keying the caps renamed every cap-adjacent edge too, and a
941        // container holding exactly one edge resolves to it. This is what keeps a
942        // stored projected-edge / axis / path reference working.
943        match self.resolve_edge_group(name).as_slice() {
944            [only] => Some(*only),
945            _ => None,
946        }
947    }
948
949    /// Resolve a profile reference name to the sketch profile it names. Tries the
950    /// exact name first, then the name with a trailing `:PROFILE` stripped — a
951    /// sketch is selectable both by its group name (`{id}`) and by its profile
952    /// face name (`{id}:PROFILE`); both must resolve to
953    /// the one profile stored under `{id}`.
954    pub fn resolve_profile(&self, name: &str) -> Option<&SketchProfile> {
955        if let Some(profile) = self.profiles.get(name) {
956            return Some(profile);
957        }
958        name.strip_suffix(":PROFILE")
959            .and_then(|base| self.profiles.get(base))
960    }
961
962    /// Resolve a named plane frame (exact match), for sketch/plane resolution.
963    pub fn resolve_frame(&self, name: &str) -> Option<Frame> {
964        self.frames.get(name).copied()
965    }
966
967    /// Resolve a named axis line (exact match), for revolve/sweep.
968    pub fn resolve_axis(&self, name: &str) -> Option<Axis> {
969        self.axes.get(name).copied()
970    }
971
972    /// Resolve a named path chain (exact match; also strips a trailing `:PROFILE`),
973    /// for sweep/path_sweep/rib trajectories.
974    pub fn resolve_path(&self, name: &str) -> Option<&Vec<NurbsCurve>> {
975        if let Some(path) = self.paths.get(name) {
976            return Some(path);
977        }
978        name.strip_suffix(":PROFILE")
979            .and_then(|base| self.paths.get(base))
980    }
981
982    /// The per-segment SOURCE NAMES of a named path chain, resolved exactly as
983    /// [`SceneMap::resolve_path`] resolves the chain itself (so the two always
984    /// answer for the same entry). `None` when the publisher named no segments —
985    /// the caller then falls back to naming by the reference itself.
986    pub fn resolve_path_segment_names(&self, name: &str) -> Option<&Vec<Option<String>>> {
987        if let Some(names) = self.path_segment_names.get(name) {
988            return Some(names);
989        }
990        name.strip_suffix(":PROFILE")
991            .and_then(|base| self.path_segment_names.get(base))
992    }
993
994    /// The per-segment SCREW AXES of a named path chain, resolved exactly as
995    /// [`SceneMap::resolve_path`] resolves the chain itself. `None` when the
996    /// publisher recorded none — every segment is then an ordinary curve.
997    pub fn resolve_path_segment_axes(&self, name: &str) -> Option<&Vec<Option<Axis>>> {
998        if let Some(axes) = self.path_segment_axes.get(name) {
999            return Some(axes);
1000        }
1001        name.strip_suffix(":PROFILE")
1002            .and_then(|base| self.path_segment_axes.get(base))
1003    }
1004
1005    /// Resolve a named world point (exact match), for hole/placement centers.
1006    /// Contract surface consumed by the spline/hole placement tests; kept as the
1007    /// documented point-resolution API even where no shipping feature reads it
1008    /// yet (mirrors `resolve_edge`).
1009    #[allow(dead_code)]
1010    pub fn resolve_point(&self, name: &str) -> Option<Vec3> {
1011        self.points.get(name).map(|point| point.position)
1012    }
1013
1014    /// Resolve a connection point by its address (exact match).
1015    pub fn resolve_port(&self, name: &str) -> Option<&PortRecord> {
1016        self.ports.get(name)
1017    }
1018
1019    /// Every MODEL (non-construction) point whose name starts with `prefix`, as
1020    /// `(name, position)` pairs in map order (the caller sorts). The scene-map
1021    /// indexes points by exact name only, so a consumer that wants "all the
1022    /// points of sketch S" prefix-scans `S:P` — safe because the prefix ends in
1023    /// `:P`, which another sketch id cannot spell into (`S1:P` never prefixes
1024    /// `S10:P…`). Construction points (the sketcher's ◐ toggle, materialized
1025    /// external references) are skipped: they constrain, they never model.
1026    pub fn model_points_with_prefix(&self, prefix: &str) -> Vec<(String, Vec3)> {
1027        self.points
1028            .iter()
1029            .filter(|(name, point)| name.starts_with(prefix) && !point.construction)
1030            .map(|(name, point)| (name.clone(), point.position))
1031            .collect()
1032    }
1033
1034    /// Apply a feature's effect: removals first (a boolean result reuses a removed
1035    /// target's name), then additions. Removal only unmaps NAMES — it never frees
1036    /// the handle. Handles are owned by the incremental history cache (each by the
1037    /// entry of the feature that produced it) and die exactly when that entry is
1038    /// invalidated; freeing here would kill a clean upstream feature's cached
1039    /// solid the moment a downstream boolean consumed it.
1040    fn apply(&mut self, result: &FeatureResult) {
1041        for name in &result.removed {
1042            // A removed COMPONENT id drops the record and unmaps every member
1043            // solid (names only — handle ownership stays with the cache, same
1044            // as plain solid removal). Feature ids never collide with solid
1045            // names, so this is a no-op for every ordinary removal.
1046            if let Some(record) = self.components.remove(name) {
1047                for member in &record.solids {
1048                    if let Some(handle) = self.solids.remove(member) {
1049                        self.faces.retain(|_, face| face.handle != handle);
1050                        self.edges.retain(|_, edge| edge.handle != handle);
1051                    }
1052                }
1053                // ...and the ports the part carried, with their entities.
1054                for port in &record.ports {
1055                    self.ports.remove(port);
1056                    self.axes.remove(port);
1057                    self.frames.remove(port);
1058                    self.points.remove(&format!("{port}:Base"));
1059                    self.paths.remove(&format!("{port}:PortLine"));
1060                }
1061            }
1062            if let Some(handle) = self.solids.remove(name) {
1063                // Purge the removed solid's face/edge entries (names only).
1064                self.faces.retain(|_, face| face.handle != handle);
1065                self.edges.retain(|_, edge| edge.handle != handle);
1066            }
1067            // A consumed sketch's PUBLISHED GEOMETRY (profile / axis / path / point)
1068            // is deliberately NOT purged here. Unlike a solid, one sketch can drive
1069            // several features — a later feature legitimately resolves the sketch's
1070            // axis or path even after another feature consumed its profile (see
1071            // `revolve::tests::extruded_face_then_revolve_face_profile`, which
1072            // revolves about a consumed sketch's `Sk:G20` axis). Removing a consumed
1073            // sketch from the SCENE DISPLAY is handled render-side (the committed-
1074            // sketch list honors `removed`); the kernel keeps its geometry resolvable.
1075            // A removed name not in the map is a silent no-op (established semantics).
1076        }
1077        // Ingest any sketch profiles this feature produced (SKETCH features only).
1078        for (name, profile) in &result.profiles {
1079            self.profiles.insert(name.clone(), profile.clone());
1080        }
1081        // Ingest any named plane frames (DATUM/PLANE features).
1082        for (name, frame) in &result.frames {
1083            self.frames.insert(name.clone(), *frame);
1084        }
1085        // Ingest any named axis lines (SKETCH line geometries).
1086        for (name, axis) in &result.axes {
1087            self.axes.insert(name.clone(), *axis);
1088        }
1089        // Ingest any named path chains (SKETCH geometry chains).
1090        for (name, names) in &result.path_segment_names {
1091            self.path_segment_names.insert(name.clone(), names.clone());
1092        }
1093        for (name, axes) in &result.path_segment_axes {
1094            self.path_segment_axes.insert(name.clone(), axes.clone());
1095        }
1096        for (name, path) in &result.paths {
1097            self.paths.insert(name.clone(), path.clone());
1098        }
1099        // Ingest any named world points (SKETCH points).
1100        for (name, point) in &result.points {
1101            self.points.insert(name.clone(), *point);
1102        }
1103        // Ingest any harness ports (PORT features).
1104        for (name, port) in &result.ports {
1105            self.ports.insert(name.clone(), port.clone());
1106        }
1107        // Ingest any component records (ACOMP features).
1108        for record in &result.components {
1109            self.components.insert(record.id.clone(), record.clone());
1110        }
1111        // Insert the added solids' names FIRST so the resident-handle set is
1112        // complete (a name colliding with another solid ADDED in the same result
1113        // is still a collision).
1114        for added in &result.added {
1115            self.solids.insert(added.name.clone(), added.handle);
1116        }
1117        let resident: std::collections::HashSet<u32> = self.solids.values().copied().collect();
1118        for added in &result.added {
1119            for (face_id, name) in &added.face_names {
1120                self.check_name_collision(name, added.handle, &added.name, &resident, true);
1121                self.faces.insert(
1122                    name.clone(),
1123                    FaceRef {
1124                        handle: added.handle,
1125                        face_id: *face_id,
1126                    },
1127                );
1128            }
1129            for (edge_id, name) in &added.edge_names {
1130                self.check_name_collision(name, added.handle, &added.name, &resident, false);
1131                self.edges.insert(
1132                    name.clone(),
1133                    EdgeRef {
1134                        handle: added.handle,
1135                        edge_id: *edge_id,
1136                    },
1137                );
1138            }
1139            // Container groups. NOT collision-checked: a container is a name for
1140            // faces this same solid owns, never a claim on another solid's name,
1141            // and a real face by that name always wins (both resolvers try the
1142            // exact maps first). Stored as member NAMES and resolved on lookup,
1143            // so a group survives the solid being consumed and re-registered by a
1144            // later feature (name fidelity carries the members across).
1145            for (container, members) in &added.face_groups {
1146                self.face_groups.insert(container.clone(), members.clone());
1147            }
1148            for (container, members) in &added.edge_groups {
1149                self.edge_groups.insert(container.clone(), members.clone());
1150            }
1151        }
1152    }
1153
1154    /// Record a naming-contract collision if `name` (a face when `is_face`, else
1155    /// an edge) already maps to a DIFFERENT still-RESIDENT solid — an added
1156    /// solid's name stealing another live solid's. A `prev` handle no longer in
1157    /// `resident` is a stale leftover (rollback / cache eviction), not a
1158    /// violation, so it is ignored. The message names the offender + the resident
1159    /// owner and points at the fix.
1160    fn check_name_collision(
1161        &mut self,
1162        name: &str,
1163        handle: u32,
1164        solid_name: &str,
1165        resident: &std::collections::HashSet<u32>,
1166        is_face: bool,
1167    ) {
1168        let prev = if is_face {
1169            self.faces.get(name).map(|face| face.handle)
1170        } else {
1171            self.edges.get(name).map(|edge| edge.handle)
1172        };
1173        let Some(prev_handle) = prev else { return };
1174        if prev_handle == handle || !resident.contains(&prev_handle) {
1175            return;
1176        }
1177        let owner = self
1178            .solids
1179            .iter()
1180            .find(|(_, resident_handle)| **resident_handle == prev_handle)
1181            .map(|(owner_name, _)| owner_name.as_str())
1182            .unwrap_or("another resident solid");
1183        let kind = if is_face { "face" } else { "edge" };
1184        // One line PER colliding name; the `namespace_copy_names` hint is appended
1185        // ONCE per feature error where these lines are joined (execute_history).
1186        self.name_collisions.push(format!(
1187            "naming-contract violation: solid `{solid_name}` {kind} name `{name}` \
1188             collides with resident solid `{owner}` (two live solids must not share a \
1189             face/edge name)."
1190        ));
1191    }
1192}
1193
1194/// Join a feature's per-name collision lines into ONE error string, appending the
1195/// `namespace_copy_names` fix hint exactly ONCE (a copy-feature can collide on
1196/// dozens of names — repeating the hint per name buried the signal). Shared so the
1197/// surfacing in `execute_history` and its test agree on the wording.
1198pub(crate) fn name_collision_error(lines: &[String]) -> String {
1199    let mut message = lines.join("; ");
1200    message.push_str(
1201        " — an operation that COPIES geometry (mirror / pattern / transform-copy / \
1202         any new one) must call `common::namespace_copy_names(&mut solid, suffix)` \
1203         so the copy's faces and edges get UNIQUE names.",
1204    );
1205    message
1206}
1207
1208// ===========================================================================
1209// FeatureContext — everything a feature's `execute(ctx)` needs
1210// ===========================================================================
1211
1212/// The read-only context handed to each feature's `execute`. Features resolve
1213/// their `reference_selection` params against `scene` and read numeric params via
1214/// [`FeatureContext::number`] (which evaluates expression strings against `env`).
1215pub struct FeatureContext<'a> {
1216    pub id: String,
1217    pub feature_type: String,
1218    pub params: &'a serde_json::Value,
1219    /// The feature's `persistentData` (round-tripped from the saved part file).
1220    /// The SKETCH feature reads its `sketch` + `basis` here (the scene-computed
1221    /// plane frame stays caller-side per directive §1 and is marshaled in via this).
1222    pub persistent: &'a serde_json::Value,
1223    pub env: &'a Env,
1224    pub scene: &'a SceneMap,
1225}
1226
1227impl<'a> FeatureContext<'a> {
1228    /// The raw param value (an object field of `inputParams`), if present.
1229    pub fn param(&self, key: &str) -> Option<&serde_json::Value> {
1230        self.params.get(key)
1231    }
1232
1233    /// A numeric param: a JSON number passes through; a JSON string is evaluated
1234    /// as an expression against the shared env; anything else is an error. This is
1235    /// how a feature declares which of its params are numeric (the engine has no
1236    /// per-feature schema).
1237    pub fn number(&self, key: &str) -> Result<f64, String> {
1238        match self.param(key) {
1239            Some(serde_json::Value::Number(number)) => number
1240                .as_f64()
1241                .ok_or_else(|| format!("param `{key}` is not a finite number")),
1242            Some(serde_json::Value::String(source)) => self
1243                .env
1244                .eval(source)
1245                .map_err(|error| format!("param `{key}`: {error}")),
1246            Some(other) => Err(format!(
1247                "param `{key}` must be a number or expression string, found {other}"
1248            )),
1249            None => Err(format!("missing required param `{key}`")),
1250        }
1251    }
1252
1253    /// A string param (read verbatim — never expression-evaluated; used for ids
1254    /// and `reference_selection` names). Contract surface for fan-out features;
1255    /// the two implemented features read the id via `ctx.id` directly.
1256    #[allow(dead_code)]
1257    pub fn string(&self, key: &str) -> Option<String> {
1258        match self.param(key) {
1259            Some(serde_json::Value::String(text)) => Some(text.clone()),
1260            _ => None,
1261        }
1262    }
1263
1264    /// A convenience [`FeatureResult::refuse`] carrying this feature's id +
1265    /// type: text from a stringly producer, or a typed [`KernelRefusal`].
1266    pub fn fail(&self, refusal: impl Into<FeatureRefusal>) -> FeatureResult {
1267        FeatureResult::refuse(self.id.clone(), self.feature_type.clone(), refusal)
1268    }
1269}
1270
1271/// What a SUCCESSFUL feature did with what it was asked, when that is not
1272/// "everything, exactly" (plan: typed-refusal-reporting, item 3). Requested and
1273/// applied are reference names as the document wrote them; every rejected one
1274/// says which kind of rejection and why. A partial fulfilment is a different
1275/// answer from the one asked for, so it is reported on the feature that made it
1276/// and displayed by the app beside the feature; it is never a refusal class
1277/// (a partial selection is a separate outcome) and never carries geometry.
1278#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1279pub struct Fulfilment {
1280    pub requested: Vec<String>,
1281    pub applied: Vec<String>,
1282    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1283    pub rejected: Vec<Rejected>,
1284}
1285
1286impl Fulfilment {
1287    /// Something asked for was not done.
1288    pub fn is_partial(&self) -> bool {
1289        !self.rejected.is_empty()
1290    }
1291
1292    /// One line for a report or a tree leaf: "2 of 3 selected references
1293    /// applied; `ghost` resolves to nothing on this model".
1294    pub fn summary(&self) -> String {
1295        let misses: Vec<String> = self
1296            .rejected
1297            .iter()
1298            .map(|rejected| format!("`{}` {}", rejected.name, rejected.reason))
1299            .collect();
1300        format!(
1301            "{} of {} selected reference{} applied; {}",
1302            self.applied.len(),
1303            self.requested.len(),
1304            if self.requested.len() == 1 { "" } else { "s" },
1305            misses.join("; ")
1306        )
1307    }
1308}
1309
1310/// One reference a successful feature did not act on.
1311#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1312pub struct Rejected {
1313    pub name: String,
1314    pub kind: RejectedKind,
1315    pub reason: String,
1316}
1317
1318/// Why a reference was rejected. Closed; a new kind is a producer that exists.
1319#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1320#[serde(rename_all = "snake_case")]
1321pub enum RejectedKind {
1322    /// The name resolves to no entity of the scene.
1323    Unresolved,
1324}
1325
1326/// What a feature's `build` hands back when it refuses. A feature that drives
1327/// a typed producer propagates the [`KernelRefusal`] whole (`?` converts it),
1328/// so its class reaches [`FeatureResult::refusal`]; one that drives a stringly
1329/// producer propagates the text. There is no `From<FeatureRefusal> for
1330/// String`: the text of a typed refusal is read through
1331/// [`FeatureRefusal::message`], never by dropping the class.
1332#[derive(Clone, Debug, PartialEq)]
1333pub enum FeatureRefusal {
1334    Text(String),
1335    Typed(KernelRefusal),
1336}
1337
1338impl FeatureRefusal {
1339    /// The human text either way, verbatim.
1340    pub fn message(&self) -> &str {
1341        match self {
1342            Self::Text(message) => message,
1343            Self::Typed(refusal) => &refusal.message,
1344        }
1345    }
1346}
1347
1348impl From<String> for FeatureRefusal {
1349    fn from(message: String) -> Self {
1350        Self::Text(message)
1351    }
1352}
1353
1354impl From<&str> for FeatureRefusal {
1355    fn from(message: &str) -> Self {
1356        Self::Text(message.to_string())
1357    }
1358}
1359
1360impl From<KernelRefusal> for FeatureRefusal {
1361    fn from(refusal: KernelRefusal) -> Self {
1362        Self::Typed(refusal)
1363    }
1364}
1365
1366impl std::fmt::Display for FeatureRefusal {
1367    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1368        f.write_str(self.message())
1369    }
1370}
1371
1372// ===========================================================================
1373// Dispatch — a match on `feature_type` to the owning `features/<feat>.rs`
1374// ===========================================================================
1375
1376/// Extract the feature id from `inputParams` (`id`, falling back to the
1377/// non-enumerable `featureID`). The solid + all face/edge names are prefixed
1378/// with this id, so name fidelity depends on it.
1379pub(crate) fn extract_id(params: &serde_json::Value) -> String {
1380    for key in ["id", "featureID"] {
1381        if let Some(serde_json::Value::String(text)) = params.get(key) {
1382            if !text.is_empty() {
1383                return text.clone();
1384            }
1385        }
1386    }
1387    String::new()
1388}
1389
1390/// Resolve one feature descriptor to a [`FeatureResult`] against the current
1391/// scene. Each stub's arm calls its own `features::<feat>::execute` so a fan-out
1392/// agent implementing a feature touches ONLY that file — never this dispatch.
1393pub fn execute_feature(
1394    descriptor: &FeatureDescriptor,
1395    env: &Env,
1396    scene: &SceneMap,
1397) -> FeatureResult {
1398    let id = extract_id(&descriptor.input_params);
1399    let feature_type = descriptor.feature_type.clone();
1400    // The feature fence: component geometry is valid input for sketch
1401    // attach/project and assembly constraints ONLY. A modeling feature naming
1402    // it as an operand fails cleanly HERE — the one altitude every feature
1403    // passes through — instead of per-feature checks. Free for componentless
1404    // scenes (the walk is skipped entirely).
1405    if let Err(message) = component::enforce_reference_fence(&feature_type, descriptor, scene) {
1406        return FeatureResult::error(id, feature_type, message);
1407    }
1408    let ctx = FeatureContext {
1409        id: id.clone(),
1410        feature_type: feature_type.clone(),
1411        params: &descriptor.input_params,
1412        persistent: &descriptor.persistent_data,
1413        env,
1414        scene,
1415    };
1416    // Alias resolution mirrors the caller's feature registry, which matches short name,
1417    // LONG name, and class name (all uppercased) — saved files and tests use
1418    // long-name type strings like "CHAMFER"/"PLANE".
1419    match feature_type.as_str() {
1420        // --- Fully implemented (the exemplary pattern) ---
1421        "P.CU" | "CUBE" => features::cube::execute(&ctx),
1422        "P.S" | "SPHERE" => features::sphere::execute(&ctx),
1423        // --- Solid-producing primitives ---
1424        "P.CY" | "CYLINDER" => features::cylinder::execute(&ctx),
1425        "P.CO" | "CONE" => features::cone::execute(&ctx),
1426        "P.T" | "TORUS" => features::torus::execute(&ctx),
1427        "P.PY" | "PYRAMID" => features::pyramid::execute(&ctx),
1428        // --- Add-material ---
1429        "E" | "EXTRUDE" => features::extrude::execute(&ctx),
1430        "R" | "REVOLVE" => features::revolve::execute(&ctx),
1431        "LOFT" => features::loft::execute(&ctx),
1432        "SW" | "SWEEP" => features::sweep::execute(&ctx),
1433        "SWP" | "PATH SWEEP" | "PATHSWEEP" => features::path_sweep::execute(&ctx),
1434        "RIB" => features::rib::execute(&ctx),
1435        "TU" | "TUBE" => features::tube::execute(&ctx),
1436        // --- Edit / boolean / transform ---
1437        "B" | "BOOLEAN" => features::boolean::execute(&ctx),
1438        "M" | "MIRROR" => features::mirror::execute(&ctx),
1439        "SPL" | "SPLIT" => features::split::execute(&ctx),
1440        "XFORM" | "TRANSFORM" => features::transform::execute(&ctx),
1441        "PATTERN" => features::pattern::execute(&ctx),
1442        // --- Dressups ---
1443        "F" | "FILLET" => features::fillet::execute(&ctx),
1444        "CH" | "CHAMFER" => features::chamfer::execute(&ctx),
1445        "O.S" | "OFFSET SHELL" | "OFFSETSHELL" => features::offset_shell::execute(&ctx),
1446        "O.F" | "OFFSET FACE" | "OFFSETFACE" => features::offset_face::execute(&ctx),
1447        "PF" | "PUSHFACE" | "PUSH FACE" => features::push_face::execute(&ctx),
1448        "TF" | "TRANSFORMFACE" | "TRANSFORM FACE" => features::transform_face::execute(&ctx),
1449        "DF" | "DELETE FACE" | "DELETEFACE" => features::delete_face::execute(&ctx),
1450        "RFS" | "REFIT FACES" | "REFITFACES" => features::refit_faces::execute(&ctx),
1451        "THK" | "THICKEN" => features::thicken::execute(&ctx),
1452        "H" | "HOLE" => features::hole::execute(&ctx),
1453        // --- No-kernel-op / long tail ---
1454        "IMPORT3D" => features::import3d::execute(&ctx),
1455        "SM.TAB" => features::sheet_metal_tab::execute(&ctx),
1456        "SM.CF" => features::sheet_metal_contour_flange::execute(&ctx),
1457        "SM.F" => features::sheet_metal_flange::execute(&ctx),
1458        "SM.HEM" => features::sheet_metal_hem::execute(&ctx),
1459        "SM.FILLET" => features::sheet_metal_corner::execute_fillet(&ctx),
1460        "SM.CHAMFER" => features::sheet_metal_corner::execute_chamfer(&ctx),
1461        "SM.CUTOUT" => features::sheet_metal_cutout::execute(&ctx),
1462        "SM.UNFOLD" => features::sheet_metal_unfold::execute(&ctx),
1463        // --- Assemblies: one placed parts-library instance. Keep these
1464        //     literals in sync with `parts_library::is_acomp_type` (the
1465        //     fingerprint-mix + GC hooks key on the same strings). ---
1466        "ACOMP" | "ASSEMBLY COMPONENT" => features::assembly_component::execute(&ctx),
1467        // --- Construction geometry that registers named plane frames (headless
1468        //     sketch-plane resolution) ---
1469        "D" | "DATUM" | "DATIUM" => features::datum::execute(&ctx),
1470        "P" | "PLANE" => features::plane::execute(&ctx),
1471        // --- Sketch: solves + extracts a profile the add-material features consume ---
1472        "S" | "SKETCH" => features::sketch::execute(&ctx),
1473        // --- Non-solid construction geometry: editor-drawn pass-throughs ---
1474        "SP" | "SPLINE" => features::spline::execute(&ctx),
1475        "WP" | "WAYPOINT" => features::waypoint::execute(&ctx),
1476        "HX" | "HELIX" => features::helix::execute(&ctx),
1477        // --- Genuinely unknown type string ---
1478        _ => FeatureResult::error(id, feature_type.clone(), format!("unknown feature type '{feature_type}'")),
1479    }
1480}
1481
1482// ===========================================================================
1483// The incremental history cache — dependency-driven dirty tracking
1484// ===========================================================================
1485//
1486// A feature is DIRTY when it itself changed (its serialized descriptor hash — or
1487// the expression env — differs) or when a NAME it references was (re)produced,
1488// removed, or re-resolved differently since its cached run. Everything else is
1489// CLEAN: its cached result (resident handles, profiles, frames, axes, paths)
1490// replays into the scene untouched and is flagged `reused` so the caller skips display
1491// rebuild. Dependency edges come from the exact-match name contract itself
1492// (contract rule 1): any string in a feature's params matching a name produced
1493// earlier in the history IS a reference.
1494//
1495// OWNERSHIP: cache entries own the handles their feature produced. A handle (and
1496// its sheet-metal tree) is freed exactly when its producing entry is invalidated
1497// (feature dirty/deleted, or `clear_history_cache`). Nothing else frees scene
1498// handles — not `SceneMap::apply`, and not the caller (display Solids are non-owning
1499// views post-flip). Consumed intermediates therefore stay resident until an edit
1500// invalidates their producer: the accepted memory cost of incremental replay.
1501
1502/// One cached feature execution.
1503struct CachedFeature {
1504    /// Hash of `type` + `inputParams` + `persistentData` + the env fingerprint.
1505    fingerprint: u64,
1506    /// The successful result (errored results are NEVER cached — an errored
1507    /// feature is unconditionally dirty next run).
1508    result: FeatureResult,
1509    /// The referenced names that RESOLVED against the names available at its
1510    /// position in the history. Compared for equality each run: a reference that
1511    /// starts/stops resolving (producer added/deleted/reordered) flips dirty.
1512    consumed: std::collections::BTreeSet<String>,
1513    /// Every name this feature produced or removed — the invalidation blast
1514    /// radius handed to `changed` when the entry dies.
1515    touched: HashMap<String, ()>,
1516    /// Content hash of this feature's EFFECTIVE inputs: its own `fingerprint`
1517    /// PLUS, for every consumed name, the producing feature's `output_version`
1518    /// (a Merkle hash over the feature DAG). This is what makes reuse correct
1519    /// ACROSS runs — including after a stop-point edit re-caches an upstream
1520    /// feature so it looks "clean" on the next full run. The per-run `changed`
1521    /// wavefront (below) only sees dirtiness WITHIN one run; this sees it across
1522    /// runs by comparing the input content directly. See `feature_output_version`.
1523    output_version: u64,
1524}
1525
1526thread_local! {
1527    /// Feature id -> its cached execution, persisted across `execute_history`
1528    /// calls (same thread = same wasm instance).
1529    static HISTORY_CACHE: std::cell::RefCell<HashMap<String, CachedFeature>> =
1530        std::cell::RefCell::new(HashMap::new());
1531}
1532
1533/// Free a dead entry's owned resources: each produced handle and its
1534/// sheet-metal tree. The SINGLE place cached handles die.
1535fn free_entry(entry: &CachedFeature) {
1536    for added in &entry.result.added {
1537        sheet_metal::remove_tree(added.handle);
1538        crate::free_registered_solid(added.handle);
1539    }
1540}
1541
1542/// Drop the whole cache, freeing every owned handle + tree. Called by the caller on a
1543/// part/document switch (and by every Rust test for hermeticity).
1544pub fn clear_history_cache() {
1545    HISTORY_CACHE.with(|cache| {
1546        let mut cache = cache.borrow_mut();
1547        for entry in cache.values() {
1548            free_entry(entry);
1549        }
1550        cache.clear();
1551    });
1552    sheet_metal::clear_trees();
1553    // The wire-harness tail's bundle solids are owned outside the per-feature
1554    // entries; they die with the cache too.
1555    wire_harness::clear_cache();
1556    // Document-switch semantics: the parts library is per-document state and
1557    // resets with the cache (the next run's request block re-seeds it).
1558    parts_library::clear_all();
1559}
1560
1561/// wasm boundary for [`clear_history_cache`].
1562#[wasm_bindgen]
1563pub fn clear_history_cache_json() {
1564    clear_history_cache();
1565}
1566
1567/// Stable-enough fingerprint of one descriptor, with its expression-bearing
1568/// string leaves EVALUATED against `env`. `DefaultHasher` is stable within a
1569/// process, which is exactly the cache's lifetime.
1570///
1571/// This is what makes expression-edit invalidation FINE-GRAINED: an edit to the
1572/// expression sheet (or the configurator) only moves the fingerprint of a feature
1573/// whose parameter VALUES actually change, instead of the old coarse
1574/// `env_fingerprint` that hashed the whole sheet into every feature and thus
1575/// dirtied everything. The env's variable + configurator values reach a feature
1576/// only through the string params it evaluates, so hashing those evaluated values
1577/// captures exactly the feature's dependency on the sheet.
1578fn descriptor_fingerprint(descriptor: &FeatureDescriptor, env: &Env) -> u64 {
1579    use std::hash::{Hash, Hasher};
1580    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1581    descriptor.feature_type.hash(&mut hasher);
1582    // Both param sources: `persistent_data` (e.g. sketch coordinates) can hold
1583    // expression strings too, so it must be evaluated or an expression-driven
1584    // sketch coord would go stale on a sheet edit.
1585    hash_evaluated(&descriptor.input_params, env, &mut hasher);
1586    hash_evaluated(&descriptor.persistent_data, env, &mut hasher);
1587    hasher.finish()
1588}
1589
1590/// Feed a descriptor value into the hasher with every string leaf contributing
1591/// BOTH its raw text AND, if it parses as an expression, its evaluated value.
1592///
1593/// - Raw-ALWAYS keeps verbatim-read strings (ids, `reference_selection` names,
1594///   enum codes) honest: swapping one for a *different* string that happens to
1595///   evaluate to the same number still moves the hash.
1596/// - The evaluated value is what lets an UNRELATED sheet edit leave a
1597///   numeric-expression field untouched (its raw text `"w"` is unchanged and its
1598///   value only moves when `w` moves).
1599/// - A non-expression string simply fails to `eval` and contributes only its raw
1600///   text. A reference name that collides with a variable evaluates too → at
1601///   worst a harmless FALSE-dirty when that variable is edited (never a stale
1602///   reuse; the reference's real dependency rides the consumed-set /
1603///   `output_version`).
1604///
1605/// Object keys are visited in sorted order so the hash is representation-stable.
1606fn hash_evaluated(value: &serde_json::Value, env: &Env, hasher: &mut impl std::hash::Hasher) {
1607    use std::hash::Hash;
1608    match value {
1609        serde_json::Value::String(text) => {
1610            0u8.hash(hasher);
1611            text.hash(hasher);
1612            // Only attempt the (parsing) eval on strings short enough to be a real
1613            // expression — `persistent_data` can be large, and the raw text above
1614            // already covers everything for correctness.
1615            if text.len() <= 64 {
1616                if let Ok(number) = env.eval(text) {
1617                    1u8.hash(hasher);
1618                    number.to_bits().hash(hasher);
1619                }
1620            }
1621        }
1622        serde_json::Value::Array(items) => {
1623            2u8.hash(hasher);
1624            for item in items {
1625                hash_evaluated(item, env, hasher);
1626            }
1627        }
1628        serde_json::Value::Object(map) => {
1629            3u8.hash(hasher);
1630            let mut keys: Vec<&String> = map.keys().collect();
1631            keys.sort();
1632            for key in keys {
1633                key.hash(hasher);
1634                hash_evaluated(&map[key], env, hasher);
1635            }
1636        }
1637        other => {
1638            // Number / bool / null — canonical serialized form.
1639            4u8.hash(hasher);
1640            other.to_string().hash(hasher);
1641        }
1642    }
1643}
1644
1645/// Every suffix a stored reference can carry that is an ALIAS for the SKETCH it
1646/// names rather than a produced name of its own. The kernel run publishes a
1647/// sketch's profile under the bare sketch id, and each of these spellings
1648/// resolves back to it:
1649///
1650/// - `:PROFILE` — the profile's face-name spelling ([`SceneMap::resolve_profile`]
1651///   and [`SceneMap::resolve_path`] both strip it).
1652/// - `:FACE` — the RENDER-side display sheet a committed sketch is drawn as; its
1653///   one planar face is PICKED as `{sketch}:FACE` (`abi::display`), a name the
1654///   kernel run NEVER materializes, so `available` can only ever hold the base.
1655///   `features::common::normalize_profile_alias` strips it at execute time, and
1656///   real saved models store exactly this spelling for a sketch pick — leaving it
1657///   un-canonicalised here is what kept a sketch edit from re-running its extrude.
1658///
1659/// Field-blind on purpose: a false-dirty from an unrelated string that happens to
1660/// end this way is harmless, a missed edge is stale geometry.
1661const SKETCH_REFERENCE_ALIASES: [&str; 2] = [":PROFILE", ":FACE"];
1662
1663/// Collect every string in the descriptor (inputParams + persistentData,
1664/// recursively) that EXACT-matches a name available earlier in the history
1665/// (directly, or with a selection ALIAS suffix — [`SKETCH_REFERENCE_ALIASES`] —
1666/// stripped) — the feature's resolvable reference set.
1667fn scan_consumed(
1668    descriptor: &FeatureDescriptor,
1669    available: &HashMap<String, ()>,
1670) -> std::collections::BTreeSet<String> {
1671    fn walk(
1672        value: &serde_json::Value,
1673        available: &HashMap<String, ()>,
1674        out: &mut std::collections::BTreeSet<String>,
1675    ) {
1676        match value {
1677            serde_json::Value::String(text) => {
1678                let trimmed = text.trim();
1679                if trimmed.is_empty() {
1680                    return;
1681                }
1682                // Record the CANONICAL produced name, not the alias spelling:
1683                // a `{sketch}:PROFILE` / `{sketch}:FACE` reference resolves
1684                // against the profile published under `{sketch}`, and the dirty
1685                // check intersects this set with produced/changed names —
1686                // recording the alias verbatim made a sketch edit invisible to
1687                // its consumers (changed = {"S1"}, consumed = {"S1:PROFILE"},
1688                // no overlap). EXACT match wins first, so a real produced name
1689                // that happens to end in an alias suffix is never rewritten.
1690                if available.contains_key(trimmed) {
1691                    out.insert(trimmed.to_string());
1692                } else if let Some(base) = SKETCH_REFERENCE_ALIASES
1693                    .iter()
1694                    .find_map(|alias| trimmed.strip_suffix(alias))
1695                    .filter(|base| available.contains_key(*base))
1696                {
1697                    out.insert(base.to_string());
1698                }
1699            }
1700            serde_json::Value::Array(items) => {
1701                for item in items {
1702                    walk(item, available, out);
1703                }
1704            }
1705            serde_json::Value::Object(map) => {
1706                for item in map.values() {
1707                    walk(item, available, out);
1708                }
1709            }
1710            _ => {}
1711        }
1712    }
1713    let mut out = std::collections::BTreeSet::new();
1714    walk(&descriptor.input_params, available, &mut out);
1715    walk(&descriptor.persistent_data, available, &mut out);
1716    out
1717}
1718
1719/// The EFFECTIVE input hash of one feature: its own descriptor `fingerprint`
1720/// combined with the `output_version` of every name it consumes. Because each
1721/// producer's `output_version` is itself computed this way, this is a Merkle hash
1722/// over the whole upstream DAG: it changes iff the feature's own descriptor OR any
1723/// transitive input changed. A feature is reusable iff its cached `output_version`
1724/// still equals this — which is exactly "nothing changed for any of its inputs",
1725/// and is correct across runs (a stop-point edit that re-cached an upstream feature
1726/// bumps that feature's version, so this consumer's version no longer matches).
1727///
1728/// `consumed` is a `BTreeSet`, so the iteration order is deterministic. A consumed
1729/// name should always have a version (it came from an already-walked producer); a
1730/// missing one hashes as `0` rather than panicking.
1731fn feature_output_version(
1732    fingerprint: u64,
1733    consumed: &std::collections::BTreeSet<String>,
1734    name_version: &HashMap<String, u64>,
1735) -> u64 {
1736    use std::hash::{Hash, Hasher};
1737    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1738    fingerprint.hash(&mut hasher);
1739    for name in consumed {
1740        name.hash(&mut hasher);
1741        name_version.get(name).copied().unwrap_or(0).hash(&mut hasher);
1742    }
1743    hasher.finish()
1744}
1745
1746/// Every name a result adds to the scene: solid/face/edge names, the CONTAINER
1747/// group aliases, plus the profile/frame/axis/path names from the side-channels.
1748///
1749/// This must mirror [`SceneMap::apply`] exactly — every map a name can be
1750/// RESOLVED from has to be versioned here, or a consumer referencing that name
1751/// records nothing in its consumed set, its `output_version` never moves, and it
1752/// replays stale geometry across a real upstream edit.
1753fn produced_names(result: &FeatureResult) -> Vec<String> {
1754    let mut names = Vec::new();
1755    for added in &result.added {
1756        names.push(added.name.clone());
1757        names.extend(added.face_names.iter().map(|(_, name)| name.clone()));
1758        names.extend(added.edge_names.iter().map(|(_, name)| name.clone()));
1759        // The un-keyed CONTAINER spellings (`{cap_base}_START` and the edge
1760        // names derived from it) are resolvable scene names — a model saved
1761        // before per-loop cap naming references them, and `resolve_face_group` /
1762        // `resolve_edge_group` answer. They are the name a dressup feature
1763        // actually stores, so they must version like any other produced name.
1764        names.extend(added.face_groups.iter().map(|(name, _)| name.clone()));
1765        names.extend(added.edge_groups.iter().map(|(name, _)| name.clone()));
1766    }
1767    names.extend(result.profiles.iter().map(|(name, _)| name.clone()));
1768    names.extend(result.frames.iter().map(|(name, _)| name.clone()));
1769    names.extend(result.axes.iter().map(|(name, _)| name.clone()));
1770    names.extend(result.paths.iter().map(|(name, _)| name.clone()));
1771    names.extend(result.points.iter().map(|(name, _)| name.clone()));
1772    names.extend(result.ports.iter().map(|(name, _)| name.clone()));
1773    // A component's id is a produced name: a reference to the component (a
1774    // COMPONENT-type selection in a descriptor) versions against its producer.
1775    names.extend(result.components.iter().map(|record| record.id.clone()));
1776    names
1777}
1778
1779// ===========================================================================
1780// The history loop
1781// ===========================================================================
1782
1783/// Run a whole feature history incrementally: build the expression env once,
1784/// then walk the ordered features. A CLEAN feature (see the cache section above)
1785/// replays its cached result; a DIRTY one re-executes (its old outputs are freed
1786/// first). On a feature error, record it and HALT (abort-on-error);
1787/// errored results are never cached. Never panics.
1788pub fn execute_history(request: &HistoryRequest) -> HistoryResult {
1789    execute_history_observed(request, &mut |_| true)
1790}
1791
1792/// A feature about to EXECUTE inside [`execute_history_observed`] — a dirty
1793/// one, not a cached replay (replays are instant and are not reported).
1794#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1795pub struct HistoryProgress<'a> {
1796    /// Zero-based position of the feature in the request.
1797    pub index: usize,
1798    /// The request's feature count.
1799    pub total: usize,
1800    pub id: &'a str,
1801    pub feature_type: &'a str,
1802}
1803
1804/// [`execute_history`] with a progress observer. `observe` is called
1805/// immediately BEFORE each feature the run executes (cached replays are not
1806/// reported); returning `false` stops the run at that boundary — the feature
1807/// is not executed, nothing after it runs, and the result holds what was
1808/// built so far. The boundary is the only cooperative stop the kernel offers:
1809/// nothing inside a feature (a boolean, a blend, a sew) checks anything.
1810///
1811/// The cache bookkeeping is untouched by an early stop: the entries of the
1812/// features that did run are as valid as after a full run, and the entries of
1813/// the ones that did not were retained as parse-only exactly as under a
1814/// `stop_before_id`, so the next full run replays the former and executes the
1815/// latter.
1816pub fn execute_history_observed(
1817    request: &HistoryRequest,
1818    observe: &mut dyn FnMut(HistoryProgress<'_>) -> bool,
1819) -> HistoryResult {
1820    let env = Env::build(&request.expressions, &request.configurator)
1821        .unwrap_or_else(Env::poisoned);
1822
1823    // Seed the kernel-resident parts library from the request block (document
1824    // load; resident-wins rules keep a stale block from undoing kernel-side
1825    // heals/refreshes — see parts_library.rs).
1826    parts_library::ingest(&request.parts_library);
1827
1828    // Entries whose feature was DELETED from the history die now. Their
1829    // consumers go dirty via the consumed-set equality check (the vanished names
1830    // drop out of the available set), so no changed-seeding is needed here.
1831    let request_ids: HashMap<String, ()> = request
1832        .features
1833        .iter()
1834        .map(|descriptor| (extract_id(&descriptor.input_params), ()))
1835        .collect();
1836    HISTORY_CACHE.with(|cache| {
1837        let mut cache = cache.borrow_mut();
1838        cache.retain(|id, entry| {
1839            let keep = request_ids.contains_key(id);
1840            if !keep {
1841                free_entry(entry);
1842            }
1843            keep
1844        });
1845    });
1846
1847    let mut scene = SceneMap::default();
1848    let mut results = Vec::with_capacity(request.features.len());
1849    // Per-feature wall-clock timing, in run order (`web_time::Instant` — plain
1850    // `std::time::Instant` traps 'unreachable' on wasm32).
1851    let mut timings: Vec<(String, f64)> = Vec::with_capacity(request.features.len());
1852    // Names whose content changed this run (produced/removed by any re-executed
1853    // or invalidated feature) — the dirty wavefront.
1854    let mut changed: HashMap<String, ()> = HashMap::new();
1855    // Names available for reference so far this run (from clean AND dirty
1856    // features alike).
1857    let mut available: HashMap<String, ()> = HashMap::new();
1858    // Each available name -> the `output_version` of the feature that produced it
1859    // this run (walk order: a boolean that reuses its target name overwrites the
1860    // producer's version, so consumers depend on the boolean's version). Feeds
1861    // `feature_output_version` so cross-run reuse is decided by input CONTENT.
1862    let mut name_version: HashMap<String, u64> = HashMap::new();
1863    let mut seen_ids: HashMap<String, ()> = HashMap::new();
1864
1865    // The declared connection points whose placement nothing the walk builds
1866    // can move, published BEFORE it so a SPLINE in the same document can
1867    // attach to one and a sketch can sit on its frame (`ports.rs` states the
1868    // rule and why this is the one deviation from tail-only resolution). Each
1869    // name carries the CONTENT VERSION of its declaration, so a consumer is
1870    // invalidated when the point moves.
1871    for (name, version) in ports::seed_history_run(request, &mut scene, &env) {
1872        available.insert(name.clone(), ());
1873        name_version.insert(name, version);
1874    }
1875
1876    let total = request.features.len();
1877    for (index, descriptor) in request.features.iter().enumerate() {
1878        let id = extract_id(&descriptor.input_params);
1879        // Editor stop-point: BEFORE this feature — the rest of the request is
1880        // parse-only (their cache entries were already retained above).
1881        if request.stop_before_id.as_deref() == Some(id.as_str()) {
1882            break;
1883        }
1884        // An empty or duplicated id cannot key a cache entry: execute plainly,
1885        // touching no cached state (a duplicate must not free the entry its
1886        // first occurrence just wrote).
1887        let cacheable = !id.is_empty() && seen_ids.insert(id.clone(), ()).is_none();
1888        let fingerprint = descriptor_fingerprint(descriptor, &env);
1889        // Assemblies: an ACOMP instance's effective input includes its
1890        // parts-library entry's CONTENT (key/signature/document — not the
1891        // snapshot, so a self-heal does not dirty it), and a DIRTY entry
1892        // (refresh_library_entry) forces re-execution outright. A no-op for
1893        // every other feature type.
1894        let (fingerprint, library_dirty) =
1895            parts_library::mix_descriptor_fingerprint(descriptor, fingerprint);
1896        let consumed = scan_consumed(descriptor, &available);
1897        // Merkle input hash over this feature + its consumed producers' versions.
1898        // Computed for EVERY feature (cacheable or not) so its produced names can
1899        // publish a version for downstream consumers.
1900        let output_version = feature_output_version(fingerprint, &consumed, &name_version);
1901
1902        let clean = cacheable
1903            && !library_dirty
1904            && HISTORY_CACHE.with(|cache| {
1905                cache.borrow().get(&id).is_some_and(|entry| {
1906                    entry.fingerprint == fingerprint
1907                        && entry.consumed == consumed
1908                        && consumed.iter().all(|name| !changed.contains_key(name))
1909                        // The cross-run clause: reuse only if the effective input
1910                        // content is byte-identical to what this entry was built
1911                        // from. Subsumes the three clauses above (they are hashed
1912                        // in); kept additively so the diff stays minimal and the
1913                        // handle-ownership / blast-radius paths are untouched.
1914                        && entry.output_version == output_version
1915                })
1916            });
1917
1918        if clean {
1919            let mut result =
1920                HISTORY_CACHE.with(|cache| cache.borrow().get(&id).unwrap().result.clone());
1921            result.reused = true;
1922            scene.apply(&result);
1923            for name in produced_names(&result) {
1924                available.insert(name.clone(), ());
1925                // A clean feature's current version equals its cached one; publish
1926                // it so downstream consumers hash the SAME value they cached against.
1927                name_version.insert(name, output_version);
1928            }
1929            results.push(result);
1930            timings.push((id.clone(), 0.0)); // replayed, not re-executed
1931            // Editor stop-point: AFTER this feature.
1932            if request.stop_at_id.as_deref() == Some(id.as_str()) {
1933                break;
1934            }
1935            continue;
1936        }
1937
1938        // Dirty: the old entry (if any) dies — its blast radius joins `changed`.
1939        if cacheable {
1940            if let Some(old) = HISTORY_CACHE.with(|cache| cache.borrow_mut().remove(&id)) {
1941                free_entry(&old);
1942                changed.extend(old.touched.iter().map(|(name, _)| (name.clone(), ())));
1943            }
1944        }
1945
1946        // The observer's boundary: the feature is dirty and about to execute.
1947        if !observe(HistoryProgress {
1948            index,
1949            total,
1950            id: &id,
1951            feature_type: &descriptor.feature_type,
1952        }) {
1953            break;
1954        }
1955
1956        // Anything left in the ledger by a DIRECT api call on this thread
1957        // (`move_faces_json`, an MCP edit) belongs to that call, not to the
1958        // feature about to run: drop it rather than attribute it wrongly.
1959        let _ = crate::take_crossing_repairs();
1960        let _ = crate::take_blend_notes();
1961        let feat_start = web_time::Instant::now();
1962        let mut result = execute_feature(descriptor, &env, &scene);
1963        timings.push((id.clone(), feat_start.elapsed().as_secs_f64() * 1000.0));
1964        // A result the soundness acceptance REPAIRED rather than returned as
1965        // built says so on the feature that built it. Drained here, so a repair
1966        // made by any lane reaches the caller's report by one route instead of
1967        // each lane growing a channel of its own.
1968        result.notes.extend(
1969            crate::take_crossing_repairs()
1970                .into_iter()
1971                .map(|repair| repair.to_string()),
1972        );
1973        // What a blend that shipped says about its own wall (a station ladder
1974        // that ran out with its rails still off their carriers), by the same
1975        // route.
1976        result.notes.extend(crate::take_blend_notes());
1977        // `halt` reflects the feature's OWN error only — a naming collision flags
1978        // the feature but does NOT truncate the history (the model still renders).
1979        let halt = result.error.is_some();
1980        let collisions_before = scene.name_collisions.len();
1981        scene.apply(&result);
1982        // Naming-contract guard: if applying this feature's output left two RESIDENT
1983        // solids sharing a face/edge name, surface it as the feature's error so the
1984        // operation trips its own tests + shows an error in the app (the hint points
1985        // at `common::namespace_copy_names`). Fresh path only — a cached replay
1986        // carries its collision error, if any, already.
1987        if result.error.is_none() && scene.name_collisions.len() > collisions_before {
1988            result.error = Some(name_collision_error(
1989                &scene.name_collisions[collisions_before..],
1990            ));
1991        }
1992
1993        // Kernel-owned topology metadata: every face/edge this feature produced
1994        // records its producer (the `_seedSourceFeatureMetadata` step, moved here).
1995        // Non-overwriting — faces propagated through a boolean keep their
1996        // original sourceFeatureId, matching the established seed semantics.
1997        if !id.is_empty() {
1998            let mut seed = serde_json::Map::new();
1999            seed.insert("sourceFeatureId".into(), serde_json::Value::String(id.clone()));
2000            for added in &result.added {
2001                for (_, face_name) in &added.face_names {
2002                    scene_metadata::merge_record(face_name, &seed, false);
2003                }
2004                for (_, edge_name) in &added.edge_names {
2005                    scene_metadata::merge_record(edge_name, &seed, false);
2006                }
2007            }
2008        }
2009
2010        // Enrich every miss with the name that replaced it, when the miss looks
2011        // like the per-loop naming change (see `annotate_rename`).
2012        for name in &mut result.unresolved {
2013            *name = annotate_rename(&scene, name);
2014        }
2015        // The same hint on the typed report, in the same pass, so the two
2016        // lists never say different things about one miss.
2017        if let Some(fulfilment) = &mut result.fulfilment {
2018            for rejected in &mut fulfilment.rejected {
2019                rejected.name = annotate_rename(&scene, &rejected.name);
2020            }
2021        }
2022
2023        let produced = produced_names(&result);
2024        for name in &produced {
2025            available.insert(name.clone(), ());
2026            changed.insert(name.clone(), ());
2027            // Publish this run's version for the name. Runs for cacheable AND
2028            // non-cacheable (empty/duplicate id) features alike — a non-cacheable
2029            // feature has no cache entry but its outputs still version their
2030            // downstream consumers correctly.
2031            name_version.insert(name.clone(), output_version);
2032        }
2033        for name in &result.removed {
2034            changed.insert(name.clone(), ());
2035        }
2036
2037        if cacheable && !halt {
2038            let mut touched: HashMap<String, ()> =
2039                produced.into_iter().map(|name| (name, ())).collect();
2040            touched.extend(result.removed.iter().map(|name| (name.clone(), ())));
2041            HISTORY_CACHE.with(|cache| {
2042                cache.borrow_mut().insert(
2043                    id.clone(),
2044                    CachedFeature {
2045                        fingerprint,
2046                        result: result.clone(),
2047                        consumed,
2048                        touched,
2049                        output_version,
2050                    },
2051                );
2052            });
2053        }
2054
2055        results.push(result);
2056        if halt {
2057            break;
2058        }
2059        // Editor stop-point: AFTER this feature.
2060        if request.stop_at_id.as_deref() == Some(id.as_str()) {
2061            break;
2062        }
2063    }
2064    // Orphan GC: a parts-library entry whose last ACOMP instance is gone from
2065    // the request is dropped now (orphaned payloads never accumulate, the
2066    // request carries the FULL feature list even under a stop point, so
2067    // truncation cannot GC live entries).
2068    parts_library::gc_after_rebuild(request);
2069    // Assembly tail: solve the request's constraints against the just-built
2070    // scene and install the post-solve session the exported assembly ABI
2071    // serves. Unconditional, so a document without an `assembly` block
2072    // resets any stale session.
2073    assembly::finish_history_run(request, &mut scene, &env);
2074    // Wire-harness tail: route the block's connections over the ports and
2075    // spline segments the run published, and build the bundle solids. Its
2076    // solids ride an extra result so the display pipeline shows them through
2077    // the standard solid path. Runs AFTER the assembly solve so the bundles
2078    // are built against the posed scene.
2079    // Ports tail: resolve the declared-ports block against the FINISHED scene
2080    // (a port point may reference any geometry in the part, which is why this
2081    // runs here and not as a history feature) and publish every point. Before
2082    // the harness, which routes over what this published.
2083    let ports = ports::finish_history_run(request, &mut scene, &env);
2084    if let Some(result) = ports.result {
2085        results.push(result);
2086    }
2087    let harness = wire_harness::finish_history_run(request, &mut scene, &env);
2088    if let Some(result) = harness.result {
2089        results.push(result);
2090    }
2091    // PMI tail: resolve every view's annotations against the finished scene
2092    // (bundles and posed components included). Pure reads — never touches
2093    // the cache.
2094    let pmi = pmi::finish_history_run(request, &scene, &env);
2095    HistoryResult {
2096        results,
2097        timings,
2098        wire_harness: harness.report,
2099        pmi,
2100        ports: ports.report,
2101    }
2102}
2103
2104// ===========================================================================
2105// wasm boundary
2106// ===========================================================================
2107
2108/// Deserialize a history request, run it, and serialize the per-feature results.
2109/// The handles in the result are `u32`s the caller tessellates / pulls names
2110/// from via the existing `*_handle` wasm exports.
2111#[wasm_bindgen]
2112pub fn execute_history_json(request_json: &str) -> Result<String, JsValue> {
2113    let request: HistoryRequest = serde_json::from_str(request_json)
2114        .map_err(|error| JsValue::from_str(&format!("execute_history_json: bad request: {error}")))?;
2115    let result = execute_history(&request);
2116    serde_json::to_string(&result).map_err(|error| JsValue::from_str(&error.to_string()))
2117}
2118
2119