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