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