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/// How base face color is chosen per solid.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum FaceColorMode {
105    /// The app look: every face gets `face_color` (unless the solid carries a
106    /// metadata override).
107    Uniform,
108    /// The artifact look: stable name-hashed color per solid.
109    HashedBySolid,
110}
111
112/// The viewer's material palette + display toggles. Defaults are the
113/// `CADmaterials` values.
114#[derive(Debug, Clone, PartialEq)]
115pub struct RenderSettings {
116    pub background: [f32; 3],
117    pub face_color_mode: FaceColorMode,
118    pub face_color: Rgba,
119    pub face_selected_color: Rgba,
120    pub hover_color: Rgba,
121    pub edge_color: Rgba,
122    pub edge_selected_color: Rgba,
123    pub edge_width_px: f32,
124    /// Occluded edges render dimmed, not dropped (R17): alpha of the
125    /// depth-failing edge pass. 0 disables the pass.
126    pub hidden_edge_alpha: f32,
127    pub vertex_color: Rgba,
128    pub vertex_selected_color: Rgba,
129    pub vertex_size_px: f32,
130    pub flat_shading: bool,
131    /// Wireframe display (R14): when true the shaded face-fill pass is skipped so
132    /// only edges draw (the CAD wireframe look; back edges show through). Does
133    /// NOT affect picking (CPU ray-based) or the overlay pass.
134    pub wireframe: bool,
135    /// World-axis helper (R20): screen length in CSS px; 0 disables.
136    pub axis_length_px: f32,
137    pub pick_double_sided: bool,
138    /// Tessellation LOD factor (1.0 = the app's "Normal" preset).
139    pub lod_factor: f64,
140    // --- Sketcher overlay palette (managed here like every other display color) ---
141    // Defaults come from `SketchColors::default()` (the single source of the
142    // literals); `sketch_colors()` re-derives a `SketchColors` view for the
143    // tessellation/overlay builders.
144    pub sketch_movable_color: Rgba,
145    pub sketch_locked_color: Rgba,
146    pub sketch_geometry_color: Rgba,
147    pub sketch_point_color: Rgba,
148    pub sketch_construction_point_color: Rgba,
149    pub sketch_under_constrained_point_color: Rgba,
150    pub sketch_selected_color: Rgba,
151    pub sketch_hovered_color: Rgba,
152    pub sketch_preview_color: Rgba,
153    pub sketch_constraint_color: Rgba,
154}
155
156impl Default for RenderSettings {
157    fn default() -> Self {
158        let sk = SketchColors::default();
159        Self {
160            background: [
161                ((0x0b) as f32) / 255.0,
162                ((0x0d) as f32) / 255.0,
163                ((0x10) as f32) / 255.0,
164            ],
165            face_color_mode: FaceColorMode::Uniform,
166            face_color: hex(0x00009e, 1.0),
167            face_selected_color: hex(0xffc400, 1.0),
168            hover_color: hex(0xfbff00, 1.0),
169            edge_color: hex(0x009dff, 1.0),
170            edge_selected_color: hex(0xff00ff, 1.0),
171            edge_width_px: 2.0,
172            hidden_edge_alpha: 0.22,
173            vertex_color: hex(0x4aff03, 1.0),
174            vertex_selected_color: hex(0x00ffff, 1.0),
175            vertex_size_px: 6.0,
176            flat_shading: false,
177            wireframe: false,
178            axis_length_px: 46.0,
179            pick_double_sided: true,
180            lod_factor: 1.0,
181            // Sketch palette: derive each Rgba from the ONE source of the literals
182            // (`SketchColors::default`) so nothing changes visually until edited.
183            sketch_movable_color: hex(sk.movable, 1.0),
184            sketch_locked_color: hex(sk.locked, 1.0),
185            sketch_geometry_color: hex(sk.geometry, 1.0),
186            sketch_point_color: hex(sk.point, 1.0),
187            sketch_construction_point_color: hex(sk.construction_point, 1.0),
188            sketch_under_constrained_point_color: hex(sk.under_constrained_point, 1.0),
189            sketch_selected_color: hex(sk.selected, 1.0),
190            sketch_hovered_color: hex(sk.hovered, 1.0),
191            sketch_preview_color: hex(sk.preview, 1.0),
192            sketch_constraint_color: hex(sk.constraint, 1.0),
193        }
194    }
195}
196
197impl RenderSettings {
198    /// The artifact-corpus preset: byte-faithful to the slice-1 artifact look
199    /// (per-solid hashed colors, 0x101418 background, 1.6px edges in
200    /// 0x0d1030, no hidden-edge pass, no vertices, no axes).
201    pub fn artifact() -> Self {
202        Self {
203            background: [
204                ((0x10) as f32) / 255.0,
205                ((0x14) as f32) / 255.0,
206                ((0x18) as f32) / 255.0,
207            ],
208            face_color_mode: FaceColorMode::HashedBySolid,
209            edge_color: hex(0x0d1030, 1.0),
210            edge_width_px: 1.6,
211            hidden_edge_alpha: 0.0,
212            vertex_size_px: 0.0,
213            axis_length_px: 0.0,
214            ..Self::default()
215        }
216    }
217
218    /// Apply a partial JSON override (R3 settings entrypoint). Unknown keys
219    /// are ignored; colors are CSS hex strings.
220    pub fn apply_json(&mut self, json: &str) -> Result<(), String> {
221        let value: serde_json::Value =
222            serde_json::from_str(json).map_err(|error| format!("settings parse: {error}"))?;
223        let color = |key: &str, target: &mut Rgba| {
224            if let Some(v) = value.get(key).and_then(|v| v.as_str()) {
225                if let Some(rgb) = parse_css_hex(v) {
226                    target[0] = rgb[0];
227                    target[1] = rgb[1];
228                    target[2] = rgb[2];
229                }
230            }
231        };
232        color("faceColor", &mut self.face_color);
233        color("faceSelectedColor", &mut self.face_selected_color);
234        color("hoverColor", &mut self.hover_color);
235        color("edgeColor", &mut self.edge_color);
236        color("edgeSelectedColor", &mut self.edge_selected_color);
237        color("vertexColor", &mut self.vertex_color);
238        color("vertexSelectedColor", &mut self.vertex_selected_color);
239        // Sketcher overlay palette (same CSS-hex shape as the face/edge/vertex colors).
240        color("sketchMovableColor", &mut self.sketch_movable_color);
241        color("sketchLockedColor", &mut self.sketch_locked_color);
242        color("sketchGeometryColor", &mut self.sketch_geometry_color);
243        color("sketchPointColor", &mut self.sketch_point_color);
244        color("sketchConstructionPointColor", &mut self.sketch_construction_point_color);
245        color("sketchUnderConstrainedPointColor", &mut self.sketch_under_constrained_point_color);
246        color("sketchSelectedColor", &mut self.sketch_selected_color);
247        color("sketchHoveredColor", &mut self.sketch_hovered_color);
248        color("sketchPreviewColor", &mut self.sketch_preview_color);
249        color("sketchConstraintColor", &mut self.sketch_constraint_color);
250        if let Some(v) = value.get("background").and_then(|v| v.as_str()) {
251            if let Some(rgb) = parse_css_hex(v) {
252                self.background = rgb;
253            }
254        }
255        if let Some(v) = value.get("edgeWidthPx").and_then(|v| v.as_f64()) {
256            self.edge_width_px = (v as f32).clamp(0.0, 32.0);
257        }
258        if let Some(v) = value.get("vertexSizePx").and_then(|v| v.as_f64()) {
259            self.vertex_size_px = (v as f32).clamp(0.0, 64.0);
260        }
261        if let Some(v) = value.get("hiddenEdgeAlpha").and_then(|v| v.as_f64()) {
262            self.hidden_edge_alpha = (v as f32).clamp(0.0, 1.0);
263        }
264        if let Some(v) = value.get("faceColorMode").and_then(|v| v.as_str()) {
265            match v.trim().to_ascii_lowercase().as_str() {
266                "hashedbysolid" | "hashed" => self.face_color_mode = FaceColorMode::HashedBySolid,
267                "uniform" => self.face_color_mode = FaceColorMode::Uniform,
268                _ => {}
269            }
270        }
271        if let Some(v) = value.get("flatShading").and_then(|v| v.as_bool()) {
272            self.flat_shading = v;
273        }
274        if let Some(v) = value.get("wireframe").and_then(|v| v.as_bool()) {
275            self.wireframe = v;
276        }
277        if let Some(v) = value.get("axisLengthPx").and_then(|v| v.as_f64()) {
278            self.axis_length_px = (v as f32).clamp(0.0, 512.0);
279        }
280        if let Some(v) = value.get("pickDoubleSided").and_then(|v| v.as_bool()) {
281            self.pick_double_sided = v;
282        }
283        // "Render Quality" is a named dropdown (Draft…Ultra) mapping to the display
284        // LOD factor (higher quality = finer mesh = smaller factor). We store the
285        // resolved f64 so the tessellation path is unchanged.
286        if let Some(label) = value.get("renderQuality").and_then(|v| v.as_str()) {
287            if let Some(lod) = lod_from_quality(label) {
288                self.lod_factor = lod;
289            }
290        }
291        Ok(())
292    }
293
294    /// Serialize EVERY setting to the SAME camelCase / CSS-hex shape
295    /// [`apply_json`] reads, so `s.apply_json(&s.to_json())` is the identity.
296    /// This is the counterpart the settings schema serializes/persists through
297    /// (there was previously no serializer, only the partial-override reader).
298    pub fn to_json(&self) -> String {
299        serde_json::json!({
300            "background": rgb_to_css_hex(self.background),
301            "faceColorMode": match self.face_color_mode {
302                FaceColorMode::Uniform => "uniform",
303                FaceColorMode::HashedBySolid => "hashedBySolid",
304            },
305            "faceColor": rgba_to_css_hex(self.face_color),
306            "faceSelectedColor": rgba_to_css_hex(self.face_selected_color),
307            "hoverColor": rgba_to_css_hex(self.hover_color),
308            "edgeColor": rgba_to_css_hex(self.edge_color),
309            "edgeSelectedColor": rgba_to_css_hex(self.edge_selected_color),
310            "edgeWidthPx": self.edge_width_px as f64,
311            "hiddenEdgeAlpha": self.hidden_edge_alpha as f64,
312            "vertexColor": rgba_to_css_hex(self.vertex_color),
313            "vertexSelectedColor": rgba_to_css_hex(self.vertex_selected_color),
314            "vertexSizePx": self.vertex_size_px as f64,
315            "flatShading": self.flat_shading,
316            "wireframe": self.wireframe,
317            "axisLengthPx": self.axis_length_px as f64,
318            "pickDoubleSided": self.pick_double_sided,
319            "renderQuality": quality_from_lod(self.lod_factor),
320            "sketchMovableColor": rgba_to_css_hex(self.sketch_movable_color),
321            "sketchLockedColor": rgba_to_css_hex(self.sketch_locked_color),
322            "sketchGeometryColor": rgba_to_css_hex(self.sketch_geometry_color),
323            "sketchPointColor": rgba_to_css_hex(self.sketch_point_color),
324            "sketchConstructionPointColor": rgba_to_css_hex(self.sketch_construction_point_color),
325            "sketchUnderConstrainedPointColor": rgba_to_css_hex(self.sketch_under_constrained_point_color),
326            "sketchSelectedColor": rgba_to_css_hex(self.sketch_selected_color),
327            "sketchHoveredColor": rgba_to_css_hex(self.sketch_hovered_color),
328            "sketchPreviewColor": rgba_to_css_hex(self.sketch_preview_color),
329            "sketchConstraintColor": rgba_to_css_hex(self.sketch_constraint_color),
330        })
331        .to_string()
332    }
333
334    /// The live [`SketchColors`] view of the sketcher palette — the tessellation /
335    /// overlay builders read their colors from THIS (via
336    /// [`crate::sketch::SketchSession::colors`]) so the display settings are the one
337    /// source of truth. Each `Rgba` is quantized back to `0xRRGGBB` the SAME way
338    /// [`rgb_to_css_hex`] serializes it, so a default settings value round-trips to
339    /// the default `SketchColors` byte-exact.
340    pub fn sketch_colors(&self) -> SketchColors {
341        SketchColors {
342            movable: rgba_to_u32(self.sketch_movable_color),
343            locked: rgba_to_u32(self.sketch_locked_color),
344            geometry: rgba_to_u32(self.sketch_geometry_color),
345            point: rgba_to_u32(self.sketch_point_color),
346            construction_point: rgba_to_u32(self.sketch_construction_point_color),
347            under_constrained_point: rgba_to_u32(self.sketch_under_constrained_point_color),
348            selected: rgba_to_u32(self.sketch_selected_color),
349            hovered: rgba_to_u32(self.sketch_hovered_color),
350            preview: rgba_to_u32(self.sketch_preview_color),
351            constraint: rgba_to_u32(self.sketch_constraint_color),
352        }
353    }
354
355    /// The settings schema WITH the current value baked into each field, as JSON
356    /// — mirrors the kernel's `feature_schemas_json` export so a UI shell (egui
357    /// here, a later `brep-ui` crate) can generate the whole form from data. The
358    /// `value` of each field is pulled live from [`to_json`], so the export
359    /// always reflects the current settings.
360    pub fn settings_schema_json(&self) -> String {
361        let current: serde_json::Value =
362            serde_json::from_str(&self.to_json()).unwrap_or(serde_json::Value::Null);
363        let fields: Vec<serde_json::Value> = settings_schema()
364            .iter()
365            .map(|field| {
366                let kind = match &field.kind {
367                    FieldKind::Color => serde_json::json!({ "type": "color" }),
368                    FieldKind::Bool => serde_json::json!({ "type": "bool" }),
369                    FieldKind::Enum { variants } => {
370                        serde_json::json!({ "type": "enum", "variants": variants })
371                    }
372                    FieldKind::Number { min, max, step } => serde_json::json!({
373                        "type": "number", "min": min, "max": max, "step": step
374                    }),
375                    FieldKind::Range { min, max, step } => serde_json::json!({
376                        "type": "range", "min": min, "max": max, "step": step
377                    }),
378                    // The feature-dialog kinds never appear in the settings
379                    // schema, but the match must stay exhaustive.
380                    FieldKind::Scalar { step } => {
381                        serde_json::json!({ "type": "scalar", "step": step })
382                    }
383                    FieldKind::Text { read_only } => {
384                        serde_json::json!({ "type": "text", "readOnly": read_only })
385                    }
386                    FieldKind::Vec3 { step } => serde_json::json!({ "type": "vec3", "step": step }),
387                    FieldKind::Reference { filter, multiple } => serde_json::json!({
388                        "type": "reference", "filter": filter, "multiple": multiple
389                    }),
390                    FieldKind::Button { label } => {
391                        serde_json::json!({ "type": "button", "label": label })
392                    }
393                };
394                serde_json::json!({
395                    "key": field.key,
396                    "label": field.label,
397                    "group": field.group,
398                    "kind": kind,
399                    "value": current.get(field.key),
400                })
401            })
402            .collect();
403        serde_json::json!({ "fields": fields }).to_string()
404    }
405}
406
407/// Quantize an sRGB channel triple to a `#rrggbb` CSS hex string (the shape
408/// [`RenderSettings::apply_json`] parses back).
409fn rgb_to_css_hex(rgb: [f32; 3]) -> String {
410    let q = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u32;
411    format!("#{:02x}{:02x}{:02x}", q(rgb[0]), q(rgb[1]), q(rgb[2]))
412}
413
414/// Like [`rgb_to_css_hex`] but for an `Rgba` (the alpha is intentionally dropped
415/// — `apply_json` only overrides the rgb channels, preserving existing alpha).
416fn rgba_to_css_hex(rgba: Rgba) -> String {
417    rgb_to_css_hex([rgba[0], rgba[1], rgba[2]])
418}
419
420/// Quantize an `Rgba` to a packed `0xRRGGBB` (alpha dropped), using the SAME
421/// per-channel rounding as [`rgb_to_css_hex`] so the sketcher's `u32` palette
422/// (`SketchColors`) is byte-identical to what the CSS-hex serialization would
423/// produce — a default settings value maps back to the default `SketchColors`.
424fn rgba_to_u32(rgba: Rgba) -> u32 {
425    let q = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u32;
426    (q(rgba[0]) << 16) | (q(rgba[1]) << 8) | q(rgba[2])
427}
428
429/// The kind of one settings field — the closed set of widget shapes the generic
430/// form renderer knows how to emit. Plain data, NO egui dependency: the schema
431/// lives in the engine, the renderer (brep-app / a later brep-ui) walks it.
432#[derive(Debug, Clone, PartialEq)]
433pub enum FieldKind {
434    /// An sRGB color (`#rrggbb`) → a color picker button.
435    Color,
436    /// A boolean toggle → a checkbox.
437    Bool,
438    /// A closed choice → a combo box. `variants` are the JSON string values
439    /// (exactly what `apply_json` accepts / `to_json` emits).
440    Enum { variants: Vec<&'static str> },
441    /// A bounded number → a slider / drag-value honoring `min`/`max`/`step`.
442    Number { min: f64, max: f64, step: f64 },
443    /// A 0..1 (or otherwise fractional) number → a slider honoring the bounds.
444    Range { min: f64, max: f64, step: f64 },
445    // --- extra kinds the FEATURE dialogs need (settings never use these) -------
446    /// An UNBOUNDED number (feature params carry no min/max) → a drag value.
447    Scalar { step: f64 },
448    /// A free-text string → a single-line text edit. `read_only` marks an
449    /// identity field (a feature `id`) that is shown but not editable here (a
450    /// rename must cascade to references — a later slice).
451    Text { read_only: bool },
452    /// A 3-vector (position / rotationEuler / scale) → three drag values.
453    Vec3 { step: f64 },
454    /// A reference-selection field (solid / face / edge / … picked in the 3D
455    /// view). The schema-driven form renders a DISABLED placeholder here — the
456    /// real engine-native picker is the NEXT slice (#42). `filter` is the
457    /// selection filter (e.g. `["SOLID"]`), `multiple` whether it takes a list.
458    Reference { filter: Vec<String>, multiple: bool },
459    /// An ACTION button (schema `"type":"button"`) → a clickable button. It binds
460    /// to no value; a click is surfaced to the caller by the field `key` (e.g.
461    /// `editSketch`), which the host acts on. `label` is the button caption.
462    Button { label: String },
463}
464
465/// One field of the settings form: the camelCase `apply_json`/`to_json` key, a
466/// human label, a UI group, and the widget `kind`. The ordered list of these
467/// (see [`settings_schema`]) fully describes the form — a UI shell generates a
468/// widget per field with no per-field code.
469#[derive(Debug, Clone)]
470pub struct SettingsField {
471    /// The camelCase key — the SAME one `apply_json`/`to_json` use.
472    pub key: &'static str,
473    pub label: &'static str,
474    pub group: &'static str,
475    pub kind: FieldKind,
476}
477
478/// The GENERAL form field — the schema element ONE form engine renders for BOTH
479/// the display-settings dialog AND the schema-driven feature dialogs. Unlike
480/// [`SettingsField`] (which uses `&'static str` because the settings schema is
481/// compile-time), this owns its strings so it can carry a per-feature schema
482/// pulled from the kernel catalogue at run time, and it carries a `path` (a
483/// chain of JSON object keys) so a field can bind to a NESTED value —
484/// `["transform","position"]`, `["boolean","operation"]` — not just a top-level
485/// key. The settings form is `path == [key]`.
486#[derive(Debug, Clone)]
487pub struct FormField {
488    /// JSON object-key chain into the document the form edits (≥ 1 segment).
489    pub path: Vec<String>,
490    pub label: String,
491    pub group: String,
492    pub kind: FieldKind,
493}
494
495impl FormField {
496    /// The last path segment — a stable per-field id for egui salting / probing.
497    pub fn key(&self) -> &str {
498        self.path.last().map(String::as_str).unwrap_or("")
499    }
500}
501
502/// The "Render Quality" dropdown levels shown in Settings, each mapped to a
503/// display-tessellation LOD factor. Higher quality = finer mesh = SMALLER factor
504/// (chord tolerance = extent · 1.5e-3 · factor). Order is coarse→fine, the order
505/// the ComboBox lists them. `lod_factor` stays the internal representation the
506/// tessellation path reads; only the SETTINGS UI/serialization speaks in levels.
507pub const RENDER_QUALITY: &[(&str, f64)] = &[
508    ("Draft", 4.0),
509    ("Low", 2.0),
510    ("Medium", 1.0),
511    ("High", 0.5),
512    ("Ultra", 0.25),
513];
514
515/// The LOD factor for a quality label (`None` if not a known level).
516fn lod_from_quality(label: &str) -> Option<f64> {
517    RENDER_QUALITY
518        .iter()
519        .find(|(name, _)| *name == label)
520        .map(|(_, factor)| *factor)
521}
522
523/// The quality label NEAREST a LOD factor — the serialization inverse of
524/// [`lod_from_quality`]. A stored factor is always one of the level values in
525/// normal use; nearest-match keeps a hand-set/legacy value mapping to a sane label.
526fn quality_from_lod(lod: f64) -> &'static str {
527    RENDER_QUALITY
528        .iter()
529        .min_by(|(_, a), (_, b)| {
530            (a - lod).abs().total_cmp(&(b - lod).abs())
531        })
532        .map(|(name, _)| *name)
533        .unwrap_or("Medium")
534}
535
536/// Lift the compile-time display-settings schema into general [`FormField`]s so
537/// the ONE `field_input` engine (which the feature dialogs also use) renders the
538/// settings panel too — a single schema-driven dialog engine, not two.
539pub fn settings_form_fields() -> Vec<FormField> {
540    settings_schema()
541        .into_iter()
542        .map(|field| FormField {
543            path: vec![field.key.to_string()],
544            label: field.label.to_string(),
545            group: field.group.to_string(),
546            kind: field.kind,
547        })
548        .collect()
549}
550
551/// The Rust-owned settings schema: the ordered list of display-settings fields,
552/// grouped, analogous to the kernel feature schemas. Adding a field here (plus
553/// its `apply_json`/`to_json` handling) makes the whole UI grow a widget for it
554/// with ZERO renderer changes.
555pub fn settings_schema() -> Vec<SettingsField> {
556    let f = |key, label, group, kind| SettingsField { key, label, group, kind };
557    vec![
558        // --- Scene ---------------------------------------------------------
559        f("background", "Background", "Scene", FieldKind::Color),
560        f(
561            "axisLengthPx",
562            "Axis length (px)",
563            "Scene",
564            FieldKind::Number { min: 0.0, max: 512.0, step: 1.0 },
565        ),
566        f(
567            "renderQuality",
568            "Render Quality",
569            "Scene",
570            FieldKind::Enum { variants: RENDER_QUALITY.iter().map(|(label, _)| *label).collect() },
571        ),
572        // --- Faces ---------------------------------------------------------
573        f(
574            "faceColorMode",
575            "Face color mode",
576            "Faces",
577            FieldKind::Enum { variants: vec!["uniform", "hashedBySolid"] },
578        ),
579        f("faceColor", "Face color", "Faces", FieldKind::Color),
580        f("faceSelectedColor", "Selected face", "Faces", FieldKind::Color),
581        f("hoverColor", "Hover", "Faces", FieldKind::Color),
582        f("flatShading", "Flat shading", "Faces", FieldKind::Bool),
583        f("wireframe", "Wireframe", "Faces", FieldKind::Bool),
584        // --- Edges ---------------------------------------------------------
585        f("edgeColor", "Edge color", "Edges", FieldKind::Color),
586        f("edgeSelectedColor", "Selected edge", "Edges", FieldKind::Color),
587        f(
588            "edgeWidthPx",
589            "Edge width (px)",
590            "Edges",
591            FieldKind::Number { min: 0.0, max: 32.0, step: 0.1 },
592        ),
593        f(
594            "hiddenEdgeAlpha",
595            "Hidden-edge alpha",
596            "Edges",
597            FieldKind::Range { min: 0.0, max: 1.0, step: 0.01 },
598        ),
599        // --- Vertices ------------------------------------------------------
600        f("vertexColor", "Vertex color", "Vertices", FieldKind::Color),
601        f("vertexSelectedColor", "Selected vertex", "Vertices", FieldKind::Color),
602        f(
603            "vertexSizePx",
604            "Vertex size (px)",
605            "Vertices",
606            FieldKind::Number { min: 0.0, max: 64.0, step: 0.5 },
607        ),
608        // --- Picking -------------------------------------------------------
609        f("pickDoubleSided", "Pick double-sided", "Picking", FieldKind::Bool),
610        // --- Sketch --------------------------------------------------------
611        // The sketcher overlay palette, editable live like every other display
612        // color. `sketch_colors()` feeds these to the tessellation/overlay builders.
613        f("sketchMovableColor", "Movable geometry", "Sketch", FieldKind::Color),
614        f("sketchLockedColor", "Locked geometry", "Sketch", FieldKind::Color),
615        f("sketchGeometryColor", "Geometry (no mobility)", "Sketch", FieldKind::Color),
616        f("sketchPointColor", "Point", "Sketch", FieldKind::Color),
617        f("sketchConstructionPointColor", "Construction point", "Sketch", FieldKind::Color),
618        f("sketchUnderConstrainedPointColor", "Under-constrained point", "Sketch", FieldKind::Color),
619        f("sketchSelectedColor", "Selected", "Sketch", FieldKind::Color),
620        f("sketchHoveredColor", "Hovered", "Sketch", FieldKind::Color),
621        f("sketchPreviewColor", "Draw preview", "Sketch", FieldKind::Color),
622        f("sketchConstraintColor", "Constraint / dimension", "Sketch", FieldKind::Color),
623    ]
624}
625
626/// A selected/hovered vertex reference: vertices have no kernel names, so they
627/// resolve by owning solid + position.
628#[derive(Debug, Clone)]
629pub struct VertexRef {
630    pub solid: String,
631    pub position: [f64; 3],
632}
633
634/// The emphasis state (selection + hover), name-keyed like `SelectionFilter`.
635/// Solid-level emphasis cascades to that solid's faces/edges (the
636/// `SelectionState._applyToSolid` behavior).
637#[derive(Debug, Default)]
638pub struct Emphasis {
639    pub selected_solids: HashSet<String>,
640    pub selected_faces: HashSet<String>,
641    pub selected_edges: HashSet<String>,
642    pub selected_vertices: Vec<VertexRef>,
643    /// Selected construction datum/plane FRAMES, keyed by frame NAME (`{id}:XY`
644    /// for a DATUM base plane, `{id}` for a PLANE feature). Datums carry no
645    /// resident geometry, so — like the render-color feed — they emphasize purely
646    /// by name; a selected datum is re-colored with the selection accent when the
647    /// engine re-feeds the datum planes.
648    pub selected_datums: HashSet<String>,
649    pub hovered_solids: HashSet<String>,
650    pub hovered_faces: HashSet<String>,
651    pub hovered_edges: HashSet<String>,
652    pub hovered_vertices: Vec<VertexRef>,
653    /// Bumped on every change — cache key for derived GPU state.
654    pub generation: u64,
655}
656
657impl Emphasis {
658    pub fn is_empty(&self) -> bool {
659        self.selected_solids.is_empty()
660            && self.selected_faces.is_empty()
661            && self.selected_edges.is_empty()
662            && self.selected_vertices.is_empty()
663            && self.selected_datums.is_empty()
664            && self.hovered_solids.is_empty()
665            && self.hovered_faces.is_empty()
666            && self.hovered_edges.is_empty()
667            && self.hovered_vertices.is_empty()
668    }
669
670    /// Replace the whole emphasis state from the R3 JSON shape:
671    /// `{selected: {solids, faces, edges, vertices:[{solid,position}]}, hovered: {...}}`.
672    pub fn apply_json(&mut self, json: &str) -> Result<(), String> {
673        let value: serde_json::Value =
674            serde_json::from_str(json).map_err(|error| format!("emphasis parse: {error}"))?;
675        let names = |group: &serde_json::Value, key: &str| -> HashSet<String> {
676            group
677                .get(key)
678                .and_then(|v| v.as_array())
679                .map(|list| {
680                    list.iter()
681                        .filter_map(|v| v.as_str().map(str::to_string))
682                        .collect()
683                })
684                .unwrap_or_default()
685        };
686        let vertices = |group: &serde_json::Value| -> Vec<VertexRef> {
687            group
688                .get("vertices")
689                .and_then(|v| v.as_array())
690                .map(|list| {
691                    list.iter()
692                        .filter_map(|v| {
693                            let solid = v.get("solid")?.as_str()?.to_string();
694                            let p = v.get("position")?.as_array()?;
695                            Some(VertexRef {
696                                solid,
697                                position: [
698                                    p.first()?.as_f64()?,
699                                    p.get(1)?.as_f64()?,
700                                    p.get(2)?.as_f64()?,
701                                ],
702                            })
703                        })
704                        .collect()
705                })
706                .unwrap_or_default()
707        };
708        let empty = serde_json::json!({});
709        let selected = value.get("selected").unwrap_or(&empty);
710        let hovered = value.get("hovered").unwrap_or(&empty);
711        self.selected_solids = names(selected, "solids");
712        self.selected_faces = names(selected, "faces");
713        self.selected_edges = names(selected, "edges");
714        self.selected_datums = names(selected, "datums");
715        self.selected_vertices = vertices(selected);
716        self.hovered_solids = names(hovered, "solids");
717        self.hovered_faces = names(hovered, "faces");
718        self.hovered_edges = names(hovered, "edges");
719        self.hovered_vertices = vertices(hovered);
720        self.generation = self.generation.wrapping_add(1);
721        Ok(())
722    }
723}
724
725/// The visual state of one displayed face/edge (hover wins over selected, the
726/// `SelectionState` order).
727#[derive(Debug, Clone, Copy, PartialEq, Eq)]
728pub enum EmphasisState {
729    Base,
730    Selected,
731    Hovered,
732}
733
734impl Emphasis {
735    pub fn face_state(&self, solid: &str, face: &str) -> EmphasisState {
736        if self.hovered_solids.contains(solid) || (!face.is_empty() && self.hovered_faces.contains(face)) {
737            EmphasisState::Hovered
738        } else if self.selected_solids.contains(solid)
739            || (!face.is_empty() && self.selected_faces.contains(face))
740        {
741            EmphasisState::Selected
742        } else {
743            EmphasisState::Base
744        }
745    }
746
747    pub fn edge_state(&self, solid: &str, edge: &str) -> EmphasisState {
748        if self.hovered_solids.contains(solid) || (!edge.is_empty() && self.hovered_edges.contains(edge)) {
749            EmphasisState::Hovered
750        } else if self.selected_solids.contains(solid)
751            || (!edge.is_empty() && self.selected_edges.contains(edge))
752        {
753            EmphasisState::Selected
754        } else {
755            EmphasisState::Base
756        }
757    }
758
759    pub fn vertex_state(&self, solid: &str, position: [f64; 3], tol: f64) -> EmphasisState {
760        let matches = |refs: &[VertexRef]| {
761            refs.iter().any(|r| {
762                r.solid == solid
763                    && (r.position[0] - position[0]).abs() <= tol
764                    && (r.position[1] - position[1]).abs() <= tol
765                    && (r.position[2] - position[2]).abs() <= tol
766            })
767        };
768        if matches(&self.hovered_vertices) {
769            EmphasisState::Hovered
770        } else if matches(&self.selected_vertices) {
771            EmphasisState::Selected
772        } else {
773            EmphasisState::Base
774        }
775    }
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781
782    #[test]
783    fn settings_json_overrides() {
784        let mut settings = RenderSettings::default();
785        settings
786            .apply_json(
787                r##"{"faceColor": "#ff0000", "edgeWidthPx": 4.5, "flatShading": true,
788                     "hoverColor": "#0f0", "unknownKey": 1}"##,
789            )
790            .unwrap();
791        assert_eq!(settings.face_color[0], 1.0);
792        assert_eq!(settings.face_color[1], 0.0);
793        assert_eq!(settings.edge_width_px, 4.5);
794        assert!(settings.flat_shading);
795        assert_eq!(settings.hover_color[1], 1.0);
796    }
797
798    #[test]
799    fn settings_json_face_color_mode_and_wireframe() {
800        let mut settings = RenderSettings::default();
801        // Defaults: uniform faces, shaded (not wireframe).
802        assert_eq!(settings.face_color_mode, FaceColorMode::Uniform);
803        assert!(!settings.wireframe);
804        settings
805            .apply_json(r##"{"faceColorMode": "hashedBySolid", "wireframe": true}"##)
806            .unwrap();
807        assert_eq!(settings.face_color_mode, FaceColorMode::HashedBySolid);
808        assert!(settings.wireframe);
809        // Round-trip back to uniform + shaded.
810        settings
811            .apply_json(r##"{"faceColorMode": "uniform", "wireframe": false}"##)
812            .unwrap();
813        assert_eq!(settings.face_color_mode, FaceColorMode::Uniform);
814        assert!(!settings.wireframe);
815        // An unknown mode string is ignored (stays uniform).
816        settings.apply_json(r##"{"faceColorMode": "bogus"}"##).unwrap();
817        assert_eq!(settings.face_color_mode, FaceColorMode::Uniform);
818    }
819
820    #[test]
821    fn emphasis_states_cascade_from_solid() {
822        let mut emphasis = Emphasis::default();
823        emphasis
824            .apply_json(
825                r#"{"selected": {"solids": ["A"], "faces": ["F1"]},
826                    "hovered": {"edges": ["E1"], "vertices": [{"solid": "A", "position": [1, 2, 3]}]}}"#,
827            )
828            .unwrap();
829        assert_eq!(emphasis.face_state("A", "anything"), EmphasisState::Selected);
830        assert_eq!(emphasis.face_state("B", "F1"), EmphasisState::Selected);
831        assert_eq!(emphasis.face_state("B", "F2"), EmphasisState::Base);
832        assert_eq!(emphasis.edge_state("B", "E1"), EmphasisState::Hovered);
833        assert_eq!(
834            emphasis.vertex_state("A", [1.0, 2.0, 3.0], 1e-9),
835            EmphasisState::Hovered
836        );
837        assert_eq!(
838            emphasis.vertex_state("B", [1.0, 2.0, 3.0], 1e-9),
839            EmphasisState::Base
840        );
841        let gen0 = emphasis.generation;
842        emphasis.apply_json(r#"{}"#).unwrap();
843        assert!(emphasis.is_empty());
844        assert_ne!(emphasis.generation, gen0);
845    }
846
847    #[test]
848    fn settings_to_json_roundtrip_is_identity() {
849        // Start from defaults, then mutate a spread of fields with hex-exact
850        // colors / representable numbers so the round-trip is exact.
851        let mut s = RenderSettings::default();
852        s.face_color = hex(0x123456, 1.0);
853        s.edge_color = hex(0xabcdef, 1.0);
854        s.background = [0.0, 0.0, 0.0];
855        s.face_color_mode = FaceColorMode::HashedBySolid;
856        s.edge_width_px = 3.5;
857        s.hidden_edge_alpha = 0.5;
858        s.vertex_size_px = 8.0;
859        s.axis_length_px = 30.0;
860        // Must be a "Render Quality" LEVEL value now (serialized as its label +
861        // read back to the same factor) — Low = 2.0; a between-levels value would
862        // snap to the nearest level and break the identity by design.
863        s.lod_factor = 2.0;
864        s.flat_shading = true;
865        s.wireframe = true;
866        s.pick_double_sided = false;
867        // Sketch palette carried through the round-trip too (hex-exact values).
868        s.sketch_movable_color = hex(0x112233, 1.0);
869        s.sketch_constraint_color = hex(0x00ff00, 1.0);
870        s.sketch_selected_color = hex(0xfedcba, 1.0);
871
872        // The task's literal identity form: applying its own serialization is a
873        // no-op.
874        let mut identity = s.clone();
875        identity.apply_json(&s.to_json()).unwrap();
876        assert_eq!(identity, s);
877
878        // And it reconstructs the same value from a fresh default (alphas match,
879        // since apply_json preserves the target's existing alpha = 1.0).
880        let mut rebuilt = RenderSettings::default();
881        rebuilt.apply_json(&s.to_json()).unwrap();
882        assert_eq!(rebuilt, s);
883    }
884
885    #[test]
886    fn settings_schema_covers_every_json_key() {
887        // Every schema key must be a key `to_json` emits (so the form can read a
888        // live value for it) — the schema and the serializer stay in lockstep.
889        let json: serde_json::Value =
890            serde_json::from_str(&RenderSettings::default().to_json()).unwrap();
891        for field in settings_schema() {
892            assert!(
893                json.get(field.key).is_some(),
894                "schema field {} has no to_json value",
895                field.key
896            );
897        }
898        // The schema export reflects the current value (e.g. wireframe flips).
899        let mut s = RenderSettings::default();
900        s.wireframe = true;
901        let export: serde_json::Value = serde_json::from_str(&s.settings_schema_json()).unwrap();
902        let wf = export["fields"]
903            .as_array()
904            .unwrap()
905            .iter()
906            .find(|f| f["key"] == "wireframe")
907            .unwrap();
908        assert_eq!(wf["value"], serde_json::json!(true));
909    }
910
911    #[test]
912    fn sketch_colors_default_matches_the_hex_constants() {
913        // The default RenderSettings sketch palette round-trips BYTE-EXACT to the
914        // single source of the literals (`SketchColors::default`), so nothing
915        // changes visually until edited.
916        assert_eq!(RenderSettings::default().sketch_colors(), SketchColors::default());
917    }
918
919    #[test]
920    fn sketch_colors_follow_apply_json() {
921        // Editing a sketch color in the settings (the dialog's apply path) reaches
922        // the `SketchColors` view the tessellation reads.
923        let mut s = RenderSettings::default();
924        s.apply_json(r##"{"sketchConstraintColor": "#ff0000", "sketchMovableColor": "#00ff00"}"##)
925            .unwrap();
926        let c = s.sketch_colors();
927        assert_eq!(c.constraint, 0xff0000);
928        assert_eq!(c.movable, 0x00ff00);
929        // Untouched entries keep their defaults.
930        assert_eq!(c.locked, SketchColors::default().locked);
931    }
932
933    #[test]
934    fn css_hex_parsing() {
935        assert_eq!(parse_css_hex("#ffffff"), Some([1.0, 1.0, 1.0]));
936        assert_eq!(parse_css_hex("#f00"), Some([1.0, 0.0, 0.0]));
937        assert!(parse_css_hex("red").is_none());
938        assert_eq!(parse_css_hex("0x00009e").map(|c| c[2]), Some(0x9e as f32 / 255.0));
939    }
940}