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