brep_render/engine_state/assembly_ops.rs
1use super::*;
2use serde_json::Value;
3
4// Assembly sessions are thread-local. The runner reads its session at the end of
5// every run and ships it (`pipeline::AssemblySync`); the main thread ADOPTS that
6// — the component projection, the solved constraint state, the statuses, the DOF
7// summary and the overlay rows — instead of executing the history a second time.
8// Componentless histories ship nothing and the projection is cleared.
9//
10// The ONE main-side replay left is `ensure_assembly_session`: a constraint
11// mutation auto-solves against the resident geometry of the thread that asks, and
12// the kernel's registry is thread-local, so the session has to exist HERE. That
13// is per USER constraint edit, not per run. Mutations then call the kernel ABI,
14// adopt the result with an undo checkpoint, and re-snapshot so the panels read
15// the mutation before the rerun's reply lands. Auto-solve reruns the display
16// history; otherwise manual Solve does.
17
18/// What a component-insert names: an EXISTING parts-library entry (skip the
19/// store read — just add an instance) or a NEW part payload to hand to
20/// `add_part_to_library` (which dedups by sourceKey+signature and returns the
21/// EFFECTIVE entry name the instance must reference).
22pub enum ComponentInsert<'a> {
23 Existing {
24 part_name: &'a str,
25 },
26 New {
27 name: &'a str,
28 source_key: &'a str,
29 source_signature: &'a str,
30 document_json: &'a str,
31 },
32}
33
34/// A STABLE content signature of a document: a sorted-key JSON walk hashed
35/// with the (fixed-key, cross-process-deterministic) std SipHash. The ONE
36/// signature fn, at the ENGINE altitude so app-side and engine-side writers
37/// share one copy: the app's insert flow (`panels::file`), the edit-in-place
38/// Finish (`panels::assembly_edit::refresh_library_entry`) and the
39/// update-components comparison (`panels::update_components`) all write/compare
40/// a parts-library `sourceSignature` with THIS function — a freshly inserted,
41/// unchanged part must always compare up-to-date, which a second copy would
42/// break the moment either drifted.
43/// Uses the kernel's shared sorted-key hash, also used for in-memory library
44/// fingerprints. Invalid JSON retains the raw-text hashing fallback.
45pub fn document_signature(doc_json: &str) -> String {
46 use std::hash::{Hash, Hasher};
47 let hash = match serde_json::from_str::<serde_json::Value>(doc_json) {
48 Ok(value) => brep_kernel::stable_json_hash(&value),
49 Err(_) => {
50 let mut hasher = std::collections::hash_map::DefaultHasher::new();
51 doc_json.hash(&mut hasher);
52 hasher.finish()
53 }
54 };
55 format!("{hash:016x}")
56}
57
58/// A nested occurrence's Fix/Float as the assembly solve reads it.
59///
60/// `top_params` is the top-level instance's `inputParams`, `rest` the
61/// occurrence's path inside it (`ACOMP2`, `ACOMP1:ACOMP2`) and `document` the
62/// definition that instance places. Only an occurrence the solve moves can be
63/// fixed or freed here: every instance above it must be flexible (a rigid
64/// sub-assembly moves as one body, its parts never move on their own). The
65/// state is the instance's own `flexibleFixed[rest]`, else the grounding of
66/// the definition that places it (an explicit `isFixed`, else the first
67/// component, else a legacy Fixed row that definition still carries — an
68/// embedded copy keeps it until the part is saved again), as
69/// `assembly::flexible` decides it.
70pub fn nested_fixed_state(top_params: &Value, rest: &str, document: &Value) -> Result<bool, String> {
71 let acomp = |document: &'_ Value, id: &str| -> Option<Value> {
72 document["features"].as_array()?.iter().find(|f| {
73 matches!(f["type"].as_str(), Some("ACOMP" | "ASSEMBLY COMPONENT")) && f["inputParams"]["id"].as_str() == Some(id)
74 }).cloned()
75 };
76 let rigid = |name: &str| {
77 format!("'{name}' is a rigid sub-assembly: it moves as one body, so its parts are not fixed or freed here. Tick Flexible on it, or edit it as the Work part")
78 };
79 let segments: Vec<&str> = rest.split(':').collect();
80 let (last, above) = segments.split_last().ok_or("empty occurrence path")?;
81 let top_name = top_params["partName"].as_str().unwrap_or_default();
82 if top_params["flexible"] != Value::Bool(true) {
83 return Err(rigid(top_name));
84 }
85 let mut document = document;
86 for id in above {
87 let feature = acomp(document, id).ok_or_else(|| format!("component '{id}' is missing"))?;
88 let name = feature["inputParams"]["partName"].as_str().unwrap_or_default().to_string();
89 if feature["inputParams"]["flexible"] != Value::Bool(true) {
90 return Err(rigid(&name));
91 }
92 document = &document["partsLibrary"][&name]["document"];
93 }
94 acomp(document, last).ok_or_else(|| format!("component '{last}' is missing"))?;
95 if let Some(fixed) = top_params["flexibleFixed"][rest].as_bool() {
96 return Ok(fixed);
97 }
98 let components: Vec<&Value> = document["features"]
99 .as_array()
100 .into_iter()
101 .flatten()
102 .filter(|f| matches!(f["type"].as_str(), Some("ACOMP" | "ASSEMBLY COMPONENT")))
103 .collect();
104 let feature = components.iter().find(|f| f["inputParams"]["id"].as_str() == Some(last));
105 if let Some(fixed) = feature.and_then(|f| f["inputParams"]["isFixed"].as_bool()) {
106 return Ok(fixed);
107 }
108 if components.first().and_then(|f| f["inputParams"]["id"].as_str()) == Some(last) {
109 return Ok(true);
110 }
111 let fixed_by_constraint = document["assembly"]["constraints"].as_array().into_iter().flatten().any(|c| {
112 c["type"].as_str() == Some("fixed")
113 && c["inputParams"]["elements"].as_array().into_iter().flatten().filter_map(Value::as_str).any(|e| e.split(':').next() == Some(*last))
114 });
115 Ok(fixed_by_constraint)
116}
117
118/// Rigid inverse `[Rᵀ | −Rᵀ·t]` of a component pose — used to express a picked
119/// WORLD point in COMPONENT-LOCAL coordinates (the vertex-ref contract).
120fn rigid_inverse_point(transform: &brep_kernel::AffineTransform, world: [f64; 3]) -> [f64; 3] {
121 let m = &transform.elements;
122 let d = [world[0] - m[3], world[1] - m[7], world[2] - m[11]];
123 [
124 m[0] * d[0] + m[4] * d[1] + m[8] * d[2],
125 m[1] * d[0] + m[5] * d[1] + m[9] * d[2],
126 m[2] * d[0] + m[6] * d[1] + m[10] * d[2],
127 ]
128}
129
130impl EngineState {
131 // --- read surface ------------------------------------------------------
132
133 /// Whether the current document is an ASSEMBLY document: any ACOMP-typed
134 /// feature, or a present `assembly` constraint block. Componentless
135 /// documents skip every main-side sync (zero cost for modeling files).
136 pub fn history_has_assembly(&self) -> bool {
137 let has_acomp = (0..self.history.len()).any(|index| {
138 matches!(
139 self.history.feature_type(index).as_deref(),
140 Some("ACOMP") | Some("ASSEMBLY COMPONENT")
141 )
142 });
143 has_acomp
144 || self
145 .history
146 .assembly_block()
147 .map(|block| !block.is_null())
148 .unwrap_or(false)
149 }
150
151 /// The scene's component records (deterministic id order) as of the last
152 /// applied run — the Assembly Structure tree's projection source. A VIEW
153 /// over the scene, never an owning structure.
154 pub fn assembly_components(&self) -> &[crate::pipeline::ComponentSnapshot] {
155 &self.assembly_components
156 }
157
158 /// The assembly tail of the last applied run, or an empty stand-in for a
159 /// componentless document / a document that has not run yet.
160 fn assembly_sync(&self) -> std::borrow::Cow<'_, crate::pipeline::AssemblySync> {
161 match &self.assembly_sync {
162 Some(sync) => std::borrow::Cow::Borrowed(sync),
163 None => std::borrow::Cow::Owned(crate::pipeline::AssemblySync::default()),
164 }
165 }
166
167 /// The per-constraint status rows (`[{id, type, enabled, open, status,
168 /// message, satisfied, error}]`) the run reported.
169 pub fn assembly_statuses_value(&mut self) -> Value {
170 match &self.assembly_sync().statuses {
171 Value::Null => Value::Array(Vec::new()),
172 value => value.clone(),
173 }
174 }
175
176 /// The current `assembly` block (post-solve): `{constraints, idCounter}`.
177 pub fn assembly_state_value(&mut self) -> Value {
178 match &self.assembly_sync().state {
179 // Before the first run there is no state at all; answer with the
180 // empty block every caller expects rather than a bare null.
181 Value::Null => serde_json::json!({ "constraints": [], "idCounter": 0 }),
182 value => value.clone(),
183 }
184 }
185
186 /// The last solve's DOF/diagnostics summary (`{ok, dof, rank, redundant,
187 /// …}` — `movedSolids` may be absent; tolerate it).
188 pub fn assembly_dof_value(&mut self) -> Value {
189 self.assembly_sync().dof.clone()
190 }
191
192 /// Per-constraint overlay rows (world anchors/directions/status/value) —
193 /// the constraints panel reads the evaluated value/unit for the
194 /// distance/angle label suffix; lane G's viewport graphics read the rest.
195 pub fn assembly_overlay_value(&mut self) -> Value {
196 match &self.assembly_sync().overlay {
197 Value::Null => Value::Array(Vec::new()),
198 value => value.clone(),
199 }
200 }
201
202 /// The parts-library entry names currently resident (insert-flow "existing
203 /// entries first" list). The main-side store, seeded by document ingest and
204 /// kept current by the runs that heal or GC it (`apply_run_output` installs
205 /// the store a run moved), and grown by [`insert_component`](Self::insert_component).
206 pub fn parts_library_names(&mut self) -> Vec<String> {
207 serde_json::from_str::<Value>(&brep_kernel::parts_library_json())
208 .ok()
209 .and_then(|value| {
210 value
211 .as_object()
212 .map(|map| map.keys().cloned().collect::<Vec<_>>())
213 })
214 .unwrap_or_default()
215 }
216
217 // --- the adopt + fold (pose-authority contract) -------------------------
218
219 /// Adopt the assembly tail the runner shipped with the applied run: the
220 /// component projection, and the post-solve constraint state folded back
221 /// into the document (silent — a solver write-back is not a user edit; the
222 /// pose/`isFixed` half was already folded in `apply_run_output`, and the
223 /// parts-library store the run moved was installed there too).
224 /// Componentless documents just clear the projection.
225 ///
226 /// This is the call that used to be `sync_assembly`, a second full
227 /// `execute_history` on the UI thread. See `pipeline::AssemblySync`.
228 pub(crate) fn adopt_assembly_sync(&mut self) {
229 let Some(sync) = &self.assembly_sync else {
230 self.assembly_components.clear();
231 // No ACOMP references left ⇒ any partsLibrary block is orphaned
232 // payload (the kernel GCs the store the same way at the end of
233 // every run) — drop it so deleting the last instance never leaves
234 // a dangling entry in the saved document. Gated on the WHOLE
235 // history, not on the run: the run executes the rolled-to PREFIX,
236 // so rolling back behind the first ACOMP ships no tail while the
237 // document's components — and therefore its library — are still
238 // very much there.
239 if !self.history_has_assembly() {
240 self.history
241 .set_parts_library(Value::Object(serde_json::Map::new()));
242 }
243 return;
244 };
245 self.assembly_components = sync.components.clone();
246 let state = sync.state.clone();
247 // Legacy Fixed rows this run turned into the instance setting (the
248 // kernel lifecycle's migration): say so once — the fold below drops
249 // them from the document, so the next run carries none.
250 let migrated: Vec<String> = sync.dof["migratedFixed"]
251 .as_array()
252 .into_iter()
253 .flatten()
254 .map(|row| {
255 let id = row["id"].as_str().unwrap_or("Fixed");
256 match row["component"].as_str() {
257 Some(component) => format!(
258 "Fixed constraint {id} is now {component}'s Fixed setting: a part is fixed on its own row, not by a constraint"
259 ),
260 None => format!(
261 "Fixed constraint {id} was removed: it fixed nothing ({}); fix a part on its own row",
262 if row["enabled"] == false { "it was disabled".to_string() } else { format!("'{}' names no component", row["element"].as_str().unwrap_or("")) }
263 ),
264 }
265 })
266 .collect();
267 for notice in migrated {
268 self.push_notice(notice);
269 }
270 self.fold_assembly_state(state, false);
271 }
272
273 /// Write the post-solve `assembly` block into the document — the state half
274 /// of the pose-authority write-back, which the kernel's
275 /// `assembly_apply_document_json` performs alongside the pose half.
276 /// `checkpoint` = true for a USER mutation (undoable), false for the silent
277 /// post-run adopt. A null state (no session / no constraints yet) writes
278 /// nothing.
279 fn fold_assembly_state(&mut self, state: Value, checkpoint: bool) {
280 if state.is_null() {
281 return;
282 }
283 if self.history.assembly_block() == Some(&state) {
284 return;
285 }
286 self.history.set_assembly_block(state, checkpoint);
287 }
288
289 /// Make sure a main-side kernel assembly SESSION exists for the last applied
290 /// run — the one thing the runner's reply cannot carry.
291 ///
292 /// A constraint mutation auto-solves immediately, against the resident
293 /// geometry of whichever thread holds it, and the kernel's registry and
294 /// session are both thread-local; so a mutation needs the history executed
295 /// HERE. That is per USER constraint edit — adding, editing, enabling,
296 /// reordering or removing a mate, inferring constraints, or pressing Solve —
297 /// not per run, which is the whole difference from the old `sync_assembly`.
298 /// Cheap no-op when the session is already current, and for a componentless
299 /// document (nothing to solve).
300 ///
301 /// The runner's own session comes first: an owned copy of it
302 /// ([`crate::runner::HistoryRunner::session_for_main`]), imported when it is
303 /// the session of the run applied here, and only while no newer run is in
304 /// flight. Otherwise, and on any refusal, the history is replayed here as
305 /// before.
306 pub(crate) fn ensure_assembly_session(&mut self) {
307 if self.assembly_session_generation == Some(self.applied_generation)
308 && self.assembly_session_serial == brep_kernel::assembly_session_serial()
309 {
310 return;
311 }
312 if !self.history_has_assembly() {
313 return;
314 }
315 if self.session_transfer && !self.run_pending() {
316 let mut _trace = crate::run_trace::span("assembly_session_import");
317 match self.runner.session_for_main(self.applied_generation) {
318 crate::runner::SessionOnMain::Resident => {
319 self.session_imports += 1;
320 self.mark_session_current();
321 return;
322 }
323 crate::runner::SessionOnMain::Copied(transfer) => {
324 let retired = brep_kernel::assembly_session_import(transfer);
325 // Dropping the replaced copy costs milliseconds: not here.
326 #[cfg(not(target_arch = "wasm32"))]
327 std::thread::spawn(move || drop(retired));
328 #[cfg(target_arch = "wasm32")]
329 drop(retired);
330 self.session_imports += 1;
331 self.mark_session_current();
332 return;
333 }
334 crate::runner::SessionOnMain::Unavailable => {}
335 }
336 }
337 self.session_replays += 1;
338 let request: brep_kernel::HistoryRequest =
339 match serde_json::from_value(self.run_request_value()) {
340 Ok(request) => request,
341 Err(_) => return, // unparseable mid-edit document — retry next frame
342 };
343 let mut _trace = crate::run_trace::span("assembly_session");
344 let result = self.execute_plugin_history(&request);
345 if let Some(trace) = _trace.as_mut() {
346 trace.result(&result);
347 }
348 self.mark_session_current();
349 }
350
351 fn mark_session_current(&mut self) {
352 self.assembly_session_generation = Some(self.applied_generation);
353 self.assembly_session_serial = brep_kernel::assembly_session_serial();
354 }
355
356 /// Drop the main-side session's currency stamp, so the next
357 /// `Self::ensure_assembly_session` replays this document. The kernel
358 /// session is one per thread, shared by every open document: a caller that
359 /// may have let another document replay (or changed the kernel's parts
360 /// library) since this one's last replay calls this first.
361 pub fn forget_assembly_session(&mut self) {
362 self.assembly_session_generation = None;
363 }
364
365 /// Re-read the main-side session into [`Self::assembly_sync`] after a
366 /// MUTATION solved against it, so the panels (which read the snapshot, not
367 /// the kernel) show the mutation immediately instead of lagging until the
368 /// rerun's reply lands. The component projection is unchanged by a
369 /// constraint mutation — only the solve moves poses, and the rerun brings
370 /// those — so it is carried over.
371 pub(crate) fn snapshot_assembly_from_session(&mut self) {
372 let parse = |json: String| serde_json::from_str::<Value>(&json).unwrap_or(Value::Null);
373 let components = self.assembly_components.clone();
374 self.assembly_sync = Some(crate::pipeline::AssemblySync {
375 components,
376 state: parse(brep_kernel::assembly_state_json()),
377 statuses: parse(brep_kernel::assembly_statuses_json()),
378 dof: parse(brep_kernel::assembly_dof_json()),
379 overlay: parse(brep_kernel::assembly_overlay_json()),
380 parts_library: None,
381 });
382 }
383
384 /// Run the document fold: `assembly_apply_document_json(document)` →
385 /// adopt the returned document (assembly block replaced; solved poses +
386 /// isFixed folded into features by `inputParams.id`). `checkpoint` = true
387 /// for USER mutations (undoable), false for the silent post-run fold.
388 /// A missing session (no run yet) is tolerated silently.
389 fn apply_assembly_fold(&mut self, checkpoint: bool) {
390 // The fold only rewrites the `assembly` block and per-feature
391 // `inputParams` — it never reads `partsLibrary` — so hand it the
392 // document WITHOUT the library. Otherwise every edit serialized,
393 // parsed and re-serialized the whole embedded part payload three times
394 // over for nothing. `adopt_document` keeps the field when the adopted
395 // document carries no block, so the library survives the round trip.
396 let document = self.history.request_json_without_parts_library();
397 match brep_kernel::assembly_apply_document_impl(&document) {
398 Ok(folded) => {
399 let adopted = if checkpoint {
400 self.history.adopt_document_checkpointed(&folded)
401 } else {
402 self.history.adopt_document(&folded)
403 };
404 if let Err(error) = adopted {
405 self.push_notice(format!("assembly fold failed: {error}"));
406 }
407 // The panels read the SHIPPED snapshot, not the kernel, so a
408 // mutation that solved against the live session has to refresh
409 // it — otherwise the constraint the user just added shows its
410 // pre-solve status until the rerun's reply lands a frame or more
411 // later. ONLY on the Ok arm: with no session the kernel's reads
412 // answer with defaults, which would blank a good snapshot.
413 self.snapshot_assembly_from_session();
414 }
415 Err(_) => {} // no session yet (fresh document before its first run)
416 }
417 }
418
419 /// Shared tail of every USER constraint mutation: fold (checkpointed) and,
420 /// when auto-solve is on, re-run the display history so the viewport
421 /// re-poses (changed ACOMP transforms dirty their fingerprints → those
422 /// instances re-execute + re-tessellate; the runner's tail re-solves).
423 ///
424 /// `pub(super)` because a constraint mutation also arrives from the VIEWPORT:
425 /// an overlay handle drag commits on release
426 /// ([`EngineState::constraint_drag_release`]) and needs the same two things
427 /// this gives every panel edit — an undo entry for the drag, and a rerun
428 /// that re-keys the moved instance's cache entry to its new pose.
429 pub(super) fn after_constraint_mutation(&mut self) {
430 self.apply_assembly_fold(true);
431 if self.settings.assembly_auto_solve {
432 self.rerun_history();
433 } else {
434 self.dirty = true;
435 }
436 }
437
438 // --- constraint mutations (kernel ABI + fold + optional rerun) ----------
439
440 /// Add a constraint (`params_json` = inputParams; the kernel mints the id
441 /// from the type's short name when absent). Returns the minted id.
442 pub fn assembly_add_constraint(
443 &mut self,
444 constraint_type: &str,
445 params_json: &str,
446 ) -> Result<String, String> {
447 if constraint_type == "fixed" {
448 return Err(brep_kernel::FIXED_IS_NOT_A_CONSTRAINT.to_string());
449 }
450 self.ensure_assembly_session();
451 let reply =
452 brep_kernel::assembly_add_constraint_impl(constraint_type, params_json)?;
453 self.after_constraint_mutation();
454 let id = serde_json::from_str::<Value>(&reply)
455 .ok()
456 .and_then(|value| value.get("id").and_then(|id| id.as_str()).map(String::from))
457 .unwrap_or_default();
458 Ok(id)
459 }
460
461 /// Replace a constraint's `inputParams` (the dialog commit).
462 pub fn assembly_update_constraint(&mut self, id: &str, params_json: &str) -> Result<(), String> {
463 self.ensure_assembly_session();
464 brep_kernel::assembly_update_constraint_impl(id, params_json)?;
465 self.after_constraint_mutation();
466 Ok(())
467 }
468
469 /// Update WITHOUT the rerun tail — for callers whose own continuation
470 /// re-runs anyway (the ref-select Finish, whose `end_ref_select` reruns).
471 /// Still folds (checkpointed) so the document is current before that run.
472 pub(crate) fn assembly_update_constraint_no_rerun(
473 &mut self,
474 id: &str,
475 params_json: &str,
476 ) -> Result<(), String> {
477 self.ensure_assembly_session();
478 brep_kernel::assembly_update_constraint_impl(id, params_json)?;
479 self.apply_assembly_fold(true);
480 Ok(())
481 }
482
483 /// Delete a constraint.
484 pub fn assembly_remove_constraint(&mut self, id: &str) -> Result<(), String> {
485 self.ensure_assembly_session();
486 brep_kernel::assembly_remove_constraint_impl(id)?;
487 self.after_constraint_mutation();
488 Ok(())
489 }
490
491 /// Enable/disable a constraint (the row checkbox).
492 pub fn assembly_set_constraint_enabled(&mut self, id: &str, enabled: bool) -> Result<(), String> {
493 self.ensure_assembly_session();
494 brep_kernel::assembly_set_constraint_enabled_impl(id, enabled)?;
495 self.after_constraint_mutation();
496 Ok(())
497 }
498
499 /// Persist WHICH constraint has its dialog open (view state — SILENT fold,
500 /// no solve, no rerun, no undo entry). An open COUNTS
501 /// ([`Self::assembly_constraint_opens`]) — including a re-open of the
502 /// constraint already open, which is what the dialog door needs to surface
503 /// the pane for a label click on a form the user cannot see. ACCORDION: at
504 /// most ONE constraint is open — opening one first closes every other inside the same silent fold,
505 /// so the panel toggle, the context bar's add-from-selection, and a viewport
506 /// label click all converge on a single open constraint. The constraints
507 /// PANEL reads this flag as its mode: open one and its form replaces the
508 /// tree (`panels::assembly_constraints`), which is why every one of those
509 /// surfaces opens the dialog without knowing the panel exists.
510 pub fn assembly_set_constraint_open(&mut self, id: &str, open: bool) -> Result<(), String> {
511 self.ensure_assembly_session();
512 if open {
513 let others: Vec<String> =
514 serde_json::from_str::<Value>(&brep_kernel::assembly_state_json())
515 .ok()
516 .and_then(|state| {
517 state.get("constraints").and_then(|l| l.as_array()).map(|entries| {
518 entries
519 .iter()
520 .filter_map(|entry| {
521 let cid =
522 entry.get("inputParams")?.get("id")?.as_str()?;
523 let is_open =
524 entry.get("open").and_then(|v| v.as_bool()).unwrap_or(false);
525 (is_open && cid != id).then(|| cid.to_string())
526 })
527 .collect()
528 })
529 })
530 .unwrap_or_default();
531 for other in others {
532 brep_kernel::assembly_set_constraint_open_impl(&other, false)?;
533 }
534 }
535 brep_kernel::assembly_set_constraint_open_impl(id, open)?;
536 if open {
537 self.constraint_opens = self.constraint_opens.wrapping_add(1);
538 }
539 self.apply_assembly_fold(false);
540 Ok(())
541 }
542
543 /// How many constraint dialogs this engine has OPENED through
544 /// [`Self::assembly_set_constraint_open`] — the app's dialog door reads it
545 /// beside [`Self::assembly_open_constraint`] so a re-open of the constraint
546 /// already open is still an open (the id alone does not move: a viewport
547 /// label click on the constraint whose form is up, made precisely because
548 /// the user is on another tab).
549 ///
550 /// It counts the OPENS, not the flag's history. The flag itself lives in
551 /// the kernel and a freshly minted constraint arrives `open: true` without
552 /// passing through the setter — that one needs no count, because a new
553 /// constraint brings a new id and the door's id half already sees it.
554 pub fn assembly_constraint_opens(&self) -> u64 {
555 self.constraint_opens
556 }
557
558 /// The id of the constraint whose DIALOG is open, or `None`.
559 ///
560 /// The accordion keeps at most one (`assembly_set_constraint_open` closes
561 /// every other), so this is the panel's mode as ONE value. Reads the same
562 /// two fields the Constraints panel's row builder reads — `inputParams.id`
563 /// and `open` — off the borrowed fold state, without cloning the whole
564 /// assembly block the way `assembly_state_value` does: the app's dialog
565 /// door calls this every frame.
566 pub fn assembly_open_constraint(&self) -> Option<String> {
567 self.assembly_sync()
568 .state
569 .get("constraints")?
570 .as_array()?
571 .iter()
572 .find(|entry| entry.get("open").and_then(Value::as_bool).unwrap_or(false))
573 .and_then(|entry| entry.get("inputParams")?.get("id")?.as_str())
574 .map(str::to_string)
575 }
576
577 /// Reorder a constraint to `index` (drag-reorder).
578 pub fn assembly_move_constraint(&mut self, id: &str, index: usize) -> Result<(), String> {
579 self.ensure_assembly_session();
580 brep_kernel::assembly_move_constraint_impl(id, index)?;
581 self.after_constraint_mutation();
582 Ok(())
583 }
584
585 // --- automatic inference (the Auto Constraints button) ------------------
586
587 /// The constraint types the inference lane can produce — `[{type, label,
588 /// icon, longName, detects, defaultOn}]`, straight off the kernel's rule
589 /// table. The dialog lists THIS; it keeps no roster of its own, so a rule
590 /// added in the kernel shows up as a row without an app change.
591 pub fn assembly_inferable_types(&self) -> Value {
592 serde_json::from_str(&brep_kernel::assembly_inferable_types_json())
593 .unwrap_or_else(|_| Value::Array(Vec::new()))
594 }
595
596 /// SCAN the placed components for the constraints their placement implies,
597 /// creating nothing. `options` is the kernel's `InferOptions` JSON (`{}` for
598 /// the defaults; the dialog sends the ticked `types`).
599 pub fn assembly_infer_constraints(&mut self, options: &str) -> Value {
600 self.ensure_assembly_session();
601 serde_json::from_str(&brep_kernel::assembly_infer_constraints_json(options))
602 .unwrap_or(Value::Null)
603 }
604
605 /// Scan and CREATE, as one undoable step: the kernel adds every accepted
606 /// candidate and solves the batch once, then the usual mutation tail folds
607 /// the result into the document and re-runs the display. A scan that found
608 /// nothing skips the tail entirely (no checkpoint, no rerun).
609 pub fn assembly_apply_inferred_constraints(&mut self, options: &str) -> Value {
610 self.ensure_assembly_session();
611 let reply: Value =
612 serde_json::from_str(&brep_kernel::assembly_apply_inferred_constraints_json(options))
613 .unwrap_or(Value::Null);
614 let created = reply
615 .get("created")
616 .and_then(|value| value.as_array())
617 .map(|list| list.len())
618 .unwrap_or(0);
619 if created > 0 {
620 self.after_constraint_mutation();
621 }
622 reply
623 }
624
625 /// Manual solve (the panel's Solve button): solve the session, fold, and
626 /// ALWAYS re-run the display (that is the point of pressing Solve — it
627 /// works with auto-solve disabled).
628 pub fn assembly_run_solve(&mut self) -> Result<(), String> {
629 self.ensure_assembly_session();
630 // Nothing to solve without components — bail BEFORE the kernel solve:
631 // an empty assembly solving to a no-op is the correct result. (This
632 // bail was once also the only thing between a missing session and a
633 // native abort; the `_impl` doors now return that error as text.)
634 if self.assembly_components().is_empty() {
635 return Ok(());
636 }
637 brep_kernel::assembly_run_solve_impl()?;
638 self.apply_assembly_fold(true);
639 self.rerun_history();
640 Ok(())
641 }
642
643 // --- component actions (route to the owning ACOMP feature) --------------
644
645 /// Insert a component instance (the palette/insert flow): resolve the
646 /// parts-library entry (add or reuse), refresh the document's
647 /// `partsLibrary` block, append an ACOMP feature referencing the RETURNED
648 /// effective part name with an identity transform, and re-run. The FIRST
649 /// component of the document writes `isFixed: true` EXPLICITLY (dialog-
650 /// visible); later instances write `false`. Returns the new feature id
651 /// (`ACOMP<digits>` — the history's global counter mints that exact form).
652 pub fn insert_component(&mut self, insert: ComponentInsert<'_>) -> Result<String, String> {
653 let part_name = self.resolve_component_insert(insert)?;
654 let first = !(0..self.history.len()).any(|index| {
655 matches!(
656 self.history.feature_type(index).as_deref(),
657 Some("ACOMP") | Some("ASSEMBLY COMPONENT")
658 )
659 });
660 let id = self.history.next_feature_id("ACOMP");
661 let feature = serde_json::json!({
662 "type": "ACOMP",
663 "inputParams": {
664 "id": id,
665 "partName": part_name,
666 "transform": { "translate": [0, 0, 0], "rotateEulerDeg": [0, 0, 0] },
667 "isFixed": first,
668 },
669 "persistentData": {}
670 });
671 self.add_feature(&feature.to_string())?;
672 Ok(id)
673 }
674
675 /// The parts-library entry an insert (or a replace) names: an existing
676 /// entry as is, or a new payload added (deduplicated by
677 /// sourceKey+signature) — with the document's `partsLibrary` block
678 /// refreshed so the next run ingests it. Returns the EFFECTIVE entry name.
679 pub(crate) fn resolve_component_insert(&mut self, insert: ComponentInsert<'_>) -> Result<String, String> {
680 let part_name = match insert {
681 ComponentInsert::Existing { part_name } => {
682 if !self.parts_library_names().iter().any(|name| name == part_name) {
683 return Err(format!("part '{part_name}' is not in the parts library"));
684 }
685 part_name.to_string()
686 },
687 ComponentInsert::New {
688 name,
689 source_key,
690 source_signature,
691 document_json,
692 } => match brep_kernel::add_part_to_library_impl(
693 name,
694 source_key,
695 source_signature,
696 document_json,
697 ) {
698 Ok(part_name) => part_name,
699 Err(error) => {
700 // The Insert component dialog closes on Insert, so its
701 // status line never shows this; the toast is what the user
702 // sees (a file that is not a part document, for one).
703 self.push_notice(format!("Could not insert '{name}': {error}"));
704 return Err(error);
705 }
706 },
707 };
708 // The block must ride the request so the display runner ingests the
709 // (new) entry on the very next run.
710 if let Ok(library) = serde_json::from_str::<Value>(&brep_kernel::parts_library_json()) {
711 self.history.set_parts_library(library);
712 }
713 Ok(part_name)
714 }
715
716 /// Fix/Unfix a component: toggles the OWNING ACOMP feature's
717 /// `inputParams.isFixed` (one truth, one undo lane) and re-runs. A nested
718 /// occurrence (`ACOMP1:ACOMP2`) is fixed or freed on the top instance that
719 /// lends it (`inputParams.flexibleFixed`, by instance-local path), never in
720 /// the sub-assembly's document; [`nested_fixed_state`] says when that is
721 /// not possible.
722 pub fn set_component_fixed(&mut self, component_id: &str, fixed: bool) -> Result<(), String> {
723 if let Some((top, rest)) = component_id.split_once(':') {
724 self.occurrence_fixed(component_id)?;
725 let index = self.history.index_of(top).ok_or_else(|| format!("no component feature '{top}'"))?;
726 let mut params = self.history.feature_params(index).unwrap_or_else(|| serde_json::json!({}));
727 let Some(map) = params.as_object_mut() else {
728 return Err(format!("component '{top}': malformed inputParams"));
729 };
730 let overrides = map.entry("flexibleFixed").or_insert_with(|| serde_json::json!({}));
731 if !overrides.is_object() {
732 *overrides = serde_json::json!({});
733 }
734 overrides[rest] = Value::Bool(fixed);
735 self.update_feature_params(top, ¶ms.to_string())?;
736 return Ok(());
737 }
738 self.set_components_fixed(&[component_id.to_string()], fixed)
739 }
740
741 /// Fix/Unfix several top-level components at once (a packed BOM row's
742 /// placements): every owning ACOMP's `inputParams.isFixed`, ONE undo step,
743 /// one re-run. The same write [`Self::set_component_fixed`] makes for one.
744 pub fn set_components_fixed(&mut self, component_ids: &[String], fixed: bool) -> Result<(), String> {
745 let mut edits = Vec::with_capacity(component_ids.len());
746 for component_id in component_ids {
747 if component_id.contains(':') {
748 return Err(format!("'{component_id}' is a nested occurrence: fix or unfix it on its own"));
749 }
750 let index = self
751 .history
752 .index_of(component_id)
753 .ok_or_else(|| format!("no component feature '{component_id}'"))?;
754 let mut params = self
755 .history
756 .feature_params(index)
757 .unwrap_or_else(|| serde_json::json!({}));
758 let Some(map) = params.as_object_mut() else {
759 return Err(format!("component '{component_id}': malformed inputParams"));
760 };
761 map.insert("isFixed".into(), Value::Bool(fixed));
762 edits.push((component_id.clone(), params));
763 }
764 self.update_many_feature_params(&edits)?;
765 Ok(())
766 }
767
768 /// Whether a nested occurrence (`ACOMP1:ACOMP2`) is fixed, or why it
769 /// cannot be fixed or freed from this assembly ([`nested_fixed_state`]).
770 pub fn occurrence_fixed(&self, path: &str) -> Result<bool, String> {
771 let (top, rest) = path.split_once(':').ok_or_else(|| format!("'{path}' is not a nested occurrence"))?;
772 let index = self.history.index_of(top).ok_or_else(|| format!("no component feature '{top}'"))?;
773 let params = self.history.feature_params(index).unwrap_or_default();
774 let part = params["partName"].as_str().unwrap_or_default();
775 nested_fixed_state(¶ms, rest, &self.history.parts_library()[part]["document"])
776 }
777
778 /// Select a component in the viewport: emphasis over exactly its member
779 /// solids (the tree↔viewport sync lane; a viewport pick of any member
780 /// lights the tree row through the same emphasis set).
781 pub fn select_component(&mut self, component_id: &str) {
782 self.select_components(&[component_id.to_string()]);
783 }
784
785 /// A component's member SOLID scene names (empty for an unknown id) — the
786 /// selection/hover unit COMPONENT entries resolve to.
787 pub fn component_member_solids(&self, component_id: &str) -> Vec<String> {
788 self.assembly_components
789 .iter()
790 .filter(|record| record.id == component_id)
791 .flat_map(|record| record.solids.iter().cloned())
792 .collect()
793 }
794
795 /// TOGGLE a component in the current selection as ONE unit (the additive
796 /// Ctrl/Cmd+click under COMPONENT promotion): when EVERY member solid is
797 /// already selected the whole set deselects, otherwise the whole set joins
798 /// the selection — the rest of the selection stays.
799 pub fn toggle_component_selection(&mut self, component_id: &str) {
800 let members = self.component_member_solids(component_id);
801 if members.is_empty() {
802 return;
803 }
804 let all_selected = members
805 .iter()
806 .all(|name| self.emphasis.selected_solids.contains(name));
807 for name in members {
808 if all_selected {
809 self.emphasis.selected_solids.remove(&name);
810 } else {
811 self.emphasis.selected_solids.insert(name);
812 }
813 }
814 self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
815 self.dirty = true;
816 }
817
818 /// Select SEVERAL components at once: emphasis over the union of their
819 /// member solids (the interference window's row click highlights both
820 /// participants of a pair through this).
821 pub fn select_components(&mut self, component_ids: &[String]) {
822 // An id may be a nested occurrence (`ACOMP2:ACOMP1`): the solids of
823 // its top component under that path.
824 let members: Vec<String> = self
825 .assembly_components
826 .iter()
827 .flat_map(|record| {
828 record.solids.iter().filter(move |solid| {
829 component_ids.iter().any(|id| {
830 id == &record.id
831 || (id.split(':').next() == Some(record.id.as_str()) && solid.starts_with(&format!("{id}:")))
832 })
833 })
834 })
835 .cloned()
836 .collect();
837 let json = serde_json::json!({ "selected": { "solids": members } }).to_string();
838 let _ = self.emphasis.apply_json(&json);
839 self.dirty = true;
840 }
841
842 // --- constraint reference selection (the ref-select reuse) --------------
843
844 /// Enter the modal reference picker for an ASSEMBLY CONSTRAINT's
845 /// `elements`-style field (same widget, different commit target — Finish
846 /// routes through `Self::assembly_update_constraint_no_rerun` instead of
847 /// feature params). No roll-to-before: constraints pick against the FULL
848 /// assembly.
849 pub fn begin_ref_select_for_constraint(
850 &mut self,
851 constraint_id: &str,
852 path: Vec<String>,
853 label: String,
854 filter: Vec<String>,
855 multiple: bool,
856 seed_names: Vec<String>,
857 ) {
858 let restore_index = self.history.rollback();
859 self.selection_filter = SelectionFilter::from_ref_filter(&filter);
860 self.ref_select = Some(RefSelectState {
861 feature_id: constraint_id.to_string(),
862 path,
863 label,
864 filter,
865 multiple,
866 names: seed_names,
867 restore_index,
868 target: RefSelectTarget::AssemblyConstraint,
869 picks: 0,
870 claimed: false,
871 finish_requested: false,
872 });
873 self.sync_ref_select_emphasis();
874 }
875
876 /// Commit a finished constraint ref-select: read the constraint's params
877 /// from the session state, write the picked names at the field path, and
878 /// update (fold only — the caller's continuation reruns).
879 pub(crate) fn assembly_commit_constraint_refs(
880 &mut self,
881 constraint_id: &str,
882 path: &[String],
883 names: &[String],
884 multiple: bool,
885 ) {
886 let state = self.assembly_state_value();
887 let Some(entry) = state
888 .get("constraints")
889 .and_then(Value::as_array)
890 .and_then(|constraints| {
891 constraints.iter().find(|entry| {
892 entry
893 .get("inputParams")
894 .and_then(|params| params.get("id"))
895 .and_then(Value::as_str)
896 == Some(constraint_id)
897 })
898 })
899 else {
900 self.push_notice(format!("unknown constraint '{constraint_id}'"));
901 return;
902 };
903 let mut params = entry
904 .get("inputParams")
905 .cloned()
906 .unwrap_or_else(|| serde_json::json!({}));
907 let value = if multiple {
908 Value::Array(names.iter().cloned().map(Value::String).collect())
909 } else {
910 Value::String(names.first().cloned().unwrap_or_default())
911 };
912 super::selection_ux::set_json_at(&mut params, path, value);
913 if let Err(error) = self.assembly_update_constraint_no_rerun(constraint_id, ¶ms.to_string())
914 {
915 self.push_notice(format!("constraint update failed: {error}"));
916 }
917 }
918
919 /// Build the `{solidName}@x,y,z` COMPONENT-LOCAL vertex ref for a vertex
920 /// pick on a component member (lane-E contract: world pick transformed by
921 /// the owning component's inverse pose). `None` when the solid belongs to
922 /// no component (vertex refs only exist for constraint selection).
923 pub(crate) fn component_vertex_ref(
924 &self,
925 solid_name: &str,
926 world_position: [f64; 3],
927 ) -> Option<String> {
928 let record = self.assembly_components.iter().find(|record| {
929 record.solids.iter().any(|member| member == solid_name)
930 })?;
931 let local = rigid_inverse_point(&record.affine(), world_position);
932 Some(format!(
933 "{solid_name}@{},{},{}",
934 local[0], local[1], local[2]
935 ))
936 }
937}
938