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