Skip to main content

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