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