brep_render/style.rs
1//! Render settings (R14 — the `CADmaterials` semantics: per-kind material
2//! variants, hover color, flat-shading toggle, user-persisted overrides) and
3//! the selection/hover emphasis state (R17/R24 — name-keyed, fed by the host's
4//! `SelectionFilter`). Plain data, shared by the engine core and the wgpu
5//! renderer; JSON in/out at the R3 boundary.
6
7use std::collections::HashSet;
8
9/// sRGB color in 0..1 + alpha.
10pub type Rgba = [f32; 4];
11
12fn hex(hex: u32, alpha: f32) -> Rgba {
13 let [r, g, b] = crate::color::hex_to_srgb_f32(hex);
14 [r, g, b, alpha]
15}
16
17/// Parse `#rrggbb` / `#rgb` / `0xrrggbb` (returns None on anything else).
18pub fn parse_css_hex(value: &str) -> Option<[f32; 3]> {
19 let v = value.trim();
20 let digits = v
21 .strip_prefix('#')
22 .or_else(|| v.strip_prefix("0x"))
23 .or_else(|| v.strip_prefix("0X"))?;
24 let expand = |c: char| c.to_digit(16).map(|d| (d * 17) as f32 / 255.0);
25 match digits.len() {
26 3 => {
27 let mut chars = digits.chars();
28 Some([
29 expand(chars.next()?)?,
30 expand(chars.next()?)?,
31 expand(chars.next()?)?,
32 ])
33 }
34 6 => {
35 let n = u32::from_str_radix(digits, 16).ok()?;
36 Some(crate::color::hex_to_srgb_f32(n))
37 }
38 _ => None,
39 }
40}
41
42/// The sketcher's overlay palette as plain `0xRRGGBB` hex — the ONE place the
43/// default color literals live. [`RenderSettings`] stores each of these as an
44/// editable [`Rgba`] field (defaults derived from here via [`hex`]) and hands the
45/// sketch tessellation/overlay builders a live `SketchColors` view via
46/// [`RenderSettings::sketch_colors`], so the sketch renderer reads its colors from
47/// the display settings just like faces/edges/vertices do — no scattered constants.
48///
49/// The `constraint` green is a deliberate user directive (2026-08-22): CONSTRAINT
50/// annotations (geometric-constraint glyphs + dimension leaders/labels) read in
51/// green so they stand apart from the blue/white sketch GEOMETRY.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct SketchColors {
54 /// Movable geometry / point (blue).
55 pub movable: u32,
56 /// Locked geometry / point (near-white).
57 pub locked: u32,
58 /// No-mobility geometry fallback (yellow).
59 pub geometry: u32,
60 /// No-mobility point fallback.
61 pub point: u32,
62 /// Construction point (orange).
63 pub construction_point: u32,
64 /// Heuristic under-constrained point.
65 pub under_constrained_point: u32,
66 /// Selected entity — amber (the transform-gizmo accent); beats hover.
67 pub selected: u32,
68 /// Hovered entity — light blue (brighter than movable).
69 pub hovered: u32,
70 /// Draw-tool rubber-band preview (dim, so it reads as tentative).
71 pub preview: u32,
72 /// Constraint annotations — glyphs + dimension leaders/labels (green).
73 pub constraint: u32,
74 /// A constraint the solver named as part of a CONFLICT — the red of the
75 /// status bar's conflict dot, so the two readouts agree at a glance.
76 pub conflict: u32,
77}
78
79impl Default for SketchColors {
80 fn default() -> Self {
81 // The previous sketcher's theme defaults — the single source of these literals.
82 Self {
83 movable: 0x4aa3ff,
84 locked: 0xe6ebf2,
85 geometry: 0xffff88,
86 point: 0x9ec9ff,
87 construction_point: 0xffa86a,
88 under_constrained_point: 0xffb347,
89 selected: 0xffa500,
90 hovered: 0x7fd0ff,
91 preview: 0x8fa0b8,
92 constraint: 0x4ade80,
93 conflict: 0xff5c5c,
94 }
95 }
96}
97
98/// The GUI chrome theme (panels, windows, toolbar, text) — controls the egui
99/// look, NOT the 3D viewport background (that is the separate `background`
100/// setting). `Auto` follows the OS/system theme (`prefers-color-scheme` on web).
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum ThemeMode {
103 /// Follow the system's theme preference (falls back to dark when no OS signal).
104 Auto,
105 Light,
106 Dark,
107}
108
109/// How a plain viewport click builds a MULTI-selection (the Settings "Multi-select"
110/// dropdown). Read by the app's viewport click routing — the engine's selection
111/// primitives (`select_candidate` replace / `toggle_candidate` toggle) are
112/// mode-agnostic; this only chooses which one a plain click drives.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum MultiSelectMode {
115 /// A plain click REPLACES the selection; Ctrl/Cmd+click adds/toggles (the
116 /// classic CAD behavior).
117 CtrlClick,
118 /// A plain click TOGGLES the item in the selection (click again to unselect)
119 /// so a multi-selection needs no modifier key; a click on empty space clears.
120 ClickToggles,
121}
122
123impl MultiSelectMode {
124 /// Every mode, in the order the Settings dropdown lists them.
125 pub const ALL: [MultiSelectMode; 2] = [MultiSelectMode::CtrlClick, MultiSelectMode::ClickToggles];
126
127 /// The human label — ALSO the serialized `multiSelect` value (the
128 /// renderQuality pattern: the dropdown and the stored JSON speak the label;
129 /// `apply_json` parses it back tolerantly).
130 pub fn label(&self) -> &'static str {
131 match self {
132 MultiSelectMode::CtrlClick => "Ctrl+Click",
133 MultiSelectMode::ClickToggles => "Click toggles",
134 }
135 }
136}
137
138/// How base face color is chosen per solid.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum FaceColorMode {
141 /// The app look: every face gets `face_color` (unless the solid carries a
142 /// metadata override).
143 Uniform,
144 /// The artifact look: stable name-hashed color per solid.
145 HashedBySolid,
146}
147
148/// The viewer's material palette + display toggles. Defaults are the
149/// `CADmaterials` values.
150/// Global toolbar appearance. Missing saved values migrate to Ribbon.
151#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
152pub enum ToolbarStyle {
153 #[default]
154 Ribbon,
155 Classic,
156}
157
158impl ToolbarStyle {
159 pub fn as_str(self) -> &'static str {
160 match self {
161 Self::Ribbon => "Ribbon",
162 Self::Classic => "Classic",
163 }
164 }
165}
166
167#[derive(Debug, Clone, PartialEq)]
168pub struct RenderSettings {
169 /// GUI chrome theme (egui panels/windows/toolbar/text). Defaults to `Auto`,
170 /// following the OS light/dark preference.
171 pub theme: ThemeMode,
172 /// Global UI size scale applied to the whole egui chrome via
173 /// [`egui::Context::set_zoom_factor`]. 1.0 = native size; composes with the
174 /// device pixel ratio. Clamped to `[0.5, 3.0]`.
175 pub ui_scale: f32,
176 /// Skip the startup recovery offer without restoring documents automatically.
177 /// Autosaving remains enabled for the current session.
178 pub disable_recovery_prompt: bool,
179 /// Size multiplier for the floating TEXT LABELS the app overlays on the 3D
180 /// model — the dimension-gizmo value chips (sketch + feature dimensions), the
181 /// assembly-constraint chips, and the transform gizmo's axis letters. 1.0 = the
182 /// base monospace size; the label's glyphs, its measured edit-box width, and its
183 /// chip padding all scale together (see `brep-app`'s `viewport::labels`). A
184 /// MULTIPLIER, not a point size, so labels stay consistent with each other and
185 /// the setting survives a restyle. Clamped to `[0.25, 3.0]`: 0.25 is the
186 /// smallest label that is still a LABEL rather than a smudge — a quarter of the
187 /// 12pt base monospace is a ~3.8pt glyph row inside a ~4.5pt-tall chip, which is
188 /// the point at which the double-click/drag target stops being something a
189 /// pointer can reliably land on (and it composes with `uiScale` + the device
190 /// pixel ratio, so it is not an absolute 3pt on screen). The 3.0 ceiling keeps a
191 /// user from filling the viewport with one label. Independent of
192 /// [`RenderSettings::ui_scale`], which sizes the egui CHROME (panels/toolbar)
193 /// and not the model overlay.
194 pub label_scale: f32,
195 /// Debug overlay: draw the 1px red outline of each gizmo grab handle's hit
196 /// region (arrows/leaders as capsules, balls as circles). Off by default;
197 /// toggled from Settings to inspect exactly where a drag will grab.
198 pub debug_grab_handles: bool,
199 pub background: [f32; 3],
200 pub face_color_mode: FaceColorMode,
201 /// IGNORE the model's own colours when shading (RENDER-ONLY).
202 ///
203 /// Bodies and faces carry a durable `color` metadata attribute — stamped by
204 /// a STEP import, editable in the Info window — and by default it IS the
205 /// shaded colour. Ticking this box makes the viewport fall back to
206 /// [`Self::face_color_mode`] (the uniform or name-hashed colour) as though
207 /// the model carried no colours at all.
208 ///
209 /// It is a DISPLAY switch, not an edit: the metadata store is never touched,
210 /// so unticking it brings every model colour straight back, and a document
211 /// saved with the box ticked still carries all of its colours. Defaults to
212 /// false — a coloured model shows its colours.
213 pub override_model_colors: bool,
214 /// Show the WORKBENCH ACTIONS toolbar — a second strip under the primary
215 /// toolbar with one button per feature the active workbench offers (and, where
216 /// the Constraints panel is shown, one per assembly-constraint type). Chrome
217 /// only: it never changes what the palette or context bar offer. The app
218 /// hides the strip while a sketch is being edited regardless of this flag.
219 pub show_workbench_toolbar: bool,
220 pub toolbar_style: ToolbarStyle,
221 pub face_color: Rgba,
222 pub face_selected_color: Rgba,
223 pub hover_color: Rgba,
224 pub edge_color: Rgba,
225 pub edge_selected_color: Rgba,
226 pub edge_width_px: f32,
227 /// Draw the OCCLUDED portions of edges at all — the second, depth-failing
228 /// edge pass that [`Self::hidden_edge_alpha`] dims.
229 ///
230 /// It is a whole extra pass over every edge buffer in the scene, so turning
231 /// it off is the single largest display saving the viewer has: measured
232 /// 2026-09-11 on a heavy model, frame `draw` fell 33.61 ms -> 24.76 ms
233 /// (-26%). The viewport looks materially different with it off — edges
234 /// behind a face simply are not there — which is why this is a user's
235 /// choice rather than something a performance heuristic flips. Defaults to
236 /// FALSE since the 2026-10-14 display defaults directive (the user's chosen
237 /// look: no occluded-edge pass, see [`RenderSettings::default`]); before that
238 /// it defaulted to true.
239 ///
240 /// Distinct from setting [`Self::hidden_edge_alpha`] to 0, which also skips
241 /// the pass: the alpha is how DIM the occluded edges are, this is WHETHER
242 /// they are drawn, so a user can A/B the cost without losing their dimness.
243 pub show_occluded_edges: bool,
244 /// Occluded edges render dimmed, not dropped (R17): alpha of the
245 /// depth-failing edge pass. 0 disables the pass, as does
246 /// [`Self::show_occluded_edges`] being false.
247 pub hidden_edge_alpha: f32,
248 pub vertex_color: Rgba,
249 pub vertex_selected_color: Rgba,
250 pub vertex_size_px: f32,
251 pub flat_shading: bool,
252 /// Wireframe display (R14): when true the shaded face-fill pass is skipped so
253 /// only edges draw (the CAD wireframe look; back edges show through). Does
254 /// NOT affect picking (CPU ray-based) or the overlay pass.
255 pub wireframe: bool,
256 /// Draw the shaded FACES (R14 companion): false leaves the model as its
257 /// edges and vertices alone. Independent of [`Self::wireframe`], which
258 /// swaps the shaded fill for the triangle wireframe — with faces off there
259 /// is nothing to swap, so both face passes are skipped.
260 ///
261 /// A DISPLAY switch only: picking is CPU ray-based and unaffected, so a
262 /// hidden face still selects. This exists so a presentation capture (and
263 /// the toolbar's three visibility toggles) can drop a whole class of
264 /// geometry without editing the size settings that describe how the class
265 /// is DRAWN — a zeroed `edgeWidthPx` is not "no edges", it is a
266 /// zero-width edge the user has to restore by remembering the old number.
267 pub show_faces: bool,
268 /// Draw the EDGES (both the visible pass and the dimmed occluded one).
269 /// See [`Self::show_faces`] for why this is not `edgeWidthPx = 0`.
270 pub show_edges: bool,
271 /// Draw the VERTEX points. See [`Self::show_faces`].
272 pub show_vertices: bool,
273 /// World-axis helper (R20): screen length in CSS px; 0 disables.
274 pub axis_length_px: f32,
275 /// On-screen edge length of the always-on corner ViewCube, in CSS px. Drives
276 /// BOTH the rendered mini-camera viewport AND the hit-test corner rect (they
277 /// read the same value), so the cube and its clickable region scale together.
278 /// Defaults to [`ViewCube::DEFAULT_SIZE_PX`], so the cube is unchanged until edited.
279 pub viewcube_size_px: f32,
280 pub pick_double_sided: bool,
281 /// How a plain viewport click builds a multi-selection (see [`MultiSelectMode`]).
282 pub multi_select: MultiSelectMode,
283 /// Tessellation LOD factor — the chord-tolerance multiplier behind the
284 /// "Render Quality" dropdown (see [`RENDER_QUALITY`]: 1.0 = "Medium", 0.5 =
285 /// "High"). Defaults to 0.5, the "High" level, per the 2026-10-14 display
286 /// defaults directive.
287 pub lod_factor: f64,
288 // --- Sketcher overlay palette (managed here like every other display color) ---
289 // Defaults come from `SketchColors::default()` (the single source of the
290 // literals); `sketch_colors()` re-derives a `SketchColors` view for the
291 // tessellation/overlay builders.
292 pub sketch_movable_color: Rgba,
293 pub sketch_locked_color: Rgba,
294 pub sketch_geometry_color: Rgba,
295 pub sketch_point_color: Rgba,
296 pub sketch_construction_point_color: Rgba,
297 pub sketch_under_constrained_point_color: Rgba,
298 pub sketch_selected_color: Rgba,
299 pub sketch_hovered_color: Rgba,
300 pub sketch_preview_color: Rgba,
301 pub sketch_constraint_color: Rgba,
302 pub sketch_conflict_color: Rgba,
303 /// The active UI WORKBENCH id (`"all"` / `"modeling"` / `"sheetMetal"`), a
304 /// plain string (NOT an enum) so the app-side per-file workbench registry
305 /// stays the sole owner of the valid-id set — adding a workbench never touches
306 /// this crate. This is purely a UI FILTER over feature-CREATION: it does not
307 /// affect what the history executes or renders. Default `"modeling"`. An
308 /// unknown stored id is tolerated here and validated app-side (the registry's
309 /// resolver falls back to the default).
310 pub workbench: String,
311 /// Assembly AUTO-SOLVE: when true (the default) every constraint mutation
312 /// re-solves + re-runs immediately; when false the mutation paths only
313 /// update state and the user drives the manual Solve button. Consulted by
314 /// the app's constraint-mutation path.
315 pub assembly_auto_solve: bool,
316 /// Show Constraint Graphics: the render toggle the viewport overlay lane
317 /// consumes — per-constraint leader/label graphics draw only while this is
318 /// on. Owned here so the panel toggle, persistence, and the overlay
319 /// renderer all read ONE flag.
320 pub show_constraint_graphics: bool,
321 /// The BOM's COLUMN CONFIGURATION — the raw text of the Settings panel's
322 /// Assemblies textarea (one `[*]prefix.Field` per line). Stored VERBATIM,
323 /// exactly as the user typed it: parsing, the known-field catalogue and the
324 /// `part.` / `occurrence.` vocabulary all live app-side
325 /// (`panels::bom_columns`), so adding a BOM field never touches this crate
326 /// — the same division of labour that keeps `workbench` a plain string here.
327 ///
328 /// Default EMPTY, which means "the app's shipped default configuration".
329 /// A document that was never configured therefore persists byte-for-byte as
330 /// before, and a later change to the shipped default still reaches every
331 /// user who never overrode it.
332 pub bom_columns: String,
333}
334
335impl Default for RenderSettings {
336 fn default() -> Self {
337 let sk = SketchColors::default();
338 Self {
339 // Default to Auto so the chrome follows the OS light/dark preference
340 // (egui's `ThemePreference::System`; falls back to dark when there is
341 // no OS signal).
342 theme: ThemeMode::Auto,
343 // 1.0 = native UI size (no zoom); scales the whole egui chrome.
344 ui_scale: 1.0,
345 disable_recovery_prompt: false,
346 // 1.0 = the labels' native monospace size (no scaling).
347 label_scale: 1.0,
348 // Debug grab-handle outlines are off by default (a diagnostic aid).
349 debug_grab_handles: false,
350 // The viewport look is the user's 2026-10-14 display defaults
351 // directive (a screenshot of their Settings window, every value
352 // below read from it): a slate background, a light grey-blue face
353 // colour, dark slate 1px edges with no occluded-edge pass. The
354 // selection/hover colours, the vertex colours and the whole sketch
355 // palette were already these values; vertex dots stay shown (the
356 // user's explicit follow-up to the directive).
357 background: [
358 ((0x13) as f32) / 255.0,
359 ((0x1b) as f32) / 255.0,
360 ((0x24) as f32) / 255.0,
361 ],
362 face_color_mode: FaceColorMode::Uniform,
363 face_color: hex(0xa9bdcc, 1.0),
364 face_selected_color: hex(0xffc400, 1.0),
365 hover_color: hex(0xfbff00, 1.0),
366 edge_color: hex(0x324452, 1.0),
367 edge_selected_color: hex(0xff00ff, 1.0),
368 edge_width_px: 1.0,
369 // No occluded-edge pass by default: the toggle is off AND its alpha
370 // is 0, so switching the pass back on starts from "invisible" until
371 // the user also raises the alpha (the two are independent on purpose,
372 // see `show_occluded_edges`).
373 show_occluded_edges: false,
374 hidden_edge_alpha: 0.0,
375 vertex_color: hex(0x4aff03, 1.0),
376 vertex_selected_color: hex(0x00ffff, 1.0),
377 vertex_size_px: 6.0,
378 flat_shading: false,
379 wireframe: false,
380 // Everything visible: the three toggles are a subtractive control.
381 show_faces: true,
382 show_edges: true,
383 show_vertices: true,
384 override_model_colors: false,
385 show_workbench_toolbar: true,
386 toolbar_style: ToolbarStyle::Ribbon,
387 axis_length_px: 46.0,
388 // The corner ViewCube's current on-screen size — the single source of
389 // the literal is `ViewCube::DEFAULT_SIZE_PX`, so the settings default and
390 // the widget default can never drift.
391 viewcube_size_px: brep_gizmos::view_cube::ViewCube::DEFAULT_SIZE_PX,
392 pick_double_sided: true,
393 multi_select: MultiSelectMode::ClickToggles,
394 // "High" render quality (RENDER_QUALITY: 0.5 = half the "Medium"
395 // chord tolerance) — the directive's dropdown value.
396 lod_factor: 0.5,
397 // Sketch palette: derive each Rgba from the ONE source of the literals
398 // (`SketchColors::default`) so nothing changes visually until edited.
399 sketch_movable_color: hex(sk.movable, 1.0),
400 sketch_locked_color: hex(sk.locked, 1.0),
401 sketch_geometry_color: hex(sk.geometry, 1.0),
402 sketch_point_color: hex(sk.point, 1.0),
403 sketch_construction_point_color: hex(sk.construction_point, 1.0),
404 sketch_under_constrained_point_color: hex(sk.under_constrained_point, 1.0),
405 sketch_selected_color: hex(sk.selected, 1.0),
406 sketch_hovered_color: hex(sk.hovered, 1.0),
407 sketch_preview_color: hex(sk.preview, 1.0),
408 sketch_constraint_color: hex(sk.constraint, 1.0),
409 sketch_conflict_color: hex(sk.conflict, 1.0),
410 // Default workbench: general Modeling.
411 workbench: "modeling".to_string(),
412 // Assembly: auto-solve every constraint mutation; constraint
413 // graphics shown while a document has constraints.
414 assembly_auto_solve: true,
415 show_constraint_graphics: true,
416 // Empty = the app's shipped BOM column configuration.
417 bom_columns: String::new(),
418 }
419 }
420}
421
422impl RenderSettings {
423 /// The artifact-corpus preset: byte-faithful to the slice-1 artifact look
424 /// (per-solid hashed colors, 0x101418 background, 1.6px edges in
425 /// 0x0d1030, no hidden-edge pass, no vertices, no axes, "Medium" LOD).
426 /// Every field the corpus look depends on is pinned HERE, so a change to the
427 /// app's own defaults (2026-10-14 moved the viewport colours, the edge pass
428 /// and the LOD) never shifts the corpus.
429 pub fn artifact() -> Self {
430 Self {
431 background: [
432 ((0x10) as f32) / 255.0,
433 ((0x14) as f32) / 255.0,
434 ((0x18) as f32) / 255.0,
435 ],
436 face_color_mode: FaceColorMode::HashedBySolid,
437 edge_color: hex(0x0d1030, 1.0),
438 edge_width_px: 1.6,
439 show_occluded_edges: false,
440 hidden_edge_alpha: 0.0,
441 show_vertices: false,
442 vertex_size_px: 0.0,
443 axis_length_px: 0.0,
444 lod_factor: 1.0,
445 ..Self::default()
446 }
447 }
448
449 /// Apply a partial JSON override (R3 settings entrypoint). Unknown keys
450 /// are ignored; colors are CSS hex strings.
451 pub fn apply_json(&mut self, json: &str) -> Result<(), String> {
452 let value: serde_json::Value =
453 serde_json::from_str(json).map_err(|error| format!("settings parse: {error}"))?;
454 let color = |key: &str, target: &mut Rgba| {
455 if let Some(v) = value.get(key).and_then(|v| v.as_str()) {
456 if let Some(rgb) = parse_css_hex(v) {
457 target[0] = rgb[0];
458 target[1] = rgb[1];
459 target[2] = rgb[2];
460 }
461 }
462 };
463 color("faceColor", &mut self.face_color);
464 color("faceSelectedColor", &mut self.face_selected_color);
465 color("hoverColor", &mut self.hover_color);
466 color("edgeColor", &mut self.edge_color);
467 color("edgeSelectedColor", &mut self.edge_selected_color);
468 color("vertexColor", &mut self.vertex_color);
469 color("vertexSelectedColor", &mut self.vertex_selected_color);
470 // Sketcher overlay palette (same CSS-hex shape as the face/edge/vertex colors).
471 color("sketchMovableColor", &mut self.sketch_movable_color);
472 color("sketchLockedColor", &mut self.sketch_locked_color);
473 color("sketchGeometryColor", &mut self.sketch_geometry_color);
474 color("sketchPointColor", &mut self.sketch_point_color);
475 color("sketchConstructionPointColor", &mut self.sketch_construction_point_color);
476 color("sketchUnderConstrainedPointColor", &mut self.sketch_under_constrained_point_color);
477 color("sketchSelectedColor", &mut self.sketch_selected_color);
478 color("sketchHoveredColor", &mut self.sketch_hovered_color);
479 color("sketchPreviewColor", &mut self.sketch_preview_color);
480 color("sketchConstraintColor", &mut self.sketch_constraint_color);
481 color("sketchConflictColor", &mut self.sketch_conflict_color);
482 if let Some(v) = value.get("background").and_then(|v| v.as_str()) {
483 if let Some(rgb) = parse_css_hex(v) {
484 self.background = rgb;
485 }
486 }
487 if let Some(v) = value.get("edgeWidthPx").and_then(|v| v.as_f64()) {
488 self.edge_width_px = (v as f32).clamp(0.0, 32.0);
489 }
490 if let Some(v) = value.get("vertexSizePx").and_then(|v| v.as_f64()) {
491 self.vertex_size_px = (v as f32).clamp(0.0, 64.0);
492 }
493 if let Some(v) = value.get("showOccludedEdges").and_then(|v| v.as_bool()) {
494 self.show_occluded_edges = v;
495 }
496 if let Some(v) = value.get("hiddenEdgeAlpha").and_then(|v| v.as_f64()) {
497 self.hidden_edge_alpha = (v as f32).clamp(0.0, 1.0);
498 }
499 if let Some(v) = value.get("faceColorMode").and_then(|v| v.as_str()) {
500 match v.trim().to_ascii_lowercase().as_str() {
501 "hashedbysolid" | "hashed" => self.face_color_mode = FaceColorMode::HashedBySolid,
502 "uniform" => self.face_color_mode = FaceColorMode::Uniform,
503 _ => {}
504 }
505 }
506 if let Some(v) = value.get("theme").and_then(|v| v.as_str()) {
507 match v.trim().to_ascii_lowercase().as_str() {
508 "auto" => self.theme = ThemeMode::Auto,
509 "light" => self.theme = ThemeMode::Light,
510 "dark" => self.theme = ThemeMode::Dark,
511 _ => {}
512 }
513 }
514 if let Some(v) = value.get("uiScale").and_then(|v| v.as_f64()) {
515 self.ui_scale = (v as f32).clamp(0.5, 3.0);
516 }
517 if let Some(v) = value.get("disableRecoveryPrompt").and_then(|v| v.as_bool()) {
518 self.disable_recovery_prompt = v;
519 }
520 // Model-overlay label size. Clamped to the SAME [0.25, 3.0] domain the
521 // settings slider offers, so a persisted value never silently re-clamps on
522 // reload (the `viewcubeSizePx` rule).
523 if let Some(v) = value.get("labelScale").and_then(|v| v.as_f64()) {
524 self.label_scale = (v as f32).clamp(0.25, 3.0);
525 }
526 if let Some(v) = value.get("debugGrabHandles").and_then(|v| v.as_bool()) {
527 self.debug_grab_handles = v;
528 }
529 if let Some(v) = value.get("flatShading").and_then(|v| v.as_bool()) {
530 self.flat_shading = v;
531 }
532 if let Some(v) = value.get("wireframe").and_then(|v| v.as_bool()) {
533 self.wireframe = v;
534 }
535 if let Some(v) = value.get("showFaces").and_then(|v| v.as_bool()) {
536 self.show_faces = v;
537 }
538 if let Some(v) = value.get("showEdges").and_then(|v| v.as_bool()) {
539 self.show_edges = v;
540 }
541 if let Some(v) = value.get("showVertices").and_then(|v| v.as_bool()) {
542 self.show_vertices = v;
543 }
544 if let Some(v) = value.get("overrideModelColors").and_then(|v| v.as_bool()) {
545 self.override_model_colors = v;
546 }
547 if let Some(v) = value.get("toolbarStyle").and_then(|v| v.as_str()) {
548 self.toolbar_style = match v {
549 "Ribbon" => ToolbarStyle::Ribbon,
550 "Classic" => ToolbarStyle::Classic,
551 _ => return Err(format!("unknown toolbar style: {v}")),
552 };
553 }
554 if let Some(v) = value.get("showWorkbenchToolbar").and_then(|v| v.as_bool()) {
555 self.show_workbench_toolbar = v;
556 }
557 if let Some(v) = value.get("axisLengthPx").and_then(|v| v.as_f64()) {
558 self.axis_length_px = (v as f32).clamp(0.0, 512.0);
559 }
560 // ViewCube corner size — clamp to the SAME [40, 230] domain the settings
561 // slider offers, so a persisted value never silently re-clamps on reload.
562 if let Some(v) = value.get("viewcubeSizePx").and_then(|v| v.as_f64()) {
563 self.viewcube_size_px = (v as f32).clamp(40.0, 230.0);
564 }
565 if let Some(v) = value.get("pickDoubleSided").and_then(|v| v.as_bool()) {
566 self.pick_double_sided = v;
567 }
568 // The multi-select mode dropdown serializes its human label (the
569 // renderQuality pattern); match on the alphanumeric skeleton so
570 // "Ctrl+Click" / "ctrlClick" / "CTRL CLICK" all parse.
571 if let Some(v) = value.get("multiSelect").and_then(|v| v.as_str()) {
572 let skeleton: String = v
573 .chars()
574 .filter(|c| c.is_ascii_alphanumeric())
575 .collect::<String>()
576 .to_ascii_lowercase();
577 match skeleton.as_str() {
578 "ctrlclick" => self.multi_select = MultiSelectMode::CtrlClick,
579 "clicktoggles" => self.multi_select = MultiSelectMode::ClickToggles,
580 _ => {}
581 }
582 }
583 // The active UI workbench id (a plain string; the app validates it against
584 // its registry). Stored verbatim — an unknown id is tolerated here.
585 if let Some(v) = value.get("workbench").and_then(|v| v.as_str()) {
586 self.workbench = v.to_string();
587 }
588 if let Some(v) = value.get("assemblyAutoSolve").and_then(|v| v.as_bool()) {
589 self.assembly_auto_solve = v;
590 }
591 // The BOM column configuration, verbatim (see the field docs) — never
592 // normalized here, so a malformed line survives a save/reload and the
593 // panel can still point at the line the user has to fix.
594 if let Some(v) = value.get("bomColumns").and_then(|v| v.as_str()) {
595 self.bom_columns = v.to_string();
596 }
597 if let Some(v) = value.get("showConstraintGraphics").and_then(|v| v.as_bool()) {
598 self.show_constraint_graphics = v;
599 }
600 // "Render Quality" is a named dropdown (Draft…Ultra) mapping to the display
601 // LOD factor (higher quality = finer mesh = smaller factor). We store the
602 // resolved f64 so the tessellation path is unchanged.
603 if let Some(label) = value.get("renderQuality").and_then(|v| v.as_str()) {
604 if let Some(lod) = lod_from_quality(label) {
605 self.lod_factor = lod;
606 }
607 }
608 Ok(())
609 }
610
611 /// Serialize EVERY setting to the SAME camelCase / CSS-hex shape
612 /// [`apply_json`] reads, so `s.apply_json(&s.to_json())` is the identity.
613 /// This is the counterpart the settings schema serializes/persists through
614 /// (there was previously no serializer, only the partial-override reader).
615 pub fn to_json(&self) -> String {
616 serde_json::json!({
617 "theme": match self.theme {
618 ThemeMode::Auto => "auto",
619 ThemeMode::Light => "light",
620 ThemeMode::Dark => "dark",
621 },
622 "uiScale": self.ui_scale as f64,
623 "disableRecoveryPrompt": self.disable_recovery_prompt,
624 "labelScale": self.label_scale as f64,
625 "debugGrabHandles": self.debug_grab_handles,
626 "background": rgb_to_css_hex(self.background),
627 "faceColorMode": match self.face_color_mode {
628 FaceColorMode::Uniform => "uniform",
629 FaceColorMode::HashedBySolid => "hashedBySolid",
630 },
631 "faceColor": rgba_to_css_hex(self.face_color),
632 "faceSelectedColor": rgba_to_css_hex(self.face_selected_color),
633 "hoverColor": rgba_to_css_hex(self.hover_color),
634 "edgeColor": rgba_to_css_hex(self.edge_color),
635 "edgeSelectedColor": rgba_to_css_hex(self.edge_selected_color),
636 "edgeWidthPx": self.edge_width_px as f64,
637 "showOccludedEdges": self.show_occluded_edges,
638 "hiddenEdgeAlpha": self.hidden_edge_alpha as f64,
639 "vertexColor": rgba_to_css_hex(self.vertex_color),
640 "vertexSelectedColor": rgba_to_css_hex(self.vertex_selected_color),
641 "vertexSizePx": self.vertex_size_px as f64,
642 "flatShading": self.flat_shading,
643 "wireframe": self.wireframe,
644 "showFaces": self.show_faces,
645 "showEdges": self.show_edges,
646 "showVertices": self.show_vertices,
647 "overrideModelColors": self.override_model_colors,
648 "showWorkbenchToolbar": self.show_workbench_toolbar,
649 "toolbarStyle": self.toolbar_style.as_str(),
650 "axisLengthPx": self.axis_length_px as f64,
651 "viewcubeSizePx": self.viewcube_size_px as f64,
652 "pickDoubleSided": self.pick_double_sided,
653 "multiSelect": self.multi_select.label(),
654 "renderQuality": quality_from_lod(self.lod_factor),
655 "sketchMovableColor": rgba_to_css_hex(self.sketch_movable_color),
656 "sketchLockedColor": rgba_to_css_hex(self.sketch_locked_color),
657 "sketchGeometryColor": rgba_to_css_hex(self.sketch_geometry_color),
658 "sketchPointColor": rgba_to_css_hex(self.sketch_point_color),
659 "sketchConstructionPointColor": rgba_to_css_hex(self.sketch_construction_point_color),
660 "sketchUnderConstrainedPointColor": rgba_to_css_hex(self.sketch_under_constrained_point_color),
661 "sketchSelectedColor": rgba_to_css_hex(self.sketch_selected_color),
662 "sketchHoveredColor": rgba_to_css_hex(self.sketch_hovered_color),
663 "sketchPreviewColor": rgba_to_css_hex(self.sketch_preview_color),
664 "sketchConstraintColor": rgba_to_css_hex(self.sketch_constraint_color),
665 "sketchConflictColor": rgba_to_css_hex(self.sketch_conflict_color),
666 "workbench": self.workbench,
667 "assemblyAutoSolve": self.assembly_auto_solve,
668 "showConstraintGraphics": self.show_constraint_graphics,
669 "bomColumns": self.bom_columns,
670 })
671 .to_string()
672 }
673
674 /// The live [`SketchColors`] view of the sketcher palette — the tessellation /
675 /// overlay builders read their colors from THIS (via
676 /// [`crate::sketch::SketchSession::colors`]) so the display settings are the one
677 /// source of truth. Each `Rgba` is quantized back to `0xRRGGBB` the SAME way
678 /// [`rgb_to_css_hex`] serializes it, so a default settings value round-trips to
679 /// the default `SketchColors` byte-exact.
680 pub fn sketch_colors(&self) -> SketchColors {
681 SketchColors {
682 movable: rgba_to_u32(self.sketch_movable_color),
683 locked: rgba_to_u32(self.sketch_locked_color),
684 geometry: rgba_to_u32(self.sketch_geometry_color),
685 point: rgba_to_u32(self.sketch_point_color),
686 construction_point: rgba_to_u32(self.sketch_construction_point_color),
687 under_constrained_point: rgba_to_u32(self.sketch_under_constrained_point_color),
688 selected: rgba_to_u32(self.sketch_selected_color),
689 hovered: rgba_to_u32(self.sketch_hovered_color),
690 preview: rgba_to_u32(self.sketch_preview_color),
691 constraint: rgba_to_u32(self.sketch_constraint_color),
692 conflict: rgba_to_u32(self.sketch_conflict_color),
693 }
694 }
695
696 /// The settings schema WITH the current value baked into each field, as JSON
697 /// — mirrors the kernel's `feature_schemas_json` export so a UI shell (egui
698 /// here, a later `brep-ui` crate) can generate the whole form from data. The
699 /// `value` of each field is pulled live from [`to_json`], so the export
700 /// always reflects the current settings.
701 pub fn settings_schema_json(&self) -> String {
702 let current: serde_json::Value =
703 serde_json::from_str(&self.to_json()).unwrap_or(serde_json::Value::Null);
704 let fields: Vec<serde_json::Value> = settings_schema()
705 .iter()
706 .map(|field| {
707 let kind = match &field.kind {
708 FieldKind::Color => serde_json::json!({ "type": "color" }),
709 FieldKind::Bool => serde_json::json!({ "type": "bool" }),
710 FieldKind::Enum { variants } => {
711 serde_json::json!({ "type": "enum", "variants": variants })
712 }
713 FieldKind::Number { min, max, step } => serde_json::json!({
714 "type": "number", "min": min, "max": max, "step": step
715 }),
716 FieldKind::Range { min, max, step } => serde_json::json!({
717 "type": "range", "min": min, "max": max, "step": step
718 }),
719 // The feature-dialog kinds never appear in the settings
720 // schema, but the match must stay exhaustive.
721 FieldKind::Scalar { step } => {
722 serde_json::json!({ "type": "scalar", "step": step })
723 }
724 FieldKind::BoundedScalar { min, max, step } => serde_json::json!({
725 "type": "scalar", "min": min, "max": max, "step": step
726 }),
727 FieldKind::Text { read_only } => {
728 serde_json::json!({ "type": "text", "readOnly": read_only })
729 }
730 FieldKind::Vec3 { step } => serde_json::json!({ "type": "vec3", "step": step }),
731 FieldKind::Reference { filter, multiple } => serde_json::json!({
732 "type": "reference", "filter": filter, "multiple": multiple
733 }),
734 FieldKind::Button { label } => {
735 serde_json::json!({ "type": "button", "label": label })
736 }
737 };
738 serde_json::json!({
739 "key": field.key,
740 "label": field.label,
741 "group": field.group,
742 "kind": kind,
743 "value": current.get(field.key),
744 })
745 })
746 .collect();
747 serde_json::json!({ "fields": fields }).to_string()
748 }
749}
750
751/// Quantize an sRGB channel triple to a `#rrggbb` CSS hex string (the shape
752/// [`RenderSettings::apply_json`] parses back).
753fn rgb_to_css_hex(rgb: [f32; 3]) -> String {
754 format!("#{:06x}", rgba_to_u32([rgb[0], rgb[1], rgb[2], 1.0]))
755}
756
757/// Like [`rgb_to_css_hex`] but for an `Rgba` (the alpha is intentionally dropped
758/// — `apply_json` only overrides the rgb channels, preserving existing alpha).
759fn rgba_to_css_hex(rgba: Rgba) -> String {
760 rgb_to_css_hex([rgba[0], rgba[1], rgba[2]])
761}
762
763/// Clamp and round sRGB channels to packed `0xRRGGBB`, dropping alpha.
764/// Shared by CSS serialization and the sketch palette.
765fn rgba_to_u32(rgba: Rgba) -> u32 {
766 let q = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u32;
767 (q(rgba[0]) << 16) | (q(rgba[1]) << 8) | q(rgba[2])
768}
769
770/// The kind of one settings field — the closed set of widget shapes the generic
771/// form renderer knows how to emit. Plain data, NO egui dependency: the schema
772/// lives in the engine, the renderer (brep-app / a later brep-ui) walks it.
773#[derive(Debug, Clone, PartialEq)]
774pub enum FieldKind {
775 /// An sRGB color (`#rrggbb`) → a color picker button.
776 Color,
777 /// A boolean toggle → a checkbox.
778 Bool,
779 /// A closed choice → a combo box. `variants` are the JSON string values
780 /// (exactly what `apply_json` accepts / `to_json` emits).
781 Enum { variants: Vec<String> },
782 /// A bounded number → a slider / drag-value honoring `min`/`max`/`step`.
783 Number { min: f64, max: f64, step: f64 },
784 /// A 0..1 (or otherwise fractional) number → a slider honoring the bounds.
785 Range { min: f64, max: f64, step: f64 },
786 // --- extra kinds the FEATURE dialogs need (settings never use these) -------
787 /// An UNBOUNDED number (feature params carry no min/max) → a drag value.
788 Scalar { step: f64 },
789 /// A scalar text field whose numeric edits are bounded on commit. Drawing
790 /// the field never clamps or quantizes its existing value.
791 BoundedScalar { min: f64, max: f64, step: f64 },
792 /// A single-line text edit. `read_only` protects identity fields whose
793 /// renaming must also update references.
794 Text { read_only: bool },
795 /// A 3-vector (position / rotationEuler / scale) → three drag values.
796 Vec3 { step: f64 },
797 /// A reference-selection field for viewport picking. `filter` limits entity
798 /// kinds (e.g. `["SOLID"]`); `multiple` allows a list of selections.
799 Reference { filter: Vec<String>, multiple: bool },
800 /// An ACTION button (schema `"type":"button"`) → a clickable button. It binds
801 /// to no value; a click is surfaced to the caller by the field `key` (e.g.
802 /// `editSketch`), which the host acts on. `label` is the button caption.
803 Button { label: String },
804}
805
806/// One field of the settings form: the camelCase `apply_json`/`to_json` key, a
807/// human label, a UI group, and the widget `kind`. The ordered list of these
808/// (see [`settings_schema`]) fully describes the form — a UI shell generates a
809/// widget per field with no per-field code.
810#[derive(Debug, Clone)]
811pub struct SettingsField {
812 /// The camelCase key — the SAME one `apply_json`/`to_json` use.
813 pub key: &'static str,
814 pub label: &'static str,
815 pub group: &'static str,
816 pub kind: FieldKind,
817}
818
819/// The GENERAL form field — the schema element ONE form engine renders for BOTH
820/// the display-settings dialog AND the schema-driven feature dialogs. Unlike
821/// [`SettingsField`] (which uses `&'static str` because the settings schema is
822/// compile-time), this owns its strings so it can carry a per-feature schema
823/// pulled from the kernel catalogue at run time, and it carries a `path` (a
824/// chain of JSON object keys) so a field can bind to a NESTED value —
825/// `["transform","position"]`, `["boolean","operation"]` — not just a top-level
826/// key. The settings form is `path == [key]`.
827#[derive(Debug, Clone)]
828pub struct FormField {
829 /// JSON object-key chain into the document the form edits (≥ 1 segment).
830 pub path: Vec<String>,
831 pub label: String,
832 pub group: String,
833 pub kind: FieldKind,
834}
835
836impl FormField {
837 /// The last path segment — a stable per-field id for egui salting / probing.
838 pub fn key(&self) -> &str {
839 self.path.last().map(String::as_str).unwrap_or("")
840 }
841}
842
843/// The "Render Quality" dropdown levels shown in Settings, each mapped to a
844/// display-tessellation LOD factor. Higher quality = finer mesh = SMALLER factor
845/// (chord tolerance = extent · 1.5e-3 · factor). Order is coarse→fine, the order
846/// the ComboBox lists them. `lod_factor` stays the internal representation the
847/// tessellation path reads; only the SETTINGS UI/serialization speaks in levels.
848pub const RENDER_QUALITY: &[(&str, f64)] = &[
849 ("Draft", 4.0),
850 ("Low", 2.0),
851 ("Medium", 1.0),
852 ("High", 0.5),
853 ("Ultra", 0.25),
854];
855
856/// The LOD factor for a quality label (`None` if not a known level).
857fn lod_from_quality(label: &str) -> Option<f64> {
858 RENDER_QUALITY
859 .iter()
860 .find(|(name, _)| *name == label)
861 .map(|(_, factor)| *factor)
862}
863
864/// The quality label NEAREST a LOD factor — the serialization inverse of
865/// [`lod_from_quality`]. A stored factor is always one of the level values in
866/// normal use; nearest-match keeps a hand-set/legacy value mapping to a sane label.
867fn quality_from_lod(lod: f64) -> &'static str {
868 RENDER_QUALITY
869 .iter()
870 .min_by(|(_, a), (_, b)| {
871 (a - lod).abs().total_cmp(&(b - lod).abs())
872 })
873 .map(|(name, _)| *name)
874 .unwrap_or("Medium")
875}
876
877/// Lift the compile-time display-settings schema into general [`FormField`]s so
878/// the ONE `field_input` engine (which the feature dialogs also use) renders the
879/// settings panel too — a single schema-driven dialog engine, not two.
880pub fn settings_form_fields() -> Vec<FormField> {
881 settings_schema()
882 .into_iter()
883 .map(|field| FormField {
884 path: vec![field.key.to_string()],
885 label: field.label.to_string(),
886 group: field.group.to_string(),
887 kind: field.kind,
888 })
889 .collect()
890}
891
892/// The Rust-owned settings schema: the ordered list of display-settings fields,
893/// grouped, analogous to the kernel feature schemas. Adding a field here (plus
894/// its `apply_json`/`to_json` handling) makes the whole UI grow a widget for it
895/// with ZERO renderer changes.
896pub fn settings_schema() -> Vec<SettingsField> {
897 let f = |key, label, group, kind| SettingsField { key, label, group, kind };
898 vec![
899 // --- Appearance ----------------------------------------------------
900 // FIRST so the "Appearance" group renders at the TOP of the settings tree
901 // (groups are ordered by first field appearance). The GUI-chrome theme —
902 // NOT the 3D viewport background (that lives under Scene).
903 f(
904 "theme",
905 "Theme",
906 "Appearance",
907 FieldKind::Enum { variants: ["auto", "light", "dark"].iter().map(|s| s.to_string()).collect() },
908 ),
909 // A SLIDER (both `Range` and `Number` render an `egui::Slider` over the
910 // given domain) scaling the whole egui UI via `Context::set_zoom_factor`.
911 f(
912 "uiScale",
913 "UI scale",
914 "Appearance",
915 FieldKind::Range { min: 0.5, max: 3.0, step: 0.05 },
916 ),
917 // The size of the floating text labels overlaid on the MODEL (dimension
918 // value chips, constraint chips, gizmo axis letters) — a multiplier over
919 // their base monospace size, NOT a point size. Bounds MATCH the
920 // `apply_json` clamp so the slider can't set a value that re-clamps on
921 // reload. The 0.25 floor (a quarter of the base, on the 0.05 step) is the
922 // smallest chip whose click/drag target a pointer can still land on — see
923 // `RenderSettings::label_scale`. Separate from `uiScale`, which sizes the
924 // egui chrome.
925 f(
926 "labelScale",
927 "Label scale",
928 "Appearance",
929 FieldKind::Range { min: 0.25, max: 3.0, step: 0.05 },
930 ),
931 // The workbench actions toolbar (the feature-button strip under the
932 // primary toolbar). Chrome, so it sits with the other chrome settings.
933 f(
934 "showWorkbenchToolbar",
935 "Show workbench actions toolbar",
936 "Appearance",
937 FieldKind::Bool,
938 ),
939 f("toolbarStyle", "Toolbar style", "Appearance",
940 FieldKind::Enum { variants: ["Ribbon", "Classic"].iter().map(|s| s.to_string()).collect() }),
941 // --- Startup -------------------------------------------------------
942 f(
943 "disableRecoveryPrompt",
944 "Disable recovery dialog on startup",
945 "Startup",
946 FieldKind::Bool,
947 ),
948 // --- Scene ---------------------------------------------------------
949 f("background", "Background", "Scene", FieldKind::Color),
950 f(
951 "axisLengthPx",
952 "Axis length (px)",
953 "Scene",
954 FieldKind::Number { min: 0.0, max: 512.0, step: 1.0 },
955 ),
956 // The corner ViewCube's on-screen size. Bounds MATCH the `apply_json` clamp
957 // ([40, 230], centered on the 135px default) so the slider can't set a value
958 // that re-clamps on reload. Renders as an `egui::Slider`.
959 f(
960 "viewcubeSizePx",
961 "ViewCube size (px)",
962 "Scene",
963 FieldKind::Number { min: 40.0, max: 230.0, step: 1.0 },
964 ),
965 f(
966 "renderQuality",
967 "Render Quality",
968 "Scene",
969 FieldKind::Enum { variants: RENDER_QUALITY.iter().map(|(label, _)| label.to_string()).collect() },
970 ),
971 // --- Faces ---------------------------------------------------------
972 f(
973 "faceColorMode",
974 "Face color mode",
975 "Faces",
976 FieldKind::Enum { variants: ["uniform", "hashedBySolid"].iter().map(|s| s.to_string()).collect() },
977 ),
978 f("faceColor", "Face color", "Faces", FieldKind::Color),
979 f("faceSelectedColor", "Selected face", "Faces", FieldKind::Color),
980 f("hoverColor", "Hover", "Faces", FieldKind::Color),
981 f("flatShading", "Flat shading", "Faces", FieldKind::Bool),
982 f("wireframe", "Wireframe", "Faces", FieldKind::Bool),
983 f("showFaces", "Show faces", "Faces", FieldKind::Bool),
984 f(
985 "overrideModelColors",
986 "Override model colors",
987 "Faces",
988 FieldKind::Bool,
989 ),
990 // --- Edges ---------------------------------------------------------
991 f("showEdges", "Show edges", "Edges", FieldKind::Bool),
992 f("edgeColor", "Edge color", "Edges", FieldKind::Color),
993 f("edgeSelectedColor", "Selected edge", "Edges", FieldKind::Color),
994 f(
995 "edgeWidthPx",
996 "Edge width (px)",
997 "Edges",
998 FieldKind::Number { min: 0.0, max: 32.0, step: 0.1 },
999 ),
1000 f("showOccludedEdges", "Show occluded edges", "Edges", FieldKind::Bool),
1001 f(
1002 "hiddenEdgeAlpha",
1003 "Hidden-edge alpha",
1004 "Edges",
1005 FieldKind::Range { min: 0.0, max: 1.0, step: 0.01 },
1006 ),
1007 // --- Vertices ------------------------------------------------------
1008 f("showVertices", "Show vertices", "Vertices", FieldKind::Bool),
1009 f("vertexColor", "Vertex color", "Vertices", FieldKind::Color),
1010 f("vertexSelectedColor", "Selected vertex", "Vertices", FieldKind::Color),
1011 f(
1012 "vertexSizePx",
1013 "Vertex size (px)",
1014 "Vertices",
1015 FieldKind::Number { min: 0.0, max: 64.0, step: 0.5 },
1016 ),
1017 // --- Picking -------------------------------------------------------
1018 f("pickDoubleSided", "Pick double-sided", "Picking", FieldKind::Bool),
1019 // How a plain viewport click builds a multi-selection: the classic
1020 // Ctrl+Click add, or modifier-free click-toggles (click an item to add
1021 // it, click it again to remove it).
1022 f(
1023 "multiSelect",
1024 "Multi-select",
1025 "Picking",
1026 FieldKind::Enum {
1027 variants: MultiSelectMode::ALL.iter().map(|m| m.label().to_string()).collect(),
1028 },
1029 ),
1030 // --- Sketch --------------------------------------------------------
1031 // The sketcher overlay palette, editable live like every other display
1032 // color. `sketch_colors()` feeds these to the tessellation/overlay builders.
1033 f("sketchMovableColor", "Movable geometry", "Sketch", FieldKind::Color),
1034 f("sketchLockedColor", "Locked geometry", "Sketch", FieldKind::Color),
1035 f("sketchGeometryColor", "Geometry (no mobility)", "Sketch", FieldKind::Color),
1036 f("sketchPointColor", "Point", "Sketch", FieldKind::Color),
1037 f("sketchConstructionPointColor", "Construction point", "Sketch", FieldKind::Color),
1038 f("sketchUnderConstrainedPointColor", "Under-constrained point", "Sketch", FieldKind::Color),
1039 f("sketchSelectedColor", "Selected", "Sketch", FieldKind::Color),
1040 f("sketchHoveredColor", "Hovered", "Sketch", FieldKind::Color),
1041 f("sketchPreviewColor", "Draw preview", "Sketch", FieldKind::Color),
1042 f("sketchConstraintColor", "Constraint / dimension", "Sketch", FieldKind::Color),
1043 f("sketchConflictColor", "Conflicting constraint", "Sketch", FieldKind::Color),
1044 // --- Debug ---------------------------------------------------------
1045 // LAST so the "Debug" group renders at the BOTTOM of the settings tree.
1046 f("debugGrabHandles", "Debug grab handles", "Debug", FieldKind::Bool),
1047 ]
1048}
1049
1050/// A selected/hovered vertex reference: vertices have no kernel names, so they
1051/// resolve by owning solid + position.
1052#[derive(Debug, Clone)]
1053pub struct VertexRef {
1054 pub solid: String,
1055 pub position: [f64; 3],
1056}
1057
1058/// A set of entity NAMES that remembers the order they were ADDED in — the
1059/// selection's pick order.
1060///
1061/// The selection used to be a plain `HashSet`, so which of two picked faces
1062/// came out first was the hash seed's choice and differed between processes.
1063/// That is not cosmetic: an assembly Distance takes element 0 as its BASE face,
1064/// whose outward normal gives the value its sign and the arrow its direction,
1065/// so the same two picks could seed opposite constraints in two runs. Every
1066/// reader of the selection — [`EngineState::selection_json`], the context bar's
1067/// feature offers, the constraint seeder — now sees the picks in the order they
1068/// were made.
1069///
1070/// [`insert`](Self::insert) appends, and a name already in the set keeps its
1071/// place; [`remove`](Self::remove) keeps the rest in order. Toggling a name off
1072/// and on therefore moves it to the END, which is where a re-pick belongs.
1073/// Membership rides in a `HashSet` beside the order, because `contains` is
1074/// called for every face and every edge of every solid, every frame, by the
1075/// emphasis colour feed.
1076#[derive(Debug, Clone, Default, PartialEq, Eq)]
1077pub struct OrderedNames {
1078 order: Vec<String>,
1079 index: HashSet<String>,
1080}
1081
1082impl OrderedNames {
1083 /// Add `name` at the END. Returns whether it was not already there (the
1084 /// `HashSet::insert` contract), and an already-present name does NOT move.
1085 pub fn insert(&mut self, name: String) -> bool {
1086 if !self.index.insert(name.clone()) {
1087 return false;
1088 }
1089 self.order.push(name);
1090 true
1091 }
1092
1093 /// Drop `name`, keeping the order of everything else. Returns whether it
1094 /// was there (the `HashSet::remove` contract).
1095 pub fn remove(&mut self, name: &str) -> bool {
1096 if !self.index.remove(name) {
1097 return false;
1098 }
1099 self.order.retain(|n| n != name);
1100 true
1101 }
1102
1103 pub fn contains(&self, name: &str) -> bool {
1104 self.index.contains(name)
1105 }
1106
1107 pub fn clear(&mut self) {
1108 self.order.clear();
1109 self.index.clear();
1110 }
1111
1112 pub fn len(&self) -> usize {
1113 self.order.len()
1114 }
1115
1116 pub fn is_empty(&self) -> bool {
1117 self.order.is_empty()
1118 }
1119
1120 /// The names in PICK ORDER, oldest first.
1121 pub fn iter(&self) -> std::slice::Iter<'_, String> {
1122 self.order.iter()
1123 }
1124}
1125
1126impl FromIterator<String> for OrderedNames {
1127 fn from_iter<I: IntoIterator<Item = String>>(iter: I) -> Self {
1128 let mut out = Self::default();
1129 for name in iter {
1130 out.insert(name);
1131 }
1132 out
1133 }
1134}
1135
1136impl Extend<String> for OrderedNames {
1137 fn extend<I: IntoIterator<Item = String>>(&mut self, iter: I) {
1138 for name in iter {
1139 self.insert(name);
1140 }
1141 }
1142}
1143
1144impl<'a> IntoIterator for &'a OrderedNames {
1145 type Item = &'a String;
1146 type IntoIter = std::slice::Iter<'a, String>;
1147 fn into_iter(self) -> Self::IntoIter {
1148 self.order.iter()
1149 }
1150}
1151
1152/// The emphasis state (selection + hover), name-keyed like `SelectionFilter`.
1153/// Solid-level emphasis cascades to that solid's faces/edges (the
1154/// `SelectionState._applyToSolid` behavior).
1155#[derive(Debug, Default, Clone)]
1156pub struct Emphasis {
1157 pub selected_solids: OrderedNames,
1158 pub selected_faces: OrderedNames,
1159 pub selected_edges: OrderedNames,
1160 pub selected_vertices: Vec<VertexRef>,
1161 /// Selected construction datum/plane FRAMES, keyed by frame NAME (`{id}:XY`
1162 /// for a DATUM base plane, `{id}` for a PLANE feature). Datums carry no
1163 /// resident geometry, so — like the render-color feed — they emphasize purely
1164 /// by name; a selected datum is re-colored with the selection accent when the
1165 /// engine re-feeds the datum planes.
1166 pub selected_datums: OrderedNames,
1167 pub hovered_solids: HashSet<String>,
1168 pub hovered_faces: HashSet<String>,
1169 pub hovered_edges: HashSet<String>,
1170 pub hovered_vertices: Vec<VertexRef>,
1171 /// HOVERED construction datum/plane FRAMES — the hover twin of
1172 /// [`selected_datums`](Self::selected_datums), keyed the same way. Construction
1173 /// planes are ordinary pick candidates, so the pointer (and a pick-list row)
1174 /// pre-highlights one exactly like a face; the accent is applied when the engine
1175 /// re-feeds the datum planes.
1176 pub hovered_datums: HashSet<String>,
1177 /// Bumped on every change — cache key for derived GPU state.
1178 pub generation: u64,
1179}
1180
1181impl Emphasis {
1182 pub fn is_empty(&self) -> bool {
1183 self.selected_solids.is_empty()
1184 && self.selected_faces.is_empty()
1185 && self.selected_edges.is_empty()
1186 && self.selected_vertices.is_empty()
1187 && self.selected_datums.is_empty()
1188 && self.hovered_solids.is_empty()
1189 && self.hovered_faces.is_empty()
1190 && self.hovered_edges.is_empty()
1191 && self.hovered_vertices.is_empty()
1192 && self.hovered_datums.is_empty()
1193 }
1194
1195 /// Replace the whole emphasis state from the R3 JSON shape:
1196 /// `{selected: {solids, faces, edges, vertices:[{solid,position}]}, hovered: {...}}`.
1197 pub fn apply_json(&mut self, json: &str) -> Result<(), String> {
1198 let value: serde_json::Value =
1199 serde_json::from_str(json).map_err(|error| format!("emphasis parse: {error}"))?;
1200 let names = |group: &serde_json::Value, key: &str| -> HashSet<String> {
1201 crate::json_support::string_values(group.get(key))
1202 .map(str::to_string)
1203 .collect()
1204 };
1205 // The SELECTED buckets keep the order they are given: a saved selection
1206 // comes back in the order it was picked in.
1207 let ordered = |group: &serde_json::Value, key: &str| -> OrderedNames {
1208 crate::json_support::string_values(group.get(key))
1209 .map(str::to_string)
1210 .collect()
1211 };
1212 let vertices = |group: &serde_json::Value| -> Vec<VertexRef> {
1213 group
1214 .get("vertices")
1215 .and_then(|v| v.as_array())
1216 .map(|list| {
1217 list.iter()
1218 .filter_map(|v| {
1219 let solid = v.get("solid")?.as_str()?.to_string();
1220 let p = v.get("position")?.as_array()?;
1221 Some(VertexRef {
1222 solid,
1223 position: [
1224 p.first()?.as_f64()?,
1225 p.get(1)?.as_f64()?,
1226 p.get(2)?.as_f64()?,
1227 ],
1228 })
1229 })
1230 .collect()
1231 })
1232 .unwrap_or_default()
1233 };
1234 let empty = serde_json::json!({});
1235 let selected = value.get("selected").unwrap_or(&empty);
1236 let hovered = value.get("hovered").unwrap_or(&empty);
1237 self.selected_solids = ordered(selected, "solids");
1238 self.selected_faces = ordered(selected, "faces");
1239 self.selected_edges = ordered(selected, "edges");
1240 self.selected_datums = ordered(selected, "datums");
1241 self.selected_vertices = vertices(selected);
1242 self.hovered_solids = names(hovered, "solids");
1243 self.hovered_faces = names(hovered, "faces");
1244 self.hovered_edges = names(hovered, "edges");
1245 self.hovered_datums = names(hovered, "datums");
1246 self.hovered_vertices = vertices(hovered);
1247 self.generation = self.generation.wrapping_add(1);
1248 Ok(())
1249 }
1250}
1251
1252/// The visual state of one displayed face/edge (hover wins over selected, the
1253/// `SelectionState` order).
1254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1255pub enum EmphasisState {
1256 Base,
1257 Selected,
1258 Hovered,
1259}
1260
1261impl Emphasis {
1262 pub fn face_state(&self, solid: &str, face: &str) -> EmphasisState {
1263 if self.hovered_solids.contains(solid) || (!face.is_empty() && self.hovered_faces.contains(face)) {
1264 EmphasisState::Hovered
1265 } else if self.selected_solids.contains(solid)
1266 || (!face.is_empty() && self.selected_faces.contains(face))
1267 {
1268 EmphasisState::Selected
1269 } else {
1270 EmphasisState::Base
1271 }
1272 }
1273
1274 pub fn edge_state(&self, solid: &str, edge: &str) -> EmphasisState {
1275 if self.hovered_solids.contains(solid) || (!edge.is_empty() && self.hovered_edges.contains(edge)) {
1276 EmphasisState::Hovered
1277 } else if self.selected_solids.contains(solid)
1278 || (!edge.is_empty() && self.selected_edges.contains(edge))
1279 {
1280 EmphasisState::Selected
1281 } else {
1282 EmphasisState::Base
1283 }
1284 }
1285
1286 pub fn vertex_state(&self, solid: &str, position: [f64; 3], tol: f64) -> EmphasisState {
1287 let matches = |refs: &[VertexRef]| {
1288 refs.iter().any(|r| {
1289 r.solid == solid
1290 && (r.position[0] - position[0]).abs() <= tol
1291 && (r.position[1] - position[1]).abs() <= tol
1292 && (r.position[2] - position[2]).abs() <= tol
1293 })
1294 };
1295 if matches(&self.hovered_vertices) {
1296 EmphasisState::Hovered
1297 } else if matches(&self.selected_vertices) {
1298 EmphasisState::Selected
1299 } else {
1300 EmphasisState::Base
1301 }
1302 }
1303}
1304
1305