Skip to main content

brep_kernel/feature_pipeline/
mod.rs

1//! Feature-history execution with exact name resolution and stable output names.
2//!
3//! [`execute_history`] updates its live [`SceneMap`] after every feature. Reference
4//! parameters resolve by exact name; misses produce structured `unresolved`
5//! entries in [`FeatureResult`]. Output names must preserve saved-document naming
6//! conventions because later features store those names as references.
7//!
8//! Feature descriptors retain `{type, inputParams, persistentData, timestamp}`.
9//! Numeric parameters accept literals or expressions evaluated against [`Env`].
10//!
11//! Registry borrows stay local to each operation and never span feature execution.
12//! Features call Rust operations directly, preserving names on resident solids.
13//! Removed scene solids and consumed intermediates must release their handles.
14
15pub mod extension;
16
17/// Reject known-invalid cylinder literals before a recipe mutation. Expressions
18/// remain evaluated by the feature, which applies the same radius rule.
19pub fn validate_literal_feature_params(feature_type: &str, params: &serde_json::Value) -> Result<(), String> {
20    if feature_type == "P.CY" {
21        if let Some(radius) = params.get("radius").and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.trim().parse::<f64>().ok()))) {
22            features::cylinder::validate_radius(radius)?;
23        }
24    }
25    Ok(())
26}
27
28
29use serde::{Deserialize, Serialize};
30use std::collections::HashMap;
31use wasm_bindgen::prelude::*;
32
33use crate::{KernelRefusal, NurbsCurve, Vec3};
34
35// The scene component concept (assemblies): component records + per-instance
36// entity-name namespacing at the component boundary. See component.rs.
37pub(crate) mod component;
38pub use component::ComponentRecord;
39// A family seed's table and its pure functions (rewrite, effective row,
40// fingerprints), shared by the CAD app and the PLM server. See family.rs.
41pub mod family;
42// The per-document parts library (assemblies): unique part payloads the ACOMP
43// instance feature references by name; kernel-resident, request-seeded,
44// GC'd at the end of every history run. See parts_library.rs.
45pub(crate) mod parts_library;
46pub use parts_library::{
47    add_part_to_library, add_part_to_library_impl, install_parts_library, missing_library_parts,
48    parts_library_json, parts_library_map, parts_library_revision, refresh_library_entry,
49    refresh_library_entry_impl, same_build, follow_saved_rows, follow_saved_rows_known, kernel_source_stamp,
50    PartsLibraryEntry, PartsLibraryMap, stable_json_hash,
51};
52// Assembly constraint state + lifecycle: the `assembly` history block, the
53// ten constraint schemas, the mate-mapping + solve tail, and the exported
54// constraint-mutation ABI. See assembly.rs.
55pub mod assembly;
56/// Wire-harness routing: the document's `wireHarness` block, the sided port
57/// graph, per-connection routes and the bundle solids — solved at the tail of
58/// every run like `assembly` (see `wire_harness.rs`).
59pub mod wire_harness;
60/// A part's symbol pins ARE its harness ports, bound by label (eCAD plan
61/// decision 8): the pin/port report, following an edit from one side to the
62/// other, and resolving a placed part's pin to its port id (see `part_pins.rs`).
63pub mod part_pins;
64pub mod pmi;
65pub mod ports;
66pub(crate) use features::import3d::imported_solid_names;
67pub use assembly::{AssemblyState, ConstraintEntry};
68mod expression;
69#[path = "features/mod.rs"]
70pub(crate) mod features;
71pub use features::common::{first_reference_name, reference_names};
72/// The NURBS Solid's cage resolver, which the engine's cage editor reads its
73/// handles through (`resolve_cage`, `primitive_cage`, `has_persisted_cage`).
74pub use features::nurbs_solid;
75pub(crate) use features::transform::bbox_center as transform_bbox_center;
76// Transform Face's default pivot over a replayed prefix: the value the app
77// stores into `pivot` when a face selection is committed, computed by the same
78// resolution and centre the feature falls back to for a null pivot.
79pub use features::transform_face::face_transform_pivot;
80/// A Pattern Component instance id (`APAT1.ACOMP3.i4`) as `(pattern, seed, n)`.
81pub fn pattern_component_instance(id: &str) -> Option<(String, String, i64)> {
82    features::assembly_pattern::parse_instance_id(id).map(|(p, s, n)| (p.to_string(), s.to_string(), n))
83}
84
85/// The part Pattern's `suppressed` instance numbers, as the feature reads them.
86pub fn pattern_parse_suppressed(value: Option<&serde_json::Value>) -> Vec<i64> {
87    features::pattern::parse_suppressed(value).into_iter().collect()
88}
89// The native IMPORT3D payload encoder: finished solids → an `io/snapshot`
90// payload carrying IMPORT3D's own names, ready to become an
91// `inputParams.nativeBrep` part document. Lives WITH the feature that reads it
92// back (one naming convention, one implementation); exported for the import
93// lanes that build part documents.
94pub use features::import3d::{native_import_payload, native_import_payload_with_appearance};
95pub(crate) mod scene_metadata;
96// The scene-metadata isolation bracket, for out-of-crate producers that encode a
97// payload against a LIVE document (the STEP-assembly import lane).
98pub use scene_metadata::IsolatedSceneMetadata;
99// The colour-only read of the name-keyed store: THREAD-LOCAL, so a caller whose
100// history runs off-thread must read it runner-side (`io/appearance.rs`).
101pub use scene_metadata::scene_metadata_colors_json;
102mod schema;
103// The kernel-owned feature-schema catalogue, reachable by NATIVE Rust consumers
104// (the engine-native egui UI in brep-app, via a brep-render re-export) — the same
105// definitions the wasm `feature_schemas_json` export serves the caller's feature registry.
106pub use schema::feature_schema_catalogue;
107pub use schema::feature_hidden_params;
108pub mod context_offer;
109// Selection-context applicability: the per-feature show/no-show predicates the
110// app's context bar runs against the current selection (schema.rs's sibling —
111// definitions live with each feature, this only aggregates).
112pub use context_offer::{feature_context_applicable, SelectionProbe};
113#[path = "sheet_metal/mod.rs"]
114mod sheet_metal;
115// Flat-pattern (unfold) → 2D vector export, for the native engine's export lane.
116// Keyed by resident handle so `SheetTree` stays private to the pipeline.
117pub use sheet_metal::{flat_pattern_dxf, flat_pattern_svg, is_sheet_metal_handle};
118
119pub use expression::Env;
120// The sketch loop-id write-back (the editor calls it on commit) — the
121// persistence half of the per-loop face-naming scheme. See
122// `features/sketch/loop_ids.rs`.
123pub use features::sketch::assign_sketch_loop_ids;
124pub use features::sketch::text::expand_text as expand_sketch_text;
125
126// ===========================================================================
127// Descriptor — the serialized `{ type, inputParams, persistentData, timestamp }`
128// ===========================================================================
129
130/// One feature as serialized by `PartHistory.toSerializable`. Permissive: unknown
131/// fields are ignored, so an entire saved part file's `features[]` deserializes
132/// as-is. `input_params` stays a `serde_json::Value` (an object) so features read
133/// their own params with their own type knowledge (which params are numeric).
134// `persistent_data` / `timestamp` are contract pass-throughs (round-tripped for
135// features that need them, e.g. hole/pattern persistent state); not read yet.
136#[allow(dead_code)]
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct FeatureDescriptor {
139    #[serde(rename = "type", default)]
140    pub feature_type: String,
141    #[serde(rename = "inputParams", default)]
142    pub input_params: serde_json::Value,
143    #[serde(rename = "persistentData", default)]
144    pub persistent_data: serde_json::Value,
145    /// Opaque pass-through (any shape) so one odd saved file cannot fail the parse.
146    #[serde(default)]
147    pub timestamp: Option<serde_json::Value>,
148}
149
150/// The `execute_history_json` request: the expression source, the configurator
151/// state, and the ordered feature list. Permissive — a whole saved part file
152/// parses as a request (extra top-level fields ignored).
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct HistoryRequest {
155    #[serde(default, skip_serializing_if = "Vec::is_empty")]
156    pub plugins: Vec<extension::PluginPin>,
157    #[serde(default, deserialize_with = "de_string_lenient")]
158    pub expressions: String,
159    #[serde(default)]
160    pub configurator: serde_json::Value,
161    #[serde(default)]
162    pub features: Vec<FeatureDescriptor>,
163    /// One-shot execution directive: rebuild these features even if their inputs
164    /// match the cache (for example, when a dimension drag is finalized). Their
165    /// consumers follow the usual dirty wavefront. Not part of the saved model.
166    #[serde(default, rename = "forceRebuild", skip_serializing_if = "Vec::is_empty")]
167    pub force_rebuild: Vec<String>,
168    /// Stop AFTER executing the feature with this id (the editor's "stop at the
169    /// expanded feature"). The features past the stop stay in the request so the
170    /// incremental cache RETAINS their entries — expanding/collapsing a panel
171    /// must not thrash the cache (marshal-side truncation would free them).
172    #[serde(default, rename = "stopAtId")]
173    pub stop_at_id: Option<String>,
174    /// Stop BEFORE executing the feature with this id.
175    #[serde(default, rename = "stopBeforeId")]
176    pub stop_before_id: Option<String>,
177    /// The assembly constraint block: `{ constraints, idCounter }`. Absent on
178    /// every non-assembly document (and omitted on re-serialize, so
179    /// componentless files persist byte-identically). Solved at the tail of
180    /// every run; see `assembly.rs`.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub assembly: Option<assembly::AssemblyState>,
183    /// The wire-harness block: `{ connections, idCounter, buildBundles }`.
184    /// Absent on every document without harness connections (and omitted on
185    /// re-serialize). Routed at the tail of every run; see `wire_harness.rs`.
186    #[serde(default, rename = "wireHarness", skip_serializing_if = "Option::is_none")]
187    pub wire_harness: Option<wire_harness::WireHarnessState>,
188    /// The PMI block: `{ views: [{ id, name, camera, display, annotations }],
189    /// idCounter }`. Absent on every document without PMI (and omitted on
190    /// re-serialize). Resolved at the tail of every run; see `pmi.rs`.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub pmi: Option<pmi::PmiState>,
193    /// Render level-of-detail factor for DISPLAY tessellation (the app's "LOD
194    /// factor" setting). `execute_history` ignores it — it rides on the request
195    /// only because the request is the serialized run boundary the display runner
196    /// receives, and the runner scales its per-solid chord tolerance by it
197    /// (higher = coarser mesh). Defaults to `1.0` (the "Normal" preset) so a saved
198    /// file / seed request with no `displayLod` tessellates exactly as before.
199    #[serde(default = "default_display_lod", rename = "displayLod")]
200    pub display_lod: f64,
201    /// The parts library block: unique part payloads the ACOMP instance
202    /// features reference by name. Ingested into the kernel-resident library
203    /// at the start of every run (this is how a LOADED document seeds it);
204    /// SAVE serializes `parts_library_json()` back out — never this loaded
205    /// block (see parts_library.rs).
206    #[serde(default, rename = "partsLibrary")]
207    pub parts_library: parts_library::PartsLibraryMap,
208    /// The declared-ports block: the part's connection points as DATA, a
209    /// sibling of `features` (see `ports.rs`). Absent on every document that
210    /// declares none (and omitted on re-serialize). Resolved at the tail of
211    /// every run, against the finished scene.
212    #[serde(default, skip_serializing_if = "Vec::is_empty")]
213    pub ports: Vec<ports::PortDeclaration>,
214    /// The EXPLICIT encapsulation mark: `true` hides this document's children
215    /// behind its declared ports, `false` keeps them reachable. Absent means
216    /// derive it — see [`ports::document_is_boundary`].
217    #[serde(default, rename = "portBoundary", skip_serializing_if = "Option::is_none")]
218    pub port_boundary: Option<bool>,
219    /// Whether the document carries a `pcb` block, which makes it a board and
220    /// therefore a port boundary by construction. Presence ONLY: the block
221    /// itself is eCAD's and the kernel neither reads nor rewrites it, so this
222    /// never serializes back out.
223    #[serde(
224        default,
225        rename = "pcb",
226        deserialize_with = "de_present",
227        skip_serializing
228    )]
229    pub pcb_present: bool,
230}
231
232/// Deserialize any value as "it was there" - see [`HistoryRequest::pcb_present`].
233fn de_present<'de, D>(deserializer: D) -> Result<bool, D::Error>
234where
235    D: serde::Deserializer<'de>,
236{
237    serde::Deserialize::deserialize(deserializer).map(|_: serde::de::IgnoredAny| true)
238}
239
240/// The "Normal" render LOD — the [`HistoryRequest::display_lod`] serde default.
241fn default_display_lod() -> f64 {
242    1.0
243}
244
245/// Accept `null`/missing as `""` for a string field (saved files sometimes store
246/// `expressions: null`).
247fn de_string_lenient<'de, D>(deserializer: D) -> Result<String, D::Error>
248where
249    D: serde::Deserializer<'de>,
250{
251    let value = Option::<String>::deserialize(deserializer)?;
252    Ok(value.unwrap_or_default())
253}
254
255// ===========================================================================
256// Result — the per-feature output contract the caller consumes
257// ===========================================================================
258
259/// A solid produced by a feature: its resident handle, its solid name, and its
260/// named faces/edges (`(topology_id, name)`). the caller tessellates/pulls names via the
261/// handle; the scene-map registers the names for downstream reference resolution.
262#[derive(Debug, Clone, Default, Serialize)]
263pub struct AddedSolid {
264    pub handle: u32,
265    pub name: String,
266    pub face_names: Vec<(u64, String)>,
267    /// Edge names propagated/authored on the solid. Present so edge-selecting
268    /// features (fillet/chamfer) can resolve "the edge of the extrude above"
269    /// against the scene-map — the pinned example in contract rule 1.
270    pub edge_names: Vec<(u64, String)>,
271    /// CONTAINER groups: `(container_name, member face NAMES)`. A profile-swept
272    /// feature registers the un-keyed name of each of its per-loop face families
273    /// here (`{cap_base}_START` -> every per-loop START cap), so a reference
274    /// stored before per-loop naming still resolves. See [`SceneMap::face_groups`].
275    ///
276    /// Members are NAMES, not `(handle, id)` refs, and are resolved at lookup
277    /// time. Name fidelity carries a face through a later boolean or dressup, so
278    /// a group registered by the extrude keeps naming the right faces after the
279    /// solid is consumed and re-registered by something downstream — which a
280    /// captured handle would not survive.
281    #[serde(default)]
282    pub face_groups: Vec<(String, Vec<String>)>,
283    /// The same, for the derived edge names that embed those face names.
284    #[serde(default)]
285    pub edge_groups: Vec<(String, Vec<String>)>,
286}
287
288/// The output of one feature execution.
289/// A named world point a feature published (a SKETCH publishes every solved
290/// point as `{sketchId}:P{pid}`; a spline its anchors, a helix its ends).
291/// `construction` carries the sketch point's own flag: a construction point (the
292/// sketcher's ◐ toggle, or a materialized external reference) still resolves by
293/// name — a helix may start at one — but it is NOT model geometry, so a placement
294/// consumer (the hole) skips it exactly as the profile skips construction lines.
295/// Serialized because the render-side build report carries it across the worker
296/// boundary.
297#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
298pub struct ScenePoint {
299    pub position: Vec3,
300    pub construction: bool,
301}
302
303impl ScenePoint {
304    /// A model point (the default for every non-sketch publisher).
305    pub fn model(position: Vec3) -> Self {
306        Self { position, construction: false }
307    }
308}
309
310#[derive(Debug, Clone, Serialize)]
311pub struct FeatureResult {
312    #[serde(default, rename = "persistentData", skip_serializing_if = "Option::is_none")]
313    pub persistent_data: Option<serde_json::Value>,
314    pub id: String,
315    pub feature_type: String,
316    pub added: Vec<AddedSolid>,
317    /// Names of prior solids this feature consumed/superseded (removed from the
318    /// scene-map; their handles are freed by the loop).
319    pub removed: Vec<String>,
320    /// Set on a hard failure. `execute_history` records it and HALTS the
321    /// remaining features (mirrors the abort-on-error loop). Never a panic.
322    pub error: Option<String>,
323    /// The TYPED refusal behind `error`, when the producer that refused minted
324    /// one (`KernelRefusal`: a closed class, the stage, the same text). `error`
325    /// is always that refusal's message verbatim, so a consumer that only
326    /// reads text sees nothing new; one that dispatches on the class reads it
327    /// here. `None` on success and on a refusal from a producer that still
328    /// returns text.
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub refusal: Option<KernelRefusal>,
331    /// What a SUCCESSFUL feature did with what it was asked, when that is not
332    /// "everything, exactly": the references it applied and the ones it
333    /// rejected, each with why. `None` on a refusal and on a feature that did
334    /// everything it was asked. A feature that proceeds on the subset of its
335    /// selection that resolved says so HERE, typed, instead of only in
336    /// `unresolved` (which stays populated for the rename hint). This is a
337    /// success stating what it did, not a refusal carrying a partial result —
338    /// the payload the 2026-09-11 survey rejected.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub fulfilment: Option<Fulfilment>,
341    /// Which named motion STEP of a feature refused: Transform Face records
342    /// `"rotation"` or `"translation"` (`features::transform_face::REFUSED_STEP_*`)
343    /// for a refusal from either motion, alone or in rotate-then-translate.
344    /// `None` for every other result, including a Transform Face refused before
345    /// any motion ran. The refusal's class is the step's own; this says which
346    /// step it was, which no class can. Additive: absent when `None`.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub refused_step: Option<String>,
349    /// The solid a no-target boolean ran into: the part's ONLY solid, which a
350    /// feature whose `boolean.operation` is UNION/SUBTRACT/INTERSECT with no
351    /// `targets` uses as if the user had picked it (owner 2026-11-07;
352    /// `features::common::implicit_boolean_target`). Decided at every
353    /// evaluation and never written into the document, so the document stays
354    /// "auto" and follows a rename. `None` for every result that booleaned an
355    /// explicit target, or none. The feature panel shows it as `Auto: <name>`.
356    /// Additive: absent when `None`.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub boolean_auto_target: Option<String>,
359    /// `reference_selection` names this feature could not resolve against the
360    /// scene-map. NOT an error/halt — the caller runs a snapshot-repair pass and
361    /// re-dispatches (migration-plan Stage 5 gate).
362    pub unresolved: Vec<String>,
363    /// What the kernel REPAIRED while building this feature, in the words of the
364    /// repair that made it. A repair changes the answer the lane returns — the
365    /// self-crossing fixer drops material and re-trims its neighbours — so it is
366    /// never silent: the feature that ran it says so here, and the case gate
367    /// records it per case. Empty on every feature that repaired nothing, which
368    /// is nearly all of them.
369    #[serde(default, skip_serializing_if = "Vec::is_empty")]
370    pub notes: Vec<String>,
371    /// MEASURED approximations a SUCCESSFUL result carries
372    /// ([`crate::Approximation`]): the result stands, and each entry says by
373    /// how much and why it is not exact — today, an imported body whose shell
374    /// closes over the pcurve fit bar (`import.shell_closure`), with the bound
375    /// that puts on its volume. Distinct from `notes` (a REPAIR changed the
376    /// answer), from `fulfilment` (what was done of what was asked) and from
377    /// `refusal` (the result does not stand). The case runner records them per
378    /// feature and will not read a volume oracle as `correct` inside the bound.
379    /// Empty on nearly every feature.
380    #[serde(default, skip_serializing_if = "Vec::is_empty")]
381    pub approximations: Vec<crate::Approximation>,
382    /// True when this result was REPLAYED from the incremental history cache (the
383    /// feature and everything it references are unchanged since the last run).
384    /// The handles are the same resident solids — the caller can skip re-tessellation.
385    pub reused: bool,
386    /// Sketch profiles this feature produced (a SKETCH feature emits one under
387    /// its name; solid features emit none). INTERNAL side-channel: `#[serde(skip)]`
388    /// so it never crosses the wasm boundary to the caller — `SceneMap::apply` ingests it
389    /// into `scene.profiles` for downstream profile-consumers (extrude/revolve/…).
390    #[serde(skip)]
391    pub profiles: Vec<(String, SketchProfile)>,
392    /// Named plane frames this feature produced (DATUM emits three, PLANE one).
393    /// Same INTERNAL `#[serde(skip)]` side-channel — ingested into `scene.frames`
394    /// so a later SKETCH can resolve its plane by name, fully headless.
395    #[serde(skip)]
396    pub frames: Vec<(String, Frame)>,
397    /// Named axis lines this feature produced (a SKETCH emits one per line
398    /// geometry). Same INTERNAL `#[serde(skip)]` side-channel — ingested into
399    /// `scene.axes` so a later revolve/sweep can resolve its axis by name.
400    #[serde(skip)]
401    pub axes: Vec<(String, Axis)>,
402    /// Named path chains this feature produced (a SKETCH publishes its ordered
403    /// open/closed geometry chain). Same INTERNAL `#[serde(skip)]` side-channel —
404    /// ingested into `scene.paths` for sweep/path_sweep/rib trajectory resolution.
405    #[serde(skip)]
406    pub paths: Vec<(String, Vec<NurbsCurve>)>,
407    /// The SOURCE NAME of each curve in a published path chain, parallel to the
408    /// matching [`Self::paths`] entry (`{sketchId}:G{gid}` for a sketch segment,
409    /// `None` for a curve with no name behind it). Same INTERNAL `#[serde(skip)]`
410    /// side-channel — ingested into `scene.path_segment_names` so a multi-segment
411    /// consumer (the SWEEP) can name the geometry each PATH SEGMENT built after
412    /// that segment, instead of after its position in the chain.
413    #[serde(skip)]
414    pub path_segment_names: Vec<(String, Vec<Option<String>>)>,
415    /// The SCREW AXIS of each curve in a published path chain, parallel to the
416    /// matching [`Self::paths`] entry: `Some` for a curve its builder KNOWS is a
417    /// helix about that axis (the HELIX feature), `None` for everything else.
418    /// Same INTERNAL `#[serde(skip)]` side-channel — ingested into
419    /// `scene.path_segment_axes` so the SWEEP's `pathAlign` can carry a profile by
420    /// the helix's own screw motion rather than by a rotation-minimizing frame,
421    /// which rolls away from it by the helix's integrated torsion.
422    #[serde(skip)]
423    pub path_segment_axes: Vec<(String, Vec<Option<Axis>>)>,
424    /// Named world points this feature produced (a SKETCH publishes every point
425    /// as `{sketchId}:P{pid}`, construction flag included — see [`ScenePoint`]).
426    /// Same side-channel — ingested into `scene.points` for hole-center /
427    /// placement resolution and drawn by the committed-sketch display.
428    #[serde(skip)]
429    pub points: Vec<(String, ScenePoint)>,
430    /// Harness ports this feature produced (a PORT feature emits one under its
431    /// id). Same INTERNAL `#[serde(skip)]` side-channel — ingested into
432    /// `scene.ports` for spline attachments and the wire-harness tail.
433    #[serde(skip)]
434    pub ports: Vec<(String, PortRecord)>,
435    /// Component records this feature produced (an ACOMP feature emits one per
436    /// instance; its members ride `added` like any solids). Same INTERNAL
437    /// `#[serde(skip)]` side-channel — ingested into `scene.components` so the
438    /// component surface (`component.rs`) resolves against the live scene.
439    #[serde(skip)]
440    pub components: Vec<ComponentRecord>,
441    /// Former solid names this feature's result still answers to, as
442    /// `(legacy, canonical)`: [`SceneMap::resolve_solid`] reads `legacy` as
443    /// the resident solid named `canonical`. An Offset Shell that replaces
444    /// its source keeps the source's name (since 2026-11-07); a
445    /// document saved before that names the shell `{target}_{featureId}` in
446    /// later features, and the alias keeps every such reference resolving.
447    /// Same INTERNAL `#[serde(skip)]` side-channel; a produced name, so it
448    /// versions like one for the history cache.
449    #[serde(skip)]
450    pub solid_aliases: Vec<(String, String)>,
451}
452
453impl FeatureResult {
454    /// An empty successful result (no solids, no error) — the shape a non-solid
455    /// pass-through feature returns.
456    pub fn empty(id: impl Into<String>, feature_type: impl Into<String>) -> Self {
457        Self {
458            persistent_data: None,
459            id: id.into(),
460            feature_type: feature_type.into(),
461            added: Vec::new(),
462            removed: Vec::new(),
463            error: None,
464            refusal: None,
465            fulfilment: None,
466            refused_step: None,
467            boolean_auto_target: None,
468            unresolved: Vec::new(),
469            notes: Vec::new(),
470            approximations: Vec::new(),
471            reused: false,
472            profiles: Vec::new(),
473            frames: Vec::new(),
474            axes: Vec::new(),
475            paths: Vec::new(),
476            path_segment_names: Vec::new(),
477            path_segment_axes: Vec::new(),
478            points: Vec::new(),
479            ports: Vec::new(),
480            components: Vec::new(),
481            solid_aliases: Vec::new(),
482        }
483    }
484
485    /// A hard failure. Halts the history loop.
486    pub fn error(
487        id: impl Into<String>,
488        feature_type: impl Into<String>,
489        message: impl Into<String>,
490    ) -> Self {
491        Self::refuse(id, feature_type, message.into())
492    }
493
494    /// The feature proceeded on the references that resolved and skipped the
495    /// ones in `unresolved`: record that as a typed partial fulfilment.
496    /// `requested` is the selection as the document names it; `applied` is
497    /// what remains after the misses. Nothing is recorded when every reference
498    /// resolved. Call it at the point the feature DECIDES to proceed, not at
499    /// resolution: a feature that no-ops or refuses on a miss has no partial.
500    pub fn note_partial_resolution(&mut self, requested: &[String]) {
501        if self.unresolved.is_empty() {
502            return;
503        }
504        let applied: Vec<String> = requested
505            .iter()
506            .filter(|name| !self.unresolved.contains(name))
507            .cloned()
508            .collect();
509        let rejected = self
510            .unresolved
511            .iter()
512            .map(|name| Rejected {
513                name: name.clone(),
514                kind: RejectedKind::Unresolved,
515                reason: "resolves to nothing on this model".into(),
516            })
517            .collect();
518        self.fulfilment = Some(Fulfilment {
519            requested: requested.to_vec(),
520            applied,
521            rejected,
522            extended: Vec::new(),
523            followed: Vec::new(),
524        });
525    }
526
527    /// A hard failure from either kind of producer: `error` carries the text
528    /// (the refusal's own message, verbatim, when it is typed) and `refusal`
529    /// the class and stage when there is one.
530    pub fn refuse(
531        id: impl Into<String>,
532        feature_type: impl Into<String>,
533        refusal: impl Into<FeatureRefusal>,
534    ) -> Self {
535        let mut result = Self::empty(id, feature_type);
536        match refusal.into() {
537            FeatureRefusal::Text(message) => result.error = Some(message),
538            FeatureRefusal::Typed(refusal) => {
539                result.error = Some(refusal.message.clone());
540                result.refusal = Some(refusal);
541            }
542        }
543        result
544    }
545
546    /// A non-solid construction feature (datum/plane/sketch/spline/port/helix):
547    /// a descriptor pass-through with no resident handle. The loop continues.
548    pub fn pass_through(id: impl Into<String>, feature_type: impl Into<String>) -> Self {
549        Self::empty(id, feature_type)
550    }
551
552    /// A solid-producing (or no-kernel) feature not yet ported to Rust. Halts the
553    /// loop with a clear message — replaced when a fan-out agent implements the
554    /// feature's `features/<feat>.rs`.
555    pub fn not_yet_migrated(id: impl Into<String>, feature_type: impl Into<String>) -> Self {
556        let feature_type = feature_type.into();
557        let message = format!("feature type '{feature_type}' is not yet migrated to the Rust pipeline");
558        Self::error(id, feature_type, message)
559    }
560}
561
562/// The whole-history output.
563#[derive(Debug, Clone, Serialize)]
564pub struct HistoryResult {
565    pub results: Vec<FeatureResult>,
566    /// Per-feature wall-clock execution time `(feature id, milliseconds)` in run
567    /// order — the lightweight timing surface the engine-native history tree's
568    /// "N ms" readout consumes. A REUSED (cache-replayed) feature records `0.0`
569    /// (its geometry was not re-executed this run). INTERNAL side-channel:
570    /// `#[serde(skip)]` so it never changes the caller-facing `execute_history_json`
571    /// shape; brep-render reads it natively via the re-exported struct.
572    #[serde(skip)]
573    pub timings: Vec<(String, f64)>,
574    /// The wire-harness tail's routing report (`None` when the request carries
575    /// no `wireHarness` block). INTERNAL side-channel like `timings`: never on
576    /// the JSON ABI; brep-render ships it to the harness panel.
577    #[serde(skip)]
578    pub wire_harness: Option<wire_harness::WireHarnessReport>,
579    /// The PMI tail's resolution of every view's annotations (`None` when the
580    /// request carries no `pmi` block). INTERNAL side-channel like `timings`.
581    #[serde(skip)]
582    pub pmi: Option<pmi::PmiReport>,
583    /// The ports tail's resolution of the declared-ports block (`None` when the
584    /// document declares none). INTERNAL side-channel like `timings`: the tail
585    /// is not a feature, so this is where its unresolved references and refused
586    /// names surface.
587    #[serde(skip)]
588    pub ports: Option<ports::PortsReport>,
589}
590
591// ===========================================================================
592// SceneMap — the live name -> handle/topology index maintained across the loop
593// ===========================================================================
594
595// `handle` + `face_id`/`edge_id` are the contract the caller (and edge-selecting
596// fan-out features) read to resolve a selection to a resident topology entity.
597#[allow(dead_code)]
598#[derive(Debug, Clone, Copy)]
599pub struct FaceRef {
600    pub handle: u32,
601    pub face_id: u64,
602}
603
604#[allow(dead_code)]
605#[derive(Debug, Clone, Copy)]
606pub struct EdgeRef {
607    pub handle: u32,
608    pub edge_id: u64,
609}
610
611/// A solved sketch's extracted profile, placed in 3D. Produced by the SKETCH
612/// pipeline feature and consumed BY NAME by the profile-consumers (extrude,
613/// revolve, loft, sweep, rib, sheet-metal tab/contour-flange) — the exact-BREP
614/// replacement for the old "marshal the resolved curves into `inputParams`".
615///
616/// The closed loops are grouped into disjoint REGIONS by even-odd containment
617/// depth: a depth-EVEN loop is a region's OUTER boundary, a depth-ODD loop is a
618/// hole of the region whose outer directly contains it (so an island inside a
619/// hole is its own region). Each region is `[outer, holes…]`; consumers build
620/// one solid per region and UNION them — the established multi-region behavior.
621/// The plane frame (`origin` + orthonormal `x_axis`/`y_axis`/`z_axis`) is resolved
622/// LIVE in Rust from the sketch's plane reference (DATUM/PLANE feature frame or a
623/// resident face), falling back to the persisted `basis`; the loop curves are
624/// emitted in WORLD space via that frame, so `z_axis` is the natural extrude
625/// direction / a revolve-plane hint.
626#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
627pub struct SketchProfile {
628    pub origin: Vec3,
629    pub x_axis: Vec3,
630    pub y_axis: Vec3,
631    pub z_axis: Vec3,
632    /// Disjoint regions, each `[outer, holes…]` (containment-classified).
633    pub regions: Vec<Vec<ProfileLoop>>,
634}
635
636/// One closed boundary of a [`SketchProfile`]: head-to-tail world-space curves plus
637/// the per-curve source edge name (`{sketchId}:G{gid}`, parallel to `curves`). The
638/// profile-consumers stamp sidewall names from `edge_names` (extrude → `{name}_E`).
639#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
640pub struct ProfileLoop {
641    pub curves: Vec<NurbsCurve>,
642    pub edge_names: Vec<Option<String>>,
643    /// The loop's STABLE identity within its source sketch (see the sketch
644    /// feature's `loop_ids`), persisted per edge in `persistentData.sketch` and
645    /// resilient to adding/removing edges. The profile-swept features embed it in
646    /// their per-loop cap and hole-wall names, so those names no longer move when
647    /// a DIFFERENT loop is added or removed. `None` for a loop with no numeric
648    /// geometry id behind it (a FACE profile, or a hand-built test profile) —
649    /// [`ProfileLoop::key`] then falls back to the loop's own edge names.
650    #[serde(default)]
651    pub loop_id: Option<u64>,
652}
653
654impl ProfileLoop {
655    /// This loop's naming KEY — the token the profile-swept features embed in the
656    /// faces they build from it (`{cap_base}:{key}_START`, `{id}:HOLE:{key}`).
657    ///
658    /// `L{loop_id}` for a sketch loop. `None` when the loop has no identity to key
659    /// on, and the caller keeps its previous un-keyed spelling: a FACE profile
660    /// (always a single region — nothing to disambiguate, and its faces keep the
661    /// names every saved model already stores) or a hand-built profile with no
662    /// numeric geometry ids behind it.
663    pub fn key(&self) -> Option<String> {
664        self.loop_id.map(|id| format!("L{id}"))
665    }
666}
667
668/// Annotate an UNRESOLVED reference with the name that replaced it, when the miss
669/// looks like the per-loop naming change.
670///
671/// Per-loop cap/hole naming keyed names that used to be positional
672/// (`{cap_base}_START` -> `{cap_base}:L4_START`, `{id}:HOLE:1` -> `{id}:HOLE:L9`).
673/// Live models mostly do not need this — the CONTAINER groups keep the old
674/// spelling resolvable — but a reference the containers cannot cover (a positional
675/// `X[1]` disambiguation from before the change, or a `{id}:HOLE:{n}` written
676/// against a since-edited sketch) still misses, and a bare "unresolved" says
677/// nothing about why. This turns the miss into the fix:
678///
679/// ```text
680/// E4:S3:PROFILE_START[1] (renamed: E4:S3:PROFILE:L9_START)
681/// ```
682///
683/// The candidates come from the live scene, so the hint is what the model
684/// ACTUALLY built, not a guess.
685pub fn annotate_rename(scene: &SceneMap, name: &str) -> String {
686    let candidates = rename_candidates(scene, name);
687    if candidates.is_empty() {
688        return name.to_string();
689    }
690    format!("{name} (renamed: {})", candidates.join(", "))
691}
692
693/// The keyed names that plausibly replaced `name`. A stale reference differs from
694/// its replacement only by the inserted loop key, so a candidate is any live face
695/// name that matches once the key is accounted for — `{stem}:{key}_START` for a
696/// cap, `{id}:HOLE:{key}` for a hole wall. A trailing positional `[n]` (the old
697/// duplicate-name disambiguation, the very thing this change removes) is stripped
698/// before matching.
699fn rename_candidates(scene: &SceneMap, name: &str) -> Vec<String> {
700    let stale = name.rsplit_once('[').map_or(name, |(head, tail)| {
701        if tail.ends_with(']') && tail[..tail.len() - 1].chars().all(|c| c.is_ascii_digit()) {
702            head
703        } else {
704            name
705        }
706    });
707    // Cap: `{stem}_START` / `{stem}_END` -> `{stem}:{key}_START` / `_END`.
708    let cap = ["_START", "_END"]
709        .into_iter()
710        .find_map(|suffix| stale.strip_suffix(suffix).map(|stem| (stem.to_string(), suffix)));
711    // Hole wall: `{id}:HOLE:{anything}` -> `{id}:HOLE:{key}`.
712    let hole = stale
713        .rsplit_once(":HOLE:")
714        .map(|(owner, _)| format!("{owner}:HOLE:"));
715
716    let mut candidates: Vec<String> = scene
717        .faces
718        .keys()
719        .filter(|live| {
720            if let Some((stem, suffix)) = &cap {
721                if let Some(rest) = live.strip_prefix(stem.as_str()) {
722                    // Exactly `:{key}` was inserted before the suffix.
723                    return rest.starts_with(':')
724                        && rest.ends_with(suffix)
725                        && rest.len() > suffix.len() + 1
726                        && !rest[1..rest.len() - suffix.len()].contains(':');
727                }
728            }
729            if let Some(prefix) = &hole {
730                return live.starts_with(prefix.as_str()) && live.as_str() != stale;
731            }
732            false
733        })
734        .cloned()
735        .collect();
736    candidates.sort();
737    candidates
738}
739
740/// An orthonormal placement frame — a named plane (DATUM/PLANE feature) or a
741/// resolved sketch plane. `z_axis` is the plane normal.
742#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
743pub struct Frame {
744    pub origin: Vec3,
745    pub x_axis: Vec3,
746    pub y_axis: Vec3,
747    pub z_axis: Vec3,
748}
749
750impl Frame {
751    /// Derive an orthonormal frame from an origin + plane normal via the worldUp
752    /// convention, the
753    /// SINGLE source of truth for how a sketch/plane reference becomes in-plane
754    /// axes: `refUp = |n·(0,1,0)| > 0.9 ? (1,0,0) : (0,1,0)`;
755    /// `x = norm(refUp × n)`; `y = norm(n × x)`; `z = n`. The referenced object's
756    /// own x/y are intentionally ignored — only its (origin, normal) matter.
757    pub fn from_origin_normal(origin: Vec3, normal: Vec3) -> Result<Self, String> {
758        let z_axis = normal.normalized()?;
759        let world_up = Vec3::new(0.0, 1.0, 0.0);
760        let ref_up = if z_axis.dot(world_up).abs() > 0.9 {
761            Vec3::new(1.0, 0.0, 0.0)
762        } else {
763            world_up
764        };
765        let x_axis = ref_up.cross(z_axis).normalized()?;
766        let y_axis = z_axis.cross(x_axis).normalized()?;
767        Ok(Frame {
768            origin,
769            x_axis,
770            y_axis,
771            z_axis,
772        })
773    }
774
775    /// Derive an orthonormal frame whose **x_axis** is `direction` — the frame a
776    /// connection point's placement is an offset in (its seat, `ports::seat_of`).
777    ///
778    /// A connection point's direction is its rotation applied to **+X** (the
779    /// convention every feature transform reads), so the frame that seats one
780    /// must carry the direction on X, not on Z. Rather than invent a second
781    /// worldUp rule this is the CYCLIC RELABEL of [`Self::from_origin_normal`]'s
782    /// triad — x = z', y = x', z = y' — which is still right-handed and keeps
783    /// ONE convention for how a direction becomes a basis. A sketch on the
784    /// point's published frame and the point's own offset axes therefore agree
785    /// by construction: they are the same three vectors, named round.
786    pub fn from_origin_direction(origin: Vec3, direction: Vec3) -> Result<Self, String> {
787        let normal = Self::from_origin_normal(origin, direction)?;
788        Ok(Frame {
789            origin,
790            x_axis: normal.z_axis,
791            y_axis: normal.x_axis,
792            z_axis: normal.y_axis,
793        })
794    }
795
796    /// Map a plane-local `(u, v)` into world space: `origin + u·x + v·y`.
797    pub fn to_3d(&self, u: f64, v: f64) -> Vec3 {
798        self.origin
799            .add(self.x_axis.scale(u))
800            .add(self.y_axis.scale(v))
801    }
802}
803
804/// A named world-space line — a revolve/sweep axis. Published by SKETCH features
805/// (every sketch line geometry, construction included) and resolvable from a
806/// resident solid edge, so an `axis`/path reference resolves fully headless.
807#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
808pub struct Axis {
809    pub point: Vec3,
810    pub direction: Vec3,
811}
812
813/// What a connection point is to the harness router: a cable END, or a
814/// pass-through that joins spline paths into a network. Not a user param any
815/// more - it is decided by WHICH producer made the record (a declared port
816/// point is a termination, a WAYPOINT feature is a waypoint).
817#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
818#[serde(rename_all = "lowercase")]
819pub enum PortKind {
820    Termination,
821    Waypoint,
822}
823
824/// One resolved connection point, keyed in [`SceneMap::ports`] by its ADDRESS
825/// (`J1.VCC`, `ACOMP3:J1.VCC` once placed - see `ports.rs`). Published by the
826/// ports tail, by the WAYPOINT feature (under its own feature id) and by an
827/// ACOMP re-publishing a placed part's points.
828///
829/// `direction` is the unit vector a wire leaves along on side **A**; side **B**
830/// is its negation. `extension` is the straight run a wire keeps from `point`
831/// before it may bend (a spline anchor attached here takes it as its
832/// forward/backward distance) and `display_length` the length of the drawn
833/// line.
834///
835/// `port_name` / `point_name` / `purpose` stay PART-LOCAL through every
836/// namespacing: a placed part's point is still point `VCC` of port `J1`
837/// whatever occurrence chain its address carries. That is what a pin binds to.
838/// A WAYPOINT carries an empty `port_name` and `purpose`, and its own name as
839/// `point_name`.
840#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
841pub struct PortRecord {
842    pub point: Vec3,
843    pub direction: Vec3,
844    pub kind: PortKind,
845    pub extension: f64,
846    pub display_length: f64,
847    #[serde(default, rename = "portName")]
848    pub port_name: String,
849    #[serde(default, rename = "pointName")]
850    pub point_name: String,
851    #[serde(default)]
852    pub purpose: String,
853}
854
855impl PortRecord {
856    /// The PART-LOCAL address of this point (`J1.VCC`), or just the name for a
857    /// WAYPOINT, which belongs to no port. The occurrence chain is the map key's
858    /// business, not the record's.
859    pub fn local_address(&self) -> String {
860        if self.port_name.is_empty() {
861            self.point_name.clone()
862        } else {
863            ports::address(&self.port_name, &self.point_name)
864        }
865    }
866
867    /// The direction a wire leaves this port along on `side` (`A` = the port
868    /// direction, `B` = its negation).
869    pub fn side_direction(&self, side: wire_harness::PortSide) -> Vec3 {
870        match side {
871            wire_harness::PortSide::A => self.direction,
872            wire_harness::PortSide::B => self.direction.scale(-1.0),
873        }
874    }
875}
876
877/// The live scene-map. Exact-match resolution only (contract rule 1).
878#[derive(Debug, Clone, Default)]
879pub struct SceneMap {
880    pub solids: HashMap<String, u32>,
881    pub faces: HashMap<String, FaceRef>,
882    pub edges: HashMap<String, EdgeRef>,
883    /// CONTAINER name -> the faces it stands for. A profile-swept feature names
884    /// each of its per-loop caps individually (`{cap_base}:L{loopId}_START`) and
885    /// registers the un-keyed `{cap_base}_START` here as the name of the whole
886    /// sketch container — every cap the feature made. So the name a model saved
887    /// before per-loop naming still resolves, and still means the right thing:
888    /// the one cap on a single-loop sketch, all of them on a multi-loop one.
889    pub face_groups: HashMap<String, Vec<String>>,
890    /// CONTAINER name -> the edges it stands for. The derived edge-name
891    /// convention embeds face names (`{faceA}|{faceB}[n]`), so keying the caps
892    /// renamed every cap-adjacent edge too; this holds the container-name
893    /// spelling of each such edge for the same reason [`Self::face_groups`] does.
894    pub edge_groups: HashMap<String, Vec<String>>,
895    /// Sketch name -> its extracted profile. Populated by SKETCH features; read by
896    /// profile-consumers via [`SceneMap::resolve_profile`].
897    pub profiles: HashMap<String, SketchProfile>,
898    /// Plane name -> its frame. Populated by DATUM/PLANE features; read by SKETCH
899    /// (and PLANE datum-refs) via [`SceneMap::resolve_frame`] for headless plane
900    /// resolution (fully headless).
901    pub frames: HashMap<String, Frame>,
902    /// Axis name -> its world line. Populated by SKETCH line geometries; read by
903    /// revolve/sweep via [`SceneMap::resolve_axis`].
904    pub axes: HashMap<String, Axis>,
905    /// Path name -> its ordered world curve chain (open or closed). Populated by
906    /// SKETCH features; read by sweep/path_sweep/rib via [`SceneMap::resolve_path`].
907    pub paths: HashMap<String, Vec<NurbsCurve>>,
908    /// Path name -> the SOURCE NAME of each curve in its chain, parallel to the
909    /// [`Self::paths`] entry of the same name. Populated by SKETCH features; read
910    /// by the SWEEP via [`SceneMap::resolve_path_segment_names`] so the faces one
911    /// path SEGMENT builds carry that segment's name rather than its position.
912    pub path_segment_names: HashMap<String, Vec<Option<String>>>,
913    /// Path name -> the SCREW AXIS of each curve in its chain (`Some` for a
914    /// helix), parallel to the [`Self::paths`] entry of the same name. Populated
915    /// by HELIX features; read by the SWEEP via
916    /// [`SceneMap::resolve_path_segment_axes`].
917    pub path_segment_axes: HashMap<String, Vec<Option<Axis>>>,
918    /// Point name -> its world position + construction flag. Populated by SKETCH
919    /// features (`{sketchId}:P{pid}`), splines and helices; read by hole placement
920    /// via [`SceneMap::model_points_with_prefix`] and by name via
921    /// [`SceneMap::resolve_point`].
922    pub points: HashMap<String, ScenePoint>,
923    /// Connection-point ADDRESS -> its record. Populated by the ports tail
924    /// (the document's declared points), by WAYPOINT features and by ACOMP
925    /// (a placed part's points, namespaced and posed); read by SPLINE
926    /// attachments ([`SceneMap::resolve_port`]) and by the wire-harness tail,
927    /// which builds its sided routing graph from it.
928    pub ports: HashMap<String, PortRecord>,
929    /// Owning feature id -> component record (assemblies). Populated by ACOMP
930    /// features via the `components` side-channel; the whole component API
931    /// surface (`resolve_component` / `owning_component` / the fence predicate)
932    /// lives in component.rs. Empty in every componentless scene — nothing in
933    /// the ordinary modeling path reads or writes it.
934    pub components: component::ComponentMap,
935    /// The occurrences of FLEXIBLE sub-assembly instances (`ACOMP1:ACOMP2`),
936    /// registered by the constraint run (`assembly::flexible`) as bodies the
937    /// solver moves individually. Not components: the tree and BOM never see
938    /// them; their solids stay members of the placing instance too.
939    pub occurrences: component::ComponentMap,
940    /// Naming-contract guard (accumulated across `SceneMap::apply` calls): one
941    /// message per detected cross-solid name COLLISION — an added solid's face /
942    /// edge name that already maps to a DIFFERENT still-resident solid. Two live
943    /// solids must never share a face/edge name (the SceneMap is a global
944    /// name → one-solid map). `execute_history` surfaces any collision introduced
945    /// by a freshly-run feature as that feature's error, so the offending
946    /// operation trips its own tests + shows an error in the app.
947    pub name_collisions: Vec<String>,
948    /// Legacy solid name -> the canonical resident name it stands for
949    /// ([`FeatureResult::solid_aliases`]). A resident solid of the legacy
950    /// name itself always wins; an alias whose canonical solid is consumed
951    /// (removed and not re-added by the same result) is dropped.
952    pub solid_aliases: HashMap<String, String>,
953}
954
955impl SceneMap {
956    /// Rewrite each `removed` name that spells a legacy solid alias (and no
957    /// resident solid) to the canonical name it stands for, so an old
958    /// document's boolean on `{target}_{featureId}` consumes the shell
959    /// everywhere `removed` is read (this map, the render scene, the
960    /// history cache's blast radius).
961    pub(crate) fn canonical_removed(&self, result: &mut FeatureResult) {
962        for name in &mut result.removed {
963            if self.solids.contains_key(name.as_str()) {
964                continue;
965            }
966            if let Some(canonical) = self.solid_aliases.get(name.as_str()) {
967                *name = canonical.clone();
968            }
969        }
970    }
971
972    /// Resolve a solid name to its resident handle (exact match, then a
973    /// legacy alias of a resident solid — [`Self::solid_aliases`]).
974    pub fn resolve_solid(&self, name: &str) -> Option<u32> {
975        self.solids.get(name).copied().or_else(|| {
976            self.solid_aliases
977                .get(name)
978                .and_then(|canonical| self.solids.get(canonical))
979                .copied()
980        })
981    }
982
983    /// Resolve a face name to its `(handle, face_id)` (exact match).
984    pub fn resolve_face(&self, name: &str) -> Option<FaceRef> {
985        if let Some(face) = self.faces.get(name) {
986            return Some(*face);
987        }
988        // A CONTAINER name holding exactly ONE face resolves to it. Not a scoring
989        // heuristic (contract rule 1 stands): the container is a name the feature
990        // registered deliberately, and a single member leaves nothing to choose
991        // between. This is what keeps every reference in a model saved before
992        // per-loop cap naming resolving unchanged — a single-loop sketch has one
993        // cap, so its container has one member. A container with SEVERAL members
994        // is ambiguous for a single-face consumer and stays unresolved; the
995        // multi-face consumers take it through [`Self::resolve_face_group`].
996        match self.resolve_face_group(name).as_slice() {
997            [only] => Some(*only),
998            _ => None,
999        }
1000    }
1001
1002    /// Every face a name stands for: the one exact match, else the members of the
1003    /// container by that name (possibly several). The multi-face resolution the
1004    /// selection-list consumers use — [`Self::resolve_face`] is its single-face
1005    /// sibling.
1006    pub fn resolve_face_group(&self, name: &str) -> Vec<FaceRef> {
1007        if let Some(face) = self.faces.get(name) {
1008            return vec![*face];
1009        }
1010        // Members are resolved NOW, so a member a later operation consumed simply
1011        // drops out instead of leaving a stale handle behind.
1012        self.face_groups
1013            .get(name)
1014            .map(|members| {
1015                members
1016                    .iter()
1017                    .filter_map(|member| self.faces.get(member).copied())
1018                    .collect()
1019            })
1020            .unwrap_or_default()
1021    }
1022
1023    /// Every edge a name stands for — [`Self::resolve_face_group`] for edges.
1024    pub fn resolve_edge_group(&self, name: &str) -> Vec<EdgeRef> {
1025        if let Some(edge) = self.edges.get(name) {
1026            return vec![*edge];
1027        }
1028        if let Some(members) = self.edge_groups.get(name) {
1029            return members
1030                .iter()
1031                .filter_map(|member| self.edges.get(member).copied())
1032                .collect();
1033        }
1034        self.resolve_edge_reversed(name).into_iter().collect()
1035    }
1036
1037    /// A topology edge named with its two faces the other way round: `B|A[n]`
1038    /// for the stamped `A|B[n]` ([`features::common::stamp_derived_edge_names`]
1039    /// sorts the pair, and numbers each sorted pair, so `[n]` carries over).
1040    /// Face names may themselves hold `|` (a blend face is named after its
1041    /// edge), so every split is tried, and the name resolves only when exactly
1042    /// one of them names a resident edge — never a guess between two.
1043    fn resolve_edge_reversed(&self, name: &str) -> Option<EdgeRef> {
1044        let (base, index) = match name.rfind('[') {
1045            Some(at) if name.ends_with(']') && name[at + 1..name.len() - 1].bytes().all(|b| b.is_ascii_digit()) => {
1046                (&name[..at], &name[at..])
1047            }
1048            _ => (name, ""),
1049        };
1050        let mut found: Option<EdgeRef> = None;
1051        for (at, _) in base.match_indices('|') {
1052            let (left, right) = (&base[..at], &base[at + 1..]);
1053            if left.is_empty() || right.is_empty() {
1054                continue;
1055            }
1056            if let Some(edge) = self.edges.get(&format!("{right}|{left}{index}")) {
1057                if found.is_some_and(|seen| (seen.handle, seen.edge_id) != (edge.handle, edge.edge_id)) {
1058                    return None;
1059                }
1060                found = Some(*edge);
1061            }
1062        }
1063        found
1064    }
1065
1066    /// Resolve an edge name to its `(handle, edge_id)` (exact match). Contract
1067    /// surface for edge-selecting fan-out features (fillet/chamfer); no
1068    /// implemented feature consumes it yet.
1069    #[allow(dead_code)]
1070    pub fn resolve_edge(&self, name: &str) -> Option<EdgeRef> {
1071        if let Some(edge) = self.edges.get(name) {
1072            return Some(*edge);
1073        }
1074        // The [`Self::resolve_face`] rule for edges: derived edge names embed face
1075        // names, so keying the caps renamed every cap-adjacent edge too, and a
1076        // container holding exactly one edge resolves to it. This is what keeps a
1077        // stored projected-edge / axis / path reference working.
1078        match self.resolve_edge_group(name).as_slice() {
1079            [only] => Some(*only),
1080            _ => None,
1081        }
1082    }
1083
1084    /// Resolve a profile reference name to the sketch profile it names, by THE
1085    /// sketch-name rule ([`sketch_alias_lookup`]): `{id}`, `{id}:PROFILE` and the
1086    /// display-sheet pick `{id}:FACE` all resolve to the one profile stored
1087    /// under `{id}`.
1088    pub fn resolve_profile(&self, name: &str) -> Option<&SketchProfile> {
1089        self.resolve_profile_named(name).map(|(_, profile)| profile)
1090    }
1091
1092    /// [`Self::resolve_profile`] with the CANONICAL name the profile is stored
1093    /// under — what a consumer names caps by and consumes. One call, so the
1094    /// resolution and the canonical name can never disagree.
1095    pub fn resolve_profile_named<'a>(&'a self, name: &str) -> Option<(&'a str, &'a SketchProfile)> {
1096        sketch_alias_lookup(&self.profiles, name)
1097    }
1098
1099    /// Resolve a named plane frame (exact match), for sketch/plane resolution.
1100    pub fn resolve_frame(&self, name: &str) -> Option<Frame> {
1101        self.frames.get(name).copied()
1102    }
1103
1104    /// Resolve a named axis line (exact match), for revolve/sweep.
1105    pub fn resolve_axis(&self, name: &str) -> Option<Axis> {
1106        self.axes.get(name).copied()
1107    }
1108
1109    /// Resolve a named path chain by THE sketch-name rule ([`sketch_alias_lookup`]),
1110    /// for sweep/path_sweep/rib trajectories.
1111    pub fn resolve_path(&self, name: &str) -> Option<&Vec<NurbsCurve>> {
1112        sketch_alias_lookup(&self.paths, name).map(|(_, value)| value)
1113    }
1114
1115    /// The per-segment SOURCE NAMES of a named path chain, resolved exactly as
1116    /// [`SceneMap::resolve_path`] resolves the chain itself (so the two always
1117    /// answer for the same entry). `None` when the publisher named no segments —
1118    /// the caller then falls back to naming by the reference itself.
1119    pub fn resolve_path_segment_names(&self, name: &str) -> Option<&Vec<Option<String>>> {
1120        sketch_alias_lookup(&self.path_segment_names, name).map(|(_, value)| value)
1121    }
1122
1123    /// The per-segment SCREW AXES of a named path chain, resolved exactly as
1124    /// [`SceneMap::resolve_path`] resolves the chain itself. `None` when the
1125    /// publisher recorded none — every segment is then an ordinary curve.
1126    pub fn resolve_path_segment_axes(&self, name: &str) -> Option<&Vec<Option<Axis>>> {
1127        sketch_alias_lookup(&self.path_segment_axes, name).map(|(_, value)| value)
1128    }
1129
1130    /// Resolve a named world point (exact match), for hole/placement centers.
1131    /// Contract surface consumed by the spline/hole placement tests; kept as the
1132    /// documented point-resolution API even where no shipping feature reads it
1133    /// yet (mirrors `resolve_edge`).
1134    #[allow(dead_code)]
1135    pub fn resolve_point(&self, name: &str) -> Option<Vec3> {
1136        self.points.get(name).map(|point| point.position)
1137    }
1138
1139    /// Resolve a connection point by its address (exact match).
1140    pub fn resolve_port(&self, name: &str) -> Option<&PortRecord> {
1141        self.ports.get(name)
1142    }
1143
1144    /// Every MODEL (non-construction) point whose name starts with `prefix`, as
1145    /// `(name, position)` pairs in map order (the caller sorts). The scene-map
1146    /// indexes points by exact name only, so a consumer that wants "all the
1147    /// points of sketch S" prefix-scans `S:P` — safe because the prefix ends in
1148    /// `:P`, which another sketch id cannot spell into (`S1:P` never prefixes
1149    /// `S10:P…`). Construction points (the sketcher's ◐ toggle, materialized
1150    /// external references) are skipped: they constrain, they never model.
1151    pub fn model_points_with_prefix(&self, prefix: &str) -> Vec<(String, Vec3)> {
1152        self.points
1153            .iter()
1154            .filter(|(name, point)| name.starts_with(prefix) && !point.construction)
1155            .map(|(name, point)| (name.clone(), point.position))
1156            .collect()
1157    }
1158
1159    /// Apply a feature's effect: removals first (a boolean result reuses a removed
1160    /// target's name), then additions. Removal only unmaps NAMES — it never frees
1161    /// the handle. Handles are owned by the incremental history cache (each by the
1162    /// entry of the feature that produced it) and die exactly when that entry is
1163    /// invalidated; freeing here would kill a clean upstream feature's cached
1164    /// solid the moment a downstream boolean consumed it.
1165    fn apply(&mut self, result: &FeatureResult) {
1166        for name in &result.removed {
1167            // (A removal spelling a legacy alias was canonicalized when the
1168            // feature ran — `canonical_removed` — so every consumer of
1169            // `removed` sees the resident name.)
1170            if self.solids.contains_key(name)
1171                && !result.added.iter().any(|added| &added.name == name)
1172            {
1173                self.solid_aliases.retain(|_, canonical| canonical != name);
1174            }
1175            // A removed COMPONENT id drops the record and unmaps every member
1176            // solid (names only — handle ownership stays with the cache, same
1177            // as plain solid removal). Feature ids never collide with solid
1178            // names, so this is a no-op for every ordinary removal.
1179            if let Some(record) = self.components.remove(name) {
1180                let prefix = format!("{}:", record.id);
1181                self.occurrences.retain(|id, _| !id.starts_with(&prefix));
1182                for member in &record.solids {
1183                    if let Some(handle) = self.solids.remove(member) {
1184                        self.faces.retain(|_, face| face.handle != handle);
1185                        self.edges.retain(|_, edge| edge.handle != handle);
1186                    }
1187                }
1188                // ...and the ports the part carried, with their entities.
1189                for port in &record.ports {
1190                    self.ports.remove(port);
1191                    self.axes.remove(port);
1192                    self.frames.remove(port);
1193                    self.points.remove(&format!("{port}:Base"));
1194                    self.paths.remove(&format!("{port}:PortLine"));
1195                }
1196            }
1197            if let Some(handle) = self.solids.remove(name) {
1198                // Purge the removed solid's face/edge entries (names only).
1199                self.faces.retain(|_, face| face.handle != handle);
1200                self.edges.retain(|_, edge| edge.handle != handle);
1201            }
1202            // A consumed sketch's PUBLISHED GEOMETRY (profile / axis / path / point)
1203            // is deliberately NOT purged here. Unlike a solid, one sketch can drive
1204            // several features — a later feature legitimately resolves the sketch's
1205            // axis or path even after another feature consumed its profile (see
1206            // `revolve::tests::extruded_face_then_revolve_face_profile`, which
1207            // revolves about a consumed sketch's `Sk:G20` axis). Removing a consumed
1208            // sketch from the SCENE DISPLAY is handled render-side (the committed-
1209            // sketch list honors `removed`); the kernel keeps its geometry resolvable.
1210            // A removed name not in the map is a silent no-op (established semantics).
1211        }
1212        // Ingest any sketch profiles this feature produced (SKETCH features only).
1213        for (name, profile) in &result.profiles {
1214            self.profiles.insert(name.clone(), profile.clone());
1215        }
1216        // Ingest any named plane frames (DATUM/PLANE features).
1217        for (name, frame) in &result.frames {
1218            self.frames.insert(name.clone(), *frame);
1219        }
1220        // Ingest any named axis lines (SKETCH line geometries).
1221        for (name, axis) in &result.axes {
1222            self.axes.insert(name.clone(), *axis);
1223        }
1224        // Ingest any named path chains (SKETCH geometry chains).
1225        for (name, names) in &result.path_segment_names {
1226            self.path_segment_names.insert(name.clone(), names.clone());
1227        }
1228        for (name, axes) in &result.path_segment_axes {
1229            self.path_segment_axes.insert(name.clone(), axes.clone());
1230        }
1231        for (name, path) in &result.paths {
1232            self.paths.insert(name.clone(), path.clone());
1233        }
1234        // Ingest any named world points (SKETCH points).
1235        for (name, point) in &result.points {
1236            self.points.insert(name.clone(), *point);
1237        }
1238        // Ingest any harness ports (PORT features).
1239        for (name, port) in &result.ports {
1240            self.ports.insert(name.clone(), port.clone());
1241        }
1242        for (legacy, canonical) in &result.solid_aliases {
1243            self.solid_aliases.insert(legacy.clone(), canonical.clone());
1244        }
1245        // Ingest any component records (ACOMP features).
1246        for record in &result.components {
1247            // A re-placed flexible instance re-expands from its fresh pose.
1248            let prefix = format!("{}:", record.id);
1249            self.occurrences.retain(|id, _| !id.starts_with(&prefix));
1250            self.components.insert(record.id.clone(), record.clone());
1251        }
1252        // Insert the added solids' names FIRST so the resident-handle set is
1253        // complete (a name colliding with another solid ADDED in the same result
1254        // is still a collision).
1255        for added in &result.added {
1256            self.solids.insert(added.name.clone(), added.handle);
1257        }
1258        let resident: std::collections::HashSet<u32> = self.solids.values().copied().collect();
1259        for added in &result.added {
1260            for (face_id, name) in &added.face_names {
1261                self.check_name_collision(name, added.handle, &added.name, &resident, true);
1262                self.faces.insert(
1263                    name.clone(),
1264                    FaceRef {
1265                        handle: added.handle,
1266                        face_id: *face_id,
1267                    },
1268                );
1269            }
1270            for (edge_id, name) in &added.edge_names {
1271                self.check_name_collision(name, added.handle, &added.name, &resident, false);
1272                self.edges.insert(
1273                    name.clone(),
1274                    EdgeRef {
1275                        handle: added.handle,
1276                        edge_id: *edge_id,
1277                    },
1278                );
1279            }
1280            // Container groups. NOT collision-checked: a container is a name for
1281            // faces this same solid owns, never a claim on another solid's name,
1282            // and a real face by that name always wins (both resolvers try the
1283            // exact maps first). Stored as member NAMES and resolved on lookup,
1284            // so a group survives the solid being consumed and re-registered by a
1285            // later feature (name fidelity carries the members across).
1286            for (container, members) in &added.face_groups {
1287                self.face_groups.insert(container.clone(), members.clone());
1288            }
1289            for (container, members) in &added.edge_groups {
1290                self.edge_groups.insert(container.clone(), members.clone());
1291            }
1292        }
1293    }
1294
1295    /// Record a naming-contract collision if `name` (a face when `is_face`, else
1296    /// an edge) already maps to a DIFFERENT still-RESIDENT solid — an added
1297    /// solid's name stealing another live solid's. A `prev` handle no longer in
1298    /// `resident` is a stale leftover (rollback / cache eviction), not a
1299    /// violation, so it is ignored. The message names the offender + the resident
1300    /// owner and points at the fix.
1301    fn check_name_collision(
1302        &mut self,
1303        name: &str,
1304        handle: u32,
1305        solid_name: &str,
1306        resident: &std::collections::HashSet<u32>,
1307        is_face: bool,
1308    ) {
1309        let prev = if is_face {
1310            self.faces.get(name).map(|face| face.handle)
1311        } else {
1312            self.edges.get(name).map(|edge| edge.handle)
1313        };
1314        let Some(prev_handle) = prev else { return };
1315        if prev_handle == handle || !resident.contains(&prev_handle) {
1316            return;
1317        }
1318        let owner = self
1319            .solids
1320            .iter()
1321            .find(|(_, resident_handle)| **resident_handle == prev_handle)
1322            .map(|(owner_name, _)| owner_name.as_str())
1323            .unwrap_or("another resident solid");
1324        let kind = if is_face { "face" } else { "edge" };
1325        // One line PER colliding name; the `namespace_copy_names` hint is appended
1326        // ONCE per feature error where these lines are joined (execute_history).
1327        self.name_collisions.push(format!(
1328            "naming-contract violation: solid `{solid_name}` {kind} name `{name}` \
1329             collides with resident solid `{owner}` (two live solids must not share a \
1330             face/edge name)."
1331        ));
1332    }
1333}
1334
1335/// Join a feature's per-name collision lines into ONE error string, appending the
1336/// `namespace_copy_names` fix hint exactly ONCE (a copy-feature can collide on
1337/// dozens of names — repeating the hint per name buried the signal). Shared so the
1338/// surfacing in `execute_history` and its test agree on the wording.
1339pub(crate) fn name_collision_error(lines: &[String]) -> String {
1340    let mut message = lines.join("; ");
1341    message.push_str(
1342        " — an operation that COPIES geometry (mirror / pattern / transform-copy / \
1343         any new one) must call `common::namespace_copy_names(&mut solid, suffix)` \
1344         so the copy's faces and edges get UNIQUE names.",
1345    );
1346    message
1347}
1348
1349// ===========================================================================
1350// FeatureContext — everything a feature's `execute(ctx)` needs
1351// ===========================================================================
1352
1353/// The read-only context handed to each feature's `execute`. Features resolve
1354/// their `reference_selection` params against `scene` and read numeric params via
1355/// [`FeatureContext::number`] (which evaluates expression strings against `env`).
1356pub struct FeatureContext<'a> {
1357    pub id: String,
1358    pub feature_type: String,
1359    pub params: &'a serde_json::Value,
1360    /// The feature's `persistentData` (round-tripped from the saved part file).
1361    /// The SKETCH feature reads its `sketch` + `basis` here (the scene-computed
1362    /// plane frame stays caller-side per directive §1 and is marshaled in via this).
1363    pub persistent: &'a serde_json::Value,
1364    pub env: &'a Env,
1365    pub scene: &'a SceneMap,
1366    /// `Some` when the history walk knows nothing that follows this feature
1367    /// references the solids it consumes, by face name or by persistent data
1368    /// (lane X row 11b): the boolean feature may then re-seat an operand's
1369    /// cylinder seam before imprint. `None` (the walk cannot tell, or a later
1370    /// feature might reference them, or a direct `execute_feature` call)
1371    /// never re-seats.
1372    pub later_inputs: Option<&'a [String]>,
1373}
1374
1375impl<'a> FeatureContext<'a> {
1376    /// The raw param value (an object field of `inputParams`), if present.
1377    pub fn param(&self, key: &str) -> Option<&serde_json::Value> {
1378        self.params.get(key)
1379    }
1380
1381    /// A numeric param: a JSON number passes through; a JSON string is evaluated
1382    /// as an expression against the shared env; anything else is an error. This is
1383    /// how a feature declares which of its params are numeric (the engine has no
1384    /// per-feature schema).
1385    pub fn number(&self, key: &str) -> Result<f64, String> {
1386        match self.param(key) {
1387            Some(serde_json::Value::Number(number)) => number
1388                .as_f64()
1389                .ok_or_else(|| format!("param `{key}` is not a finite number")),
1390            Some(serde_json::Value::String(source)) => self
1391                .env
1392                .eval(source)
1393                .map_err(|error| format!("param `{key}`: {error}")),
1394            Some(other) => Err(format!(
1395                "param `{key}` must be a number or expression string, found {other}"
1396            )),
1397            None => Err(format!("missing required param `{key}`")),
1398        }
1399    }
1400
1401    /// A string param (read verbatim — never expression-evaluated; used for ids
1402    /// and `reference_selection` names). Contract surface for fan-out features;
1403    /// the two implemented features read the id via `ctx.id` directly.
1404    #[allow(dead_code)]
1405    pub fn string(&self, key: &str) -> Option<String> {
1406        match self.param(key) {
1407            Some(serde_json::Value::String(text)) => Some(text.clone()),
1408            _ => None,
1409        }
1410    }
1411
1412    /// A convenience [`FeatureResult::refuse`] carrying this feature's id +
1413    /// type: text from a stringly producer, or a typed [`KernelRefusal`].
1414    pub fn fail(&self, refusal: impl Into<FeatureRefusal>) -> FeatureResult {
1415        FeatureResult::refuse(self.id.clone(), self.feature_type.clone(), refusal)
1416    }
1417}
1418
1419/// What a SUCCESSFUL feature did with what it was asked, when that is not
1420/// "everything, exactly" (plan: typed-refusal-reporting, item 3). Requested and
1421/// applied are reference names as the document wrote them; every rejected one
1422/// says which kind of rejection and why. A partial fulfilment is a different
1423/// answer from the one asked for, so it is reported on the feature that made it
1424/// and displayed by the app beside the feature; it is never a refusal class
1425/// (a partial selection is a separate outcome) and never carries geometry.
1426#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1427pub struct Fulfilment {
1428    pub requested: Vec<String>,
1429    pub applied: Vec<String>,
1430    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1431    pub rejected: Vec<Rejected>,
1432    /// Edges the feature acted on that were NOT selected: today a fillet
1433    /// whose selection ends inside a tangent chain and refused there, rerun
1434    /// once along the open chain
1435    /// (`common::open_tangent_chain_extension`). Their names, in walk order.
1436    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1437    pub extended: Vec<String>,
1438    /// Edges the fillet's "Follow tangent" option added to the selection:
1439    /// the unselected edges on the selected edges' G1 tangent chains
1440    /// (`common::tangent_chain_expansion`), in walk order. Reported so the
1441    /// user sees what was filleted beyond the picks.
1442    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1443    pub followed: Vec<String>,
1444}
1445
1446impl Fulfilment {
1447    /// Something asked for was not done.
1448    pub fn is_partial(&self) -> bool {
1449        !self.rejected.is_empty()
1450    }
1451
1452    /// One line for a report or a tree leaf: "2 of 3 selected references
1453    /// applied; `ghost` resolves to nothing on this model".
1454    pub fn summary(&self) -> String {
1455        let mut notes: Vec<String> = self
1456            .rejected
1457            .iter()
1458            .map(|rejected| format!("`{}` {}", rejected.name, rejected.reason))
1459            .collect();
1460        if !self.extended.is_empty() {
1461            let names: Vec<String> = self.extended.iter().map(|name| format!("`{name}`")).collect();
1462            notes.push(format!(
1463                "the selection ends inside a tangent chain, so the fillet continued along it onto {}",
1464                names.join(", ")
1465            ));
1466        }
1467        if !self.followed.is_empty() {
1468            let names: Vec<String> = self.followed.iter().map(|name| format!("`{name}`")).collect();
1469            notes.push(format!(
1470                "Follow tangent added {} tangent-chain edge{}: {}",
1471                self.followed.len(),
1472                if self.followed.len() == 1 { "" } else { "s" },
1473                names.join(", ")
1474            ));
1475        }
1476        format!(
1477            "{} of {} selected reference{} applied; {}",
1478            self.applied.len(),
1479            self.requested.len(),
1480            if self.requested.len() == 1 { "" } else { "s" },
1481            notes.join("; ")
1482        )
1483    }
1484}
1485
1486/// One reference a successful feature did not act on.
1487#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1488pub struct Rejected {
1489    pub name: String,
1490    pub kind: RejectedKind,
1491    pub reason: String,
1492}
1493
1494/// Why a reference was rejected. Closed; a new kind is a producer that exists.
1495#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1496#[serde(rename_all = "snake_case")]
1497pub enum RejectedKind {
1498    /// The name resolves to no entity of the scene.
1499    Unresolved,
1500    /// The feature was given nothing to act on (an Offset Shell with no
1501    /// opening face and no solid): it did nothing, and says so.
1502    NothingSelected,
1503}
1504
1505/// What a feature's `build` hands back when it refuses. A feature that drives
1506/// a typed producer propagates the [`KernelRefusal`] whole (`?` converts it),
1507/// so its class reaches [`FeatureResult::refusal`]; one that drives a stringly
1508/// producer propagates the text. There is no `From<FeatureRefusal> for
1509/// String`: the text of a typed refusal is read through
1510/// [`FeatureRefusal::message`], never by dropping the class.
1511#[derive(Clone, Debug, PartialEq)]
1512pub enum FeatureRefusal {
1513    Text(String),
1514    Typed(KernelRefusal),
1515}
1516
1517impl FeatureRefusal {
1518    /// The human text either way, verbatim.
1519    pub fn message(&self) -> &str {
1520        match self {
1521            Self::Text(message) => message,
1522            Self::Typed(refusal) => &refusal.message,
1523        }
1524    }
1525}
1526
1527impl From<String> for FeatureRefusal {
1528    fn from(message: String) -> Self {
1529        Self::Text(message)
1530    }
1531}
1532
1533impl From<&str> for FeatureRefusal {
1534    fn from(message: &str) -> Self {
1535        Self::Text(message.to_string())
1536    }
1537}
1538
1539impl From<KernelRefusal> for FeatureRefusal {
1540    fn from(refusal: KernelRefusal) -> Self {
1541        Self::Typed(refusal)
1542    }
1543}
1544
1545impl std::fmt::Display for FeatureRefusal {
1546    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1547        f.write_str(self.message())
1548    }
1549}
1550
1551// ===========================================================================
1552// Dispatch — a match on `feature_type` to the owning `features/<feat>.rs`
1553// ===========================================================================
1554
1555/// Extract the feature id from `inputParams` (`id`, falling back to the
1556/// non-enumerable `featureID`). The solid + all face/edge names are prefixed
1557/// with this id, so name fidelity depends on it.
1558pub(crate) fn extract_id(params: &serde_json::Value) -> String {
1559    for key in ["id", "featureID"] {
1560        if let Some(serde_json::Value::String(text)) = params.get(key) {
1561            if !text.is_empty() {
1562                return text.clone();
1563            }
1564        }
1565    }
1566    String::new()
1567}
1568
1569/// Resolve one feature descriptor to a [`FeatureResult`] against the current
1570/// scene. Each stub's arm calls its own `features::<feat>::execute` so a fan-out
1571/// agent implementing a feature touches ONLY that file — never this dispatch.
1572pub fn execute_feature(
1573    descriptor: &FeatureDescriptor,
1574    env: &Env,
1575    scene: &SceneMap,
1576) -> FeatureResult {
1577    execute_feature_with_later(descriptor, env, scene, None)
1578}
1579
1580/// [`execute_feature`] with what the history walk knows about the features
1581/// after this one ([`FeatureContext::later_inputs`]).
1582pub(crate) fn execute_feature_with_later(
1583    descriptor: &FeatureDescriptor,
1584    env: &Env,
1585    scene: &SceneMap,
1586    later_inputs: Option<&[String]>,
1587) -> FeatureResult {
1588    let id = extract_id(&descriptor.input_params);
1589    let feature_type = descriptor.feature_type.clone();
1590    // The feature fence: component geometry is valid input for sketch
1591    // attach/project and assembly constraints ONLY. A modeling feature naming
1592    // it as an operand fails cleanly HERE — the one altitude every feature
1593    // passes through — instead of per-feature checks. Free for componentless
1594    // scenes (the walk is skipped entirely).
1595    if let Err(message) = component::enforce_reference_fence(&feature_type, descriptor, scene) {
1596        return FeatureResult::error(id, feature_type, message);
1597    }
1598    let ctx = FeatureContext {
1599        id: id.clone(),
1600        feature_type: feature_type.clone(),
1601        params: &descriptor.input_params,
1602        persistent: &descriptor.persistent_data,
1603        env,
1604        scene,
1605        later_inputs,
1606    };
1607    // Alias resolution mirrors the caller's feature registry, which matches short name,
1608    // LONG name, and class name (all uppercased) — saved files and tests use
1609    // long-name type strings like "CHAMFER"/"PLANE".
1610    let mut result = match feature_type.as_str() {
1611        // --- Fully implemented (the exemplary pattern) ---
1612        "P.CU" | "CUBE" => features::cube::execute(&ctx),
1613        "P.S" | "SPHERE" => features::sphere::execute(&ctx),
1614        // --- Solid-producing primitives ---
1615        "P.CY" | "CYLINDER" => features::cylinder::execute(&ctx),
1616        "P.CO" | "CONE" => features::cone::execute(&ctx),
1617        "P.T" | "TORUS" => features::torus::execute(&ctx),
1618        "P.PY" | "PYRAMID" => features::pyramid::execute(&ctx),
1619        "NS" | "NURBS SOLID" | "NURBSSOLID" => features::nurbs_solid::execute(&ctx),
1620        // --- Add-material ---
1621        "E" | "EXTRUDE" => features::extrude::execute(&ctx),
1622        "R" | "REVOLVE" => features::revolve::execute(&ctx),
1623        "LOFT" => features::loft::execute(&ctx),
1624        "SW" | "SWEEP" => features::sweep::execute(&ctx),
1625        "SWP" | "PATH SWEEP" | "PATHSWEEP" => features::path_sweep::execute(&ctx),
1626        "RIB" => features::rib::execute(&ctx),
1627        "TU" | "TUBE" => features::tube::execute(&ctx),
1628        // --- Edit / boolean / transform ---
1629        "B" | "BOOLEAN" => features::boolean::execute(&ctx),
1630        "M" | "MIRROR" => features::mirror::execute(&ctx),
1631        "SPL" | "SPLIT" => features::split::execute(&ctx),
1632        "XFORM" | "TRANSFORM" => features::transform::execute(&ctx),
1633        "PATTERN" => features::pattern::execute(&ctx),
1634        // --- Dressups ---
1635        "F" | "FILLET" => features::fillet::execute(&ctx),
1636        "CH" | "CHAMFER" => features::chamfer::execute(&ctx),
1637        "O.S" | "OFFSET SHELL" | "OFFSETSHELL" => features::offset_shell::execute(&ctx),
1638        "O.F" | "OFFSET FACE" | "OFFSETFACE" => features::offset_face::execute(&ctx),
1639        "PF" | "PUSHFACE" | "PUSH FACE" => features::push_face::execute(&ctx),
1640        "TF" | "TRANSFORMFACE" | "TRANSFORM FACE" => features::transform_face::execute(&ctx),
1641        "DF" | "DELETE FACE" | "DELETEFACE" => features::delete_face::execute(&ctx),
1642        "RFS" | "REFIT FACES" | "REFITFACES" => features::refit_faces::execute(&ctx),
1643        "THK" | "THICKEN" => features::thicken::execute(&ctx),
1644        "H" | "HOLE" => features::hole::execute(&ctx),
1645        // --- No-kernel-op / long tail ---
1646        "IMPORT3D" => features::import3d::execute(&ctx),
1647        "SM.TAB" => features::sheet_metal_tab::execute(&ctx),
1648        "SM.CF" => features::sheet_metal_contour_flange::execute(&ctx),
1649        "SM.F" => features::sheet_metal_flange::execute(&ctx),
1650        "SM.HEM" => features::sheet_metal_hem::execute(&ctx),
1651        "SM.CLOSED_CORNER" => features::sheet_metal_closed_corner::execute(&ctx),
1652        "SM.MIRROR" => features::sheet_metal_mirror::execute(&ctx),
1653        "SM.FILLET" => features::sheet_metal_corner::execute_fillet(&ctx),
1654        "SM.CHAMFER" => features::sheet_metal_corner::execute_chamfer(&ctx),
1655        "SM.CUTOUT" => features::sheet_metal_cutout::execute(&ctx),
1656        "SM.UNFOLD" => features::sheet_metal_unfold::execute(&ctx),
1657        // --- Assemblies: one placed parts-library instance. Keep these
1658        //     literals in sync with `parts_library::is_acomp_type` (the
1659        //     fingerprint-mix + GC hooks key on the same strings). ---
1660        "ACOMP" | "ASSEMBLY COMPONENT" => features::assembly_component::execute(&ctx),
1661        // Pattern Component: instances of placed components that follow a seed.
1662        "APAT" | "PATTERN COMPONENT" => features::assembly_pattern::execute(&ctx),
1663        // --- Construction geometry that registers named plane frames (headless
1664        //     sketch-plane resolution) ---
1665        "D" | "DATUM" | "DATIUM" => features::datum::execute(&ctx),
1666        "P" | "PLANE" => features::plane::execute(&ctx),
1667        // --- Sketch: solves + extracts a profile the add-material features consume ---
1668        "S" | "SKETCH" => features::sketch::execute(&ctx),
1669        // --- 3D sketch: free-space points/lines/arcs/beziers solved in 3D, published as paths ---
1670        "S3D" | "SKETCH3D" | "3D SKETCH" => features::sketch3d::execute(&ctx),
1671        // --- Non-solid construction geometry: editor-drawn pass-throughs ---
1672        "SP" | "SPLINE" => features::spline::execute(&ctx),
1673        "WP" | "WAYPOINT" => features::waypoint::execute(&ctx),
1674        "HX" | "HELIX" => features::helix::execute(&ctx),
1675        // --- Scoped extensions cannot replace built-ins. ---
1676        _ if feature_type.contains('/') => extension::execute(&ctx),
1677        // --- Genuinely unknown type string ---
1678        _ => FeatureResult::error(id, feature_type.clone(), format!("unknown feature type '{feature_type}'")),
1679    };
1680    // Every entry point (the history walk and a direct `execute_feature`)
1681    // reports a removal spelled as a legacy solid alias by its resident name.
1682    scene.canonical_removed(&mut result);
1683    result
1684}
1685
1686// ===========================================================================
1687// The incremental history cache — dependency-driven dirty tracking
1688// ===========================================================================
1689//
1690// A feature is DIRTY when it itself changed (its serialized descriptor hash — or
1691// the expression env — differs) or when a NAME it references was (re)produced,
1692// removed, or re-resolved differently since its cached run. Everything else is
1693// CLEAN: its cached result (resident handles, profiles, frames, axes, paths)
1694// replays into the scene untouched and is flagged `reused` so the caller skips display
1695// rebuild. Dependency edges come from the exact-match name contract itself
1696// (contract rule 1): any string in a feature's params matching a name produced
1697// earlier in the history IS a reference.
1698//
1699// OWNERSHIP: cache entries own the handles their feature produced. A handle (and
1700// its sheet-metal tree) is freed exactly when its producing entry is invalidated
1701// (feature dirty/deleted, or `clear_history_cache`). Nothing else frees scene
1702// handles — not `SceneMap::apply`, and not the caller (display Solids are non-owning
1703// views post-flip). Consumed intermediates therefore stay resident until an edit
1704// invalidates their producer: the accepted memory cost of incremental replay.
1705
1706/// One cached feature execution.
1707struct CachedFeature {
1708    /// A component's resident members were re-posed after feature execution.
1709    /// Its cached pre-solve component record must not replay against those
1710    /// mutated handles, even when authored expressions evaluate identically.
1711    posed_externally: bool,
1712    /// Hash of `type` + `inputParams` + `persistentData` + the env fingerprint.
1713    fingerprint: u64,
1714    /// The successful result (errored results are NEVER cached — an errored
1715    /// feature is unconditionally dirty next run).
1716    result: FeatureResult,
1717    /// The referenced names that RESOLVED against the names available at its
1718    /// position in the history. Compared for equality each run: a reference that
1719    /// starts/stops resolving (producer added/deleted/reordered) flips dirty.
1720    consumed: std::collections::BTreeSet<String>,
1721    /// Every name this feature produced or removed — the invalidation blast
1722    /// radius handed to `changed` when the entry dies.
1723    touched: HashMap<String, ()>,
1724    /// Content hash of this feature's EFFECTIVE inputs: its own `fingerprint`
1725    /// PLUS, for every consumed name, the producing feature's `output_version`
1726    /// (a Merkle hash over the feature DAG). This is what makes reuse correct
1727    /// ACROSS runs — including after a stop-point edit re-caches an upstream
1728    /// feature so it looks "clean" on the next full run. The per-run `changed`
1729    /// wavefront (below) only sees dirtiness WITHIN one run; this sees it across
1730    /// runs by comparing the input content directly. See `feature_output_version`.
1731    output_version: u64,
1732}
1733
1734thread_local! {
1735    /// Feature id -> its cached execution, persisted across `execute_history`
1736    /// calls (same thread = same wasm instance).
1737    static HISTORY_CACHE: std::cell::RefCell<HashMap<String, CachedFeature>> =
1738        std::cell::RefCell::new(HashMap::new());
1739}
1740
1741/// In-place component motion invalidates the cached producer, but must not
1742/// free its handles until the next rebuild (the current scene still uses them).
1743fn mark_component_cache_posed(id: &str) {
1744    // A sub-assembly's own solve runs inside an isolated part run, whose
1745    // component ids (`ACOMP1`, …) are the SUB-document's and name different
1746    // features of the parent's cache: marking them would force the parent's
1747    // unrelated ACOMPs to re-run. The isolated run never caches, so there is
1748    // nothing of its own to mark.
1749    if parts_library::in_isolated_run() {
1750        return;
1751    }
1752    // A pattern instance (`APAT1.ACOMP3.i2`) is cached under its pattern.
1753    let id = id.split('.').next().unwrap_or(id);
1754    HISTORY_CACHE.with(|cache| {
1755        if let Some(entry) = cache.borrow_mut().get_mut(id) {
1756            entry.posed_externally = true;
1757        }
1758    });
1759}
1760
1761/// Free a dead entry's owned resources: each produced handle and its
1762/// sheet-metal tree. The SINGLE place cached handles die.
1763fn free_entry(entry: &CachedFeature) {
1764    for added in &entry.result.added {
1765        sheet_metal::remove_tree(added.handle);
1766        crate::free_registered_solid(added.handle);
1767    }
1768}
1769
1770/// Drop the whole cache, freeing every owned handle + tree. Called by the caller on a
1771/// part/document switch (and by every Rust test for hermeticity).
1772pub fn clear_history_cache() {
1773    HISTORY_CACHE.with(|cache| {
1774        let mut cache = cache.borrow_mut();
1775        for entry in cache.values() {
1776            free_entry(entry);
1777        }
1778        cache.clear();
1779    });
1780    sheet_metal::clear_trees();
1781    // Memoized part rebuilds (the self-heal / insert lane).
1782    parts_library::clear_rebuilt_memo();
1783    // The per-coedge reading memo the boolean's operand preparation keeps.
1784    crate::reading_memo::clear();
1785    crate::mass_properties::clear_cell_memo();
1786    // The per-face mesh fragments the soundness scan and the display share.
1787    crate::watertight_tessellation::clear_face_memo();
1788    crate::soundness::pair_memo::clear();
1789    crate::soundness::fold_memo::clear();
1790    crate::pair_classification::clear_classify_memo();
1791    // The wire-harness tail's bundle solids are owned outside the per-feature
1792    // entries; they die with the cache too.
1793    wire_harness::clear_cache();
1794    // Document-switch semantics: the parts library is per-document state and
1795    // resets with the cache (the next run's request block re-seeds it).
1796    parts_library::clear_all();
1797}
1798
1799/// wasm boundary for [`clear_history_cache`].
1800#[wasm_bindgen]
1801pub fn clear_history_cache_json() {
1802    clear_history_cache();
1803}
1804
1805/// Stable-enough fingerprint of one descriptor, with its expression-bearing
1806/// string leaves EVALUATED against `env`. `DefaultHasher` is stable within a
1807/// process, which is exactly the cache's lifetime.
1808///
1809/// This is what makes expression-edit invalidation FINE-GRAINED: an edit to the
1810/// expression sheet (or the configurator) only moves the fingerprint of a feature
1811/// whose parameter VALUES actually change, instead of the old coarse
1812/// `env_fingerprint` that hashed the whole sheet into every feature and thus
1813/// dirtied everything. The env's variable + configurator values reach a feature
1814/// only through the string params it evaluates, so hashing those evaluated values
1815/// captures exactly the feature's dependency on the sheet.
1816fn descriptor_fingerprint(descriptor: &FeatureDescriptor, env: &Env) -> u64 {
1817    use std::hash::{Hash, Hasher};
1818    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1819    descriptor.feature_type.hash(&mut hasher);
1820    extension::identity(&descriptor.feature_type).hash(&mut hasher);
1821    // Both param sources: `persistent_data` (e.g. sketch coordinates) can hold
1822    // expression strings too, so it must be evaluated or an expression-driven
1823    // sketch coord would go stale on a sheet edit.
1824    hash_evaluated(&descriptor.input_params, env, &mut hasher);
1825    hash_evaluated(&descriptor.persistent_data, env, &mut hasher);
1826    hasher.finish()
1827}
1828
1829/// Feed a descriptor value into the hasher with every string leaf contributing
1830/// BOTH its raw text AND, if it parses as an expression, its evaluated value.
1831///
1832/// - Raw-ALWAYS keeps verbatim-read strings (ids, `reference_selection` names,
1833///   enum codes) honest: swapping one for a *different* string that happens to
1834///   evaluate to the same number still moves the hash.
1835/// - The evaluated value is what lets an UNRELATED sheet edit leave a
1836///   numeric-expression field untouched (its raw text `"w"` is unchanged and its
1837///   value only moves when `w` moves).
1838/// - A non-expression string simply fails to `eval` and contributes only its raw
1839///   text. A reference name that collides with a variable evaluates too → at
1840///   worst a harmless FALSE-dirty when that variable is edited (never a stale
1841///   reuse; the reference's real dependency rides the consumed-set /
1842///   `output_version`).
1843///
1844/// Object keys are visited in sorted order so the hash is representation-stable.
1845fn hash_evaluated(value: &serde_json::Value, env: &Env, hasher: &mut impl std::hash::Hasher) {
1846    use std::hash::Hash;
1847    match value {
1848        serde_json::Value::String(text) => {
1849            0u8.hash(hasher);
1850            text.hash(hasher);
1851            // Only attempt the (parsing) eval on strings short enough to be a real
1852            // expression — `persistent_data` can be large, and the raw text above
1853            // already covers everything for correctness.
1854            if text.len() <= 64 {
1855                if let Ok(number) = env.eval(text) {
1856                    1u8.hash(hasher);
1857                    number.to_bits().hash(hasher);
1858                }
1859            }
1860        }
1861        serde_json::Value::Array(items) => {
1862            2u8.hash(hasher);
1863            for item in items {
1864                hash_evaluated(item, env, hasher);
1865            }
1866        }
1867        serde_json::Value::Object(map) => {
1868            3u8.hash(hasher);
1869            let mut keys: Vec<&String> = map.keys().collect();
1870            keys.sort();
1871            for key in keys {
1872                key.hash(hasher);
1873                hash_evaluated(&map[key], env, hasher);
1874            }
1875        }
1876        other => {
1877            // Number / bool / null — canonical serialized form.
1878            4u8.hash(hasher);
1879            other.to_string().hash(hasher);
1880        }
1881    }
1882}
1883
1884/// Every suffix a stored reference can carry that is an ALIAS for the SKETCH it
1885/// names rather than a produced name of its own. The kernel run publishes a
1886/// sketch's profile under the bare sketch id, and each of these spellings
1887/// resolves back to it:
1888///
1889/// - `:PROFILE` — the profile's face-name spelling ([`SceneMap::resolve_profile`]
1890///   and [`SceneMap::resolve_path`] both strip it).
1891/// - `:FACE` — the RENDER-side display sheet a committed sketch is drawn as; its
1892///   one planar face is PICKED as `{sketch}:FACE` (`abi::display`), a name the
1893///   kernel run NEVER materializes, so `available` can only ever hold the base.
1894///   `features::common::normalize_profile_alias` strips it at execute time, and
1895///   real saved models store exactly this spelling for a sketch pick — leaving it
1896///   un-canonicalised here is what kept a sketch edit from re-running its extrude.
1897///
1898/// Field-blind on purpose: a false-dirty from an unrelated string that happens to
1899/// end this way is harmless, a missed edge is stale geometry.
1900pub const SKETCH_REFERENCE_ALIASES: [&str; 2] = [":PROFILE", ":FACE"];
1901
1902/// THE sketch-name rule, the one every profile/path consumer, the dirty
1903/// tracker (`scan_consumed`), the render gizmo and the CAM chain use: `name`
1904/// with ONE trailing [`SKETCH_REFERENCE_ALIASES`] suffix stripped, or `None`
1905/// when it carries none (or nothing would remain). Resident solid faces are
1906/// `{solid}|{face}[n]` and end in neither suffix.
1907pub fn sketch_alias_base(name: &str) -> Option<&str> {
1908    SKETCH_REFERENCE_ALIASES
1909        .iter()
1910        .find_map(|alias| name.strip_suffix(alias))
1911        .filter(|base| !base.is_empty())
1912}
1913
1914/// The canonical sketch id `name` spells: [`sketch_alias_base`], or `name`
1915/// itself when it carries no alias.
1916pub fn canonical_sketch_name(name: &str) -> &str {
1917    sketch_alias_base(name).unwrap_or(name)
1918}
1919
1920/// Look `name` up by the sketch-name rule: the EXACT key first (a real name
1921/// that happens to end in an alias suffix is never rewritten), then its
1922/// [`sketch_alias_base`]. Returns the stored key with the value.
1923pub fn sketch_alias_lookup<'a, V>(map: &'a HashMap<String, V>, name: &str) -> Option<(&'a str, &'a V)> {
1924    map.get_key_value(name)
1925        .or_else(|| sketch_alias_base(name).and_then(|base| map.get_key_value(base)))
1926        .map(|(key, value)| (key.as_str(), value))
1927}
1928
1929/// Collect every string in the descriptor (inputParams + persistentData,
1930/// recursively) that EXACT-matches a name available earlier in the history
1931/// (directly, or with a selection ALIAS suffix — [`SKETCH_REFERENCE_ALIASES`] —
1932/// stripped) — the feature's resolvable reference set.
1933/// Whether a feature's persistent data holds anything (a later feature's
1934/// id-based references live there).
1935fn has_persistent_data(value: &serde_json::Value) -> bool {
1936    match value {
1937        serde_json::Value::Null => false,
1938        serde_json::Value::Object(map) => !map.is_empty(),
1939        serde_json::Value::Array(items) => !items.is_empty(),
1940        serde_json::Value::String(text) => !text.is_empty(),
1941        _ => true,
1942    }
1943}
1944
1945/// The boolean's seam re-seat gate (lane X row 11b): `None` when a feature
1946/// after it carries persistent data (an id-based reference this walk cannot
1947/// read) or it consumes no resident solid; otherwise every face and edge name
1948/// of the solids it consumes that a later feature's inputParams text spells
1949/// (every derived edge name embeds its faces' names), sorted. The kernel
1950/// re-seats only a wall none of these names touches.
1951fn seam_reseat_references(
1952    consumed: &std::collections::BTreeSet<String>,
1953    scene: &SceneMap,
1954    later_texts: &[String],
1955    later_persistent: bool,
1956) -> Option<Vec<String>> {
1957    if later_persistent {
1958        return None;
1959    }
1960    let handles: Vec<u32> = consumed.iter().filter_map(|name| scene.solids.get(name).copied()).collect();
1961    if handles.is_empty() {
1962        return None;
1963    }
1964    let spelled = |name: &String| later_texts.iter().any(|text| text.contains(name.as_str()));
1965    let mut names: Vec<String> = scene
1966        .faces
1967        .iter()
1968        .filter(|(name, face)| handles.contains(&face.handle) && spelled(name))
1969        .map(|(name, _)| name.clone())
1970        .chain(scene.edges.iter().filter(|(name, edge)| handles.contains(&edge.handle) && spelled(name)).map(|(name, _)| name.clone()))
1971        .collect();
1972    names.sort();
1973    names.dedup();
1974    Some(names)
1975}
1976
1977fn scan_consumed(
1978    descriptor: &FeatureDescriptor,
1979    available: &HashMap<String, ()>,
1980) -> std::collections::BTreeSet<String> {
1981    fn walk(
1982        value: &serde_json::Value,
1983        available: &HashMap<String, ()>,
1984        out: &mut std::collections::BTreeSet<String>,
1985    ) {
1986        match value {
1987            serde_json::Value::String(text) => {
1988                let trimmed = text.trim();
1989                if trimmed.is_empty() {
1990                    return;
1991                }
1992                // Record the CANONICAL produced name, not the alias spelling:
1993                // a `{sketch}:PROFILE` / `{sketch}:FACE` reference resolves
1994                // against the profile published under `{sketch}`, and the dirty
1995                // check intersects this set with produced/changed names —
1996                // recording the alias verbatim made a sketch edit invisible to
1997                // its consumers (changed = {"S1"}, consumed = {"S1:PROFILE"},
1998                // no overlap). EXACT match wins first, so a real produced name
1999                // that happens to end in an alias suffix is never rewritten.
2000                if let Some((name, _)) = sketch_alias_lookup(available, trimmed) {
2001                    out.insert(name.to_string());
2002                }
2003            }
2004            serde_json::Value::Array(items) => {
2005                for item in items {
2006                    walk(item, available, out);
2007                }
2008            }
2009            serde_json::Value::Object(map) => {
2010                for item in map.values() {
2011                    walk(item, available, out);
2012                }
2013            }
2014            _ => {}
2015        }
2016    }
2017    let mut out = std::collections::BTreeSet::new();
2018    walk(&descriptor.input_params, available, &mut out);
2019    walk(&descriptor.persistent_data, available, &mut out);
2020    out
2021}
2022
2023/// The EFFECTIVE input hash of one feature: its own descriptor `fingerprint`
2024/// combined with the `output_version` of every name it consumes. Because each
2025/// producer's `output_version` is itself computed this way, this is a Merkle hash
2026/// over the whole upstream DAG: it changes iff the feature's own descriptor OR any
2027/// transitive input changed. A feature is reusable iff its cached `output_version`
2028/// still equals this — which is exactly "nothing changed for any of its inputs",
2029/// and is correct across runs (a stop-point edit that re-cached an upstream feature
2030/// bumps that feature's version, so this consumer's version no longer matches).
2031///
2032/// `consumed` is a `BTreeSet`, so the iteration order is deterministic. A consumed
2033/// name should always have a version (it came from an already-walked producer); a
2034/// missing one hashes as `0` rather than panicking.
2035fn feature_output_version(
2036    fingerprint: u64,
2037    consumed: &std::collections::BTreeSet<String>,
2038    name_version: &HashMap<String, u64>,
2039) -> u64 {
2040    use std::hash::{Hash, Hasher};
2041    let mut hasher = std::collections::hash_map::DefaultHasher::new();
2042    fingerprint.hash(&mut hasher);
2043    for name in consumed {
2044        name.hash(&mut hasher);
2045        name_version.get(name).copied().unwrap_or(0).hash(&mut hasher);
2046    }
2047    hasher.finish()
2048}
2049
2050/// Every name a result adds to the scene: solid/face/edge names, the CONTAINER
2051/// group aliases, plus the profile/frame/axis/path names from the side-channels.
2052///
2053/// This must mirror [`SceneMap::apply`] exactly — every map a name can be
2054/// RESOLVED from has to be versioned here, or a consumer referencing that name
2055/// records nothing in its consumed set, its `output_version` never moves, and it
2056/// replays stale geometry across a real upstream edit.
2057fn produced_names(result: &FeatureResult) -> Vec<String> {
2058    let mut names = Vec::new();
2059    for added in &result.added {
2060        names.push(added.name.clone());
2061        names.extend(added.face_names.iter().map(|(_, name)| name.clone()));
2062        names.extend(added.edge_names.iter().map(|(_, name)| name.clone()));
2063        // The un-keyed CONTAINER spellings (`{cap_base}_START` and the edge
2064        // names derived from it) are resolvable scene names — a model saved
2065        // before per-loop cap naming references them, and `resolve_face_group` /
2066        // `resolve_edge_group` answer. They are the name a dressup feature
2067        // actually stores, so they must version like any other produced name.
2068        names.extend(added.face_groups.iter().map(|(name, _)| name.clone()));
2069        names.extend(added.edge_groups.iter().map(|(name, _)| name.clone()));
2070    }
2071    names.extend(result.profiles.iter().map(|(name, _)| name.clone()));
2072    names.extend(result.frames.iter().map(|(name, _)| name.clone()));
2073    names.extend(result.axes.iter().map(|(name, _)| name.clone()));
2074    names.extend(result.paths.iter().map(|(name, _)| name.clone()));
2075    names.extend(result.points.iter().map(|(name, _)| name.clone()));
2076    names.extend(result.ports.iter().map(|(name, _)| name.clone()));
2077    // A component's id is a produced name: a reference to the component (a
2078    // COMPONENT-type selection in a descriptor) versions against its producer.
2079    names.extend(result.components.iter().map(|record| record.id.clone()));
2080    names.extend(result.solid_aliases.iter().map(|(legacy, _)| legacy.clone()));
2081    names
2082}
2083
2084// ===========================================================================
2085// The history loop
2086// ===========================================================================
2087
2088/// Run a whole feature history incrementally: build the expression env once,
2089/// then walk the ordered features. A CLEAN feature (see the cache section above)
2090/// replays its cached result; a DIRTY one re-executes (its old outputs are freed
2091/// first). On a feature error, record it and HALT (abort-on-error);
2092/// errored results are never cached. Never panics.
2093pub fn execute_history(request: &HistoryRequest) -> HistoryResult {
2094    execute_history_observed(request, &mut |_| true)
2095}
2096
2097/// A feature about to EXECUTE inside [`execute_history_observed`] — a dirty
2098/// one, not a cached replay (replays are instant and are not reported).
2099#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2100pub struct HistoryProgress<'a> {
2101    /// Zero-based position of the feature in the request.
2102    pub index: usize,
2103    /// The request's feature count.
2104    pub total: usize,
2105    pub id: &'a str,
2106    pub feature_type: &'a str,
2107}
2108
2109/// [`execute_history`] with a progress observer. `observe` is called
2110/// immediately BEFORE each feature the run executes (cached replays are not
2111/// reported); returning `false` stops the run at that boundary — the feature
2112/// is not executed, nothing after it runs, and the result holds what was
2113/// built so far. The boundary is the only cooperative stop the kernel offers:
2114/// nothing inside a feature (a boolean, a blend, a sew) checks anything.
2115///
2116/// The cache bookkeeping is untouched by an early stop: the entries of the
2117/// features that did run are as valid as after a full run, and the entries of
2118/// the ones that did not were retained as parse-only exactly as under a
2119/// `stop_before_id`, so the next full run replays the former and executes the
2120/// latter.
2121pub fn execute_history_observed(
2122    request: &HistoryRequest,
2123    observe: &mut dyn FnMut(HistoryProgress<'_>) -> bool,
2124) -> HistoryResult {
2125    let _plugin_scope = extension::enter(&request.plugins);
2126    let env = Env::build(&request.expressions, &request.configurator)
2127        .unwrap_or_else(Env::poisoned);
2128
2129    // Seed the kernel-resident parts library from the request block (document
2130    // load; resident-wins rules keep a stale block from undoing kernel-side
2131    // heals/refreshes — see parts_library.rs).
2132    parts_library::ingest(&request.parts_library);
2133
2134    // Entries whose feature was DELETED from the history die now. Their
2135    // consumers go dirty via the consumed-set equality check (the vanished names
2136    // drop out of the available set), so no changed-seeding is needed here.
2137    let request_ids: HashMap<String, ()> = request
2138        .features
2139        .iter()
2140        .map(|descriptor| (extract_id(&descriptor.input_params), ()))
2141        .collect();
2142    HISTORY_CACHE.with(|cache| {
2143        let mut cache = cache.borrow_mut();
2144        cache.retain(|id, entry| {
2145            let keep = request_ids.contains_key(id);
2146            if !keep {
2147                free_entry(entry);
2148            }
2149            keep
2150        });
2151    });
2152
2153    let mut scene = SceneMap::default();
2154    let mut results = Vec::with_capacity(request.features.len());
2155    // Per-feature wall-clock timing, in run order (`web_time::Instant` — plain
2156    // `std::time::Instant` traps 'unreachable' on wasm32).
2157    let mut timings: Vec<(String, f64)> = Vec::with_capacity(request.features.len());
2158    // Names whose content changed this run (produced/removed by any re-executed
2159    // or invalidated feature) — the dirty wavefront.
2160    let mut changed: HashMap<String, ()> = HashMap::new();
2161    // Names available for reference so far this run (from clean AND dirty
2162    // features alike).
2163    let mut available: HashMap<String, ()> = HashMap::new();
2164    // Each available name -> the `output_version` of the feature that produced it
2165    // this run (walk order: a boolean that reuses its target name overwrites the
2166    // producer's version, so consumers depend on the boolean's version). Feeds
2167    // `feature_output_version` so cross-run reuse is decided by input CONTENT.
2168    let mut name_version: HashMap<String, u64> = HashMap::new();
2169    let mut seen_ids: HashMap<String, ()> = HashMap::new();
2170
2171    // The declared connection points whose placement nothing the walk builds
2172    // can move, published BEFORE it so a SPLINE in the same document can
2173    // attach to one and a sketch can sit on its frame (`ports.rs` states the
2174    // rule and why this is the one deviation from tail-only resolution). Each
2175    // name carries the CONTENT VERSION of its declaration, so a consumer is
2176    // invalidated when the point moves.
2177    for (name, version) in ports::seed_history_run(request, &mut scene, &env) {
2178        available.insert(name.clone(), ());
2179        name_version.insert(name, version);
2180    }
2181
2182    let total = request.features.len();
2183    // Lane X row 11b: each feature's inputParams text, and whether any feature
2184    // AFTER index i carries persistent data (an id-based reference this walk
2185    // cannot read), for the boolean feature's seam re-seat gate.
2186    let input_texts: Vec<String> = request.features.iter().map(|descriptor| descriptor.input_params.to_string()).collect();
2187    let mut persistent_after = vec![false; total + 1];
2188    for index in (0..total).rev() {
2189        persistent_after[index] = persistent_after[index + 1] || has_persistent_data(&request.features[index].persistent_data);
2190    }
2191    for (index, descriptor) in request.features.iter().enumerate() {
2192        let id = extract_id(&descriptor.input_params);
2193        // Editor stop-point: BEFORE this feature — the rest of the request is
2194        // parse-only (their cache entries were already retained above).
2195        if request.stop_before_id.as_deref() == Some(id.as_str()) {
2196            break;
2197        }
2198        // An empty or duplicated id cannot key a cache entry: execute plainly,
2199        // touching no cached state (a duplicate must not free the entry its
2200        // first occurrence just wrote).
2201        let cacheable = !id.is_empty() && seen_ids.insert(id.clone(), ()).is_none();
2202        let fingerprint = descriptor_fingerprint(descriptor, &env);
2203        // Assemblies: an ACOMP instance's effective input includes its
2204        // parts-library entry's CONTENT (key/signature/document — not the
2205        // snapshot, so a self-heal does not dirty it), and a DIRTY entry
2206        // (refresh_library_entry) forces re-execution outright. A no-op for
2207        // every other feature type.
2208        let (fingerprint, library_dirty) =
2209            parts_library::mix_descriptor_fingerprint(descriptor, fingerprint);
2210        let mut consumed = scan_consumed(descriptor, &available);
2211        // A boolean with an operation and no picked target runs into the
2212        // part's ONLY solid, which its descriptor never names
2213        // (`common::implicit_boolean_target`). It consumes every candidate
2214        // solid instead, so the cache re-runs it when that solid changes, is
2215        // renamed, or gains a sibling — and a NONE boolean is untouched.
2216        if features::common::wants_implicit_target(&descriptor.feature_type, &descriptor.input_params) {
2217            consumed.extend(features::common::implicit_target_candidates(&scene));
2218        }
2219        let reseat_references = if matches!(descriptor.feature_type.as_str(), "B" | "BOOLEAN") {
2220            seam_reseat_references(&consumed, &scene, &input_texts[index + 1..], persistent_after[index + 1])
2221        } else {
2222            None
2223        };
2224        // What the boolean may re-seat changes what it builds, so it keys the cache.
2225        let fingerprint = match &reseat_references {
2226            Some(names) => {
2227                use std::hash::{Hash, Hasher};
2228                let mut hasher = std::collections::hash_map::DefaultHasher::new();
2229                (fingerprint, "seam-reseat", names).hash(&mut hasher);
2230                hasher.finish()
2231            }
2232            None => fingerprint,
2233        };
2234        // Merkle input hash over this feature + its consumed producers' versions.
2235        // Computed for EVERY feature (cacheable or not) so its produced names can
2236        // publish a version for downstream consumers.
2237        let output_version = feature_output_version(fingerprint, &consumed, &name_version);
2238
2239        let clean = cacheable
2240            && !library_dirty
2241            && !request.force_rebuild.iter().any(|forced| forced == &id)
2242            && HISTORY_CACHE.with(|cache| {
2243                cache.borrow().get(&id).is_some_and(|entry| {
2244                    !entry.posed_externally
2245                        && entry.fingerprint == fingerprint
2246                        && entry.consumed == consumed
2247                        && consumed.iter().all(|name| !changed.contains_key(name))
2248                        // The cross-run clause: reuse only if the effective input
2249                        // content is byte-identical to what this entry was built
2250                        // from. Subsumes the three clauses above (they are hashed
2251                        // in); kept additively so the diff stays minimal and the
2252                        // handle-ownership / blast-radius paths are untouched.
2253                        && entry.output_version == output_version
2254                })
2255            });
2256
2257        if clean {
2258            let mut result =
2259                HISTORY_CACHE.with(|cache| cache.borrow().get(&id).unwrap().result.clone());
2260            result.reused = true;
2261            scene.apply(&result);
2262            for name in produced_names(&result) {
2263                available.insert(name.clone(), ());
2264                // A clean feature's current version equals its cached one; publish
2265                // it so downstream consumers hash the SAME value they cached against.
2266                name_version.insert(name, output_version);
2267            }
2268            results.push(result);
2269            timings.push((id.clone(), 0.0)); // replayed, not re-executed
2270            // Editor stop-point: AFTER this feature.
2271            if request.stop_at_id.as_deref() == Some(id.as_str()) {
2272                break;
2273            }
2274            continue;
2275        }
2276
2277        // Dirty: the old entry (if any) dies — its blast radius joins `changed`.
2278        if cacheable {
2279            if let Some(old) = HISTORY_CACHE.with(|cache| cache.borrow_mut().remove(&id)) {
2280                free_entry(&old);
2281                changed.extend(old.touched.iter().map(|(name, _)| (name.clone(), ())));
2282            }
2283        }
2284
2285        // The observer's boundary: the feature is dirty and about to execute.
2286        if !observe(HistoryProgress {
2287            index,
2288            total,
2289            id: &id,
2290            feature_type: &descriptor.feature_type,
2291        }) {
2292            break;
2293        }
2294
2295        // Anything left in the ledger by a DIRECT api call on this thread
2296        // (`move_faces_json`, an MCP edit) belongs to that call, not to the
2297        // feature about to run: drop it rather than attribute it wrongly.
2298        let _ = crate::take_crossing_repairs();
2299        let _ = crate::take_blend_notes();
2300        let _ = crate::take_blend_approximations(&[]);
2301        // The feature is ONE blend operation however many blend calls it
2302        // makes: none of them drops another's reports before the consume below.
2303        let blend_operation = crate::blend::BlendOperation::enter();
2304        let feature_profile = crate::boolean::profile::Feature::begin();
2305        let feat_start = web_time::Instant::now();
2306        let mut result = execute_feature_with_later(descriptor, &env, &scene, reseat_references.as_deref());
2307        let feature_ms = feat_start.elapsed().as_secs_f64() * 1000.0;
2308        feature_profile.finish(&id, feature_ms);
2309        timings.push((id.clone(), feature_ms));
2310        // A result the soundness acceptance REPAIRED rather than returned as
2311        // built says so on the feature that built it. Drained here, so a repair
2312        // made by any lane reaches the caller's report by one route instead of
2313        // each lane growing a channel of its own.
2314        result.notes.extend(
2315            crate::take_crossing_repairs()
2316                .into_iter()
2317                .map(|repair| repair.to_string()),
2318        );
2319        // What a blend that shipped says about its own wall (a station ladder
2320        // that ran out with its rails still off their carriers), by the same
2321        // route.
2322        result.notes.extend(crate::take_blend_notes());
2323        // A fillet wall that shipped short of its construction request says so
2324        // TYPED, on the body that carries it: ONE snapshot of the ledger read
2325        // against EVERY body this feature returned. A report whose wall no
2326        // returned body carries (a lane that refused, a body a later step
2327        // discarded) is dropped; a refused feature returns no body, so it
2328        // carries none. Attached before the result is cached, so a replay
2329        // carries the same typed reports.
2330        if crate::blend::blend_approximations_pending() {
2331            let solids: Vec<(String, crate::BrepSolid)> = result
2332                .added
2333                .iter()
2334                .filter_map(|added| crate::registered_solid_clone(added.handle).ok().map(|solid| (added.name.clone(), solid)))
2335                .collect();
2336            let bodies: Vec<(&str, &crate::BrepSolid)> = solids.iter().map(|(name, solid)| (name.as_str(), solid)).collect();
2337            result.approximations.extend(crate::take_blend_approximations(&bodies));
2338        }
2339        drop(blend_operation);
2340        // `halt` reflects the feature's OWN error only — a naming collision flags
2341        // the feature but does NOT truncate the history (the model still renders).
2342        let halt = result.error.is_some();
2343        let collisions_before = scene.name_collisions.len();
2344        scene.apply(&result);
2345        // Naming-contract guard: if applying this feature's output left two RESIDENT
2346        // solids sharing a face/edge name, surface it as the feature's error so the
2347        // operation trips its own tests + shows an error in the app (the hint points
2348        // at `common::namespace_copy_names`). Fresh path only — a cached replay
2349        // carries its collision error, if any, already.
2350        if result.error.is_none() && scene.name_collisions.len() > collisions_before {
2351            result.error = Some(name_collision_error(
2352                &scene.name_collisions[collisions_before..],
2353            ));
2354        }
2355
2356        // Kernel-owned topology metadata: every face/edge this feature produced
2357        // records its producer (the `_seedSourceFeatureMetadata` step, moved here).
2358        // Non-overwriting — faces propagated through a boolean keep their
2359        // original sourceFeatureId, matching the established seed semantics.
2360        if !id.is_empty() {
2361            let mut seed = serde_json::Map::new();
2362            seed.insert("sourceFeatureId".into(), serde_json::Value::String(id.clone()));
2363            for added in &result.added {
2364                for (_, face_name) in &added.face_names {
2365                    scene_metadata::merge_record(face_name, &seed, false);
2366                }
2367                for (_, edge_name) in &added.edge_names {
2368                    scene_metadata::merge_record(edge_name, &seed, false);
2369                }
2370            }
2371        }
2372
2373        // Enrich every miss with the name that replaced it, when the miss looks
2374        // like the per-loop naming change (see `annotate_rename`).
2375        for name in &mut result.unresolved {
2376            *name = annotate_rename(&scene, name);
2377        }
2378        // The same hint on the typed report, in the same pass, so the two
2379        // lists never say different things about one miss.
2380        if let Some(fulfilment) = &mut result.fulfilment {
2381            for rejected in &mut fulfilment.rejected {
2382                rejected.name = annotate_rename(&scene, &rejected.name);
2383            }
2384        }
2385
2386        let produced = produced_names(&result);
2387        for name in &produced {
2388            available.insert(name.clone(), ());
2389            changed.insert(name.clone(), ());
2390            // Publish this run's version for the name. Runs for cacheable AND
2391            // non-cacheable (empty/duplicate id) features alike — a non-cacheable
2392            // feature has no cache entry but its outputs still version their
2393            // downstream consumers correctly.
2394            name_version.insert(name.clone(), output_version);
2395        }
2396        for name in &result.removed {
2397            changed.insert(name.clone(), ());
2398        }
2399
2400        if cacheable && !halt {
2401            let mut touched: HashMap<String, ()> =
2402                produced.into_iter().map(|name| (name, ())).collect();
2403            touched.extend(result.removed.iter().map(|name| (name.clone(), ())));
2404            HISTORY_CACHE.with(|cache| {
2405                cache.borrow_mut().insert(
2406                    id.clone(),
2407                    CachedFeature {
2408                        posed_externally: false,
2409                        fingerprint,
2410                        result: result.clone(),
2411                        consumed,
2412                        touched,
2413                        output_version,
2414                    },
2415                );
2416            });
2417        }
2418
2419        results.push(result);
2420        if halt {
2421            break;
2422        }
2423        // Editor stop-point: AFTER this feature.
2424        if request.stop_at_id.as_deref() == Some(id.as_str()) {
2425            break;
2426        }
2427    }
2428    // Orphan GC: a parts-library entry whose last ACOMP instance is gone from
2429    // the request is dropped now (orphaned payloads never accumulate, the
2430    // request carries the FULL feature list even under a stop point, so
2431    // truncation cannot GC live entries).
2432    parts_library::gc_after_rebuild(request);
2433    // Assembly tail: solve the request's constraints against the just-built
2434    // scene and install the post-solve session the exported assembly ABI
2435    // serves. Unconditional, so a document without an `assembly` block
2436    // resets any stale session.
2437    assembly::finish_history_run(request, &mut scene, &env);
2438    // Wire-harness tail: route the block's connections over the ports and
2439    // spline segments the run published, and build the bundle solids. Its
2440    // solids ride an extra result so the display pipeline shows them through
2441    // the standard solid path. Runs AFTER the assembly solve so the bundles
2442    // are built against the posed scene.
2443    // Ports tail: resolve the declared-ports block against the FINISHED scene
2444    // (a port point may reference any geometry in the part, which is why this
2445    // runs here and not as a history feature) and publish every point. Before
2446    // the harness, which routes over what this published.
2447    let ports = ports::finish_history_run(request, &mut scene, &env);
2448    if let Some(result) = ports.result {
2449        results.push(result);
2450    }
2451    let harness = wire_harness::finish_history_run(request, &mut scene, &env);
2452    if let Some(result) = harness.result {
2453        results.push(result);
2454    }
2455    // PMI tail: resolve every view's annotations against the finished scene
2456    // (bundles and posed components included). Pure reads — never touches
2457    // the cache.
2458    let pmi = pmi::finish_history_run(request, &scene, &env);
2459    HistoryResult {
2460        results,
2461        timings,
2462        wire_harness: harness.report,
2463        pmi,
2464        ports: ports.report,
2465    }
2466}
2467
2468// ===========================================================================
2469// wasm boundary
2470// ===========================================================================
2471
2472/// Deserialize a history request, run it, and serialize the per-feature results.
2473/// The handles in the result are `u32`s the caller tessellates / pulls names
2474/// from via the existing `*_handle` wasm exports.
2475#[wasm_bindgen]
2476pub fn execute_history_json(request_json: &str) -> Result<String, JsValue> {
2477    let request: HistoryRequest = serde_json::from_str(request_json)
2478        .map_err(|error| JsValue::from_str(&format!("execute_history_json: bad request: {error}")))?;
2479    let result = execute_history(&request);
2480    serde_json::to_string(&result).map_err(|error| JsValue::from_str(&error.to_string()))
2481}
2482
2483
2484