brep_render/engine_state/assembly_ops.rs
1use super::*;
2use serde_json::Value;
3
4// Assembly sessions are thread-local. After a display run on the background
5// runner, sync_assembly replays the history on the main thread to populate its
6// session and fold solved component state into the document. Componentless
7// histories skip this work; the first cold sync can block the UI.
8// Mutations synchronize, call the kernel ABI, and adopt the result with an undo
9// checkpoint. Auto-solve reruns the display history; otherwise manual Solve does.
10
11/// What a component-insert names: an EXISTING parts-library entry (skip the
12/// store read — just add an instance) or a NEW part payload to hand to
13/// `add_part_to_library` (which dedups by sourceKey+signature and returns the
14/// EFFECTIVE entry name the instance must reference).
15pub enum ComponentInsert<'a> {
16 Existing {
17 part_name: &'a str,
18 },
19 New {
20 name: &'a str,
21 source_key: &'a str,
22 source_signature: &'a str,
23 document_json: &'a str,
24 },
25}
26
27/// A STABLE content signature of a document: a sorted-key JSON walk hashed
28/// with the (fixed-key, cross-process-deterministic) std SipHash. The ONE
29/// signature fn, at the ENGINE altitude so app-side and engine-side writers
30/// share one copy: the app's insert flow (`panels::file`), the edit-in-place
31/// Finish (`panels::assembly_edit::refresh_library_entry`) and the
32/// update-components comparison (`panels::update_components`) all write/compare
33/// a parts-library `sourceSignature` with THIS function — a freshly inserted,
34/// unchanged part must always compare up-to-date, which a second copy would
35/// break the moment either drifted.
36/// Uses the kernel's shared sorted-key hash, also used for in-memory library
37/// fingerprints. Invalid JSON retains the raw-text hashing fallback.
38pub fn document_signature(doc_json: &str) -> String {
39 use std::hash::{Hash, Hasher};
40 let hash = match serde_json::from_str::<serde_json::Value>(doc_json) {
41 Ok(value) => brep_kernel::stable_json_hash(&value),
42 Err(_) => {
43 let mut hasher = std::collections::hash_map::DefaultHasher::new();
44 doc_json.hash(&mut hasher);
45 hasher.finish()
46 }
47 };
48 format!("{hash:016x}")
49}
50
51/// Stringify a kernel ABI error. The kernel exports return `JsValue` errors
52/// (wasm-bindgen, built from plain strings); this crate does not depend on
53/// wasm-bindgen directly, so stringify generically via `Debug` — for a
54/// string-payload `JsValue` that is the message (quoted), which is all the
55/// notice/status lanes need.
56fn js_err<E: std::fmt::Debug>(error: E) -> String {
57 format!("{error:?}")
58}
59
60/// Rigid inverse `[Rᵀ | −Rᵀ·t]` of a component pose — used to express a picked
61/// WORLD point in COMPONENT-LOCAL coordinates (the vertex-ref contract).
62fn rigid_inverse_point(transform: &brep_kernel::AffineTransform, world: [f64; 3]) -> [f64; 3] {
63 let m = &transform.elements;
64 let d = [world[0] - m[3], world[1] - m[7], world[2] - m[11]];
65 [
66 m[0] * d[0] + m[4] * d[1] + m[8] * d[2],
67 m[1] * d[0] + m[5] * d[1] + m[9] * d[2],
68 m[2] * d[0] + m[6] * d[1] + m[10] * d[2],
69 ]
70}
71
72impl EngineState {
73 // --- read surface ------------------------------------------------------
74
75 /// Whether the current document is an ASSEMBLY document: any ACOMP-typed
76 /// feature, or a present `assembly` constraint block. Componentless
77 /// documents skip every main-side sync (zero cost for modeling files).
78 pub fn history_has_assembly(&self) -> bool {
79 let has_acomp = (0..self.history.len()).any(|index| {
80 matches!(
81 self.history.feature_type(index).as_deref(),
82 Some("ACOMP") | Some("ASSEMBLY COMPONENT")
83 )
84 });
85 has_acomp
86 || self
87 .history
88 .assembly_block()
89 .map(|block| !block.is_null())
90 .unwrap_or(false)
91 }
92
93 /// The scene's component records (deterministic id order) as of the last
94 /// [`sync_assembly`] — the Assembly Structure tree's projection source.
95 /// A VIEW over the scene, never an owning structure.
96 pub fn assembly_components(&self) -> &[brep_kernel::ComponentRecord] {
97 &self.assembly_components
98 }
99
100 /// Make sure the main-side assembly session + component projection are
101 /// current with the last APPLIED display run. Cheap no-op when already
102 /// synced (or for componentless documents). Panels call this at frame
103 /// start; the post-run tail (`finish_apply`) also syncs eagerly.
104 pub fn ensure_assembly_synced(&mut self) {
105 if self.assembly_synced_generation == Some(self.applied_generation) {
106 return;
107 }
108 self.sync_assembly();
109 }
110
111 /// The per-constraint status rows (`[{id, type, enabled, open, status,
112 /// message, satisfied, error}]`) from the main-side session.
113 pub fn assembly_statuses_value(&mut self) -> Value {
114 self.ensure_assembly_synced();
115 serde_json::from_str(&brep_kernel::assembly_statuses_json())
116 .unwrap_or_else(|_| Value::Array(Vec::new()))
117 }
118
119 /// The current `assembly` block (post-solve): `{constraints, idCounter}`.
120 pub fn assembly_state_value(&mut self) -> Value {
121 self.ensure_assembly_synced();
122 serde_json::from_str(&brep_kernel::assembly_state_json()).unwrap_or(Value::Null)
123 }
124
125 /// The last solve's DOF/diagnostics summary (`{ok, dof, rank, redundant,
126 /// …}` — `movedSolids` may be absent; tolerate it).
127 pub fn assembly_dof_value(&mut self) -> Value {
128 self.ensure_assembly_synced();
129 serde_json::from_str(&brep_kernel::assembly_dof_json()).unwrap_or(Value::Null)
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 self.ensure_assembly_synced();
137 serde_json::from_str(&brep_kernel::assembly_overlay_json())
138 .unwrap_or_else(|_| Value::Array(Vec::new()))
139 }
140
141 /// The parts-library entry names currently resident (insert-flow "existing
142 /// entries first" list). Main-side store — seeded by document ingest on
143 /// sync and grown by [`insert_component`].
144 pub fn parts_library_names(&mut self) -> Vec<String> {
145 if self.history_has_assembly() {
146 self.ensure_assembly_synced();
147 }
148 serde_json::from_str::<Value>(&brep_kernel::parts_library_json())
149 .ok()
150 .and_then(|value| {
151 value
152 .as_object()
153 .map(|map| map.keys().cloned().collect::<Vec<_>>())
154 })
155 .unwrap_or_default()
156 }
157
158 // --- the sync + fold (pose-authority contract) --------------------------
159
160 /// Re-execute the rolled-to history MAIN-SIDE (warm-cache replay), fold
161 /// the resulting ComponentRecords into [`Self::assembly_components`], fold
162 /// the post-solve session state + poses back into the document (silent
163 /// adopt — solver write-back is not a user edit), and refresh the
164 /// document's `partsLibrary` block from the kernel store (post-GC; SAVE
165 /// must serialize the store, never echo a loaded block). Componentless
166 /// documents just clear the projection.
167 pub(crate) fn sync_assembly(&mut self) {
168 if !self.history_has_assembly() {
169 self.assembly_components.clear();
170 // No ACOMP references left ⇒ any partsLibrary block is orphaned
171 // payload (the kernel GCs the store the same way at the end of
172 // every run) — drop it so deleting the last instance never leaves
173 // a dangling entry in the saved document.
174 self.history
175 .set_parts_library(Value::Object(serde_json::Map::new()));
176 self.assembly_synced_generation = Some(self.applied_generation);
177 return;
178 }
179 let request: brep_kernel::HistoryRequest =
180 match serde_json::from_value(self.history.prefix_request()) {
181 Ok(request) => request,
182 Err(_) => return, // unparseable mid-edit document — retry next frame
183 };
184 let result = brep_kernel::execute_history(&request);
185
186 // Fold the per-feature component side-channel exactly like
187 // SceneMap::apply: removals unmap by id, additions insert in order
188 // (BTreeMap ⇒ deterministic id order for the tree/BOM).
189 let mut components: std::collections::BTreeMap<String, brep_kernel::ComponentRecord> =
190 std::collections::BTreeMap::new();
191 for feature in &result.results {
192 for removed in &feature.removed {
193 components.remove(removed);
194 }
195 for record in &feature.components {
196 components.insert(record.id.clone(), record.clone());
197 }
198 }
199 self.assembly_components = components.into_values().collect();
200
201 // Fold the session (solved constraint state + poses/isFixed) into the
202 // document — the pose-authority write-back, silent (not a user edit).
203 self.apply_assembly_fold(false);
204
205 // SAVE-side contract: the document's partsLibrary block mirrors the
206 // kernel store (heals + GC included). Fingerprint-neutral: snapshots
207 // are excluded from the ACOMP content hash.
208 // Read the revision AFTER the run above: its ACOMP self-heal and orphan
209 // GC are library mutations, and they bump it. Serializing the store
210 // costs the whole embedded part payload, so do it only when the library
211 // actually moved — in the steady state (edit, roll, re-solve) it does
212 // not, and this is a single integer compare.
213 let revision = brep_kernel::parts_library_revision();
214 if self.parts_library_block_revision != Some(revision)
215 || !self.history.parts_library_mirrors_store()
216 {
217 if let Ok(library) = serde_json::from_str::<Value>(&brep_kernel::parts_library_json()) {
218 self.history.set_parts_library(library);
219 self.parts_library_block_revision = Some(revision);
220 }
221 }
222 self.assembly_synced_generation = Some(self.applied_generation);
223 }
224
225 /// Run the document fold: `assembly_apply_document_json(document)` →
226 /// adopt the returned document (assembly block replaced; solved poses +
227 /// isFixed folded into features by `inputParams.id`). `checkpoint` = true
228 /// for USER mutations (undoable), false for the silent post-run fold.
229 /// A missing session (no run yet) is tolerated silently.
230 fn apply_assembly_fold(&mut self, checkpoint: bool) {
231 // The fold only rewrites the `assembly` block and per-feature
232 // `inputParams` — it never reads `partsLibrary` — so hand it the
233 // document WITHOUT the library. Otherwise every edit serialized,
234 // parsed and re-serialized the whole embedded part payload three times
235 // over for nothing. `adopt_document` keeps the field when the adopted
236 // document carries no block, so the library survives the round trip.
237 let document = self.history.request_json_without_parts_library();
238 match brep_kernel::assembly_apply_document_json(&document) {
239 Ok(folded) => {
240 let adopted = if checkpoint {
241 self.history.adopt_document_checkpointed(&folded)
242 } else {
243 self.history.adopt_document(&folded)
244 };
245 if let Err(error) = adopted {
246 self.push_notice(format!("assembly fold failed: {error}"));
247 }
248 }
249 Err(_) => {} // no session yet (fresh document before its first run)
250 }
251 }
252
253 /// Shared tail of every USER constraint mutation: fold (checkpointed) and,
254 /// when auto-solve is on, re-run the display history so the viewport
255 /// re-poses (changed ACOMP transforms dirty their fingerprints → those
256 /// instances re-execute + re-tessellate; the runner's tail re-solves).
257 fn after_constraint_mutation(&mut self) {
258 self.apply_assembly_fold(true);
259 if self.settings.assembly_auto_solve {
260 self.rerun_history();
261 } else {
262 self.dirty = true;
263 }
264 }
265
266 // --- constraint mutations (kernel ABI + fold + optional rerun) ----------
267
268 /// Add a constraint (`params_json` = inputParams; the kernel mints the id
269 /// from the type's short name when absent). Returns the minted id.
270 pub fn assembly_add_constraint(
271 &mut self,
272 constraint_type: &str,
273 params_json: &str,
274 ) -> Result<String, String> {
275 self.ensure_assembly_synced();
276 let reply =
277 brep_kernel::assembly_add_constraint_json(constraint_type, params_json).map_err(js_err)?;
278 self.after_constraint_mutation();
279 let id = serde_json::from_str::<Value>(&reply)
280 .ok()
281 .and_then(|value| value.get("id").and_then(|id| id.as_str()).map(String::from))
282 .unwrap_or_default();
283 Ok(id)
284 }
285
286 /// Replace a constraint's `inputParams` (the dialog commit).
287 pub fn assembly_update_constraint(&mut self, id: &str, params_json: &str) -> Result<(), String> {
288 self.ensure_assembly_synced();
289 brep_kernel::assembly_update_constraint_json(id, params_json).map_err(js_err)?;
290 self.after_constraint_mutation();
291 Ok(())
292 }
293
294 /// Update WITHOUT the rerun tail — for callers whose own continuation
295 /// re-runs anyway (the ref-select Finish, whose `end_ref_select` reruns).
296 /// Still folds (checkpointed) so the document is current before that run.
297 pub(crate) fn assembly_update_constraint_no_rerun(
298 &mut self,
299 id: &str,
300 params_json: &str,
301 ) -> Result<(), String> {
302 self.ensure_assembly_synced();
303 brep_kernel::assembly_update_constraint_json(id, params_json).map_err(js_err)?;
304 self.apply_assembly_fold(true);
305 Ok(())
306 }
307
308 /// Delete a constraint.
309 pub fn assembly_remove_constraint(&mut self, id: &str) -> Result<(), String> {
310 self.ensure_assembly_synced();
311 brep_kernel::assembly_remove_constraint_json(id).map_err(js_err)?;
312 self.after_constraint_mutation();
313 Ok(())
314 }
315
316 /// Enable/disable a constraint (the row checkbox).
317 pub fn assembly_set_constraint_enabled(&mut self, id: &str, enabled: bool) -> Result<(), String> {
318 self.ensure_assembly_synced();
319 brep_kernel::assembly_set_constraint_enabled_json(id, enabled).map_err(js_err)?;
320 self.after_constraint_mutation();
321 Ok(())
322 }
323
324 /// Persist WHICH constraint has its dialog open (view state — SILENT fold,
325 /// no solve, no rerun, no undo entry). ACCORDION: at most ONE constraint is
326 /// open — opening one first closes every other inside the same silent fold,
327 /// so the panel toggle, the context bar's add-from-selection, and a viewport
328 /// label click all converge on a single open constraint. The constraints
329 /// PANEL reads this flag as its mode: open one and its form replaces the
330 /// tree (`panels::assembly_constraints`), which is why every one of those
331 /// surfaces opens the dialog without knowing the panel exists.
332 pub fn assembly_set_constraint_open(&mut self, id: &str, open: bool) -> Result<(), String> {
333 self.ensure_assembly_synced();
334 if open {
335 let others: Vec<String> =
336 serde_json::from_str::<Value>(&brep_kernel::assembly_state_json())
337 .ok()
338 .and_then(|state| {
339 state.get("constraints").and_then(|l| l.as_array()).map(|entries| {
340 entries
341 .iter()
342 .filter_map(|entry| {
343 let cid =
344 entry.get("inputParams")?.get("id")?.as_str()?;
345 let is_open =
346 entry.get("open").and_then(|v| v.as_bool()).unwrap_or(false);
347 (is_open && cid != id).then(|| cid.to_string())
348 })
349 .collect()
350 })
351 })
352 .unwrap_or_default();
353 for other in others {
354 brep_kernel::assembly_set_constraint_open_json(&other, false).map_err(js_err)?;
355 }
356 }
357 brep_kernel::assembly_set_constraint_open_json(id, open).map_err(js_err)?;
358 self.apply_assembly_fold(false);
359 Ok(())
360 }
361
362 /// Reorder a constraint to `index` (drag-reorder).
363 pub fn assembly_move_constraint(&mut self, id: &str, index: usize) -> Result<(), String> {
364 self.ensure_assembly_synced();
365 brep_kernel::assembly_move_constraint_json(id, index).map_err(js_err)?;
366 self.after_constraint_mutation();
367 Ok(())
368 }
369
370 // --- automatic inference (the Auto Constraints button) ------------------
371
372 /// The constraint types the inference lane can produce — `[{type, label,
373 /// icon, longName, detects, defaultOn}]`, straight off the kernel's rule
374 /// table. The dialog lists THIS; it keeps no roster of its own, so a rule
375 /// added in the kernel shows up as a row without an app change.
376 pub fn assembly_inferable_types(&self) -> Value {
377 serde_json::from_str(&brep_kernel::assembly_inferable_types_json())
378 .unwrap_or_else(|_| Value::Array(Vec::new()))
379 }
380
381 /// SCAN the placed components for the constraints their placement implies,
382 /// creating nothing. `options` is the kernel's `InferOptions` JSON (`{}` for
383 /// the defaults; the dialog sends the ticked `types`).
384 pub fn assembly_infer_constraints(&mut self, options: &str) -> Value {
385 self.ensure_assembly_synced();
386 serde_json::from_str(&brep_kernel::assembly_infer_constraints_json(options))
387 .unwrap_or(Value::Null)
388 }
389
390 /// Scan and CREATE, as one undoable step: the kernel adds every accepted
391 /// candidate and solves the batch once, then the usual mutation tail folds
392 /// the result into the document and re-runs the display. A scan that found
393 /// nothing skips the tail entirely (no checkpoint, no rerun).
394 pub fn assembly_apply_inferred_constraints(&mut self, options: &str) -> Value {
395 self.ensure_assembly_synced();
396 let reply: Value =
397 serde_json::from_str(&brep_kernel::assembly_apply_inferred_constraints_json(options))
398 .unwrap_or(Value::Null);
399 let created = reply
400 .get("created")
401 .and_then(|value| value.as_array())
402 .map(|list| list.len())
403 .unwrap_or(0);
404 if created > 0 {
405 self.after_constraint_mutation();
406 }
407 reply
408 }
409
410 /// Manual solve (the panel's Solve button): solve the session, fold, and
411 /// ALWAYS re-run the display (that is the point of pressing Solve — it
412 /// works with auto-solve disabled).
413 pub fn assembly_run_solve(&mut self) -> Result<(), String> {
414 self.ensure_assembly_synced();
415 // Nothing to solve without components — bail BEFORE the kernel solve.
416 // With no assembly session the kernel's solve/fold error paths build a
417 // `JsValue`, and wasm-bindgen's `JsValue` is unimplemented on native
418 // targets: the construction panics ("function not implemented on
419 // non-wasm32 targets") in a nounwind context and ABORTS the desktop
420 // app. An empty assembly solving to a no-op is also the correct result.
421 if self.assembly_components().is_empty() {
422 return Ok(());
423 }
424 brep_kernel::assembly_run_solve_json().map_err(js_err)?;
425 self.apply_assembly_fold(true);
426 self.rerun_history();
427 Ok(())
428 }
429
430 // --- component actions (route to the owning ACOMP feature) --------------
431
432 /// Insert a component instance (the palette/insert flow): resolve the
433 /// parts-library entry (add or reuse), refresh the document's
434 /// `partsLibrary` block, append an ACOMP feature referencing the RETURNED
435 /// effective part name with an identity transform, and re-run. The FIRST
436 /// component of the document writes `isFixed: true` EXPLICITLY (dialog-
437 /// visible); later instances write `false`. Returns the new feature id
438 /// (`ACOMP<digits>` — the history's global counter mints that exact form).
439 pub fn insert_component(&mut self, insert: ComponentInsert<'_>) -> Result<String, String> {
440 let part_name = match insert {
441 ComponentInsert::Existing { part_name } => part_name.to_string(),
442 ComponentInsert::New {
443 name,
444 source_key,
445 source_signature,
446 document_json,
447 } => brep_kernel::add_part_to_library(name, source_key, source_signature, document_json)
448 .map_err(js_err)?,
449 };
450 // The block must ride the request so the display runner ingests the
451 // (new) entry on the very next run.
452 if let Ok(library) = serde_json::from_str::<Value>(&brep_kernel::parts_library_json()) {
453 self.history.set_parts_library(library);
454 }
455 let first = !(0..self.history.len()).any(|index| {
456 matches!(
457 self.history.feature_type(index).as_deref(),
458 Some("ACOMP") | Some("ASSEMBLY COMPONENT")
459 )
460 });
461 let id = self.history.next_feature_id("ACOMP");
462 let feature = serde_json::json!({
463 "type": "ACOMP",
464 "inputParams": {
465 "id": id,
466 "partName": part_name,
467 "transform": { "translate": [0, 0, 0], "rotateEulerDeg": [0, 0, 0] },
468 "isFixed": first,
469 },
470 "persistentData": {}
471 });
472 self.add_feature(&feature.to_string())?;
473 Ok(id)
474 }
475
476 /// Fix/Unfix a component: toggles the OWNING ACOMP feature's
477 /// `inputParams.isFixed` (one truth, one undo lane) and re-runs.
478 pub fn set_component_fixed(&mut self, component_id: &str, fixed: bool) -> Result<(), String> {
479 let index = self
480 .history
481 .index_of(component_id)
482 .ok_or_else(|| format!("no component feature '{component_id}'"))?;
483 let mut params = self
484 .history
485 .feature_params(index)
486 .unwrap_or_else(|| serde_json::json!({}));
487 if let Some(map) = params.as_object_mut() {
488 map.insert("isFixed".into(), Value::Bool(fixed));
489 } else {
490 return Err(format!("component '{component_id}': malformed inputParams"));
491 }
492 self.update_feature_params(component_id, ¶ms.to_string())?;
493 Ok(())
494 }
495
496 /// Select a component in the viewport: emphasis over exactly its member
497 /// solids (the tree↔viewport sync lane; a viewport pick of any member
498 /// lights the tree row through the same emphasis set).
499 pub fn select_component(&mut self, component_id: &str) {
500 self.select_components(&[component_id.to_string()]);
501 }
502
503 /// A component's member SOLID scene names (empty for an unknown id) — the
504 /// selection/hover unit COMPONENT entries resolve to.
505 pub fn component_member_solids(&self, component_id: &str) -> Vec<String> {
506 self.assembly_components
507 .iter()
508 .filter(|record| record.id == component_id)
509 .flat_map(|record| record.solids.iter().cloned())
510 .collect()
511 }
512
513 /// TOGGLE a component in the current selection as ONE unit (the additive
514 /// Ctrl/Cmd+click under COMPONENT promotion): when EVERY member solid is
515 /// already selected the whole set deselects, otherwise the whole set joins
516 /// the selection — the rest of the selection stays.
517 pub fn toggle_component_selection(&mut self, component_id: &str) {
518 let members = self.component_member_solids(component_id);
519 if members.is_empty() {
520 return;
521 }
522 let all_selected = members
523 .iter()
524 .all(|name| self.emphasis.selected_solids.contains(name));
525 for name in members {
526 if all_selected {
527 self.emphasis.selected_solids.remove(&name);
528 } else {
529 self.emphasis.selected_solids.insert(name);
530 }
531 }
532 self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
533 self.dirty = true;
534 }
535
536 /// Select SEVERAL components at once: emphasis over the union of their
537 /// member solids (the interference window's row click highlights both
538 /// participants of a pair through this).
539 pub fn select_components(&mut self, component_ids: &[String]) {
540 let members: Vec<String> = self
541 .assembly_components
542 .iter()
543 .filter(|record| component_ids.iter().any(|id| id == &record.id))
544 .flat_map(|record| record.solids.iter().cloned())
545 .collect();
546 let json = serde_json::json!({ "selected": { "solids": members } }).to_string();
547 let _ = self.emphasis.apply_json(&json);
548 self.dirty = true;
549 }
550
551 // --- constraint reference selection (the ref-select reuse) --------------
552
553 /// Enter the modal reference picker for an ASSEMBLY CONSTRAINT's
554 /// `elements`-style field (same widget, different commit target — Finish
555 /// routes through [`Self::assembly_update_constraint_no_rerun`] instead of
556 /// feature params). No roll-to-before: constraints pick against the FULL
557 /// assembly.
558 pub fn begin_ref_select_for_constraint(
559 &mut self,
560 constraint_id: &str,
561 path: Vec<String>,
562 label: String,
563 filter: Vec<String>,
564 multiple: bool,
565 seed_names: Vec<String>,
566 ) {
567 let restore_index = self.history.rollback();
568 self.selection_filter = SelectionFilter::from_ref_filter(&filter);
569 self.ref_select = Some(RefSelectState {
570 feature_id: constraint_id.to_string(),
571 path,
572 label,
573 filter,
574 multiple,
575 names: seed_names,
576 restore_index,
577 target: RefSelectTarget::AssemblyConstraint,
578 });
579 self.sync_ref_select_emphasis();
580 }
581
582 /// Commit a finished constraint ref-select: read the constraint's params
583 /// from the session state, write the picked names at the field path, and
584 /// update (fold only — the caller's continuation reruns).
585 pub(crate) fn assembly_commit_constraint_refs(
586 &mut self,
587 constraint_id: &str,
588 path: &[String],
589 names: &[String],
590 multiple: bool,
591 ) {
592 let state = self.assembly_state_value();
593 let Some(entry) = state
594 .get("constraints")
595 .and_then(Value::as_array)
596 .and_then(|constraints| {
597 constraints.iter().find(|entry| {
598 entry
599 .get("inputParams")
600 .and_then(|params| params.get("id"))
601 .and_then(Value::as_str)
602 == Some(constraint_id)
603 })
604 })
605 else {
606 self.push_notice(format!("unknown constraint '{constraint_id}'"));
607 return;
608 };
609 let mut params = entry
610 .get("inputParams")
611 .cloned()
612 .unwrap_or_else(|| serde_json::json!({}));
613 let value = if multiple {
614 Value::Array(names.iter().cloned().map(Value::String).collect())
615 } else {
616 Value::String(names.first().cloned().unwrap_or_default())
617 };
618 super::selection_ux::set_json_at(&mut params, path, value);
619 if let Err(error) = self.assembly_update_constraint_no_rerun(constraint_id, ¶ms.to_string())
620 {
621 self.push_notice(format!("constraint update failed: {error}"));
622 }
623 }
624
625 /// Build the `{solidName}@x,y,z` COMPONENT-LOCAL vertex ref for a vertex
626 /// pick on a component member (lane-E contract: world pick transformed by
627 /// the owning component's inverse pose). `None` when the solid belongs to
628 /// no component (vertex refs only exist for constraint selection).
629 pub(crate) fn component_vertex_ref(
630 &self,
631 solid_name: &str,
632 world_position: [f64; 3],
633 ) -> Option<String> {
634 let record = self.assembly_components.iter().find(|record| {
635 record.solids.iter().any(|member| member == solid_name)
636 })?;
637 let local = rigid_inverse_point(&record.transform, world_position);
638 Some(format!(
639 "{solid_name}@{},{},{}",
640 local[0], local[1], local[2]
641 ))
642 }
643}
644
645// BREP private tests: 2e3068b0de683536