Skip to main content

brep_render/
pipeline.rs

1//! History → scene: run a whole feature history through the kernel's native
2//! `execute_history` (same process, same thread — the solid registry is
3//! thread-local) and populate a [`RenderScene`] from the resident handles via
4//! the kernel's native display payload accessor. No JSON, no typed-array
5//! boundary — the R1 promise.
6
7use crate::scene::{solid_display_from_payload, RenderScene, SolidDisplay};
8use brep_kernel::{display_payload_handle_native, execute_history, HistoryRequest};
9use std::collections::HashMap;
10
11/// Non-fatal diagnostics from a scene build (mirrors the previous app's run-history
12/// reporting: a failed feature halts the remaining features but the solids
13/// built so far still display — seeing what a failing history DID build is the
14/// point of the artifact).
15#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
16pub struct SceneBuildReport {
17    /// Successful plugin callback state, committed with the accepted scene only.
18    #[serde(default)]
19    pub plugin_persistent_data: Vec<(String, serde_json::Value)>,
20    /// Per-feature hard errors, as `"<feature id>: <message>"`.
21    pub feature_errors: Vec<String>,
22    /// Unresolved reference-selection names, as `"<feature id>: <name>"`.
23    pub unresolved: Vec<String>,
24    /// What the kernel REPAIRED while building a feature, as
25    /// `"<feature id>: <what>"` — today, a self-crossing the soundness
26    /// acceptance split and re-trimmed rather than refused. Not an error: the
27    /// feature SUCCEEDED, with a result the kernel changed on its way out, and
28    /// a change the user cannot see is the one thing a repair must never be.
29    /// `#[serde(default)]` keeps reports serialized before this field crossing
30    /// the worker boundary.
31    #[serde(default)]
32    pub feature_notes: Vec<String>,
33    /// What a SUCCESSFUL feature did with what it was asked, when not everything
34    /// (`brep_kernel::FeatureResult::fulfilment`): `(feature id, fulfilment)` in
35    /// run order, only for the features that report one. A partial is a
36    /// different answer from the one asked for, so the history tree shows it on
37    /// the feature's row and the run report carries it typed. `#[serde(default)]`
38    /// for reports serialized before this field.
39    #[serde(default)]
40    pub feature_fulfilment: Vec<(String, brep_kernel::Fulfilment)>,
41    /// The kernel's typed refusal for each feature that failed with one
42    /// (`brep_kernel::FeatureResult::refusal`): `(feature id, refusal, refused
43    /// step)` in run order — the class, stage and text `feature_errors` flattens
44    /// to a line, and the motion step that refused when the feature records one
45    /// (`FeatureResult::refused_step`). Features whose refusal is text only do
46    /// not appear. Additive: `#[serde(default)]` for reports serialized before
47    /// it, and omitted when empty.
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub feature_refusals: Vec<(String, brep_kernel::KernelRefusal, Option<String>)>,
50    /// The MEASURED approximations each SUCCESSFUL feature carries
51    /// (`brep_kernel::FeatureResult::approximations`): `(feature id,
52    /// approximations)` in run order, only for the features that carry any.
53    /// The result stands and this says by how much, and why, it is not exact —
54    /// the third kind of report beside a refusal (the result does not stand)
55    /// and a fulfilment (what was done of what was asked); today the STEP
56    /// import's shell-closure gate (`import.shell_closure`) is the producer.
57    /// Additive: `#[serde(default)]` for reports serialized before it, and
58    /// omitted when empty.
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub feature_approximations: Vec<(String, Vec<brep_kernel::Approximation>)>,
61    /// The solid each no-target boolean ran into
62    /// (`brep_kernel::FeatureResult::boolean_auto_target`): `(feature id, solid
63    /// name)` in run order, only for the features whose boolean named no target
64    /// and found the part's ONLY solid. The feature panel shows it on the
65    /// boolean's target field as `Auto: <name>`. Additive: `#[serde(default)]`
66    /// for reports serialized before it, and omitted when empty.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub feature_auto_targets: Vec<(String, String)>,
69    /// Solids whose display payload (tessellation) failed, as
70    /// `"<solid name>: <message>"` — the solid is skipped, not fatal.
71    pub display_errors: Vec<String>,
72    /// Per-feature wall-clock execution time `(feature id, milliseconds)` in run
73    /// order, carried through from the kernel's [`brep_kernel::HistoryResult`]
74    /// timings — the history tree's "N ms" readout.
75    pub feature_timings: Vec<(String, f64)>,
76    /// Per-feature output solid names `(feature id, [solid names])` in run order —
77    /// the history tree's read-only "Outputs" node.
78    pub feature_outputs: Vec<(String, Vec<String>)>,
79    /// Named plane FRAMES this run resolved `(frame name, frame)`, in run order —
80    /// every DATUM registers three (`{id}:XY|XZ|YZ`), every PLANE one (`{id}`), and
81    /// a SKETCH its own plane (`{id}`). Surfaced here so the engine can DISPLAY the
82    /// construction datum/plane frames (filtered to the D/P producing features) as
83    /// first-class scene citizens — the resolved frames ride
84    /// [`brep_kernel::FeatureResult::frames`] straight through, no kernel change.
85    pub frames: Vec<(String, brep_kernel::Frame)>,
86    /// Solved sketch PROFILES this run produced `(sketch id, profile)`, in run
87    /// order — every SKETCH feature publishes one under its own id. Surfaced here
88    /// (exactly like [`Self::frames`]) so the engine can display each committed
89    /// sketch as a SHEET SOLID (planar face + named boundary edges + corner
90    /// vertices) via `sketch_display_payload`, no kernel-contract change.
91    pub profiles: Vec<(String, brep_kernel::SketchProfile)>,
92    /// Named axis LINES this run produced `(axis name, line)`, in run order — a
93    /// SKETCH publishes one per line geometry (construction included). Surfaced
94    /// here (exactly like [`Self::frames`]/[`Self::profiles`]) so the engine can
95    /// resolve a revolve/sweep `axis` reference to a world line at annotation-build
96    /// time, fully headless. `#[serde(default)]` keeps older serialized reports
97    /// (pre-`axes`) deserializing cleanly across the worker boundary.
98    #[serde(default)]
99    pub axes: Vec<(String, brep_kernel::Axis)>,
100    /// Named PATH chains this run produced `(path name, curves)`, in run order — a
101    /// SKETCH publishes its whole ordered chain under `{id}` and EACH model segment
102    /// under `{id}:G{gid}`. Surfaced here (exactly like [`Self::profiles`]) so the
103    /// engine can draw a committed sketch's OPEN geometry: an open chain closes no
104    /// region, so it publishes no profile, and before this the sheet builder had
105    /// nothing to draw it from — an open sketch was invisible in 3D. `#[serde(default)]`
106    /// keeps older serialized reports (pre-`paths`) deserializing cleanly across the
107    /// worker boundary.
108    #[serde(default)]
109    pub paths: Vec<(String, Vec<brep_kernel::NurbsCurve>)>,
110    /// Named world POINTS this run produced `(point name, point)`, in run order —
111    /// a SKETCH publishes every solved point under `{id}:P{pid}` with its
112    /// construction flag. Surfaced here (exactly like [`Self::paths`]) so the
113    /// engine can draw a committed sketch's STANDALONE points: a points-only
114    /// sketch (a hole-placement sketch) has no segment and no profile, so before
115    /// this the sheet builder had nothing to draw it from — it was invisible and
116    /// unpickable in 3D. `#[serde(default)]` keeps older serialized reports
117    /// (pre-`points`) deserializing cleanly across the worker boundary.
118    #[serde(default)]
119    pub points: Vec<(String, brep_kernel::ScenePoint)>,
120    /// The wire-harness tail's routing report (endpoints, segments, one route
121    /// per connection, bundles). Read RUNNER-SIDE like everything else here —
122    /// the tail ran on the runner's thread — and shipped so the harness panel
123    /// reads it off the applied run. `#[serde(default)]` keeps older serialized
124    /// reports deserializing across the worker boundary.
125    #[serde(default)]
126    pub wire_harness: Option<brep_kernel::WireHarnessReport>,
127    /// The PMI tail's resolution of every view's annotations (`None` when the
128    /// document carries no `pmi` block).
129    #[serde(default)]
130    pub pmi: Option<brep_kernel::PmiReport>,
131    /// The PORTS tail's resolution of the declared-ports block (`None` when the
132    /// document declares none). Shipped for the same reason the two above are:
133    /// the tail is not a feature, so its refused names and unresolved
134    /// references reach no feature row — the Qualify panel reads them here.
135    #[serde(default)]
136    pub ports: Option<brep_kernel::PortsReport>,
137}
138
139/// Execute a serialized `HistoryRequest` (the `execute_history_json` request
140/// shape — a saved part file parses as one) and build the display scene from
141/// the final resident solids.
142pub fn scene_from_history_json(
143    request_json: &str,
144) -> Result<(RenderScene, SceneBuildReport), String> {
145    let request: HistoryRequest = serde_json::from_str(request_json)
146        .map_err(|error| format!("history request parse: {error}"))?;
147    scene_from_history(&request)
148}
149
150/// Typed-request variant of [`scene_from_history_json`].
151pub fn scene_from_history(
152    request: &HistoryRequest,
153) -> Result<(RenderScene, SceneBuildReport), String> {
154    let mut scene = RenderScene::new();
155    let report = update_scene_from_history(&mut scene, request)?;
156    Ok((scene, report))
157}
158
159/// Explicitly provision trusted packages for a one-shot headless scene build.
160/// The document itself provides only exact pins; loading it never installs code.
161pub fn scene_from_history_with_plugins(
162    request: &HistoryRequest,
163    runtime: &brep_plugins::Runtime,
164) -> Result<(RenderScene, SceneBuildReport), String> {
165    runtime.with_provider(|| scene_from_history(request))
166}
167
168/// The fold of a history run into an ordered scene layout: each entry is the
169/// solid's final name, its resident handle, and whether the feature that
170/// produced it REPLAYED from the incremental cache (R10 — a reused solid is the
171/// same resident geometry, so its display can be kept verbatim and its GPU
172/// buffers reused).
173struct SceneLayout {
174    order: Vec<String>,
175    handles: std::collections::HashMap<String, u32>,
176    reused: std::collections::HashSet<String>,
177    /// `name -> creating-feature id` of the FINAL resident SOLIDS (last writer
178    /// wins; a `removed` name drops its entry) — the eager provenance the runner
179    /// ships so the main thread never has to re-run the history to answer
180    /// "what feature produced this SOLID?". Matches the resident semantics of the
181    /// old `resident_handles_and_creators`.
182    creators: std::collections::HashMap<String, String>,
183    /// `face/edge NAME -> ORIGINATING feature id` — the FIRST feature (timeline
184    /// order) to emit each face/edge name, i.e. the entity's TRUE origin (the
185    /// feature that gave it its name). Unlike `creators` this is FIRST-writer-wins
186    /// and is NEVER pruned on `removed`: a boolean removes its target solid and
187    /// re-adds the same solid name carrying mostly the same face names, so pruning
188    /// then re-adding would reset those origins to the boolean feature — the same
189    /// last-writer bug one level down. Accepted edge case: a fully-deleted solid
190    /// whose name later recurs on unrelated geometry keeps its old origin — but
191    /// under this app's DETERMINISTIC naming a recurring name is the same
192    /// conceptual entity, and the whole map is rebuilt every run, so it can never
193    /// point at a feature that was deleted from the history.
194    entity_origin: std::collections::HashMap<String, String>,
195}
196
197/// Whether `request` is an ASSEMBLY document: any ACOMP-typed feature, or a
198/// present `assembly` constraint block. Mirrors
199/// `EngineState::history_has_assembly` — a componentless document ships no
200/// [`AssemblySync`] and the main side clears its projection, exactly as the
201/// main-side sync used to.
202fn request_has_assembly(request: &HistoryRequest) -> bool {
203    request.assembly.is_some()
204        || request.features.iter().any(|feature| {
205            feature.feature_type.eq_ignore_ascii_case("ACOMP")
206                || feature.feature_type.eq_ignore_ascii_case("ASSEMBLY COMPONENT")
207        })
208}
209
210fn fold_history(result: &brep_kernel::HistoryResult, report: &mut SceneBuildReport) -> SceneLayout {
211    // Removals first (a boolean result reuses a removed target's name), then
212    // additions, insertion-ordered — mirrors SceneMap::apply.
213    let mut order: Vec<String> = Vec::new();
214    let mut handles: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
215    let mut reused: std::collections::HashSet<String> = std::collections::HashSet::new();
216    let mut creators: std::collections::HashMap<String, String> = std::collections::HashMap::new();
217    let mut entity_origin: std::collections::HashMap<String, String> =
218        std::collections::HashMap::new();
219    for (index, feature) in result.results.iter().enumerate() {
220        if let Some(data) = &feature.persistent_data {
221            if feature.error.is_none() { report.plugin_persistent_data.push((feature.id.clone(), data.clone())); }
222        }
223        // A feature whose `inputParams` carry no `id` still has to be nameable in
224        // the report, or its errors read as `": <message>"` and point at nothing.
225        // Its POSITION is the only handle it has, and the one `feature_delete`
226        // takes to remove it again.
227        let label = if feature.id.is_empty() { format!("feature[{index}]") } else { feature.id.clone() };
228        for removed in &feature.removed {
229            if handles.remove(removed).is_some() {
230                order.retain(|name| name != removed);
231            }
232            reused.remove(removed);
233            creators.remove(removed);
234            // NOTE: `entity_origin` is deliberately NOT pruned here — see its doc on
235            // `SceneLayout`. First-writer-with-no-pruning is the whole point.
236        }
237        for added in &feature.added {
238            if handles.insert(added.name.clone(), added.handle).is_none() {
239                order.push(added.name.clone());
240            }
241            creators.insert(added.name.clone(), feature.id.clone());
242            // The FIRST feature to emit a given face/edge name IS its origin (this
243            // loop is in timeline order). `entry(..).or_insert_with` keeps that
244            // first writer — using `insert` here would be last-writer and silently
245            // reproduce the exact bug this map exists to fix.
246            for (_, name) in &added.face_names {
247                entity_origin
248                    .entry(name.clone())
249                    .or_insert_with(|| feature.id.clone());
250            }
251            for (_, name) in &added.edge_names {
252                entity_origin
253                    .entry(name.clone())
254                    .or_insert_with(|| feature.id.clone());
255            }
256            // A solid displays as reused only when its whole producing feature
257            // replayed unchanged; a re-run feature re-tessellates.
258            if feature.reused {
259                reused.insert(added.name.clone());
260            } else {
261                reused.remove(&added.name);
262            }
263        }
264        if let Some(error) = &feature.error {
265            report.feature_errors.push(format!("{label}: {error}"));
266        }
267        for name in &feature.unresolved {
268            report.unresolved.push(format!("{label}: {name}"));
269        }
270        for note in &feature.notes {
271            report.feature_notes.push(format!("{label}: {note}"));
272        }
273        if let Some(fulfilment) = &feature.fulfilment {
274            report
275                .feature_fulfilment
276                .push((feature.id.clone(), fulfilment.clone()));
277        }
278        if let Some(refusal) = &feature.refusal {
279            report
280                .feature_refusals
281                .push((feature.id.clone(), refusal.clone(), feature.refused_step.clone()));
282        }
283        if !feature.approximations.is_empty() {
284            report
285                .feature_approximations
286                .push((feature.id.clone(), feature.approximations.clone()));
287        }
288        if let Some(target) = &feature.boolean_auto_target {
289            report
290                .feature_auto_targets
291                .push((feature.id.clone(), target.clone()));
292        }
293        // The feature's output solid name(s) — the history tree's Outputs node.
294        report.feature_outputs.push((
295            feature.id.clone(),
296            feature.added.iter().map(|a| a.name.clone()).collect(),
297        ));
298        // The named plane frames this feature resolved (DATUM three / PLANE one /
299        // SKETCH its own). Carried straight through so the engine can display the
300        // construction datum/plane frames (it filters to the D/P producers).
301        for (name, frame) in &feature.frames {
302            report.frames.push((name.clone(), *frame));
303        }
304        // The solved sketch profile (SKETCH features publish one under `{id}`) —
305        // carried straight through so the engine can synthesize its sheet solid.
306        for (name, profile) in &feature.profiles {
307            report.profiles.push((name.clone(), profile.clone()));
308        }
309        // The named axis lines this feature published (a SKETCH emits one per line
310        // geometry) — carried through so the engine can resolve a revolve `axis`
311        // reference to a world line for the angle gizmo.
312        for (name, axis) in &feature.axes {
313            report.axes.push((name.clone(), *axis));
314        }
315        // The named path chains this feature published (a SKETCH emits its whole
316        // chain under `{id}` and every model segment under `{id}:G{gid}`) — carried
317        // through so the engine can draw the segments no closed profile covers.
318        for (name, curves) in &feature.paths {
319            report.paths.push((name.clone(), curves.clone()));
320        }
321        // The named world points this feature published (a SKETCH emits every
322        // solved point under `{id}:P{pid}`) — carried through so the engine can
323        // draw a sketch's standalone points, which no segment covers.
324        for (name, point) in &feature.points {
325            report.points.push((name.clone(), *point));
326        }
327    }
328    // Per-feature timing rides the kernel result straight through.
329    report.feature_timings = result.timings.clone();
330    // The wire-harness routing report rides through the same way; its bundle
331    // solids arrived above as the appended `WireHarness` result's `added`.
332    report.wire_harness = result.wire_harness.clone();
333    report.pmi = result.pmi.clone();
334    report.ports = result.ports.clone();
335    SceneLayout { order, handles, reused, creators, entity_origin }
336}
337
338/// Executes history without accessing the render scene and emits display deltas.
339/// Tracks resident handles so unchanged solids retain their meshes and GPU
340/// buffers when the main thread applies the resulting [`RunOutput`].
341pub struct SceneRunner {
342    pub(crate) plugin_revision: Option<u64>,
343    plugins: Option<brep_plugins::Runtime>,
344    plugin_error: Option<String>,
345    /// Last emitted handle per solid name. Handles are never recycled, so a
346    /// matching handle identifies a cache replay whose display can be reused.
347    last_sent: HashMap<String, u32>,
348    /// Baseline tessellation LOD. A change clears `last_sent` so even unchanged
349    /// handles receive meshes built with the new chord tolerance.
350    last_lod: f64,
351    /// Revision installed via `Command::SetPartsLibrary`. Runs with a different
352    /// revision are refused; `reset` clears this alongside the kernel store.
353    pub(crate) parts_library_revision: Option<u64>,
354    /// Names in the last library install. A missing installed part triggers a
355    /// library reload; a name never installed proceeds to the feature's normal
356    /// unresolved-reference error, avoiding an endless reload loop.
357    pub(crate) parts_library_names: std::collections::BTreeSet<String>,
358    /// The run whose tail installed this thread's assembly session: its
359    /// generation and the kernel's session serial right after it. A session
360    /// anything else has replaced since (serial moved) is not that run's.
361    pub(crate) session_of: Option<(u64, u64)>,
362}
363
364/// One placed component as the run left it — the engine's own projection of the
365/// kernel's `ComponentRecord`, which is a `#[serde(skip)]` side-channel and
366/// carries no serde derives of its own. Shipped in the reply so the main thread
367/// has the post-solve component set without re-running the history.
368#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
369pub struct ComponentSnapshot {
370    /// The owning ACOMP feature id — also the namespace prefix segment.
371    pub id: String,
372    /// The display / parts-library part name.
373    pub part_name: String,
374    /// The rigid instance pose (part-local snapshot space -> assembly space)
375    /// as the kernel's row-major 4x4; [`Self::affine`] wraps it back up.
376    pub transform: [f64; 16],
377    /// Evaluated pose before the constraint solver adjusts it.
378    #[serde(default)]
379    pub requested_transform: Option<[f64; 16]>,
380    /// Authored expressions captured at the run boundary.
381    #[serde(default)]
382    pub authored_transform: serde_json::Value,
383    /// Grounded flag (the feature's `isFixed`).
384    pub fixed: bool,
385    /// Opaque source metadata (`sourceKey` / `sourceSignature` / …).
386    pub source: serde_json::Value,
387    /// Member solid scene names, already namespaced (`{id}:{part solid name}`).
388    pub solids: Vec<String>,
389    /// The part's harness PORT ids, namespaced (`{id}:{part port id}`).
390    pub ports: Vec<String>,
391    /// A sub-assembly's SOLVED occurrence poses: path below this component
392    /// (`ACOMP2`, `ACOMP2:ACOMP1`) → pose in this component's part-local
393    /// frame, row-major 4x4. A nested occurrence sits at `transform` composed
394    /// with this, NOT at the transform its document authors: the
395    /// sub-assembly's own mates may have moved it. Empty for a part.
396    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
397    pub nested_poses: std::collections::BTreeMap<String, [f64; 16]>,
398}
399
400impl ComponentSnapshot {
401    /// The pose as the kernel's transform type (for `transform_brep` and the
402    /// structured STEP writer).
403    pub fn affine(&self) -> brep_kernel::AffineTransform {
404        brep_kernel::AffineTransform { elements: self.transform }
405    }
406}
407
408/// Bake an ACOMP `transform` param (`{translate, rotateEulerDeg}`, degrees,
409/// intrinsic `XYZ` — `R = Rx·Ry·Rz`) into the kernel's row-major 4x4. The
410/// inverse of [`brep_kernel::transform_to_pose_params`], which is the encoder
411/// the solver's write-back uses; a component's record pose and its
412/// `inputParams.transform` are the same rigid placement in the two spellings,
413/// and `component_snapshot_pose_matches_the_kernel_bake` pins this against the
414/// kernel's own bake of the same params.
415///
416/// Needed because the per-feature `components` side-channel is captured when the
417/// ACOMP EXECUTES, which is before the constraint tail solves; the solved pose
418/// arrives separately as the write-back. Missing/garbage values fall back to the
419/// identity placement, exactly like the feature's own reader.
420pub(crate) fn pose_params_to_transform(pose: &serde_json::Value) -> [f64; 16] {
421    let vec3 = |key: &str| -> [f64; 3] {
422        let value = &pose[key];
423        std::array::from_fn(|axis| value[axis].as_f64().unwrap_or(0.0))
424    };
425    let translate = vec3("translate");
426    let [rx, ry, rz] = vec3("rotateEulerDeg").map(f64::to_radians);
427    let (sx, cx) = rx.sin_cos();
428    let (sy, cy) = ry.sin_cos();
429    let (sz, cz) = rz.sin_cos();
430    let x = [[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]];
431    let y = [[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]];
432    let z = [[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]];
433    let mul = |a: [[f64; 3]; 3], b: [[f64; 3]; 3]| -> [[f64; 3]; 3] {
434        std::array::from_fn(|i| {
435            std::array::from_fn(|j| (0..3).map(|k| a[i][k] * b[k][j]).sum())
436        })
437    };
438    let r = mul(x, mul(y, z));
439    [
440        r[0][0], r[0][1], r[0][2], translate[0],
441        r[1][0], r[1][1], r[1][2], translate[1],
442        r[2][0], r[2][1], r[2][2], translate[2],
443        0.0, 0.0, 0.0, 1.0,
444    ]
445}
446
447impl From<&brep_kernel::ComponentRecord> for ComponentSnapshot {
448    fn from(record: &brep_kernel::ComponentRecord) -> Self {
449        Self {
450            id: record.id.clone(),
451            part_name: record.part_name.clone(),
452            transform: record.transform.elements,
453            requested_transform: Some(record.transform.elements),
454            authored_transform: serde_json::Value::Null,
455            fixed: record.fixed,
456            source: record.source.clone(),
457            solids: record.solids.clone(),
458            ports: record.ports.clone(),
459            nested_poses: record
460                .nested_poses
461                .iter()
462                .map(|(path, pose)| (path.clone(), pose.elements))
463                .collect(),
464        }
465    }
466}
467
468/// Everything the main thread needs from the run's ASSEMBLY tail, read on the
469/// runner right after the run and shipped in the reply.
470///
471/// The kernel's assembly session — the post-solve constraint state, its
472/// per-constraint statuses, the DOF summary and the overlay geometry — is
473/// thread-local to whoever ran `execute_history`. Before this the main thread
474/// obtained them by executing the whole history a SECOND time
475/// (`sync_assembly`), which on a document with a wire harness swept every
476/// bundle twice per edit. It reads them from here instead; the only main-side
477/// replay left is the one a constraint MUTATION needs, because that solve runs
478/// against resident geometry on the thread that asks (see
479/// `EngineState::ensure_assembly_session`).
480///
481/// `None` for a componentless document: nothing here is read and the reads cost
482/// nothing.
483#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
484pub struct AssemblySync {
485    /// The scene's component set in deterministic id order — the same fold
486    /// (`removed` unmaps, `components` inserts) the scene build honours.
487    pub components: Vec<ComponentSnapshot>,
488    /// The post-solve `assembly` block (`{constraints, idCounter}`), which the
489    /// applier folds back into the document — the pose-authority write-back's
490    /// state half (the pose/`isFixed` half rides
491    /// [`RunOutput::assembly_poses`]/[`RunOutput::assembly_fixed`]).
492    pub state: serde_json::Value,
493    /// Per-constraint status rows for the constraints panel.
494    pub statuses: serde_json::Value,
495    /// The solve's DOF / diagnostics summary.
496    pub dof: serde_json::Value,
497    /// Per-constraint overlay rows (world anchors/directions/status/value).
498    pub overlay: serde_json::Value,
499    /// The runner's parts-library store, shipped ONLY when the run itself moved
500    /// it — the ACOMP self-heal and the orphan GC are library mutations and the
501    /// main side has no other way to see them. Omitted (`None`) otherwise: it
502    /// carries every embedded part payload, which for an imported STEP assembly
503    /// is megabytes.
504    #[serde(default, skip_serializing_if = "Option::is_none")]
505    pub parts_library: Option<brep_kernel::PartsLibraryMap>,
506}
507
508/// The delta a [`SceneRunner::run`] produces: the displayed KERNEL solids IN
509/// ORDER plus the run's [`SceneBuildReport`]. The applier walks `snapshot` in
510/// order, reusing or replacing each entry (see the field docs).
511#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
512pub struct RunOutput {
513    /// The displayed KERNEL solids IN ORDER. `display: Some` = freshly tessellated
514    /// (new, or the handle changed since last emit); `None` = UNCHANGED — the
515    /// applier keeps its existing [`SolidDisplay`] for this name, whose
516    /// `source_handle` equals `handle`.
517    pub snapshot: Vec<(String /* name */, u32 /* handle */, Option<SolidDisplay>)>,
518    pub report: SceneBuildReport,
519    /// Eager PROVENANCE: `name -> creating-feature id` for the run's FINAL resident
520    /// SOLIDS (same last-writer-wins fold as the display snapshot). Shipped WITH the
521    /// run so the main thread can answer SOLID provenance without a cold
522    /// `execute_history` — critical once the run lives on a background thread and the
523    /// main-side registry is cold.
524    pub provenance: Vec<(String, String)>,
525    /// Eager ENTITY ORIGIN: `face/edge NAME -> ORIGINATING feature id` (FIRST writer
526    /// in timeline order, never pruned — see `SceneLayout::entity_origin`). Shipped
527    /// beside `provenance` so `creating_feature` can answer "which feature gave this
528    /// face/edge its name?" (the "Edit owning feature" context action + the Info
529    /// tab's `creatingFeature`) with no cold re-run. Crosses the background-worker
530    /// seam, so the serde round-trip test asserts it survives.
531    pub entity_origin: Vec<(String, String)>,
532    /// Assembly-solver POSE write-backs from this run's constraint tail:
533    /// `(component feature id, {translate, rotateEulerDeg})` — read RUNNER-SIDE
534    /// (the kernel assembly session is thread-local to the thread/worker that ran
535    /// `execute_history`, so only the runner can see it) and shipped here so the
536    /// engine folds them into the owning ACOMP features' `inputParams` (the
537    /// pose-authority contract). Empty on a no-motion solve — a satisfied
538    /// assembly must not churn feature fingerprints. `#[serde(default)]` keeps
539    /// pre-assembly serialized replies deserializing.
540    #[serde(default)]
541    pub assembly_poses: Vec<(String, serde_json::Value)>,
542    /// `isFixed` write-backs riding the same fold (the Fixed-constraint lane
543    /// grounds a component); shape mirrors [`Self::assembly_poses`].
544    #[serde(default)]
545    pub assembly_fixed: Vec<(String, bool)>,
546    /// Names of solids the solve re-posed IN PLACE this run (the display seam:
547    /// their producing feature may have replayed `reused`, yet their geometry
548    /// moved). The runner already defeated the handle-unchanged reuse fast path
549    /// for these (their snapshot entries arrive `Some`, freshly tessellated);
550    /// shipped for observability/tests. Empty on zero-mate/no-motion runs.
551    #[serde(default)]
552    pub moved_solids: Vec<String>,
553    /// IMPORTED COLOURS: `entity name -> "#RRGGBB"` for every solid/face this run
554    /// left a `color` scene-metadata record on (STEP presentation entities, read
555    /// by `brep_kernel::io/appearance.rs` and stamped by IMPORT3D).
556    ///
557    /// Read RUNNER-SIDE for the same reason as `assembly_poses`: the kernel's
558    /// scene-metadata store is thread-local to whoever ran `execute_history`, so
559    /// a main-thread read under a background runner sees nothing. The applier
560    /// folds these into the engine's own [`crate::metadata::MetadataStore`]
561    /// WITHOUT overwriting, so the Info window shows an imported colour and a
562    /// user's edit of it still wins.
563    ///
564    /// Filtered to the names this run actually produced, so a colour left in the
565    /// (never-cleared) kernel store by a previous document cannot bleed into
566    /// this one.
567    #[serde(default)]
568    pub imported_colors: Vec<(String, String)>,
569    /// Every output name some feature of this run CONSUMED — the union of the
570    /// per-feature `removed` lists, sorted. The committed-sketch refresh hides a
571    /// sketch a downstream extrude/revolve/sheet-metal consumer absorbed, and it
572    /// used to learn that by executing the history again on the UI thread; it
573    /// reads this instead. NOT pruned the way the display fold prunes: a name
574    /// removed and later re-added is still a name this run consumed.
575    #[serde(default)]
576    pub consumed: Vec<String>,
577    /// The run's assembly tail, for a document that has one (see
578    /// [`AssemblySync`]).
579    #[serde(default)]
580    pub assembly: Option<AssemblySync>,
581}
582
583impl SceneRunner {
584    /// Provision explicitly trusted packages on this runner's execution thread.
585    pub fn set_plugins(&mut self, revision: u64, packages: serde_json::Value) {
586        let runtime = serde_json::from_value(packages)
587            .map_err(|e| format!("plugin packages: {e}"))
588            .and_then(brep_plugins::Runtime::new);
589        match runtime {
590            Ok(runtime) => { self.plugins = Some(runtime); self.plugin_error = None; }
591            Err(error) => { self.plugins = None; self.plugin_error = Some(error); }
592        }
593        self.plugin_revision = Some(revision);
594        self.last_sent.clear();
595    }
596
597    pub(crate) fn plugin_action(&self, request: crate::runner::PluginActionRequest) -> crate::runner::PluginActionReply {
598        let result = if let Some(source) = &request.script {
599            brep_plugins::execute_script(source, &request.input)
600        } else { match &self.plugins {
601            Some(runtime) => runtime.execute_action(&request.pins, &request.action, &request.input),
602            None => Err(self.plugin_error.clone().unwrap_or_else(|| "no plugin packages installed".into())),
603        }};
604        crate::runner::PluginActionReply { id: request.id, result }
605    }
606
607    pub fn new() -> Self {
608        Self {
609            plugin_revision: None,
610            plugins: None,
611            plugin_error: None,
612            last_sent: HashMap::new(),
613            last_lod: 1.0,
614            parts_library_revision: None,
615            parts_library_names: std::collections::BTreeSet::new(),
616            session_of: None,
617        }
618    }
619
620    /// Reset the delta baseline (call on a wholesale document switch so the next
621    /// run is a full rebuild — no stale reuse across unrelated models).
622    pub fn reset(&mut self) {
623        self.last_sent.clear();
624        // A reset always accompanies the `clear_history_cache` that empties
625        // this side's parts library, so forget what was installed — the next
626        // run's stamp will not match and the library is re-sent.
627        self.parts_library_revision = None;
628        self.parts_library_names.clear();
629        self.session_of = None;
630    }
631
632    /// An owned copy of the assembly session the run of `generation` left on
633    /// this thread, or `None` when this thread's session is not that run's.
634    pub(crate) fn export_session(&self, generation: u64) -> Option<brep_kernel::SessionTransfer> {
635        match self.session_of {
636            Some((ran, serial)) if ran == generation && serial == brep_kernel::assembly_session_serial() => {
637                brep_kernel::assembly_session_export()
638            }
639            _ => None,
640        }
641    }
642
643    /// Execute `request` and fold it into an ordered delta snapshot (does NOT
644    /// touch any scene). For each displayed kernel solid in order: if the handle
645    /// is unchanged since the last emit, emit `(name, handle, None)` (the applier
646    /// keeps its existing display); otherwise (re-)tessellate the resident handle
647    /// and emit `(name, handle, Some(display))`. A display-payload error is
648    /// recorded in `report.display_errors` and the entry is SKIPPED — and NOT
649    /// recorded in the new baseline, so a later successful run re-tessellates it.
650    ///
651    ///
652    /// Colours are NOT applied here. A display arrives colourless and the main
653    /// side paints it from the metadata store
654    /// ([`EngineState::sync_colors_from_metadata`](crate::engine_state::EngineState::sync_colors_from_metadata)),
655    /// which is the only colour authority — a runner may be a background thread
656    /// or worker with no view of that store.
657    pub fn run(&mut self, request: &HistoryRequest) -> RunOutput {
658        self.run_observed(request, &mut |_| true)
659    }
660
661    /// [`Self::run`] with a progress observer: `observe` is called before every
662    /// feature the kernel actually executes (see
663    /// [`brep_kernel::execute_history_observed`]); returning `false` stops the
664    /// run at that boundary, and the partial result is folded like any other.
665    pub fn run_observed(
666        &mut self,
667        request: &HistoryRequest,
668        observe: &mut dyn FnMut(brep_kernel::HistoryProgress<'_>) -> bool,
669    ) -> RunOutput {
670        // The parts-library revision ACROSS the run: a run heals a dirty entry
671        // and GCs orphans, and those are the only library mutations the main
672        // side cannot see for itself. Compared below to decide whether the
673        // (potentially megabyte) store has to ride the reply.
674        let library_revision_before = brep_kernel::parts_library_revision();
675        let result = {
676            let mut trace = crate::run_trace::span("runner");
677            let result = match &self.plugins {
678                Some(runtime) => runtime.with_provider(|| brep_kernel::execute_history_observed(request, observe)),
679                None => brep_kernel::execute_history_observed(request, observe),
680            };
681            if let Some(trace) = trace.as_mut() {
682                trace.result(&result);
683            }
684            result
685        };
686        let mut report = SceneBuildReport::default();
687        let layout = fold_history(&result, &mut report);
688
689        // --- assembly pose-authority read (RUNNER-SIDE) ---- The kernel assembly
690        // session (constraint solve tail) is THREAD-LOCAL to whoever ran
691        // `execute_history` — i.e. this thread/worker — so the pose and isFixed
692        // write-backs must be read HERE and shipped in the RunOutput; a
693        // main-thread read under a background runner would see a cold session.
694        let pose_updates: serde_json::Value =
695            serde_json::from_str(&brep_kernel::assembly_pose_updates_json())
696                .unwrap_or(serde_json::Value::Null);
697        let assembly_poses: Vec<(String, serde_json::Value)> = pose_updates["poses"]
698            .as_object()
699            .map(|map| map.iter().map(|(id, pose)| (id.clone(), pose.clone())).collect())
700            .unwrap_or_default();
701        let assembly_fixed: Vec<(String, bool)> = pose_updates["isFixed"]
702            .as_object()
703            .map(|map| {
704                map.iter()
705                    .filter_map(|(id, flag)| flag.as_bool().map(|b| (id.clone(), b)))
706                    .collect()
707            })
708            .unwrap_or_default();
709        // The display seam: solids the solve re-posed IN PLACE keep their resident
710        // handle, so the handle-unchanged reuse fast path below would wrongly skip
711        // re-tessellating them even though their geometry moved. Drop them from
712        // the baseline so they re-emit fresh displays. The `movedSolids` key is
713        // ABSENT on zero-mate reports — tolerated (empty).
714        let dof: serde_json::Value = serde_json::from_str(&brep_kernel::assembly_dof_json())
715            .unwrap_or(serde_json::Value::Null);
716        let moved_solids: Vec<String> = dof["movedSolids"]
717            .as_array()
718            .map(|items| {
719                items
720                    .iter()
721                    .filter_map(|item| item["name"].as_str().map(String::from))
722                    .collect()
723            })
724            .unwrap_or_default();
725        for name in &moved_solids {
726            self.last_sent.remove(name);
727        }
728
729        // --- the assembly session + consumed names (RUNNER-SIDE) -------------
730        // Same thread-local reason as the poses above, and the whole point of
731        // `AssemblySync`: read here, applied there, never re-executed.
732        let assembly = request_has_assembly(request).then(|| {
733            let authored: HashMap<&str, &serde_json::Value> = request.features.iter()
734                .filter_map(|f| f.input_params["id"].as_str().map(|id| (id, &f.input_params["transform"])))
735                .collect();
736            let mut components: std::collections::BTreeMap<String, ComponentSnapshot> =
737                std::collections::BTreeMap::new();
738            for feature in &result.results {
739                for removed in &feature.removed {
740                    components.remove(removed);
741                }
742                for record in &feature.components {
743                    let mut snapshot = ComponentSnapshot::from(record);
744                    snapshot.authored_transform = authored.get(record.id.as_str())
745                        .map(|value| (*value).clone()).unwrap_or_default();
746                    components.insert(record.id.clone(), snapshot);
747                }
748            }
749            // A record's pose is captured when its ACOMP EXECUTES, which is
750            // before the constraint tail solves — so a solved component's record
751            // still carries its authored placement. Re-pose from the same solver
752            // write-back the document adopts, or the projection (the structure
753            // tree, the BOM, the structured STEP writer, the component vertex
754            // ref) would lag the solve by a run.
755            for (id, pose) in &assembly_poses {
756                if let Some(component) = components.get_mut(id) {
757                    component.transform = pose_params_to_transform(pose);
758                }
759            }
760            // Same for a grounded flag the tail wrote (a legacy Fixed row the
761            // lifecycle migrated into the instance setting): the record was
762            // captured before it, and the projection must not show the part
763            // free until some later run.
764            for (id, fixed) in &assembly_fixed {
765                if let Some(component) = components.get_mut(id) {
766                    component.fixed = *fixed;
767                }
768            }
769            let parse = |json: String| {
770                serde_json::from_str::<serde_json::Value>(&json).unwrap_or(serde_json::Value::Null)
771            };
772            AssemblySync {
773                components: components.into_values().collect(),
774                state: parse(brep_kernel::assembly_state_json()),
775                statuses: parse(brep_kernel::assembly_statuses_json()),
776                dof: dof.clone(),
777                overlay: parse(brep_kernel::assembly_overlay_json()),
778                parts_library: (brep_kernel::parts_library_revision() != library_revision_before)
779                    .then(brep_kernel::parts_library_map),
780            }
781        });
782        // The consumed-name union the committed-sketch refresh needs. Sorted, so
783        // the reply is byte-stable for a given run (the worker seam serializes it).
784        let consumed: Vec<String> = result
785            .results
786            .iter()
787            .flat_map(|feature| feature.removed.iter().cloned())
788            .collect::<std::collections::BTreeSet<String>>()
789            .into_iter()
790            .collect();
791
792        // Display LOD: a finite, positive factor (garbage from a hand-edited saved
793        // file → the "Normal" 1.0, since chord = extent·1.5e-3·lod and a 0/NaN lod
794        // would zero the tolerance → runaway refinement). A change since the last
795        // run means every resident mesh must re-tessellate at the new chord even
796        // though its handle is unchanged, so drop the reuse baseline.
797        let lod = if request.display_lod.is_finite() && request.display_lod > 0.0 {
798            request.display_lod
799        } else {
800            1.0
801        };
802        if lod != self.last_lod {
803            self.last_sent.clear();
804            self.last_lod = lod;
805        }
806
807        let mut snapshot: Vec<(String, u32, Option<SolidDisplay>)> =
808            Vec::with_capacity(layout.order.len());
809        let mut next_sent: HashMap<String, u32> = HashMap::with_capacity(layout.order.len());
810
811        for name in &layout.order {
812            let handle = layout.handles[name];
813            if self.last_sent.get(name) == Some(&handle) {
814                // Handle unchanged since last emit ⇒ same resident geometry ⇒ the
815                // applier keeps its existing display. This is EXACTLY the old
816                // `reused && source_handle == handle` fast path (monotonic handles):
817                // the canary below verifies the implication holds.
818                debug_assert!(
819                    layout.reused.contains(name),
820                    "handle unchanged must imply reused (monotonic handles): {name}"
821                );
822                snapshot.push((name.clone(), handle, None));
823                next_sent.insert(name.clone(), handle);
824                continue;
825            }
826            match display_payload_handle_native(handle, lod) {
827                Ok(payload) => {
828                    let mut solid = solid_display_from_payload(name, payload);
829                    solid.source_handle = handle;
830                    // Runner thread → the SheetTree thread-local is warm here; stamp
831                    // the sheet-metal marker so the UI thread reads it off the scene
832                    // (never calling the thread-local from a cold UI thread).
833                    solid.is_sheet_metal = brep_kernel::is_sheet_metal_handle(handle);
834                    snapshot.push((name.clone(), handle, Some(solid)));
835                    next_sent.insert(name.clone(), handle);
836                }
837                // A tessellation failure skips the solid (non-fatal) and does NOT
838                // poison the baseline — dropping it lets a later successful run
839                // re-tessellate from scratch.
840                Err(error) => report.display_errors.push(format!("{name}: {error}")),
841            }
842        }
843
844        self.last_sent = next_sent;
845        // Eager provenance for the run's final resident solids (the applier stores
846        // this map main-side so per-frame provenance queries never re-run history).
847        let provenance: Vec<(String, String)> = layout
848            .creators
849            .iter()
850            .map(|(name, id)| (name.clone(), id.clone()))
851            .collect();
852        // Eager entity origin (face/edge NAME -> originating feature id) rides
853        // alongside, so face/edge provenance survives the off-thread seam too.
854        let entity_origin: Vec<(String, String)> = layout
855            .entity_origin
856            .iter()
857            .map(|(name, id)| (name.clone(), id.clone()))
858            .collect();
859        // Imported colours, read HERE for the thread-local reason above and
860        // filtered to this run's own entities: the solids it displays plus the
861        // faces/edges it named. The kernel store is never cleared between
862        // documents, so an unfiltered ship could hand the applier a colour that
863        // belongs to a model the user closed.
864        let imported_colors: Vec<(String, String)> = serde_json::from_str::<serde_json::Value>(
865            &brep_kernel::scene_metadata_colors_json(),
866        )
867        .ok()
868        .and_then(|value| value.as_object().cloned())
869        .map(|map| {
870            map.into_iter()
871                .filter(|(name, _)| {
872                    layout.handles.contains_key(name) || layout.entity_origin.contains_key(name)
873                })
874                .filter_map(|(name, hex)| hex.as_str().map(|hex| (name, hex.to_string())))
875                .collect()
876        })
877        .unwrap_or_default();
878
879        RunOutput {
880            snapshot,
881            report,
882            provenance,
883            entity_origin,
884            assembly_poses,
885            assembly_fixed,
886            moved_solids,
887            imported_colors,
888            consumed,
889            assembly,
890        }
891    }
892
893    /// The resident handle last EMITTED for `name` (the delta baseline), or `None`
894    /// if the runner has not emitted a solid of that name. The measurement-query
895    /// path resolves an object's owning-solid handle through this so the query runs
896    /// against the SAME resident geometry the last run displayed (on the runner's
897    /// own thread, whose registry is warm from that run).
898    pub fn handle_of(&self, name: &str) -> Option<u32> {
899        self.last_sent.get(name).copied()
900    }
901}
902
903impl Default for SceneRunner {
904    fn default() -> Self {
905        Self::new()
906    }
907}
908
909/// Execute the history and reconcile `scene` in place (R10 incremental update):
910/// reused solids already present keep their [`SolidDisplay`] verbatim (stable
911/// `revision` ⇒ the renderer reuses their GPU buffers); everything else is
912/// (re-)tessellated from the resident handle; departed solids are dropped.
913///
914/// Implemented on the M1 seam: a throwaway [`SceneRunner`] primed from the
915/// CURRENT scene (`name → source_handle` of what is displayed) reproduces the
916/// pre-seam reuse in a single synchronous call, and its [`RunOutput`] delta is
917/// applied back to `scene` by MOVING existing displays out (via
918/// [`RenderScene::drain`]) so a reused solid's mesh is never cloned.
919pub fn update_scene_from_history(
920    scene: &mut RenderScene,
921    request: &HistoryRequest,
922) -> Result<SceneBuildReport, String> {
923    let mut runner = SceneRunner::new();
924    runner.last_sent = scene
925        .solids()
926        .iter()
927        .map(|solid| (solid.name.clone(), solid.source_handle))
928        .collect();
929    let output = runner.run(request);
930
931    // Move the current displays out, then reinsert in snapshot ORDER: a fresh
932    // entry replaces, an UNCHANGED entry reuses the moved-out display (whose
933    // `source_handle` equals the run's handle — guaranteed by the reuse
934    // invariant). Leftovers (departed names) are dropped.
935    let mut kept: HashMap<String, SolidDisplay> = scene
936        .drain()
937        .into_iter()
938        .map(|solid| (solid.name.clone(), solid))
939        .collect();
940    for (name, _handle, maybe) in output.snapshot {
941        match maybe {
942            Some(display) => scene.insert_solid(display),
943            None => scene.insert_solid(
944                kept.remove(&name).expect("keep target present"),
945            ),
946        }
947    }
948    Ok(output.report)
949}
950
951/// Execute `request` and return the FINAL resident solids as `(name, handle)` in
952/// display order — the export lane (STEP/STL) needs the current solids' resident
953/// handles, which the scene build does not itself retain. Run right after a scene
954/// build (warm incremental cache) this replays the cached features, so the
955/// handles it returns are the very ones the displayed scene was built from; run
956/// cold it re-executes and registers fresh (still-valid) handles. Either way the
957/// handles are live in the thread-local registry when this returns, ready to
958/// hand to `brep_kernel::export_step_handles`.
959pub fn resident_solid_handles(request: &HistoryRequest) -> Vec<(String, u32)> {
960    let _trace = crate::run_trace::span("export_resident_handles");
961    let result = execute_history(request);
962    let mut report = SceneBuildReport::default();
963    let layout = fold_history(&result, &mut report);
964    layout
965        .order
966        .iter()
967        .map(|name| (name.clone(), layout.handles[name]))
968        .collect()
969}
970