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