Skip to main content

brep_kernel/feature_pipeline/
mod.rs

1//! The Rust feature-history execution engine — the load-bearing CONTRACT that
2//! per-feature migration agents fill in (migration-plan Stage 4/5 foundation).
3//!
4//! # The three contract rules (do not break these — the fan-out depends on them)
5//!
6//! 1. **Resolution happens inside the loop, by name.** [`execute_history`] keeps a
7//!    live [`SceneMap`] (`solid_name -> handle`, `face_name -> (handle, face_id)`,
8//!    `edge_name -> (handle, edge_id)`) and UPDATES it as each feature executes. A
9//!    feature resolves its `reference_selection` params against that map by
10//!    EXACT-MATCH (no scoring heuristic). A miss is a structured `unresolved`
11//!    entry in the [`FeatureResult`] (the caller repairs + re-dispatches later), never a
12//!    panic.
13//! 2. **Name fidelity is the gate.** Output face/edge/solid names must BYTE-MATCH
14//!    the established conventions (saved part files + every `reference_selection` store
15//!    these strings). Each feature replicates the established naming exactly.
16//! 3. **The descriptor IS the existing serialized `inputParams` shape** —
17//!    `{ type, inputParams, persistentData, timestamp }` (see `PartHistory`
18//!    `toSerializable`). `inputParams` is a JSON object; numeric params may be
19//!    literals OR expression strings (evaluated against the shared [`Env`]).
20//!
21//! # Handle / registry discipline
22//!
23//! The scene-map owns handles (`u32`). Each op does its OWN short
24//! `SOLID_REGISTRY` borrow internally (via the `crate::*_registered_solid*`
25//! helpers) and NEVER holds a borrow across a feature execution. The engine calls
26//! the underlying Rust fns directly (`crate::make_box_brep`, `crate::make_sphere_brep`,
27//! `crate::boolean_operation`, …), not the `*_json` wasm wrappers, so names live on
28//! the resident solids throughout. A leaked handle is a wasm32 OOM (4 GB ceiling),
29//! so removed scene-resident solids are freed and features free their own
30//! consumed intermediates.
31
32use serde::{Deserialize, Serialize};
33use std::collections::HashMap;
34use wasm_bindgen::prelude::*;
35
36use crate::{NurbsCurve, Vec3};
37
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;