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