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
38mod expression;
39#[path = "features/mod.rs"]
40mod features;
41pub(crate) mod scene_metadata;
42mod schema;
43// The kernel-owned feature-schema catalogue, reachable by NATIVE Rust consumers
44// (the engine-native egui UI in brep-app, via a brep-render re-export) — the same
45// definitions the wasm `feature_schemas_json` export serves the caller's feature registry.
46pub use schema::feature_schema_catalogue;
47#[path = "sheet_metal/mod.rs"]
48mod sheet_metal;
49
50pub use expression::Env;
51
52// ===========================================================================
53// Descriptor — the serialized `{ type, inputParams, persistentData, timestamp }`
54// ===========================================================================
55
56/// One feature as serialized by `PartHistory.toSerializable`. Permissive: unknown
57/// fields are ignored, so an entire saved part file's `features[]` deserializes
58/// as-is. `input_params` stays a `serde_json::Value` (an object) so features read
59/// their own params with their own type knowledge (which params are numeric).
60// `persistent_data` / `timestamp` are contract pass-throughs (round-tripped for
61// features that need them, e.g. hole/pattern persistent state); not read yet.
62#[allow(dead_code)]
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct FeatureDescriptor {
65 #[serde(rename = "type", default)]
66 pub feature_type: String,
67 #[serde(rename = "inputParams", default)]
68 pub input_params: serde_json::Value,
69 #[serde(rename = "persistentData", default)]
70 pub persistent_data: serde_json::Value,
71 /// Opaque pass-through (any shape) so one odd saved file cannot fail the parse.
72 #[serde(default)]
73 pub timestamp: Option<serde_json::Value>,
74}
75
76/// The `execute_history_json` request: the expression source, the configurator
77/// state, and the ordered feature list. Permissive — a whole saved part file
78/// parses as a request (extra top-level fields ignored).
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct HistoryRequest {
81 #[serde(default, deserialize_with = "de_string_lenient")]
82 pub expressions: String,
83 #[serde(default)]
84 pub configurator: serde_json::Value,
85 #[serde(default)]
86 pub features: Vec<FeatureDescriptor>,
87 /// Stop AFTER executing the feature with this id (the editor's "stop at the
88 /// expanded feature"). The features past the stop stay in the request so the
89 /// incremental cache RETAINS their entries — expanding/collapsing a panel
90 /// must not thrash the cache (marshal-side truncation would free them).
91 #[serde(default, rename = "stopAtId")]
92 pub stop_at_id: Option<String>,
93 /// Stop BEFORE executing the feature with this id.
94 #[serde(default, rename = "stopBeforeId")]
95 pub stop_before_id: Option<String>,
96 /// Render level-of-detail factor for DISPLAY tessellation (the app's "LOD
97 /// factor" setting). `execute_history` ignores it — it rides on the request
98 /// only because the request is the serialized run boundary the display runner
99 /// receives, and the runner scales its per-solid chord tolerance by it
100 /// (higher = coarser mesh). Defaults to `1.0` (the "Normal" preset) so a saved
101 /// file / seed request with no `displayLod` tessellates exactly as before.
102 #[serde(default = "default_display_lod", rename = "displayLod")]
103 pub display_lod: f64,
104}
105
106/// The "Normal" render LOD — the [`HistoryRequest::display_lod`] serde default.
107fn default_display_lod() -> f64 {
108 1.0
109}
110
111/// Accept `null`/missing as `""` for a string field (saved files sometimes store
112/// `expressions: null`).
113fn de_string_lenient<'de, D>(deserializer: D) -> Result<String, D::Error>
114where
115 D: serde::Deserializer<'de>,
116{
117 let value = Option::<String>::deserialize(deserializer)?;
118 Ok(value.unwrap_or_default())
119}
120
121// ===========================================================================
122// Result — the per-feature output contract the caller consumes
123// ===========================================================================
124
125/// A solid produced by a feature: its resident handle, its solid name, and its
126/// named faces/edges (`(topology_id, name)`). the caller tessellates/pulls names via the
127/// handle; the scene-map registers the names for downstream reference resolution.
128#[derive(Debug, Clone, Serialize)]
129pub struct AddedSolid {
130 pub handle: u32,
131 pub name: String,
132 pub face_names: Vec<(u64, String)>,
133 /// Edge names propagated/authored on the solid. Present so edge-selecting
134 /// features (fillet/chamfer) can resolve "the edge of the extrude above"
135 /// against the scene-map — the pinned example in contract rule 1.
136 pub edge_names: Vec<(u64, String)>,
137}
138
139/// The output of one feature execution.
140#[derive(Debug, Clone, Serialize)]
141pub struct FeatureResult {
142 pub id: String,
143 pub feature_type: String,
144 pub added: Vec<AddedSolid>,
145 /// Names of prior solids this feature consumed/superseded (removed from the
146 /// scene-map; their handles are freed by the loop).
147 pub removed: Vec<String>,
148 /// Set on a hard failure. `execute_history` records it and HALTS the
149 /// remaining features (mirrors the abort-on-error loop). Never a panic.
150 pub error: Option<String>,
151 /// `reference_selection` names this feature could not resolve against the
152 /// scene-map. NOT an error/halt — the caller runs a snapshot-repair pass and
153 /// re-dispatches (migration-plan Stage 5 gate).
154 pub unresolved: Vec<String>,
155 /// True when this result was REPLAYED from the incremental history cache (the
156 /// feature and everything it references are unchanged since the last run).
157 /// The handles are the same resident solids — the caller can skip re-tessellation.
158 pub reused: bool,
159 /// Sketch profiles this feature produced (a SKETCH feature emits one under
160 /// its name; solid features emit none). INTERNAL side-channel: `#[serde(skip)]`
161 /// so it never crosses the wasm boundary to the caller — [`SceneMap::apply`] ingests it
162 /// into `scene.profiles` for downstream profile-consumers (extrude/revolve/…).
163 #[serde(skip)]
164 pub profiles: Vec<(String, SketchProfile)>,
165 /// Named plane frames this feature produced (DATUM emits three, PLANE one).
166 /// Same INTERNAL `#[serde(skip)]` side-channel — ingested into `scene.frames`
167 /// so a later SKETCH can resolve its plane by name, fully headless.
168 #[serde(skip)]
169 pub frames: Vec<(String, Frame)>,
170 /// Named axis lines this feature produced (a SKETCH emits one per line
171 /// geometry). Same INTERNAL `#[serde(skip)]` side-channel — ingested into
172 /// `scene.axes` so a later revolve/sweep can resolve its axis by name.
173 #[serde(skip)]
174 pub axes: Vec<(String, Axis)>,
175 /// Named path chains this feature produced (a SKETCH publishes its ordered
176 /// open/closed geometry chain). Same INTERNAL `#[serde(skip)]` side-channel —
177 /// ingested into `scene.paths` for sweep/path_sweep/rib trajectory resolution.
178 #[serde(skip)]
179 pub paths: Vec<(String, Vec<NurbsCurve>)>,
180 /// Named world points this feature produced (a SKETCH publishes every point
181 /// as `{sketchId}:P{pid}`). Same side-channel — ingested into `scene.points`
182 /// for hole-center / placement resolution.
183 #[serde(skip)]
184 pub points: Vec<(String, Vec3)>,
185}
186
187impl FeatureResult {
188 /// An empty successful result (no solids, no error) — the shape a non-solid
189 /// pass-through feature returns.
190 pub fn empty(id: impl Into<String>, feature_type: impl Into<String>) -> Self {
191 Self {
192 id: id.into(),
193 feature_type: feature_type.into(),
194 added: Vec::new(),
195 removed: Vec::new(),
196 error: None,
197 unresolved: Vec::new(),
198 reused: false,
199 profiles: Vec::new(),
200 frames: Vec::new(),
201 axes: Vec::new(),
202 paths: Vec::new(),
203 points: Vec::new(),
204 }
205 }
206
207 /// A hard failure. Halts the history loop.
208 pub fn error(
209 id: impl Into<String>,
210 feature_type: impl Into<String>,
211 message: impl Into<String>,
212 ) -> Self {
213 let mut result = Self::empty(id, feature_type);
214 result.error = Some(message.into());
215 result
216 }
217
218 /// A non-solid construction feature (datum/plane/sketch/spline/port/helix):
219 /// a descriptor pass-through with no resident handle. The loop continues.
220 pub fn pass_through(id: impl Into<String>, feature_type: impl Into<String>) -> Self {
221 Self::empty(id, feature_type)
222 }
223
224 /// A solid-producing (or no-kernel) feature not yet ported to Rust. Halts the
225 /// loop with a clear message — replaced when a fan-out agent implements the
226 /// feature's `features/<feat>.rs`.
227 pub fn not_yet_migrated(id: impl Into<String>, feature_type: impl Into<String>) -> Self {
228 let feature_type = feature_type.into();
229 let message = format!("feature type '{feature_type}' is not yet migrated to the Rust pipeline");
230 Self::error(id, feature_type, message)
231 }
232}
233
234/// The whole-history output.
235#[derive(Debug, Clone, Serialize)]
236pub struct HistoryResult {
237 pub results: Vec<FeatureResult>,
238 /// Per-feature wall-clock execution time `(feature id, milliseconds)` in run
239 /// order — the lightweight timing surface the engine-native history tree's
240 /// "N ms" readout consumes. A REUSED (cache-replayed) feature records `0.0`
241 /// (its geometry was not re-executed this run). INTERNAL side-channel:
242 /// `#[serde(skip)]` so it never changes the caller-facing `execute_history_json`
243 /// shape; brep-render reads it natively via the re-exported struct.
244 #[serde(skip)]
245 pub timings: Vec<(String, f64)>,
246}
247
248// ===========================================================================
249// SceneMap — the live name -> handle/topology index maintained across the loop
250// ===========================================================================
251
252// `handle` + `face_id`/`edge_id` are the contract the caller (and edge-selecting
253// fan-out features) read to resolve a selection to a resident topology entity.
254#[allow(dead_code)]
255#[derive(Debug, Clone, Copy)]
256pub struct FaceRef {
257 pub handle: u32,
258 pub face_id: u64,
259}
260
261#[allow(dead_code)]
262#[derive(Debug, Clone, Copy)]
263pub struct EdgeRef {
264 pub handle: u32,
265 pub edge_id: u64,
266}
267
268/// A solved sketch's extracted profile, placed in 3D. Produced by the SKETCH
269/// pipeline feature and consumed BY NAME by the profile-consumers (extrude,
270/// revolve, loft, sweep, rib, sheet-metal tab/contour-flange) — the exact-BREP
271/// replacement for the old "marshal the resolved curves into `inputParams`".
272///
273/// The closed loops are grouped into disjoint REGIONS by even-odd containment
274/// depth: a depth-EVEN loop is a region's OUTER boundary, a depth-ODD loop is a
275/// hole of the region whose outer directly contains it (so an island inside a
276/// hole is its own region). Each region is `[outer, holes…]`; consumers build
277/// one solid per region and UNION them — the established multi-region behavior.
278/// The plane frame (`origin` + orthonormal `x_axis`/`y_axis`/`z_axis`) is resolved
279/// LIVE in Rust from the sketch's plane reference (DATUM/PLANE feature frame or a
280/// resident face), falling back to the persisted `basis`; the loop curves are
281/// emitted in WORLD space via that frame, so `z_axis` is the natural extrude
282/// direction / a revolve-plane hint.
283#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
284pub struct SketchProfile {
285 pub origin: Vec3,
286 pub x_axis: Vec3,
287 pub y_axis: Vec3,
288 pub z_axis: Vec3,
289 /// Disjoint regions, each `[outer, holes…]` (containment-classified).
290 pub regions: Vec<Vec<ProfileLoop>>,
291}
292
293/// One closed boundary of a [`SketchProfile`]: head-to-tail world-space curves plus
294/// the per-curve source edge name (`{sketchId}:G{gid}`, parallel to `curves`). The
295/// profile-consumers stamp sidewall names from `edge_names` (extrude → `{name}_E`).
296#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
297pub struct ProfileLoop {
298 pub curves: Vec<NurbsCurve>,
299 pub edge_names: Vec<Option<String>>,
300}
301
302/// An orthonormal placement frame — a named plane (DATUM/PLANE feature) or a
303/// resolved sketch plane. `z_axis` is the plane normal.
304#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
305pub struct Frame {
306 pub origin: Vec3,
307 pub x_axis: Vec3,
308 pub y_axis: Vec3,
309 pub z_axis: Vec3,
310}
311
312impl Frame {
313 /// Derive an orthonormal frame from an origin + plane normal via the worldUp
314 /// convention, the
315 /// SINGLE source of truth for how a sketch/plane reference becomes in-plane
316 /// axes: `refUp = |n·(0,1,0)| > 0.9 ? (1,0,0) : (0,1,0)`;
317 /// `x = norm(refUp × n)`; `y = norm(n × x)`; `z = n`. The referenced object's
318 /// own x/y are intentionally ignored — only its (origin, normal) matter.
319 pub fn from_origin_normal(origin: Vec3, normal: Vec3) -> Result<Self, String> {
320 let z_axis = normal.normalized()?;
321 let world_up = Vec3::new(0.0, 1.0, 0.0);
322 let ref_up = if z_axis.dot(world_up).abs() > 0.9 {
323 Vec3::new(1.0, 0.0, 0.0)
324 } else {
325 world_up
326 };
327 let x_axis = ref_up.cross(z_axis).normalized()?;
328 let y_axis = z_axis.cross(x_axis).normalized()?;
329 Ok(Frame {
330 origin,
331 x_axis,
332 y_axis,
333 z_axis,
334 })
335 }
336
337 /// Map a plane-local `(u, v)` into world space: `origin + u·x + v·y`.
338 pub fn to_3d(&self, u: f64, v: f64) -> Vec3 {
339 self.origin
340 .add(self.x_axis.scale(u))
341 .add(self.y_axis.scale(v))
342 }
343}
344
345/// A named world-space line — a revolve/sweep axis. Published by SKETCH features
346/// (every sketch line geometry, construction included) and resolvable from a
347/// resident solid edge, so an `axis`/path reference resolves fully headless.
348#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
349pub struct Axis {
350 pub point: Vec3,
351 pub direction: Vec3,
352}
353
354/// The live scene-map. Exact-match resolution only (contract rule 1).
355#[derive(Debug, Clone, Default)]
356pub struct SceneMap {
357 pub solids: HashMap<String, u32>,
358 pub faces: HashMap<String, FaceRef>,
359 pub edges: HashMap<String, EdgeRef>,
360 /// Sketch name -> its extracted profile. Populated by SKETCH features; read by
361 /// profile-consumers via [`SceneMap::resolve_profile`].
362 pub profiles: HashMap<String, SketchProfile>,
363 /// Plane name -> its frame. Populated by DATUM/PLANE features; read by SKETCH
364 /// (and PLANE datum-refs) via [`SceneMap::resolve_frame`] for headless plane
365 /// resolution (fully headless).
366 pub frames: HashMap<String, Frame>,
367 /// Axis name -> its world line. Populated by SKETCH line geometries; read by
368 /// revolve/sweep via [`SceneMap::resolve_axis`].
369 pub axes: HashMap<String, Axis>,
370 /// Path name -> its ordered world curve chain (open or closed). Populated by
371 /// SKETCH features; read by sweep/path_sweep/rib via [`SceneMap::resolve_path`].
372 pub paths: HashMap<String, Vec<NurbsCurve>>,
373 /// Point name -> its world position. Populated by SKETCH features
374 /// (`{sketchId}:P{pid}`); read by hole placement via [`SceneMap::resolve_point`].
375 pub points: HashMap<String, Vec3>,
376}
377
378impl SceneMap {
379 /// Resolve a solid name to its resident handle (exact match).
380 pub fn resolve_solid(&self, name: &str) -> Option<u32> {
381 self.solids.get(name).copied()
382 }
383
384 /// Resolve a face name to its `(handle, face_id)` (exact match).
385 pub fn resolve_face(&self, name: &str) -> Option<FaceRef> {
386 self.faces.get(name).copied()
387 }
388
389 /// Resolve an edge name to its `(handle, edge_id)` (exact match). Contract
390 /// surface for edge-selecting fan-out features (fillet/chamfer); no
391 /// implemented feature consumes it yet.
392 #[allow(dead_code)]
393 pub fn resolve_edge(&self, name: &str) -> Option<EdgeRef> {
394 self.edges.get(name).copied()
395 }
396
397 /// Resolve a profile reference name to the sketch profile it names. Tries the
398 /// exact name first, then the name with a trailing `:PROFILE` stripped — a
399 /// sketch is selectable both by its group name (`{id}`) and by its profile
400 /// face name (`{id}:PROFILE`); both must resolve to
401 /// the one profile stored under `{id}`.
402 pub fn resolve_profile(&self, name: &str) -> Option<&SketchProfile> {
403 if let Some(profile) = self.profiles.get(name) {
404 return Some(profile);
405 }
406 name.strip_suffix(":PROFILE")
407 .and_then(|base| self.profiles.get(base))
408 }
409
410 /// Resolve a named plane frame (exact match), for sketch/plane resolution.
411 pub fn resolve_frame(&self, name: &str) -> Option<Frame> {
412 self.frames.get(name).copied()
413 }
414
415 /// Resolve a named axis line (exact match), for revolve/sweep.
416 pub fn resolve_axis(&self, name: &str) -> Option<Axis> {
417 self.axes.get(name).copied()
418 }
419
420 /// Resolve a named path chain (exact match; also strips a trailing `:PROFILE`),
421 /// for sweep/path_sweep/rib trajectories.
422 pub fn resolve_path(&self, name: &str) -> Option<&Vec<NurbsCurve>> {
423 if let Some(path) = self.paths.get(name) {
424 return Some(path);
425 }
426 name.strip_suffix(":PROFILE")
427 .and_then(|base| self.paths.get(base))
428 }
429
430 /// Resolve a named world point (exact match), for hole/placement centers.
431 /// Contract surface consumed by the spline/hole placement tests; kept as the
432 /// documented point-resolution API even where no shipping feature reads it
433 /// yet (mirrors `resolve_edge`).
434 #[allow(dead_code)]
435 pub fn resolve_point(&self, name: &str) -> Option<Vec3> {
436 self.points.get(name).copied()
437 }
438
439 /// Apply a feature's effect: removals first (a boolean result reuses a removed
440 /// target's name), then additions. Removal only unmaps NAMES — it never frees
441 /// the handle. Handles are owned by the incremental history cache (each by the
442 /// entry of the feature that produced it) and die exactly when that entry is
443 /// invalidated; freeing here would kill a clean upstream feature's cached
444 /// solid the moment a downstream boolean consumed it.
445 fn apply(&mut self, result: &FeatureResult) {
446 for name in &result.removed {
447 if let Some(handle) = self.solids.remove(name) {
448 // Purge the removed solid's face/edge entries (names only).
449 self.faces.retain(|_, face| face.handle != handle);
450 self.edges.retain(|_, edge| edge.handle != handle);
451 }
452 // A removed name not in the map is a silent no-op (established semantics).
453 }
454 // Ingest any sketch profiles this feature produced (SKETCH features only).
455 for (name, profile) in &result.profiles {
456 self.profiles.insert(name.clone(), profile.clone());
457 }
458 // Ingest any named plane frames (DATUM/PLANE features).
459 for (name, frame) in &result.frames {
460 self.frames.insert(name.clone(), *frame);
461 }
462 // Ingest any named axis lines (SKETCH line geometries).
463 for (name, axis) in &result.axes {
464 self.axes.insert(name.clone(), *axis);
465 }
466 // Ingest any named path chains (SKETCH geometry chains).
467 for (name, path) in &result.paths {
468 self.paths.insert(name.clone(), path.clone());
469 }
470 // Ingest any named world points (SKETCH points).
471 for (name, point) in &result.points {
472 self.points.insert(name.clone(), *point);
473 }
474 for added in &result.added {
475 self.solids.insert(added.name.clone(), added.handle);
476 for (face_id, name) in &added.face_names {
477 self.faces.insert(
478 name.clone(),
479 FaceRef {
480 handle: added.handle,
481 face_id: *face_id,
482 },
483 );
484 }
485 for (edge_id, name) in &added.edge_names {
486 self.edges.insert(
487 name.clone(),
488 EdgeRef {
489 handle: added.handle,
490 edge_id: *edge_id,
491 },
492 );
493 }
494 }
495 }
496}
497
498// ===========================================================================
499// FeatureContext — everything a feature's `execute(ctx)` needs
500// ===========================================================================
501
502/// The read-only context handed to each feature's `execute`. Features resolve
503/// their `reference_selection` params against `scene` and read numeric params via
504/// [`FeatureContext::number`] (which evaluates expression strings against `env`).
505pub struct FeatureContext<'a> {
506 pub id: String,
507 pub feature_type: String,
508 pub params: &'a serde_json::Value,
509 /// The feature's `persistentData` (round-tripped from the saved part file).
510 /// The SKETCH feature reads its `sketch` + `basis` here (the scene-computed
511 /// plane frame stays caller-side per directive §1 and is marshaled in via this).
512 pub persistent: &'a serde_json::Value,
513 pub env: &'a Env,
514 pub scene: &'a SceneMap,
515}
516
517impl<'a> FeatureContext<'a> {
518 /// The raw param value (an object field of `inputParams`), if present.
519 pub fn param(&self, key: &str) -> Option<&serde_json::Value> {
520 self.params.get(key)
521 }
522
523 /// A numeric param: a JSON number passes through; a JSON string is evaluated
524 /// as an expression against the shared env; anything else is an error. This is
525 /// how a feature declares which of its params are numeric (the engine has no
526 /// per-feature schema).
527 pub fn number(&self, key: &str) -> Result<f64, String> {
528 match self.param(key) {
529 Some(serde_json::Value::Number(number)) => number
530 .as_f64()
531 .ok_or_else(|| format!("param `{key}` is not a finite number")),
532 Some(serde_json::Value::String(source)) => self
533 .env
534 .eval(source)
535 .map_err(|error| format!("param `{key}`: {error}")),
536 Some(other) => Err(format!(
537 "param `{key}` must be a number or expression string, found {other}"
538 )),
539 None => Err(format!("missing required param `{key}`")),
540 }
541 }
542
543 /// A string param (read verbatim — never expression-evaluated; used for ids
544 /// and `reference_selection` names). Contract surface for fan-out features;
545 /// the two implemented features read the id via `ctx.id` directly.
546 #[allow(dead_code)]
547 pub fn string(&self, key: &str) -> Option<String> {
548 match self.param(key) {
549 Some(serde_json::Value::String(text)) => Some(text.clone()),
550 _ => None,
551 }
552 }
553
554 /// A convenience [`FeatureResult::error`] carrying this feature's id + type.
555 pub fn fail(&self, message: impl Into<String>) -> FeatureResult {
556 FeatureResult::error(self.id.clone(), self.feature_type.clone(), message)
557 }
558}
559
560// ===========================================================================
561// Dispatch — a match on `feature_type` to the owning `features/<feat>.rs`
562// ===========================================================================
563
564/// Extract the feature id from `inputParams` (`id`, falling back to the
565/// non-enumerable `featureID`). The solid + all face/edge names are prefixed
566/// with this id, so name fidelity depends on it.
567fn extract_id(params: &serde_json::Value) -> String {
568 for key in ["id", "featureID"] {
569 if let Some(serde_json::Value::String(text)) = params.get(key) {
570 if !text.is_empty() {
571 return text.clone();
572 }
573 }
574 }
575 String::new()
576}
577
578/// Resolve one feature descriptor to a [`FeatureResult`] against the current
579/// scene. Each stub's arm calls its own `features::<feat>::execute` so a fan-out
580/// agent implementing a feature touches ONLY that file — never this dispatch.
581pub fn execute_feature(
582 descriptor: &FeatureDescriptor,
583 env: &Env,
584 scene: &SceneMap,
585) -> FeatureResult {
586 let id = extract_id(&descriptor.input_params);
587 let feature_type = descriptor.feature_type.clone();
588 let ctx = FeatureContext {
589 id: id.clone(),
590 feature_type: feature_type.clone(),
591 params: &descriptor.input_params,
592 persistent: &descriptor.persistent_data,
593 env,
594 scene,
595 };
596 // Alias resolution mirrors the caller's feature registry, which matches short name,
597 // LONG name, and class name (all uppercased) — saved files and tests use
598 // long-name type strings like "CHAMFER"/"PLANE".
599 match feature_type.as_str() {
600 // --- Fully implemented (the exemplary pattern) ---
601 "P.CU" | "CUBE" => features::cube::execute(&ctx),
602 "P.S" | "SPHERE" => features::sphere::execute(&ctx),
603 // --- Solid-producing primitives ---
604 "P.CY" | "CYLINDER" => features::cylinder::execute(&ctx),
605 "P.CO" | "CONE" => features::cone::execute(&ctx),
606 "P.T" | "TORUS" => features::torus::execute(&ctx),
607 "P.PY" | "PYRAMID" => features::pyramid::execute(&ctx),
608 // --- Add-material ---
609 "E" | "EXTRUDE" => features::extrude::execute(&ctx),
610 "R" | "REVOLVE" => features::revolve::execute(&ctx),
611 "LOFT" => features::loft::execute(&ctx),
612 "SW" | "SWEEP" => features::sweep::execute(&ctx),
613 "SWP" | "PATH SWEEP" | "PATHSWEEP" => features::path_sweep::execute(&ctx),
614 "RIB" => features::rib::execute(&ctx),
615 "TU" | "TUBE" => features::tube::execute(&ctx),
616 // --- Edit / boolean / transform ---
617 "B" | "BOOLEAN" => features::boolean::execute(&ctx),
618 "M" | "MIRROR" => features::mirror::execute(&ctx),
619 "SPL" | "SPLIT" => features::split::execute(&ctx),
620 "XFORM" | "TRANSFORM" => features::transform::execute(&ctx),
621 "PATTERN" => features::pattern::execute(&ctx),
622 // --- Dressups ---
623 "F" | "FILLET" => features::fillet::execute(&ctx),
624 "CH" | "CHAMFER" => features::chamfer::execute(&ctx),
625 "O.S" | "OFFSET SHELL" | "OFFSETSHELL" => features::offset_shell::execute(&ctx),
626 "O.F" | "OFFSET FACE" | "OFFSETFACE" => features::offset_face::execute(&ctx),
627 "PF" | "PUSHFACE" | "PUSH FACE" => features::push_face::execute(&ctx),
628 "DF" | "DELETE FACE" | "DELETEFACE" => features::delete_face::execute(&ctx),
629 "THK" | "THICKEN" => features::thicken::execute(&ctx),
630 "H" | "HOLE" => features::hole::execute(&ctx),
631 // --- No-kernel-op / long tail ---
632 "IMPORT3D" => features::import3d::execute(&ctx),
633 "SM.TAB" => features::sheet_metal_tab::execute(&ctx),
634 "SM.CF" => features::sheet_metal_contour_flange::execute(&ctx),
635 "SM.F" => features::sheet_metal_flange::execute(&ctx),
636 "SM.HEM" => features::sheet_metal_hem::execute(&ctx),
637 "SM.CUTOUT" => features::sheet_metal_cutout::execute(&ctx),
638 "SM.UNFOLD" => features::sheet_metal_unfold::execute(&ctx),
639 // --- Construction geometry that registers named plane frames (headless
640 // sketch-plane resolution) ---
641 "D" | "DATUM" | "DATIUM" => features::datum::execute(&ctx),
642 "P" | "PLANE" => features::plane::execute(&ctx),
643 // --- Sketch: solves + extracts a profile the add-material features consume ---
644 "S" | "SKETCH" => features::sketch::execute(&ctx),
645 // --- Non-solid construction geometry: editor-drawn pass-throughs ---
646 "SP" | "SPLINE" => features::spline::execute(&ctx),
647 "PORT" => features::port::execute(&ctx),
648 "HX" | "HELIX" => features::helix::execute(&ctx),
649 // --- Genuinely unknown type string ---
650 _ => FeatureResult::error(id, feature_type.clone(), format!("unknown feature type '{feature_type}'")),
651 }
652}
653
654// ===========================================================================
655// The incremental history cache — dependency-driven dirty tracking
656// ===========================================================================
657//
658// A feature is DIRTY when it itself changed (its serialized descriptor hash — or
659// the expression env — differs) or when a NAME it references was (re)produced,
660// removed, or re-resolved differently since its cached run. Everything else is
661// CLEAN: its cached result (resident handles, profiles, frames, axes, paths)
662// replays into the scene untouched and is flagged `reused` so the caller skips display
663// rebuild. Dependency edges come from the exact-match name contract itself
664// (contract rule 1): any string in a feature's params matching a name produced
665// earlier in the history IS a reference.
666//
667// OWNERSHIP: cache entries own the handles their feature produced. A handle (and
668// its sheet-metal tree) is freed exactly when its producing entry is invalidated
669// (feature dirty/deleted, or `clear_history_cache`). Nothing else frees scene
670// handles — not `SceneMap::apply`, and not the caller (display Solids are non-owning
671// views post-flip). Consumed intermediates therefore stay resident until an edit
672// invalidates their producer: the accepted memory cost of incremental replay.
673
674/// One cached feature execution.
675struct CachedFeature {
676 /// Hash of `type` + `inputParams` + `persistentData` + the env fingerprint.
677 fingerprint: u64,
678 /// The successful result (errored results are NEVER cached — an errored
679 /// feature is unconditionally dirty next run).
680 result: FeatureResult,
681 /// The referenced names that RESOLVED against the names available at its
682 /// position in the history. Compared for equality each run: a reference that
683 /// starts/stops resolving (producer added/deleted/reordered) flips dirty.
684 consumed: std::collections::BTreeSet<String>,
685 /// Every name this feature produced or removed — the invalidation blast
686 /// radius handed to `changed` when the entry dies.
687 touched: HashMap<String, ()>,
688 /// Content hash of this feature's EFFECTIVE inputs: its own `fingerprint`
689 /// PLUS, for every consumed name, the producing feature's `output_version`
690 /// (a Merkle hash over the feature DAG). This is what makes reuse correct
691 /// ACROSS runs — including after a stop-point edit re-caches an upstream
692 /// feature so it looks "clean" on the next full run. The per-run `changed`
693 /// wavefront (below) only sees dirtiness WITHIN one run; this sees it across
694 /// runs by comparing the input content directly. See `feature_output_version`.
695 output_version: u64,
696}
697
698thread_local! {
699 /// Feature id -> its cached execution, persisted across `execute_history`
700 /// calls (same thread = same wasm instance).
701 static HISTORY_CACHE: std::cell::RefCell<HashMap<String, CachedFeature>> =
702 std::cell::RefCell::new(HashMap::new());
703}
704
705/// Free a dead entry's owned resources: each produced handle and its
706/// sheet-metal tree. The SINGLE place cached handles die.
707fn free_entry(entry: &CachedFeature) {
708 for added in &entry.result.added {
709 sheet_metal::remove_tree(added.handle);
710 crate::free_registered_solid(added.handle);
711 }
712}
713
714/// Drop the whole cache, freeing every owned handle + tree. Called by the caller on a
715/// part/document switch (and by every Rust test for hermeticity).
716pub fn clear_history_cache() {
717 HISTORY_CACHE.with(|cache| {
718 let mut cache = cache.borrow_mut();
719 for entry in cache.values() {
720 free_entry(entry);
721 }
722 cache.clear();
723 });
724 sheet_metal::clear_trees();
725}
726
727/// wasm boundary for [`clear_history_cache`].
728#[wasm_bindgen]
729pub fn clear_history_cache_json() {
730 clear_history_cache();
731}
732
733/// Stable-enough fingerprint of one descriptor, with its expression-bearing
734/// string leaves EVALUATED against `env`. `DefaultHasher` is stable within a
735/// process, which is exactly the cache's lifetime.
736///
737/// This is what makes expression-edit invalidation FINE-GRAINED: an edit to the
738/// expression sheet (or the configurator) only moves the fingerprint of a feature
739/// whose parameter VALUES actually change, instead of the old coarse
740/// `env_fingerprint` that hashed the whole sheet into every feature and thus
741/// dirtied everything. The env's variable + configurator values reach a feature
742/// only through the string params it evaluates, so hashing those evaluated values
743/// captures exactly the feature's dependency on the sheet.
744fn descriptor_fingerprint(descriptor: &FeatureDescriptor, env: &Env) -> u64 {
745 use std::hash::{Hash, Hasher};
746 let mut hasher = std::collections::hash_map::DefaultHasher::new();
747 descriptor.feature_type.hash(&mut hasher);
748 // Both param sources: `persistent_data` (e.g. sketch coordinates) can hold
749 // expression strings too, so it must be evaluated or an expression-driven
750 // sketch coord would go stale on a sheet edit.
751 hash_evaluated(&descriptor.input_params, env, &mut hasher);
752 hash_evaluated(&descriptor.persistent_data, env, &mut hasher);
753 hasher.finish()
754}
755
756/// Feed a descriptor value into the hasher with every string leaf contributing
757/// BOTH its raw text AND, if it parses as an expression, its evaluated value.
758///
759/// - Raw-ALWAYS keeps verbatim-read strings (ids, `reference_selection` names,
760/// enum codes) honest: swapping one for a *different* string that happens to
761/// evaluate to the same number still moves the hash.
762/// - The evaluated value is what lets an UNRELATED sheet edit leave a
763/// numeric-expression field untouched (its raw text `"w"` is unchanged and its
764/// value only moves when `w` moves).
765/// - A non-expression string simply fails to `eval` and contributes only its raw
766/// text. A reference name that collides with a variable evaluates too → at
767/// worst a harmless FALSE-dirty when that variable is edited (never a stale
768/// reuse; the reference's real dependency rides the consumed-set /
769/// `output_version`).
770///
771/// Object keys are visited in sorted order so the hash is representation-stable.
772fn hash_evaluated(value: &serde_json::Value, env: &Env, hasher: &mut impl std::hash::Hasher) {
773 use std::hash::Hash;
774 match value {
775 serde_json::Value::String(text) => {
776 0u8.hash(hasher);
777 text.hash(hasher);
778 // Only attempt the (parsing) eval on strings short enough to be a real
779 // expression — `persistent_data` can be large, and the raw text above
780 // already covers everything for correctness.
781 if text.len() <= 64 {
782 if let Ok(number) = env.eval(text) {
783 1u8.hash(hasher);
784 number.to_bits().hash(hasher);
785 }
786 }
787 }
788 serde_json::Value::Array(items) => {
789 2u8.hash(hasher);
790 for item in items {
791 hash_evaluated(item, env, hasher);
792 }
793 }
794 serde_json::Value::Object(map) => {
795 3u8.hash(hasher);
796 let mut keys: Vec<&String> = map.keys().collect();
797 keys.sort();
798 for key in keys {
799 key.hash(hasher);
800 hash_evaluated(&map[key], env, hasher);
801 }
802 }
803 other => {
804 // Number / bool / null — canonical serialized form.
805 4u8.hash(hasher);
806 other.to_string().hash(hasher);
807 }
808 }
809}
810
811/// Collect every string in the descriptor (inputParams + persistentData,
812/// recursively) that EXACT-matches a name available earlier in the history
813/// (directly or with the `:PROFILE` selection alias stripped) — the feature's
814/// resolvable reference set.
815fn scan_consumed(
816 descriptor: &FeatureDescriptor,
817 available: &HashMap<String, ()>,
818) -> std::collections::BTreeSet<String> {
819 fn walk(
820 value: &serde_json::Value,
821 available: &HashMap<String, ()>,
822 out: &mut std::collections::BTreeSet<String>,
823 ) {
824 match value {
825 serde_json::Value::String(text) => {
826 let trimmed = text.trim();
827 if trimmed.is_empty() {
828 return;
829 }
830 // Record the CANONICAL produced name, not the alias spelling:
831 // a `{sketch}:PROFILE` reference resolves against the profile
832 // published under `{sketch}`, and the dirty check intersects
833 // this set with produced/changed names — recording the alias
834 // verbatim made a sketch edit invisible to its consumers
835 // (changed = {"S1"}, consumed = {"S1:PROFILE"}, no overlap).
836 if available.contains_key(trimmed) {
837 out.insert(trimmed.to_string());
838 } else if let Some(base) = trimmed
839 .strip_suffix(":PROFILE")
840 .filter(|base| available.contains_key(*base))
841 {
842 out.insert(base.to_string());
843 }
844 }
845 serde_json::Value::Array(items) => {
846 for item in items {
847 walk(item, available, out);
848 }
849 }
850 serde_json::Value::Object(map) => {
851 for item in map.values() {
852 walk(item, available, out);
853 }
854 }
855 _ => {}
856 }
857 }
858 let mut out = std::collections::BTreeSet::new();
859 walk(&descriptor.input_params, available, &mut out);
860 walk(&descriptor.persistent_data, available, &mut out);
861 out
862}
863
864/// The EFFECTIVE input hash of one feature: its own descriptor `fingerprint`
865/// combined with the `output_version` of every name it consumes. Because each
866/// producer's `output_version` is itself computed this way, this is a Merkle hash
867/// over the whole upstream DAG: it changes iff the feature's own descriptor OR any
868/// transitive input changed. A feature is reusable iff its cached `output_version`
869/// still equals this — which is exactly "nothing changed for any of its inputs",
870/// and is correct across runs (a stop-point edit that re-cached an upstream feature
871/// bumps that feature's version, so this consumer's version no longer matches).
872///
873/// `consumed` is a `BTreeSet`, so the iteration order is deterministic. A consumed
874/// name should always have a version (it came from an already-walked producer); a
875/// missing one hashes as `0` rather than panicking.
876fn feature_output_version(
877 fingerprint: u64,
878 consumed: &std::collections::BTreeSet<String>,
879 name_version: &HashMap<String, u64>,
880) -> u64 {
881 use std::hash::{Hash, Hasher};
882 let mut hasher = std::collections::hash_map::DefaultHasher::new();
883 fingerprint.hash(&mut hasher);
884 for name in consumed {
885 name.hash(&mut hasher);
886 name_version.get(name).copied().unwrap_or(0).hash(&mut hasher);
887 }
888 hasher.finish()
889}
890
891/// Every name a result adds to the scene: solid/face/edge names plus the
892/// profile/frame/axis/path names from the side-channels.
893fn produced_names(result: &FeatureResult) -> Vec<String> {
894 let mut names = Vec::new();
895 for added in &result.added {
896 names.push(added.name.clone());
897 names.extend(added.face_names.iter().map(|(_, name)| name.clone()));
898 names.extend(added.edge_names.iter().map(|(_, name)| name.clone()));
899 }
900 names.extend(result.profiles.iter().map(|(name, _)| name.clone()));
901 names.extend(result.frames.iter().map(|(name, _)| name.clone()));
902 names.extend(result.axes.iter().map(|(name, _)| name.clone()));
903 names.extend(result.paths.iter().map(|(name, _)| name.clone()));
904 names.extend(result.points.iter().map(|(name, _)| name.clone()));
905 names
906}
907
908// ===========================================================================
909// The history loop
910// ===========================================================================
911
912/// Run a whole feature history incrementally: build the expression env once,
913/// then walk the ordered features. A CLEAN feature (see the cache section above)
914/// replays its cached result; a DIRTY one re-executes (its old outputs are freed
915/// first). On a feature error, record it and HALT (abort-on-error);
916/// errored results are never cached. Never panics.
917pub fn execute_history(request: &HistoryRequest) -> HistoryResult {
918 let env = Env::build(&request.expressions, &request.configurator)
919 .unwrap_or_else(Env::poisoned);
920
921 // Entries whose feature was DELETED from the history die now. Their
922 // consumers go dirty via the consumed-set equality check (the vanished names
923 // drop out of the available set), so no changed-seeding is needed here.
924 let request_ids: HashMap<String, ()> = request
925 .features
926 .iter()
927 .map(|descriptor| (extract_id(&descriptor.input_params), ()))
928 .collect();
929 HISTORY_CACHE.with(|cache| {
930 let mut cache = cache.borrow_mut();
931 cache.retain(|id, entry| {
932 let keep = request_ids.contains_key(id);
933 if !keep {
934 free_entry(entry);
935 }
936 keep
937 });
938 });
939
940 let mut scene = SceneMap::default();
941 let mut results = Vec::with_capacity(request.features.len());
942 // Per-feature wall-clock timing, in run order (`web_time::Instant` — plain
943 // `std::time::Instant` traps 'unreachable' on wasm32).
944 let mut timings: Vec<(String, f64)> = Vec::with_capacity(request.features.len());
945 // Names whose content changed this run (produced/removed by any re-executed
946 // or invalidated feature) — the dirty wavefront.
947 let mut changed: HashMap<String, ()> = HashMap::new();
948 // Names available for reference so far this run (from clean AND dirty
949 // features alike).
950 let mut available: HashMap<String, ()> = HashMap::new();
951 // Each available name -> the `output_version` of the feature that produced it
952 // this run (walk order: a boolean that reuses its target name overwrites the
953 // producer's version, so consumers depend on the boolean's version). Feeds
954 // `feature_output_version` so cross-run reuse is decided by input CONTENT.
955 let mut name_version: HashMap<String, u64> = HashMap::new();
956 let mut seen_ids: HashMap<String, ()> = HashMap::new();
957
958 for descriptor in &request.features {
959 let id = extract_id(&descriptor.input_params);
960 // Editor stop-point: BEFORE this feature — the rest of the request is
961 // parse-only (their cache entries were already retained above).
962 if request.stop_before_id.as_deref() == Some(id.as_str()) {
963 break;
964 }
965 // An empty or duplicated id cannot key a cache entry: execute plainly,
966 // touching no cached state (a duplicate must not free the entry its
967 // first occurrence just wrote).
968 let cacheable = !id.is_empty() && seen_ids.insert(id.clone(), ()).is_none();
969 let fingerprint = descriptor_fingerprint(descriptor, &env);
970 let consumed = scan_consumed(descriptor, &available);
971 // Merkle input hash over this feature + its consumed producers' versions.
972 // Computed for EVERY feature (cacheable or not) so its produced names can
973 // publish a version for downstream consumers.
974 let output_version = feature_output_version(fingerprint, &consumed, &name_version);
975
976 let clean = cacheable
977 && HISTORY_CACHE.with(|cache| {
978 cache.borrow().get(&id).is_some_and(|entry| {
979 entry.fingerprint == fingerprint
980 && entry.consumed == consumed
981 && consumed.iter().all(|name| !changed.contains_key(name))
982 // The cross-run clause: reuse only if the effective input
983 // content is byte-identical to what this entry was built
984 // from. Subsumes the three clauses above (they are hashed
985 // in); kept additively so the diff stays minimal and the
986 // handle-ownership / blast-radius paths are untouched.
987 && entry.output_version == output_version
988 })
989 });
990
991 if clean {
992 let mut result =
993 HISTORY_CACHE.with(|cache| cache.borrow().get(&id).unwrap().result.clone());
994 result.reused = true;
995 scene.apply(&result);
996 for name in produced_names(&result) {
997 available.insert(name.clone(), ());
998 // A clean feature's current version equals its cached one; publish
999 // it so downstream consumers hash the SAME value they cached against.
1000 name_version.insert(name, output_version);
1001 }
1002 results.push(result);
1003 timings.push((id.clone(), 0.0)); // replayed, not re-executed
1004 // Editor stop-point: AFTER this feature.
1005 if request.stop_at_id.as_deref() == Some(id.as_str()) {
1006 break;
1007 }
1008 continue;
1009 }
1010
1011 // Dirty: the old entry (if any) dies — its blast radius joins `changed`.
1012 if cacheable {
1013 if let Some(old) = HISTORY_CACHE.with(|cache| cache.borrow_mut().remove(&id)) {
1014 free_entry(&old);
1015 changed.extend(old.touched.iter().map(|(name, _)| (name.clone(), ())));
1016 }
1017 }
1018
1019 let feat_start = web_time::Instant::now();
1020 let result = execute_feature(descriptor, &env, &scene);
1021 timings.push((id.clone(), feat_start.elapsed().as_secs_f64() * 1000.0));
1022 let halt = result.error.is_some();
1023 scene.apply(&result);
1024
1025 // Kernel-owned topology metadata: every face/edge this feature produced
1026 // records its producer (the `_seedSourceFeatureMetadata` step, moved here).
1027 // Non-overwriting — faces propagated through a boolean keep their
1028 // original sourceFeatureId, matching the established seed semantics.
1029 if !id.is_empty() {
1030 let mut seed = serde_json::Map::new();
1031 seed.insert("sourceFeatureId".into(), serde_json::Value::String(id.clone()));
1032 for added in &result.added {
1033 for (_, face_name) in &added.face_names {
1034 scene_metadata::merge_record(face_name, &seed, false);
1035 }
1036 for (_, edge_name) in &added.edge_names {
1037 scene_metadata::merge_record(edge_name, &seed, false);
1038 }
1039 }
1040 }
1041
1042 let produced = produced_names(&result);
1043 for name in &produced {
1044 available.insert(name.clone(), ());
1045 changed.insert(name.clone(), ());
1046 // Publish this run's version for the name. Runs for cacheable AND
1047 // non-cacheable (empty/duplicate id) features alike — a non-cacheable
1048 // feature has no cache entry but its outputs still version their
1049 // downstream consumers correctly.
1050 name_version.insert(name.clone(), output_version);
1051 }
1052 for name in &result.removed {
1053 changed.insert(name.clone(), ());
1054 }
1055
1056 if cacheable && !halt {
1057 let mut touched: HashMap<String, ()> =
1058 produced.into_iter().map(|name| (name, ())).collect();
1059 touched.extend(result.removed.iter().map(|name| (name.clone(), ())));
1060 HISTORY_CACHE.with(|cache| {
1061 cache.borrow_mut().insert(
1062 id.clone(),
1063 CachedFeature {
1064 fingerprint,
1065 result: result.clone(),
1066 consumed,
1067 touched,
1068 output_version,
1069 },
1070 );
1071 });
1072 }
1073
1074 results.push(result);
1075 if halt {
1076 break;
1077 }
1078 // Editor stop-point: AFTER this feature.
1079 if request.stop_at_id.as_deref() == Some(id.as_str()) {
1080 break;
1081 }
1082 }
1083 HistoryResult { results, timings }
1084}
1085
1086// ===========================================================================
1087// wasm boundary
1088// ===========================================================================
1089
1090/// Deserialize a history request, run it, and serialize the per-feature results.
1091/// The handles in the result are `u32`s the caller tessellates / pulls names
1092/// from via the existing `*_handle` wasm exports.
1093#[wasm_bindgen]
1094pub fn execute_history_json(request_json: &str) -> Result<String, JsValue> {
1095 let request: HistoryRequest = serde_json::from_str(request_json)
1096 .map_err(|error| JsValue::from_str(&format!("execute_history_json: bad request: {error}")))?;
1097 let result = execute_history(&request);
1098 serde_json::to_string(&result).map_err(|error| JsValue::from_str(&error.to_string()))
1099}
1100
1101#[cfg(test)]
1102mod tests;