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