Skip to main content

brep_kernel/feature_pipeline/
mod.rs

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