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