1use super::*;
2
3const SKETCH_SHEET_COLOR: [f32; 3] = [
6 0x67 as f32 / 255.0,
7 0xc7 as f32 / 255.0,
8 0xd4 as f32 / 255.0,
9];
10
11fn curve_feature_segment_prefix(feature_type: &str, id: &str) -> Option<String> {
20 match feature_type {
21 "S" => Some(format!("{id}:G")),
22 "HX" => Some(format!("{id}:HelixEdge")),
23 _ => None,
24 }
25}
26
27fn curve_feature_point_prefix(feature_type: &str, id: &str) -> Option<String> {
33 match feature_type {
34 "S" => Some(format!("{id}:P")),
35 _ => None,
36 }
37}
38
39impl EngineState {
40
41 fn committed_sketch_ids(&self) -> Vec<String> {
49 self.committed_curve_features()
50 .into_iter()
51 .map(|(id, _, _)| id)
52 .collect()
53 }
54
55 fn committed_curve_features(&self) -> Vec<(String, String, Option<String>)> {
59 let editing = self.sketch_edit.as_ref().map(|edit| edit.feature_id.as_str());
60 let rollback = self.history.rollback();
61 let mut ids = Vec::new();
62 for index in 0..=rollback {
63 let Some(feature_type) = self.history.feature_type(index) else {
64 continue;
65 };
66 let Some(id) = self.history.feature_id(index) else {
67 continue;
68 };
69 let Some(prefix) = curve_feature_segment_prefix(&feature_type, &id) else {
70 continue;
71 };
72 if Some(id.as_str()) == editing {
73 continue;
74 }
75 let point_prefix = curve_feature_point_prefix(&feature_type, &id);
76 ids.push((id, prefix, point_prefix));
77 }
78 if !ids.is_empty() {
85 let consumed = self.consumed_feature_names();
86 ids.retain(|(id, _, _)| !consumed.contains(id));
87 }
88 ids
89 }
90
91 fn consumed_feature_names(&self) -> std::collections::HashSet<String> {
103 let request: HistoryRequest = match serde_json::from_value(self.history.prefix_request()) {
104 Ok(request) => request,
105 Err(_) => return std::collections::HashSet::new(),
106 };
107 brep_kernel::execute_history(&request)
108 .results
109 .iter()
110 .flat_map(|feature| feature.removed.iter().cloned())
111 .collect()
112 }
113
114 pub fn refresh_committed_sketches(&mut self) {
129 let visible: Vec<(String, String, Option<String>)> = self
130 .committed_curve_features()
131 .into_iter()
132 .filter(|(id, _, _)| !self.hidden_sketches.contains(id))
133 .collect();
134
135 let payloads: Vec<(String, brep_kernel::DisplaySolidPayload)> = visible
141 .iter()
142 .filter_map(|(id, prefix, point_prefix)| {
143 let profile = self
144 .sketch_profiles
145 .iter()
146 .find(|(name, _)| name == id)
147 .map(|(_, profile)| profile);
148 let segments: Vec<(String, Vec<brep_kernel::NurbsCurve>)> = self
154 .sketch_paths
155 .iter()
156 .filter(|(name, _)| name.starts_with(prefix.as_str()))
157 .cloned()
158 .collect();
159 let points: Vec<brep_kernel::Vec3> = match point_prefix {
163 Some(point_prefix) => self
164 .sketch_points
165 .iter()
166 .filter(|(name, point)| {
167 name.starts_with(point_prefix.as_str()) && !point.construction
168 })
169 .map(|(_, point)| point.position)
170 .collect(),
171 None => Vec::new(),
172 };
173 let payload = brep_kernel::sketch_display_payload(profile, &segments, &points);
174 if payload.mesh.indices.is_empty()
175 && payload.edges.is_empty()
176 && payload.vertices.is_empty()
177 {
178 return None;
179 }
180 Some((id.clone(), payload))
181 })
182 .collect();
183
184 let mut fed: Vec<String> = Vec::with_capacity(payloads.len());
186 for (id, payload) in payloads {
187 let mut solid = crate::scene::solid_display_from_payload(&id, payload);
188 solid.is_sketch = true;
189 solid.color_override = Some(SKETCH_SHEET_COLOR);
190 self.scene.insert_solid(solid);
191 fed.push(id);
192 }
193
194 let fed_set: std::collections::HashSet<&str> = fed.iter().map(String::as_str).collect();
196 let stale: Vec<String> = self
197 .shown_sketch_ids
198 .iter()
199 .filter(|id| !fed_set.contains(id.as_str()))
200 .cloned()
201 .collect();
202 for id in stale {
203 self.scene.remove_solid(&id);
204 }
205
206 self.shown_sketch_ids = fed;
207 self.dirty = true;
208 }
209
210 pub fn sketch_visible(&self, id: &str) -> bool {
213 !self.hidden_sketches.contains(id)
214 }
215
216 pub fn set_sketch_visible(&mut self, id: &str, visible: bool) {
220 if visible {
221 self.hidden_sketches.remove(id);
222 } else {
223 self.hidden_sketches.insert(id.to_string());
224 }
225 self.refresh_committed_sketches();
226 }
227
228 pub fn committed_sketches(&self) -> Vec<(String, bool)> {
232 self.committed_sketch_ids()
233 .into_iter()
234 .map(|id| {
235 let visible = !self.hidden_sketches.contains(&id);
236 (id, visible)
237 })
238 .collect()
239 }
240
241 pub fn sketch_entities_json(&self) -> String {
246 let list: Vec<serde_json::Value> = self
247 .committed_sketches()
248 .into_iter()
249 .map(|(id, visible)| serde_json::json!({ "name": id, "visible": visible }))
250 .collect();
251 serde_json::Value::Array(list).to_string()
252 }
253}
254
255#[cfg(test)]
272mod committed_sketch_tests {
273 use super::*;
274
275 fn cube_and_sketch_history() -> String {
278 serde_json::json!({
279 "features": [
280 {
281 "type": "P.CU",
282 "inputParams": {
283 "id": "Box",
284 "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
285 "transform": {
286 "position": [0.0, 0.0, 0.0],
287 "rotationEuler": [0.0, 0.0, 0.0],
288 "scale": [1.0, 1.0, 1.0]
289 },
290 "boolean": { "targets": [], "operation": "NONE" }
291 },
292 "persistentData": {}
293 },
294 {
295 "type": "S",
296 "inputParams": { "id": "Sk" },
297 "persistentData": {
298 "basis": { "origin": [0, 0, 0], "x": [1, 0, 0], "y": [0, 1, 0], "z": [0, 0, 1] },
299 "sketch": {
300 "points": [
301 { "id": 0, "x": 0.0, "y": 0.0 },
302 { "id": 1, "x": 10.0, "y": 0.0 },
303 { "id": 2, "x": 10.0, "y": 6.0 },
304 { "id": 3, "x": 0.0, "y": 6.0 }
305 ],
306 "geometries": [
307 { "id": 10, "type": "line", "points": [0, 1] },
308 { "id": 11, "type": "line", "points": [1, 2] },
309 { "id": 12, "type": "line", "points": [2, 3] },
310 { "id": 13, "type": "line", "points": [3, 0] }
311 ],
312 "constraints": []
313 }
314 }
315 }
316 ]
317 })
318 .to_string()
319 }
320
321 fn has_group(engine: &EngineState, name: &str) -> bool {
322 engine.widgets.overlay_group_names().contains(&name)
323 }
324
325 fn sketch_sheet<'a>(
327 engine: &'a EngineState,
328 id: &str,
329 ) -> Option<&'a crate::scene::SolidDisplay> {
330 engine.scene.solid(id).filter(|solid| solid.is_sketch)
331 }
332
333 fn has_sketch_sheet(engine: &EngineState, id: &str) -> bool {
334 sketch_sheet(engine, id).is_some()
335 }
336
337 fn cube_and_open_sketch_history() -> String {
340 let mut history: serde_json::Value =
341 serde_json::from_str(&cube_and_sketch_history()).expect("history parses");
342 history["features"][1]["persistentData"]["sketch"] = serde_json::json!({
343 "points": [
344 { "id": 0, "x": 1.0, "y": 2.0 },
345 { "id": 1, "x": 7.0, "y": 4.0 }
346 ],
347 "geometries": [
348 { "id": 10, "type": "line", "points": [0, 1] }
349 ],
350 "constraints": []
351 });
352 history.to_string()
353 }
354
355 #[test]
360 fn open_sketch_renders_its_segments_without_a_sheet_face() {
361 let mut engine = EngineState::new();
362 engine.set_history_json(&cube_and_open_sketch_history()).unwrap();
363
364 let sheet = sketch_sheet(&engine, "Sk").expect("open sketch still displays");
365 assert!(sheet.is_sketch, "flagged as a sketch");
366 assert!(sheet.faces.is_empty(), "nothing closes, so there is no sheet face");
367 assert!(sheet.mesh.positions.is_empty(), "and no face mesh");
368 assert_eq!(sheet.edges.len(), 1, "the single line draws as one edge");
369 assert_eq!(sheet.edges[0].name, "Sk:G10", "named by its sketch geometry");
370 assert_eq!(sheet.vertices.len(), 2, "both endpoints draw");
371 assert!(!sheet.bbox.is_empty(), "the drawn segment gives the sketch an extent");
374
375 assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
377 }
378
379 fn cube_and_points_only_sketch_history() -> String {
383 let mut history: serde_json::Value =
384 serde_json::from_str(&cube_and_sketch_history()).expect("history parses");
385 history["features"][1]["persistentData"]["sketch"] = serde_json::json!({
386 "points": [
387 { "id": 0, "x": 2.0, "y": 2.0 },
388 { "id": 1, "x": 8.0, "y": 2.0 },
389 { "id": 2, "x": 5.0, "y": 5.0, "construction": true },
390 { "id": 3, "x": 8.0, "y": 8.0 }
391 ],
392 "geometries": [],
393 "constraints": []
394 });
395 history.to_string()
396 }
397
398 #[test]
405 fn points_only_sketch_renders_its_model_points_as_vertices() {
406 let mut engine = EngineState::new();
407 engine.set_history_json(&cube_and_points_only_sketch_history()).unwrap();
408
409 let sheet = sketch_sheet(&engine, "Sk").expect("points-only sketch still displays");
410 assert!(sheet.is_sketch, "flagged as a sketch");
411 assert!(sheet.faces.is_empty(), "nothing closes, so there is no sheet face");
412 assert!(sheet.mesh.positions.is_empty(), "and no face mesh");
413 assert!(sheet.edges.is_empty(), "no segment, so no edge");
414 assert_eq!(sheet.vertices.len(), 3, "the three MODEL points draw; the ◐ point does not");
415 let mut drawn: Vec<[i64; 2]> = sheet
416 .vertices
417 .iter()
418 .map(|v| [v.position[0].round() as i64, v.position[1].round() as i64])
419 .collect();
420 drawn.sort_unstable();
421 assert_eq!(drawn, vec![[2, 2], [8, 2], [8, 8]], "drawn at the solved positions");
422 assert!(sheet.vertices.iter().all(|v| v.position[2].abs() < 1e-9), "on the XY plane");
423 assert!(!sheet.bbox.is_empty(), "the drawn points give the sketch an extent");
424
425 assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
428 engine.set_sketch_visible("Sk", false);
429 assert!(!has_sketch_sheet(&engine, "Sk"), "hidden sketch draws nothing");
430 engine.set_sketch_visible("Sk", true);
431 assert!(has_sketch_sheet(&engine, "Sk"), "and comes back");
432 }
433
434 fn cube_and_helix_history() -> String {
437 let mut history: serde_json::Value =
438 serde_json::from_str(&cube_and_sketch_history()).expect("history parses");
439 history["features"][1] = serde_json::json!({
440 "type": "HX",
441 "inputParams": { "id": "HX1" },
442 "persistentData": {}
443 });
444 history.to_string()
445 }
446
447 #[test]
452 fn helix_renders_as_a_single_edge_sketch_like_object() {
453 let mut engine = EngineState::new();
454 engine.set_history_json(&cube_and_helix_history()).unwrap();
455
456 let sheet = sketch_sheet(&engine, "HX1").expect("the helix displays");
457 assert!(sheet.is_sketch, "flagged sketch-like");
458 assert!(sheet.faces.is_empty(), "a curve closes nothing, so no face");
459 assert!(sheet.mesh.positions.is_empty(), "and no face mesh");
460 assert_eq!(sheet.edges.len(), 1, "exactly one edge");
461 assert_eq!(sheet.edges[0].name, "HX1:HelixEdge", "named as the published edge");
462 assert!(
465 sheet.edges[0].polyline.len() >= 3 * 64,
466 "a three-turn helix draws smoothly ({} points)",
467 sheet.edges[0].polyline.len()
468 );
469 let [x, y, z] = sheet.edges[0].polyline[0];
470 assert!((x - 5.0).abs() < 1e-3 && y.abs() < 1e-3 && z.abs() < 1e-3, "starts at (5,0,0)");
471 let [x, y, z] = *sheet.edges[0].polyline.last().unwrap();
472 assert!((x - 5.0).abs() < 1e-3 && y.abs() < 1e-3 && (z - 15.0).abs() < 1e-3, "ends at (5,0,15)");
473 assert_eq!(sheet.vertices.len(), 2, "both ends draw");
474 assert!(!sheet.bbox.is_empty(), "the drawn edge gives the helix an extent");
475
476 assert_eq!(engine.committed_sketches(), vec![("HX1".to_string(), true)]);
477 assert!(
478 engine.enter_sketch_mode("HX1").is_err(),
479 "a helix is sketch-LIKE, never an editable sketch"
480 );
481
482 engine.set_sketch_visible("HX1", false);
484 assert!(!has_sketch_sheet(&engine, "HX1"), "hidden helix draws nothing");
485 engine.set_sketch_visible("HX1", true);
486 assert!(has_sketch_sheet(&engine, "HX1"), "and comes back");
487 }
488
489 #[test]
493 fn closed_sketch_draws_each_boundary_edge_once() {
494 let mut engine = EngineState::new();
495 engine.set_history_json(&cube_and_sketch_history()).unwrap();
496 let sheet = sketch_sheet(&engine, "Sk").expect("committed sketch sheet solid");
497 assert_eq!(sheet.edges.len(), 4, "four boundary edges, not eight");
498 let mut names: Vec<&str> = sheet.edges.iter().map(|e| e.name.as_str()).collect();
499 names.sort_unstable();
500 names.dedup();
501 assert_eq!(names.len(), 4, "each edge name appears once: {names:?}");
502 }
503
504 #[test]
505 fn committed_sketch_renders_and_lists_after_rerun() {
506 let mut engine = EngineState::new();
507 engine.set_history_json(&cube_and_sketch_history()).unwrap();
508
509 let sheet = sketch_sheet(&engine, "Sk").expect("committed sketch sheet solid");
513 assert!(sheet.is_sketch, "flagged as a sketch");
514 assert_eq!(sheet.faces.len(), 1, "one sheet face");
515 assert_eq!(sheet.edges.len(), 4, "four boundary edges");
516 assert_eq!(sheet.vertices.len(), 4, "four corner vertices");
517 assert!(!sheet.mesh.positions.is_empty(), "filled face mesh");
518
519 let solids: serde_json::Value =
521 serde_json::from_str(&engine.scene_entities_json()).unwrap();
522 assert_eq!(solids.as_array().unwrap().len(), 1);
523 assert_eq!(solids[0]["name"], "Box");
524
525 assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
527 let sketches: serde_json::Value =
528 serde_json::from_str(&engine.sketch_entities_json()).unwrap();
529 assert_eq!(sketches[0]["name"], "Sk");
530 assert_eq!(sketches[0]["visible"], true);
531 assert!(engine.sketch_visible("Sk"));
532 }
533
534 #[test]
535 fn rolling_back_before_sketch_clears_committed_sheet() {
536 let mut engine = EngineState::new();
537 engine.set_history_json(&cube_and_sketch_history()).unwrap();
538 assert!(has_sketch_sheet(&engine, "Sk"));
539
540 engine.roll_to(0);
543 assert!(!has_sketch_sheet(&engine, "Sk"), "rolled-back sketch sheet removed");
544 assert!(engine.committed_sketches().is_empty());
545
546 engine.roll_to(1);
548 assert!(has_sketch_sheet(&engine, "Sk"), "rolled-forward sketch re-shown");
549 assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
550 }
551
552 #[test]
553 fn set_sketch_visible_hides_and_restores() {
554 let mut engine = EngineState::new();
555 engine.set_history_json(&cube_and_sketch_history()).unwrap();
556 assert!(has_sketch_sheet(&engine, "Sk"));
557
558 engine.set_sketch_visible("Sk", false);
560 assert!(!has_sketch_sheet(&engine, "Sk"), "hidden sketch sheet removed");
561 assert!(!engine.sketch_visible("Sk"));
562 assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), false)]);
563 let sketches: serde_json::Value =
564 serde_json::from_str(&engine.sketch_entities_json()).unwrap();
565 assert_eq!(sketches[0]["visible"], false);
566
567 engine.set_sketch_visible("Sk", true);
569 assert!(has_sketch_sheet(&engine, "Sk"), "re-shown sketch sheet inserted");
570 assert!(engine.sketch_visible("Sk"));
571 }
572
573 #[test]
574 fn entering_sketch_mode_removes_committed_sheet_then_commit_readds() {
575 let mut engine = EngineState::new();
576 engine.set_history_json(&cube_and_sketch_history()).unwrap();
577 assert!(has_sketch_sheet(&engine, "Sk"));
578
579 engine.enter_sketch_mode("Sk").expect("enter");
582 assert!(engine.sketch_mode());
583 assert!(
584 !has_sketch_sheet(&engine, "Sk"),
585 "active sketch's committed sheet removed"
586 );
587 assert!(has_group(&engine, "sketch-geometry"), "live editing overlay fed");
588 assert!(engine.committed_sketches().is_empty());
590
591 engine.exit_sketch_mode(true);
594 assert!(!engine.sketch_mode());
595 assert!(
596 has_sketch_sheet(&engine, "Sk"),
597 "committed sketch reappears as a sheet after commit"
598 );
599 assert!(!has_group(&engine, "sketch-geometry"), "live editing overlay cleared");
600 assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
601 }
602
603 #[test]
608 fn sketch_sheet_info_reports_area_and_edge_length() {
609 let mut engine = EngineState::new();
610 engine.set_history_json(&cube_and_sketch_history()).unwrap();
611 let info: serde_json::Value =
612 serde_json::from_str(&engine.object_info_json("Sk")).unwrap();
613 assert_eq!(info["ok"], true, "sketch info must not error: {info}");
614 assert_eq!(info["kind"], "sketch");
615 assert!(info.get("volume").is_none(), "a sheet has no volume");
616 assert!((info["area"].as_f64().unwrap() - 60.0).abs() < 1e-3, "area {}", info["area"]);
617 assert!(
618 (info["edgeLengthTotal"].as_f64().unwrap() - 32.0).abs() < 1e-3,
619 "perimeter {}",
620 info["edgeLengthTotal"]
621 );
622 assert_eq!(info["creatingFeature"]["id"], "Sk");
623 assert_eq!(info["creatingFeature"]["type"], "S");
624 }
625
626 #[test]
629 fn sketch_sheet_edge_is_measurable() {
630 let mut engine = EngineState::new();
631 engine.set_history_json(&cube_and_sketch_history()).unwrap();
632 let edge_name = sketch_sheet(&engine, "Sk").unwrap().edges[0].name.clone();
633 assert!(!edge_name.is_empty(), "boundary edge is named");
634 let info: serde_json::Value =
635 serde_json::from_str(&engine.object_info_json(&edge_name)).unwrap();
636 assert_eq!(info["ok"], true, "edge info: {info}");
637 assert_eq!(info["kind"], "edge");
638 assert_eq!(info["solid"], "Sk");
639 let length = info["length"].as_f64().unwrap();
640 assert!(
641 (length - 10.0).abs() < 1e-3 || (length - 6.0).abs() < 1e-3,
642 "rectangle side length {length}"
643 );
644 }
645
646 #[test]
650 fn sketch_sheet_is_selectable_by_name() {
651 let mut engine = EngineState::new();
652 engine.set_history_json(&cube_and_sketch_history()).unwrap();
653 assert!(engine.select_by_name("solid", "Sk"));
654 let sel: serde_json::Value = serde_json::from_str(&engine.selection_json()).unwrap();
655 assert_eq!(sel["solids"][0], "Sk");
656 }
657
658 fn sketch_then_extrude_history(consume: bool) -> String {
663 serde_json::json!({
664 "features": [
665 {
666 "type": "S",
667 "inputParams": { "id": "Sk" },
668 "persistentData": {
669 "basis": { "origin": [0, 0, 0], "x": [1, 0, 0], "y": [0, 1, 0], "z": [0, 0, 1] },
670 "sketch": {
671 "points": [
672 { "id": 0, "x": 0.0, "y": 0.0 },
673 { "id": 1, "x": 10.0, "y": 0.0 },
674 { "id": 2, "x": 10.0, "y": 6.0 },
675 { "id": 3, "x": 0.0, "y": 6.0 }
676 ],
677 "geometries": [
678 { "id": 10, "type": "line", "points": [0, 1] },
679 { "id": 11, "type": "line", "points": [1, 2] },
680 { "id": 12, "type": "line", "points": [2, 3] },
681 { "id": 13, "type": "line", "points": [3, 0] }
682 ],
683 "constraints": []
684 }
685 }
686 },
687 {
688 "type": "E",
689 "inputParams": {
690 "id": "Ext",
691 "profile": "Sk",
692 "distance": 4.0,
693 "consumeProfileSketch": consume
694 },
695 "persistentData": {}
696 }
697 ]
698 })
699 .to_string()
700 }
701
702 #[test]
707 fn extrude_consumed_sketch_absent_from_scene() {
708 let mut engine = EngineState::new();
709 engine
710 .set_history_json(&sketch_then_extrude_history(true))
711 .unwrap();
712 assert!(
713 engine.committed_sketches().is_empty(),
714 "a consumed sketch is not listed: {:?}",
715 engine.committed_sketches()
716 );
717 assert!(
718 !has_sketch_sheet(&engine, "Sk"),
719 "a consumed sketch has no synthesized sheet"
720 );
721 assert!(engine.scene.solid("Ext").is_some(), "the extrude solid is present");
722 }
723
724 #[test]
727 fn extrude_kept_sketch_remains_in_scene() {
728 let mut engine = EngineState::new();
729 engine
730 .set_history_json(&sketch_then_extrude_history(false))
731 .unwrap();
732 assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
733 assert!(has_sketch_sheet(&engine, "Sk"), "a kept sketch still has a sheet");
734 }
735
736 #[test]
741 fn sketch_consumed_above_rollback_still_shows() {
742 let mut engine = EngineState::new();
743 engine
744 .set_history_json(&sketch_then_extrude_history(true))
745 .unwrap();
746 assert!(engine.committed_sketches().is_empty());
748 assert!(!has_sketch_sheet(&engine, "Sk"));
749
750 engine.roll_to(0);
752 assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
753 assert!(
754 has_sketch_sheet(&engine, "Sk"),
755 "sketch shows while its consumer is above the rollback"
756 );
757
758 engine.roll_to(1);
760 assert!(engine.committed_sketches().is_empty());
761 assert!(!has_sketch_sheet(&engine, "Sk"));
762 }
763}
764