1use std::collections::HashSet;
8
9pub 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
21pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct SketchColors {
62 pub movable: u32,
64 pub locked: u32,
66 pub geometry: u32,
68 pub point: u32,
70 pub construction_point: u32,
72 pub under_constrained_point: u32,
74 pub selected: u32,
76 pub hovered: u32,
78 pub preview: u32,
80 pub constraint: u32,
82}
83
84impl Default for SketchColors {
85 fn default() -> Self {
86 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum ThemeMode {
107 Auto,
109 Light,
110 Dark,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum MultiSelectMode {
119 CtrlClick,
122 ClickToggles,
125}
126
127impl MultiSelectMode {
128 pub const ALL: [MultiSelectMode; 2] = [MultiSelectMode::CtrlClick, MultiSelectMode::ClickToggles];
130
131 pub fn label(&self) -> &'static str {
135 match self {
136 MultiSelectMode::CtrlClick => "Ctrl+Click",
137 MultiSelectMode::ClickToggles => "Click toggles",
138 }
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum FaceColorMode {
145 Uniform,
148 HashedBySolid,
150}
151
152#[derive(Debug, Clone, PartialEq)]
155pub struct RenderSettings {
156 pub theme: ThemeMode,
159 pub ui_scale: f32,
163 pub label_scale: f32,
179 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 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 pub wireframe: bool,
202 pub axis_length_px: f32,
204 pub viewcube_size_px: f32,
209 pub pick_double_sided: bool,
210 pub multi_select: MultiSelectMode,
212 pub lod_factor: f64,
214 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 pub workbench: String,
236 pub assembly_auto_solve: bool,
241 pub show_constraint_graphics: bool,
246 pub bom_columns: String,
258}
259
260impl Default for RenderSettings {
261 fn default() -> Self {
262 let sk = SketchColors::default();
263 Self {
264 theme: ThemeMode::Auto,
268 ui_scale: 1.0,
270 label_scale: 1.0,
272 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 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_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 workbench: "modeling".to_string(),
314 assembly_auto_solve: true,
317 show_constraint_graphics: true,
318 bom_columns: String::new(),
320 }
321 }
322}
323
324impl RenderSettings {
325 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 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 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 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 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 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 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 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 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 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 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 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 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
605fn 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
612fn rgba_to_css_hex(rgba: Rgba) -> String {
615 rgb_to_css_hex([rgba[0], rgba[1], rgba[2]])
616}
617
618fn 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#[derive(Debug, Clone, PartialEq)]
631pub enum FieldKind {
632 Color,
634 Bool,
636 Enum { variants: Vec<String> },
639 Number { min: f64, max: f64, step: f64 },
641 Range { min: f64, max: f64, step: f64 },
643 Scalar { step: f64 },
646 Text { read_only: bool },
650 Vec3 { step: f64 },
652 Reference { filter: Vec<String>, multiple: bool },
657 Button { label: String },
661}
662
663#[derive(Debug, Clone)]
668pub struct SettingsField {
669 pub key: &'static str,
671 pub label: &'static str,
672 pub group: &'static str,
673 pub kind: FieldKind,
674}
675
676#[derive(Debug, Clone)]
685pub struct FormField {
686 pub path: Vec<String>,
688 pub label: String,
689 pub group: String,
690 pub kind: FieldKind,
691}
692
693impl FormField {
694 pub fn key(&self) -> &str {
696 self.path.last().map(String::as_str).unwrap_or("")
697 }
698}
699
700pub 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
713fn lod_from_quality(label: &str) -> Option<f64> {
715 RENDER_QUALITY
716 .iter()
717 .find(|(name, _)| *name == label)
718 .map(|(_, factor)| *factor)
719}
720
721fn 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
734pub 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
749pub fn settings_schema() -> Vec<SettingsField> {
754 let f = |key, label, group, kind| SettingsField { key, label, group, kind };
755 vec![
756 f(
761 "theme",
762 "Theme",
763 "Appearance",
764 FieldKind::Enum { variants: ["auto", "light", "dark"].iter().map(|s| s.to_string()).collect() },
765 ),
766 f(
769 "uiScale",
770 "UI scale",
771 "Appearance",
772 FieldKind::Range { min: 0.5, max: 3.0, step: 0.05 },
773 ),
774 f(
783 "labelScale",
784 "Label scale",
785 "Appearance",
786 FieldKind::Range { min: 0.25, max: 3.0, step: 0.05 },
787 ),
788 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 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 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 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 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 f("pickDoubleSided", "Pick double-sided", "Picking", FieldKind::Bool),
849 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 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 f("debugGrabHandles", "Debug grab handles", "Debug", FieldKind::Bool),
876 ]
877}
878
879#[derive(Debug, Clone)]
882pub struct VertexRef {
883 pub solid: String,
884 pub position: [f64; 3],
885}
886
887#[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 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 pub hovered_datums: HashSet<String>,
912 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 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#[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 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 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 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 assert_eq!(settings.theme, ThemeMode::Auto);
1086 settings.apply_json(r##"{"theme": "light"}"##).unwrap();
1088 assert_eq!(settings.theme, ThemeMode::Light);
1089 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 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 assert_eq!(settings.multi_select, MultiSelectMode::ClickToggles);
1109 settings.apply_json(r##"{"multiSelect": "Click toggles"}"##).unwrap();
1111 assert_eq!(settings.multi_select, MultiSelectMode::ClickToggles);
1112 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 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 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 assert_eq!(settings.ui_scale, 1.0);
1136 settings.apply_json(r##"{"uiScale": 1.5}"##).unwrap();
1138 assert_eq!(settings.ui_scale, 1.5);
1139 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 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 #[test]
1163 fn settings_json_label_scale() {
1164 let mut settings = RenderSettings::default();
1165 assert_eq!(settings.label_scale, 1.0);
1167 settings.apply_json(r##"{"labelScale": 1.5}"##).unwrap();
1169 assert_eq!(settings.label_scale, 1.5);
1170 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 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 settings.apply_json(r##"{"labelScale": 0.24}"##).unwrap();
1185 assert_eq!(settings.label_scale, 0.25);
1186 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 assert_eq!(round.ui_scale, 1.0);
1199 }
1200
1201 #[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 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 assert_eq!(settings.workbench, "modeling");
1240 settings.apply_json(r##"{"workbench": "sheetMetal"}"##).unwrap();
1242 assert_eq!(settings.workbench, "sheetMetal");
1243 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 round.apply_json(r##"{"wireframe": true}"##).unwrap();
1254 assert_eq!(round.workbench, "sheetMetal");
1255 round.apply_json(r##"{"workbench": "bogus"}"##).unwrap();
1257 assert_eq!(round.workbench, "bogus");
1258 }
1259
1260 #[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 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 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 s.lod_factor = 2.0;
1330 s.flat_shading = true;
1331 s.wireframe = true;
1332 s.pick_double_sided = false;
1333 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 let mut identity = s.clone();
1341 identity.apply_json(&s.to_json()).unwrap();
1342 assert_eq!(identity, s);
1343
1344 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 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 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 assert_eq!(RenderSettings::default().sketch_colors(), SketchColors::default());
1383 }
1384
1385 #[test]
1386 fn sketch_colors_follow_apply_json() {
1387 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 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}