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 override_model_colors: bool,
198 pub show_workbench_toolbar: bool,
204 pub face_color: Rgba,
205 pub face_selected_color: Rgba,
206 pub hover_color: Rgba,
207 pub edge_color: Rgba,
208 pub edge_selected_color: Rgba,
209 pub edge_width_px: f32,
210 pub hidden_edge_alpha: f32,
213 pub vertex_color: Rgba,
214 pub vertex_selected_color: Rgba,
215 pub vertex_size_px: f32,
216 pub flat_shading: bool,
217 pub wireframe: bool,
221 pub axis_length_px: f32,
223 pub viewcube_size_px: f32,
228 pub pick_double_sided: bool,
229 pub multi_select: MultiSelectMode,
231 pub lod_factor: f64,
233 pub sketch_movable_color: Rgba,
238 pub sketch_locked_color: Rgba,
239 pub sketch_geometry_color: Rgba,
240 pub sketch_point_color: Rgba,
241 pub sketch_construction_point_color: Rgba,
242 pub sketch_under_constrained_point_color: Rgba,
243 pub sketch_selected_color: Rgba,
244 pub sketch_hovered_color: Rgba,
245 pub sketch_preview_color: Rgba,
246 pub sketch_constraint_color: Rgba,
247 pub workbench: String,
255 pub assembly_auto_solve: bool,
260 pub show_constraint_graphics: bool,
265 pub bom_columns: String,
277}
278
279impl Default for RenderSettings {
280 fn default() -> Self {
281 let sk = SketchColors::default();
282 Self {
283 theme: ThemeMode::Auto,
287 ui_scale: 1.0,
289 label_scale: 1.0,
291 debug_grab_handles: false,
293 background: [
294 ((0x0b) as f32) / 255.0,
295 ((0x0d) as f32) / 255.0,
296 ((0x10) as f32) / 255.0,
297 ],
298 face_color_mode: FaceColorMode::Uniform,
299 face_color: hex(0x00009e, 1.0),
300 face_selected_color: hex(0xffc400, 1.0),
301 hover_color: hex(0xfbff00, 1.0),
302 edge_color: hex(0x009dff, 1.0),
303 edge_selected_color: hex(0xff00ff, 1.0),
304 edge_width_px: 2.0,
305 hidden_edge_alpha: 0.22,
306 vertex_color: hex(0x4aff03, 1.0),
307 vertex_selected_color: hex(0x00ffff, 1.0),
308 vertex_size_px: 6.0,
309 flat_shading: false,
310 wireframe: false,
311 override_model_colors: false,
312 show_workbench_toolbar: true,
313 axis_length_px: 46.0,
314 viewcube_size_px: brep_gizmos::view_cube::ViewCube::DEFAULT_SIZE_PX,
318 pick_double_sided: true,
319 multi_select: MultiSelectMode::ClickToggles,
320 lod_factor: 1.0,
321 sketch_movable_color: hex(sk.movable, 1.0),
324 sketch_locked_color: hex(sk.locked, 1.0),
325 sketch_geometry_color: hex(sk.geometry, 1.0),
326 sketch_point_color: hex(sk.point, 1.0),
327 sketch_construction_point_color: hex(sk.construction_point, 1.0),
328 sketch_under_constrained_point_color: hex(sk.under_constrained_point, 1.0),
329 sketch_selected_color: hex(sk.selected, 1.0),
330 sketch_hovered_color: hex(sk.hovered, 1.0),
331 sketch_preview_color: hex(sk.preview, 1.0),
332 sketch_constraint_color: hex(sk.constraint, 1.0),
333 workbench: "modeling".to_string(),
335 assembly_auto_solve: true,
338 show_constraint_graphics: true,
339 bom_columns: String::new(),
341 }
342 }
343}
344
345impl RenderSettings {
346 pub fn artifact() -> Self {
350 Self {
351 background: [
352 ((0x10) as f32) / 255.0,
353 ((0x14) as f32) / 255.0,
354 ((0x18) as f32) / 255.0,
355 ],
356 face_color_mode: FaceColorMode::HashedBySolid,
357 edge_color: hex(0x0d1030, 1.0),
358 edge_width_px: 1.6,
359 hidden_edge_alpha: 0.0,
360 vertex_size_px: 0.0,
361 axis_length_px: 0.0,
362 ..Self::default()
363 }
364 }
365
366 pub fn apply_json(&mut self, json: &str) -> Result<(), String> {
369 let value: serde_json::Value =
370 serde_json::from_str(json).map_err(|error| format!("settings parse: {error}"))?;
371 let color = |key: &str, target: &mut Rgba| {
372 if let Some(v) = value.get(key).and_then(|v| v.as_str()) {
373 if let Some(rgb) = parse_css_hex(v) {
374 target[0] = rgb[0];
375 target[1] = rgb[1];
376 target[2] = rgb[2];
377 }
378 }
379 };
380 color("faceColor", &mut self.face_color);
381 color("faceSelectedColor", &mut self.face_selected_color);
382 color("hoverColor", &mut self.hover_color);
383 color("edgeColor", &mut self.edge_color);
384 color("edgeSelectedColor", &mut self.edge_selected_color);
385 color("vertexColor", &mut self.vertex_color);
386 color("vertexSelectedColor", &mut self.vertex_selected_color);
387 color("sketchMovableColor", &mut self.sketch_movable_color);
389 color("sketchLockedColor", &mut self.sketch_locked_color);
390 color("sketchGeometryColor", &mut self.sketch_geometry_color);
391 color("sketchPointColor", &mut self.sketch_point_color);
392 color("sketchConstructionPointColor", &mut self.sketch_construction_point_color);
393 color("sketchUnderConstrainedPointColor", &mut self.sketch_under_constrained_point_color);
394 color("sketchSelectedColor", &mut self.sketch_selected_color);
395 color("sketchHoveredColor", &mut self.sketch_hovered_color);
396 color("sketchPreviewColor", &mut self.sketch_preview_color);
397 color("sketchConstraintColor", &mut self.sketch_constraint_color);
398 if let Some(v) = value.get("background").and_then(|v| v.as_str()) {
399 if let Some(rgb) = parse_css_hex(v) {
400 self.background = rgb;
401 }
402 }
403 if let Some(v) = value.get("edgeWidthPx").and_then(|v| v.as_f64()) {
404 self.edge_width_px = (v as f32).clamp(0.0, 32.0);
405 }
406 if let Some(v) = value.get("vertexSizePx").and_then(|v| v.as_f64()) {
407 self.vertex_size_px = (v as f32).clamp(0.0, 64.0);
408 }
409 if let Some(v) = value.get("hiddenEdgeAlpha").and_then(|v| v.as_f64()) {
410 self.hidden_edge_alpha = (v as f32).clamp(0.0, 1.0);
411 }
412 if let Some(v) = value.get("faceColorMode").and_then(|v| v.as_str()) {
413 match v.trim().to_ascii_lowercase().as_str() {
414 "hashedbysolid" | "hashed" => self.face_color_mode = FaceColorMode::HashedBySolid,
415 "uniform" => self.face_color_mode = FaceColorMode::Uniform,
416 _ => {}
417 }
418 }
419 if let Some(v) = value.get("theme").and_then(|v| v.as_str()) {
420 match v.trim().to_ascii_lowercase().as_str() {
421 "auto" => self.theme = ThemeMode::Auto,
422 "light" => self.theme = ThemeMode::Light,
423 "dark" => self.theme = ThemeMode::Dark,
424 _ => {}
425 }
426 }
427 if let Some(v) = value.get("uiScale").and_then(|v| v.as_f64()) {
428 self.ui_scale = (v as f32).clamp(0.5, 3.0);
429 }
430 if let Some(v) = value.get("labelScale").and_then(|v| v.as_f64()) {
434 self.label_scale = (v as f32).clamp(0.25, 3.0);
435 }
436 if let Some(v) = value.get("debugGrabHandles").and_then(|v| v.as_bool()) {
437 self.debug_grab_handles = v;
438 }
439 if let Some(v) = value.get("flatShading").and_then(|v| v.as_bool()) {
440 self.flat_shading = v;
441 }
442 if let Some(v) = value.get("wireframe").and_then(|v| v.as_bool()) {
443 self.wireframe = v;
444 }
445 if let Some(v) = value.get("overrideModelColors").and_then(|v| v.as_bool()) {
446 self.override_model_colors = v;
447 }
448 if let Some(v) = value.get("showWorkbenchToolbar").and_then(|v| v.as_bool()) {
449 self.show_workbench_toolbar = v;
450 }
451 if let Some(v) = value.get("axisLengthPx").and_then(|v| v.as_f64()) {
452 self.axis_length_px = (v as f32).clamp(0.0, 512.0);
453 }
454 if let Some(v) = value.get("viewcubeSizePx").and_then(|v| v.as_f64()) {
457 self.viewcube_size_px = (v as f32).clamp(40.0, 230.0);
458 }
459 if let Some(v) = value.get("pickDoubleSided").and_then(|v| v.as_bool()) {
460 self.pick_double_sided = v;
461 }
462 if let Some(v) = value.get("multiSelect").and_then(|v| v.as_str()) {
466 let skeleton: String = v
467 .chars()
468 .filter(|c| c.is_ascii_alphanumeric())
469 .collect::<String>()
470 .to_ascii_lowercase();
471 match skeleton.as_str() {
472 "ctrlclick" => self.multi_select = MultiSelectMode::CtrlClick,
473 "clicktoggles" => self.multi_select = MultiSelectMode::ClickToggles,
474 _ => {}
475 }
476 }
477 if let Some(v) = value.get("workbench").and_then(|v| v.as_str()) {
480 self.workbench = v.to_string();
481 }
482 if let Some(v) = value.get("assemblyAutoSolve").and_then(|v| v.as_bool()) {
483 self.assembly_auto_solve = v;
484 }
485 if let Some(v) = value.get("bomColumns").and_then(|v| v.as_str()) {
489 self.bom_columns = v.to_string();
490 }
491 if let Some(v) = value.get("showConstraintGraphics").and_then(|v| v.as_bool()) {
492 self.show_constraint_graphics = v;
493 }
494 if let Some(label) = value.get("renderQuality").and_then(|v| v.as_str()) {
498 if let Some(lod) = lod_from_quality(label) {
499 self.lod_factor = lod;
500 }
501 }
502 Ok(())
503 }
504
505 pub fn to_json(&self) -> String {
510 serde_json::json!({
511 "theme": match self.theme {
512 ThemeMode::Auto => "auto",
513 ThemeMode::Light => "light",
514 ThemeMode::Dark => "dark",
515 },
516 "uiScale": self.ui_scale as f64,
517 "labelScale": self.label_scale as f64,
518 "debugGrabHandles": self.debug_grab_handles,
519 "background": rgb_to_css_hex(self.background),
520 "faceColorMode": match self.face_color_mode {
521 FaceColorMode::Uniform => "uniform",
522 FaceColorMode::HashedBySolid => "hashedBySolid",
523 },
524 "faceColor": rgba_to_css_hex(self.face_color),
525 "faceSelectedColor": rgba_to_css_hex(self.face_selected_color),
526 "hoverColor": rgba_to_css_hex(self.hover_color),
527 "edgeColor": rgba_to_css_hex(self.edge_color),
528 "edgeSelectedColor": rgba_to_css_hex(self.edge_selected_color),
529 "edgeWidthPx": self.edge_width_px as f64,
530 "hiddenEdgeAlpha": self.hidden_edge_alpha as f64,
531 "vertexColor": rgba_to_css_hex(self.vertex_color),
532 "vertexSelectedColor": rgba_to_css_hex(self.vertex_selected_color),
533 "vertexSizePx": self.vertex_size_px as f64,
534 "flatShading": self.flat_shading,
535 "wireframe": self.wireframe,
536 "overrideModelColors": self.override_model_colors,
537 "showWorkbenchToolbar": self.show_workbench_toolbar,
538 "axisLengthPx": self.axis_length_px as f64,
539 "viewcubeSizePx": self.viewcube_size_px as f64,
540 "pickDoubleSided": self.pick_double_sided,
541 "multiSelect": self.multi_select.label(),
542 "renderQuality": quality_from_lod(self.lod_factor),
543 "sketchMovableColor": rgba_to_css_hex(self.sketch_movable_color),
544 "sketchLockedColor": rgba_to_css_hex(self.sketch_locked_color),
545 "sketchGeometryColor": rgba_to_css_hex(self.sketch_geometry_color),
546 "sketchPointColor": rgba_to_css_hex(self.sketch_point_color),
547 "sketchConstructionPointColor": rgba_to_css_hex(self.sketch_construction_point_color),
548 "sketchUnderConstrainedPointColor": rgba_to_css_hex(self.sketch_under_constrained_point_color),
549 "sketchSelectedColor": rgba_to_css_hex(self.sketch_selected_color),
550 "sketchHoveredColor": rgba_to_css_hex(self.sketch_hovered_color),
551 "sketchPreviewColor": rgba_to_css_hex(self.sketch_preview_color),
552 "sketchConstraintColor": rgba_to_css_hex(self.sketch_constraint_color),
553 "workbench": self.workbench,
554 "assemblyAutoSolve": self.assembly_auto_solve,
555 "showConstraintGraphics": self.show_constraint_graphics,
556 "bomColumns": self.bom_columns,
557 })
558 .to_string()
559 }
560
561 pub fn sketch_colors(&self) -> SketchColors {
568 SketchColors {
569 movable: rgba_to_u32(self.sketch_movable_color),
570 locked: rgba_to_u32(self.sketch_locked_color),
571 geometry: rgba_to_u32(self.sketch_geometry_color),
572 point: rgba_to_u32(self.sketch_point_color),
573 construction_point: rgba_to_u32(self.sketch_construction_point_color),
574 under_constrained_point: rgba_to_u32(self.sketch_under_constrained_point_color),
575 selected: rgba_to_u32(self.sketch_selected_color),
576 hovered: rgba_to_u32(self.sketch_hovered_color),
577 preview: rgba_to_u32(self.sketch_preview_color),
578 constraint: rgba_to_u32(self.sketch_constraint_color),
579 }
580 }
581
582 pub fn settings_schema_json(&self) -> String {
588 let current: serde_json::Value =
589 serde_json::from_str(&self.to_json()).unwrap_or(serde_json::Value::Null);
590 let fields: Vec<serde_json::Value> = settings_schema()
591 .iter()
592 .map(|field| {
593 let kind = match &field.kind {
594 FieldKind::Color => serde_json::json!({ "type": "color" }),
595 FieldKind::Bool => serde_json::json!({ "type": "bool" }),
596 FieldKind::Enum { variants } => {
597 serde_json::json!({ "type": "enum", "variants": variants })
598 }
599 FieldKind::Number { min, max, step } => serde_json::json!({
600 "type": "number", "min": min, "max": max, "step": step
601 }),
602 FieldKind::Range { min, max, step } => serde_json::json!({
603 "type": "range", "min": min, "max": max, "step": step
604 }),
605 FieldKind::Scalar { step } => {
608 serde_json::json!({ "type": "scalar", "step": step })
609 }
610 FieldKind::Text { read_only } => {
611 serde_json::json!({ "type": "text", "readOnly": read_only })
612 }
613 FieldKind::Vec3 { step } => serde_json::json!({ "type": "vec3", "step": step }),
614 FieldKind::Reference { filter, multiple } => serde_json::json!({
615 "type": "reference", "filter": filter, "multiple": multiple
616 }),
617 FieldKind::Button { label } => {
618 serde_json::json!({ "type": "button", "label": label })
619 }
620 };
621 serde_json::json!({
622 "key": field.key,
623 "label": field.label,
624 "group": field.group,
625 "kind": kind,
626 "value": current.get(field.key),
627 })
628 })
629 .collect();
630 serde_json::json!({ "fields": fields }).to_string()
631 }
632}
633
634fn rgb_to_css_hex(rgb: [f32; 3]) -> String {
637 let q = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u32;
638 format!("#{:02x}{:02x}{:02x}", q(rgb[0]), q(rgb[1]), q(rgb[2]))
639}
640
641fn rgba_to_css_hex(rgba: Rgba) -> String {
644 rgb_to_css_hex([rgba[0], rgba[1], rgba[2]])
645}
646
647fn rgba_to_u32(rgba: Rgba) -> u32 {
652 let q = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u32;
653 (q(rgba[0]) << 16) | (q(rgba[1]) << 8) | q(rgba[2])
654}
655
656#[derive(Debug, Clone, PartialEq)]
660pub enum FieldKind {
661 Color,
663 Bool,
665 Enum { variants: Vec<String> },
668 Number { min: f64, max: f64, step: f64 },
670 Range { min: f64, max: f64, step: f64 },
672 Scalar { step: f64 },
675 Text { read_only: bool },
679 Vec3 { step: f64 },
681 Reference { filter: Vec<String>, multiple: bool },
686 Button { label: String },
690}
691
692#[derive(Debug, Clone)]
697pub struct SettingsField {
698 pub key: &'static str,
700 pub label: &'static str,
701 pub group: &'static str,
702 pub kind: FieldKind,
703}
704
705#[derive(Debug, Clone)]
714pub struct FormField {
715 pub path: Vec<String>,
717 pub label: String,
718 pub group: String,
719 pub kind: FieldKind,
720}
721
722impl FormField {
723 pub fn key(&self) -> &str {
725 self.path.last().map(String::as_str).unwrap_or("")
726 }
727}
728
729pub const RENDER_QUALITY: &[(&str, f64)] = &[
735 ("Draft", 4.0),
736 ("Low", 2.0),
737 ("Medium", 1.0),
738 ("High", 0.5),
739 ("Ultra", 0.25),
740];
741
742fn lod_from_quality(label: &str) -> Option<f64> {
744 RENDER_QUALITY
745 .iter()
746 .find(|(name, _)| *name == label)
747 .map(|(_, factor)| *factor)
748}
749
750fn quality_from_lod(lod: f64) -> &'static str {
754 RENDER_QUALITY
755 .iter()
756 .min_by(|(_, a), (_, b)| {
757 (a - lod).abs().total_cmp(&(b - lod).abs())
758 })
759 .map(|(name, _)| *name)
760 .unwrap_or("Medium")
761}
762
763pub fn settings_form_fields() -> Vec<FormField> {
767 settings_schema()
768 .into_iter()
769 .map(|field| FormField {
770 path: vec![field.key.to_string()],
771 label: field.label.to_string(),
772 group: field.group.to_string(),
773 kind: field.kind,
774 })
775 .collect()
776}
777
778pub fn settings_schema() -> Vec<SettingsField> {
783 let f = |key, label, group, kind| SettingsField { key, label, group, kind };
784 vec![
785 f(
790 "theme",
791 "Theme",
792 "Appearance",
793 FieldKind::Enum { variants: ["auto", "light", "dark"].iter().map(|s| s.to_string()).collect() },
794 ),
795 f(
798 "uiScale",
799 "UI scale",
800 "Appearance",
801 FieldKind::Range { min: 0.5, max: 3.0, step: 0.05 },
802 ),
803 f(
812 "labelScale",
813 "Label scale",
814 "Appearance",
815 FieldKind::Range { min: 0.25, max: 3.0, step: 0.05 },
816 ),
817 f(
820 "showWorkbenchToolbar",
821 "Show workbench actions toolbar",
822 "Appearance",
823 FieldKind::Bool,
824 ),
825 f("background", "Background", "Scene", FieldKind::Color),
827 f(
828 "axisLengthPx",
829 "Axis length (px)",
830 "Scene",
831 FieldKind::Number { min: 0.0, max: 512.0, step: 1.0 },
832 ),
833 f(
837 "viewcubeSizePx",
838 "ViewCube size (px)",
839 "Scene",
840 FieldKind::Number { min: 40.0, max: 230.0, step: 1.0 },
841 ),
842 f(
843 "renderQuality",
844 "Render Quality",
845 "Scene",
846 FieldKind::Enum { variants: RENDER_QUALITY.iter().map(|(label, _)| label.to_string()).collect() },
847 ),
848 f(
850 "faceColorMode",
851 "Face color mode",
852 "Faces",
853 FieldKind::Enum { variants: ["uniform", "hashedBySolid"].iter().map(|s| s.to_string()).collect() },
854 ),
855 f("faceColor", "Face color", "Faces", FieldKind::Color),
856 f("faceSelectedColor", "Selected face", "Faces", FieldKind::Color),
857 f("hoverColor", "Hover", "Faces", FieldKind::Color),
858 f("flatShading", "Flat shading", "Faces", FieldKind::Bool),
859 f("wireframe", "Wireframe", "Faces", FieldKind::Bool),
860 f(
861 "overrideModelColors",
862 "Override model colors",
863 "Faces",
864 FieldKind::Bool,
865 ),
866 f("edgeColor", "Edge color", "Edges", FieldKind::Color),
868 f("edgeSelectedColor", "Selected edge", "Edges", FieldKind::Color),
869 f(
870 "edgeWidthPx",
871 "Edge width (px)",
872 "Edges",
873 FieldKind::Number { min: 0.0, max: 32.0, step: 0.1 },
874 ),
875 f(
876 "hiddenEdgeAlpha",
877 "Hidden-edge alpha",
878 "Edges",
879 FieldKind::Range { min: 0.0, max: 1.0, step: 0.01 },
880 ),
881 f("vertexColor", "Vertex color", "Vertices", FieldKind::Color),
883 f("vertexSelectedColor", "Selected vertex", "Vertices", FieldKind::Color),
884 f(
885 "vertexSizePx",
886 "Vertex size (px)",
887 "Vertices",
888 FieldKind::Number { min: 0.0, max: 64.0, step: 0.5 },
889 ),
890 f("pickDoubleSided", "Pick double-sided", "Picking", FieldKind::Bool),
892 f(
896 "multiSelect",
897 "Multi-select",
898 "Picking",
899 FieldKind::Enum {
900 variants: MultiSelectMode::ALL.iter().map(|m| m.label().to_string()).collect(),
901 },
902 ),
903 f("sketchMovableColor", "Movable geometry", "Sketch", FieldKind::Color),
907 f("sketchLockedColor", "Locked geometry", "Sketch", FieldKind::Color),
908 f("sketchGeometryColor", "Geometry (no mobility)", "Sketch", FieldKind::Color),
909 f("sketchPointColor", "Point", "Sketch", FieldKind::Color),
910 f("sketchConstructionPointColor", "Construction point", "Sketch", FieldKind::Color),
911 f("sketchUnderConstrainedPointColor", "Under-constrained point", "Sketch", FieldKind::Color),
912 f("sketchSelectedColor", "Selected", "Sketch", FieldKind::Color),
913 f("sketchHoveredColor", "Hovered", "Sketch", FieldKind::Color),
914 f("sketchPreviewColor", "Draw preview", "Sketch", FieldKind::Color),
915 f("sketchConstraintColor", "Constraint / dimension", "Sketch", FieldKind::Color),
916 f("debugGrabHandles", "Debug grab handles", "Debug", FieldKind::Bool),
919 ]
920}
921
922#[derive(Debug, Clone)]
925pub struct VertexRef {
926 pub solid: String,
927 pub position: [f64; 3],
928}
929
930#[derive(Debug, Default)]
934pub struct Emphasis {
935 pub selected_solids: HashSet<String>,
936 pub selected_faces: HashSet<String>,
937 pub selected_edges: HashSet<String>,
938 pub selected_vertices: Vec<VertexRef>,
939 pub selected_datums: HashSet<String>,
945 pub hovered_solids: HashSet<String>,
946 pub hovered_faces: HashSet<String>,
947 pub hovered_edges: HashSet<String>,
948 pub hovered_vertices: Vec<VertexRef>,
949 pub hovered_datums: HashSet<String>,
955 pub generation: u64,
957}
958
959impl Emphasis {
960 pub fn is_empty(&self) -> bool {
961 self.selected_solids.is_empty()
962 && self.selected_faces.is_empty()
963 && self.selected_edges.is_empty()
964 && self.selected_vertices.is_empty()
965 && self.selected_datums.is_empty()
966 && self.hovered_solids.is_empty()
967 && self.hovered_faces.is_empty()
968 && self.hovered_edges.is_empty()
969 && self.hovered_vertices.is_empty()
970 && self.hovered_datums.is_empty()
971 }
972
973 pub fn apply_json(&mut self, json: &str) -> Result<(), String> {
976 let value: serde_json::Value =
977 serde_json::from_str(json).map_err(|error| format!("emphasis parse: {error}"))?;
978 let names = |group: &serde_json::Value, key: &str| -> HashSet<String> {
979 group
980 .get(key)
981 .and_then(|v| v.as_array())
982 .map(|list| {
983 list.iter()
984 .filter_map(|v| v.as_str().map(str::to_string))
985 .collect()
986 })
987 .unwrap_or_default()
988 };
989 let vertices = |group: &serde_json::Value| -> Vec<VertexRef> {
990 group
991 .get("vertices")
992 .and_then(|v| v.as_array())
993 .map(|list| {
994 list.iter()
995 .filter_map(|v| {
996 let solid = v.get("solid")?.as_str()?.to_string();
997 let p = v.get("position")?.as_array()?;
998 Some(VertexRef {
999 solid,
1000 position: [
1001 p.first()?.as_f64()?,
1002 p.get(1)?.as_f64()?,
1003 p.get(2)?.as_f64()?,
1004 ],
1005 })
1006 })
1007 .collect()
1008 })
1009 .unwrap_or_default()
1010 };
1011 let empty = serde_json::json!({});
1012 let selected = value.get("selected").unwrap_or(&empty);
1013 let hovered = value.get("hovered").unwrap_or(&empty);
1014 self.selected_solids = names(selected, "solids");
1015 self.selected_faces = names(selected, "faces");
1016 self.selected_edges = names(selected, "edges");
1017 self.selected_datums = names(selected, "datums");
1018 self.selected_vertices = vertices(selected);
1019 self.hovered_solids = names(hovered, "solids");
1020 self.hovered_faces = names(hovered, "faces");
1021 self.hovered_edges = names(hovered, "edges");
1022 self.hovered_datums = names(hovered, "datums");
1023 self.hovered_vertices = vertices(hovered);
1024 self.generation = self.generation.wrapping_add(1);
1025 Ok(())
1026 }
1027}
1028
1029#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1032pub enum EmphasisState {
1033 Base,
1034 Selected,
1035 Hovered,
1036}
1037
1038impl Emphasis {
1039 pub fn face_state(&self, solid: &str, face: &str) -> EmphasisState {
1040 if self.hovered_solids.contains(solid) || (!face.is_empty() && self.hovered_faces.contains(face)) {
1041 EmphasisState::Hovered
1042 } else if self.selected_solids.contains(solid)
1043 || (!face.is_empty() && self.selected_faces.contains(face))
1044 {
1045 EmphasisState::Selected
1046 } else {
1047 EmphasisState::Base
1048 }
1049 }
1050
1051 pub fn edge_state(&self, solid: &str, edge: &str) -> EmphasisState {
1052 if self.hovered_solids.contains(solid) || (!edge.is_empty() && self.hovered_edges.contains(edge)) {
1053 EmphasisState::Hovered
1054 } else if self.selected_solids.contains(solid)
1055 || (!edge.is_empty() && self.selected_edges.contains(edge))
1056 {
1057 EmphasisState::Selected
1058 } else {
1059 EmphasisState::Base
1060 }
1061 }
1062
1063 pub fn vertex_state(&self, solid: &str, position: [f64; 3], tol: f64) -> EmphasisState {
1064 let matches = |refs: &[VertexRef]| {
1065 refs.iter().any(|r| {
1066 r.solid == solid
1067 && (r.position[0] - position[0]).abs() <= tol
1068 && (r.position[1] - position[1]).abs() <= tol
1069 && (r.position[2] - position[2]).abs() <= tol
1070 })
1071 };
1072 if matches(&self.hovered_vertices) {
1073 EmphasisState::Hovered
1074 } else if matches(&self.selected_vertices) {
1075 EmphasisState::Selected
1076 } else {
1077 EmphasisState::Base
1078 }
1079 }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use super::*;
1085
1086 #[test]
1087 fn settings_json_overrides() {
1088 let mut settings = RenderSettings::default();
1089 settings
1090 .apply_json(
1091 r##"{"faceColor": "#ff0000", "edgeWidthPx": 4.5, "flatShading": true,
1092 "hoverColor": "#0f0", "unknownKey": 1}"##,
1093 )
1094 .unwrap();
1095 assert_eq!(settings.face_color[0], 1.0);
1096 assert_eq!(settings.face_color[1], 0.0);
1097 assert_eq!(settings.edge_width_px, 4.5);
1098 assert!(settings.flat_shading);
1099 assert_eq!(settings.hover_color[1], 1.0);
1100 }
1101
1102 #[test]
1103 fn settings_json_face_color_mode_and_wireframe() {
1104 let mut settings = RenderSettings::default();
1105 assert_eq!(settings.face_color_mode, FaceColorMode::Uniform);
1107 assert!(!settings.wireframe);
1108 settings
1109 .apply_json(r##"{"faceColorMode": "hashedBySolid", "wireframe": true}"##)
1110 .unwrap();
1111 assert_eq!(settings.face_color_mode, FaceColorMode::HashedBySolid);
1112 assert!(settings.wireframe);
1113 settings
1115 .apply_json(r##"{"faceColorMode": "uniform", "wireframe": false}"##)
1116 .unwrap();
1117 assert_eq!(settings.face_color_mode, FaceColorMode::Uniform);
1118 assert!(!settings.wireframe);
1119 settings.apply_json(r##"{"faceColorMode": "bogus"}"##).unwrap();
1121 assert_eq!(settings.face_color_mode, FaceColorMode::Uniform);
1122 }
1123
1124 #[test]
1125 fn settings_json_theme_mode() {
1126 let mut settings = RenderSettings::default();
1127 assert_eq!(settings.theme, ThemeMode::Auto);
1129 settings.apply_json(r##"{"theme": "light"}"##).unwrap();
1131 assert_eq!(settings.theme, ThemeMode::Light);
1132 assert!(
1134 settings.to_json().contains(r#""theme":"light""#),
1135 "to_json must emit the theme as a lowercase string: {}",
1136 settings.to_json()
1137 );
1138 settings.apply_json(r##"{"theme": "AUTO"}"##).unwrap();
1139 assert_eq!(settings.theme, ThemeMode::Auto);
1140 settings.apply_json(r##"{"theme": "Dark"}"##).unwrap();
1141 assert_eq!(settings.theme, ThemeMode::Dark);
1142 settings.apply_json(r##"{"theme": "bogus"}"##).unwrap();
1144 assert_eq!(settings.theme, ThemeMode::Dark);
1145 }
1146
1147 #[test]
1148 fn settings_json_multi_select_mode() {
1149 let mut settings = RenderSettings::default();
1150 assert_eq!(settings.multi_select, MultiSelectMode::ClickToggles);
1152 settings.apply_json(r##"{"multiSelect": "Click toggles"}"##).unwrap();
1154 assert_eq!(settings.multi_select, MultiSelectMode::ClickToggles);
1155 settings.apply_json(r##"{"multiSelect": "ctrlClick"}"##).unwrap();
1157 assert_eq!(settings.multi_select, MultiSelectMode::CtrlClick);
1158 settings.apply_json(r##"{"multiSelect": "CLICK-TOGGLES"}"##).unwrap();
1159 assert_eq!(settings.multi_select, MultiSelectMode::ClickToggles);
1160 assert!(
1162 settings.to_json().contains(r#""multiSelect":"Click toggles""#),
1163 "to_json must emit the multi-select label: {}",
1164 settings.to_json()
1165 );
1166 let mut round = RenderSettings::default();
1167 round.apply_json(&settings.to_json()).unwrap();
1168 assert_eq!(round.multi_select, MultiSelectMode::ClickToggles);
1169 round.apply_json(r##"{"multiSelect": "bogus"}"##).unwrap();
1171 assert_eq!(round.multi_select, MultiSelectMode::ClickToggles);
1172 }
1173
1174 #[test]
1175 fn settings_json_ui_scale() {
1176 let mut settings = RenderSettings::default();
1177 assert_eq!(settings.ui_scale, 1.0);
1179 settings.apply_json(r##"{"uiScale": 1.5}"##).unwrap();
1181 assert_eq!(settings.ui_scale, 1.5);
1182 settings.apply_json(r##"{"uiScale": 10.0}"##).unwrap();
1184 assert_eq!(settings.ui_scale, 3.0);
1185 settings.apply_json(r##"{"uiScale": 0.1}"##).unwrap();
1186 assert_eq!(settings.ui_scale, 0.5);
1187 settings.ui_scale = 1.25;
1189 assert!(
1190 settings.to_json().contains(r#""uiScale":1.25"#),
1191 "to_json must emit uiScale: {}",
1192 settings.to_json()
1193 );
1194 let mut round = RenderSettings::default();
1195 round.apply_json(&settings.to_json()).unwrap();
1196 assert_eq!(round.ui_scale, 1.25);
1197 }
1198
1199 #[test]
1206 fn settings_json_label_scale() {
1207 let mut settings = RenderSettings::default();
1208 assert_eq!(settings.label_scale, 1.0);
1210 settings.apply_json(r##"{"labelScale": 1.5}"##).unwrap();
1212 assert_eq!(settings.label_scale, 1.5);
1213 settings.apply_json(r##"{"labelScale": 10.0}"##).unwrap();
1216 assert_eq!(settings.label_scale, 3.0);
1217 settings.apply_json(r##"{"labelScale": 0.1}"##).unwrap();
1218 assert_eq!(settings.label_scale, 0.25);
1219 settings.apply_json(r##"{"labelScale": 0.25}"##).unwrap();
1223 assert_eq!(settings.label_scale, 0.25);
1224 settings.apply_json(r##"{"labelScale": 0.35}"##).unwrap();
1225 assert_eq!(settings.label_scale, 0.35);
1226 settings.apply_json(r##"{"labelScale": 0.24}"##).unwrap();
1228 assert_eq!(settings.label_scale, 0.25);
1229 settings.label_scale = 1.25;
1231 assert!(
1232 settings.to_json().contains(r#""labelScale":1.25"#),
1233 "to_json must emit labelScale: {}",
1234 settings.to_json()
1235 );
1236 let mut round = RenderSettings::default();
1237 round.apply_json(&settings.to_json()).unwrap();
1238 assert_eq!(round.label_scale, 1.25);
1239 assert_eq!(round.ui_scale, 1.0);
1242 }
1243
1244 #[test]
1249 fn settings_schema_exposes_label_scale_over_the_clamped_domain() {
1250 let field = settings_form_fields()
1251 .into_iter()
1252 .find(|f| f.key() == "labelScale")
1253 .expect("the settings schema must expose labelScale");
1254 assert_eq!(field.group, "Appearance");
1255 match field.kind {
1256 FieldKind::Range { min, max, .. } => {
1257 assert_eq!(
1258 (min, max),
1259 (0.25, 3.0),
1260 "the slider domain must match the apply_json clamp"
1261 );
1262 let mut probe = RenderSettings::default();
1265 probe
1266 .apply_json(&format!(r##"{{"labelScale": {min}}}"##))
1267 .unwrap();
1268 assert_eq!(probe.label_scale, min as f32, "the slider MIN re-clamped");
1269 probe
1270 .apply_json(&format!(r##"{{"labelScale": {max}}}"##))
1271 .unwrap();
1272 assert_eq!(probe.label_scale, max as f32, "the slider MAX re-clamped");
1273 }
1274 other => panic!("labelScale must be a bounded slider, got {other:?}"),
1275 }
1276 }
1277
1278 #[test]
1279 fn settings_json_workbench_roundtrip() {
1280 let mut settings = RenderSettings::default();
1281 assert_eq!(settings.workbench, "modeling");
1283 settings.apply_json(r##"{"workbench": "sheetMetal"}"##).unwrap();
1285 assert_eq!(settings.workbench, "sheetMetal");
1286 assert!(
1288 settings.to_json().contains(r#""workbench":"sheetMetal""#),
1289 "to_json must emit the workbench id: {}",
1290 settings.to_json()
1291 );
1292 let mut round = RenderSettings::default();
1293 round.apply_json(&settings.to_json()).unwrap();
1294 assert_eq!(round.workbench, "sheetMetal");
1295 round.apply_json(r##"{"wireframe": true}"##).unwrap();
1297 assert_eq!(round.workbench, "sheetMetal");
1298 round.apply_json(r##"{"workbench": "bogus"}"##).unwrap();
1300 assert_eq!(round.workbench, "bogus");
1301 }
1302
1303 #[test]
1308 fn settings_json_bom_columns_roundtrip_verbatim() {
1309 let mut settings = RenderSettings::default();
1310 assert_eq!(
1311 settings.bom_columns, "",
1312 "empty by default = the app's shipped configuration"
1313 );
1314 let text = "# mine\n*part.Part_Number\nnonsense\n*occurrence.Notes\n";
1318 settings
1319 .apply_json(&serde_json::json!({ "bomColumns": text }).to_string())
1320 .unwrap();
1321 assert_eq!(settings.bom_columns, text);
1322 let mut round = RenderSettings::default();
1323 round.apply_json(&settings.to_json()).unwrap();
1324 assert_eq!(round.bom_columns, text, "survives to_json → apply_json");
1325 round.apply_json(r##"{"wireframe": true}"##).unwrap();
1326 assert_eq!(round.bom_columns, text, "an unrelated apply leaves it alone");
1327 }
1328
1329 #[test]
1330 fn emphasis_states_cascade_from_solid() {
1331 let mut emphasis = Emphasis::default();
1332 emphasis
1333 .apply_json(
1334 r#"{"selected": {"solids": ["A"], "faces": ["F1"]},
1335 "hovered": {"edges": ["E1"], "vertices": [{"solid": "A", "position": [1, 2, 3]}]}}"#,
1336 )
1337 .unwrap();
1338 assert_eq!(emphasis.face_state("A", "anything"), EmphasisState::Selected);
1339 assert_eq!(emphasis.face_state("B", "F1"), EmphasisState::Selected);
1340 assert_eq!(emphasis.face_state("B", "F2"), EmphasisState::Base);
1341 assert_eq!(emphasis.edge_state("B", "E1"), EmphasisState::Hovered);
1342 assert_eq!(
1343 emphasis.vertex_state("A", [1.0, 2.0, 3.0], 1e-9),
1344 EmphasisState::Hovered
1345 );
1346 assert_eq!(
1347 emphasis.vertex_state("B", [1.0, 2.0, 3.0], 1e-9),
1348 EmphasisState::Base
1349 );
1350 let gen0 = emphasis.generation;
1351 emphasis.apply_json(r#"{}"#).unwrap();
1352 assert!(emphasis.is_empty());
1353 assert_ne!(emphasis.generation, gen0);
1354 }
1355
1356 #[test]
1357 fn settings_to_json_roundtrip_is_identity() {
1358 let mut s = RenderSettings::default();
1361 s.face_color = hex(0x123456, 1.0);
1362 s.edge_color = hex(0xabcdef, 1.0);
1363 s.background = [0.0, 0.0, 0.0];
1364 s.face_color_mode = FaceColorMode::HashedBySolid;
1365 s.edge_width_px = 3.5;
1366 s.hidden_edge_alpha = 0.5;
1367 s.vertex_size_px = 8.0;
1368 s.axis_length_px = 30.0;
1369 s.lod_factor = 2.0;
1373 s.flat_shading = true;
1374 s.wireframe = true;
1375 s.pick_double_sided = false;
1376 s.sketch_movable_color = hex(0x112233, 1.0);
1378 s.sketch_constraint_color = hex(0x00ff00, 1.0);
1379 s.sketch_selected_color = hex(0xfedcba, 1.0);
1380
1381 let mut identity = s.clone();
1384 identity.apply_json(&s.to_json()).unwrap();
1385 assert_eq!(identity, s);
1386
1387 let mut rebuilt = RenderSettings::default();
1390 rebuilt.apply_json(&s.to_json()).unwrap();
1391 assert_eq!(rebuilt, s);
1392 }
1393
1394 #[test]
1395 fn settings_schema_covers_every_json_key() {
1396 let json: serde_json::Value =
1399 serde_json::from_str(&RenderSettings::default().to_json()).unwrap();
1400 for field in settings_schema() {
1401 assert!(
1402 json.get(field.key).is_some(),
1403 "schema field {} has no to_json value",
1404 field.key
1405 );
1406 }
1407 let mut s = RenderSettings::default();
1409 s.wireframe = true;
1410 let export: serde_json::Value = serde_json::from_str(&s.settings_schema_json()).unwrap();
1411 let wf = export["fields"]
1412 .as_array()
1413 .unwrap()
1414 .iter()
1415 .find(|f| f["key"] == "wireframe")
1416 .unwrap();
1417 assert_eq!(wf["value"], serde_json::json!(true));
1418 }
1419
1420 #[test]
1421 fn sketch_colors_default_matches_the_hex_constants() {
1422 assert_eq!(RenderSettings::default().sketch_colors(), SketchColors::default());
1426 }
1427
1428 #[test]
1429 fn sketch_colors_follow_apply_json() {
1430 let mut s = RenderSettings::default();
1433 s.apply_json(r##"{"sketchConstraintColor": "#ff0000", "sketchMovableColor": "#00ff00"}"##)
1434 .unwrap();
1435 let c = s.sketch_colors();
1436 assert_eq!(c.constraint, 0xff0000);
1437 assert_eq!(c.movable, 0x00ff00);
1438 assert_eq!(c.locked, SketchColors::default().locked);
1440 }
1441
1442 #[test]
1443 fn css_hex_parsing() {
1444 assert_eq!(parse_css_hex("#ffffff"), Some([1.0, 1.0, 1.0]));
1445 assert_eq!(parse_css_hex("#f00"), Some([1.0, 0.0, 0.0]));
1446 assert!(parse_css_hex("red").is_none());
1447 assert_eq!(parse_css_hex("0x00009e").map(|c| c[2]), Some(0x9e as f32 / 255.0));
1448 }
1449
1450 #[test]
1454 fn show_workbench_toolbar_is_a_persisted_appearance_bool() {
1455 let mut settings = RenderSettings::default();
1456 assert!(settings.show_workbench_toolbar, "on by default");
1457 assert!(settings.to_json().contains(r#""showWorkbenchToolbar":true"#));
1458 settings.apply_json(r#"{"showWorkbenchToolbar": false}"#).unwrap();
1459 assert!(!settings.show_workbench_toolbar);
1460 assert!(settings.to_json().contains(r#""showWorkbenchToolbar":false"#));
1461 settings.apply_json(r#"{"wireframe": true}"#).unwrap();
1463 assert!(!settings.show_workbench_toolbar);
1464
1465 let field = settings_form_fields()
1466 .into_iter()
1467 .find(|f| f.key() == "showWorkbenchToolbar")
1468 .expect("the settings schema must expose showWorkbenchToolbar");
1469 assert_eq!(field.group, "Appearance");
1470 assert_eq!(field.label, "Show workbench actions toolbar");
1471 assert!(matches!(field.kind, FieldKind::Bool), "a checkbox, got {:?}", field.kind);
1472 }
1473}