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