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
11impl EngineState {
12
13 fn committed_sketch_ids(&self) -> Vec<String> {
20 let editing = self.sketch_edit.as_ref().map(|edit| edit.feature_id.as_str());
21 let rollback = self.history.rollback();
22 let mut ids = Vec::new();
23 for index in 0..=rollback {
24 if self.history.feature_type(index).as_deref() != Some("S") {
25 continue;
26 }
27 let Some(id) = self.history.feature_id(index) else {
28 continue;
29 };
30 if Some(id.as_str()) == editing {
31 continue;
32 }
33 ids.push(id);
34 }
35 if !ids.is_empty() {
42 let consumed = self.consumed_feature_names();
43 ids.retain(|id| !consumed.contains(id));
44 }
45 ids
46 }
47
48 fn consumed_feature_names(&self) -> std::collections::HashSet<String> {
60 let request: HistoryRequest = match serde_json::from_value(self.history.prefix_request()) {
61 Ok(request) => request,
62 Err(_) => return std::collections::HashSet::new(),
63 };
64 brep_kernel::execute_history(&request)
65 .results
66 .iter()
67 .flat_map(|feature| feature.removed.iter().cloned())
68 .collect()
69 }
70
71 pub fn refresh_committed_sketches(&mut self) {
83 let visible_ids: Vec<String> = self
84 .committed_sketch_ids()
85 .into_iter()
86 .filter(|id| !self.hidden_sketches.contains(id))
87 .collect();
88
89 let payloads: Vec<(String, brep_kernel::DisplaySolidPayload)> = visible_ids
94 .iter()
95 .filter_map(|id| {
96 let profile = self
97 .sketch_profiles
98 .iter()
99 .find(|(name, _)| name == id)
100 .map(|(_, profile)| profile);
101 let prefix = format!("{id}:G");
106 let segments: Vec<(String, Vec<brep_kernel::NurbsCurve>)> = self
107 .sketch_paths
108 .iter()
109 .filter(|(name, _)| name.starts_with(&prefix))
110 .cloned()
111 .collect();
112 let payload = brep_kernel::sketch_display_payload(profile, &segments);
113 if payload.mesh.indices.is_empty() && payload.edges.is_empty() {
114 return None;
115 }
116 Some((id.clone(), payload))
117 })
118 .collect();
119
120 let mut fed: Vec<String> = Vec::with_capacity(payloads.len());
122 for (id, payload) in payloads {
123 let mut solid = crate::scene::solid_display_from_payload(&id, payload);
124 solid.is_sketch = true;
125 solid.color_override = Some(SKETCH_SHEET_COLOR);
126 self.scene.insert_solid(solid);
127 fed.push(id);
128 }
129
130 let fed_set: std::collections::HashSet<&str> = fed.iter().map(String::as_str).collect();
132 let stale: Vec<String> = self
133 .shown_sketch_ids
134 .iter()
135 .filter(|id| !fed_set.contains(id.as_str()))
136 .cloned()
137 .collect();
138 for id in stale {
139 self.scene.remove_solid(&id);
140 }
141
142 self.shown_sketch_ids = fed;
143 self.dirty = true;
144 }
145
146 pub fn sketch_visible(&self, id: &str) -> bool {
149 !self.hidden_sketches.contains(id)
150 }
151
152 pub fn set_sketch_visible(&mut self, id: &str, visible: bool) {
156 if visible {
157 self.hidden_sketches.remove(id);
158 } else {
159 self.hidden_sketches.insert(id.to_string());
160 }
161 self.refresh_committed_sketches();
162 }
163
164 pub fn committed_sketches(&self) -> Vec<(String, bool)> {
168 self.committed_sketch_ids()
169 .into_iter()
170 .map(|id| {
171 let visible = !self.hidden_sketches.contains(&id);
172 (id, visible)
173 })
174 .collect()
175 }
176
177 pub fn sketch_entities_json(&self) -> String {
182 let list: Vec<serde_json::Value> = self
183 .committed_sketches()
184 .into_iter()
185 .map(|(id, visible)| serde_json::json!({ "name": id, "visible": visible }))
186 .collect();
187 serde_json::Value::Array(list).to_string()
188 }
189}
190
191#[cfg(test)]
208mod committed_sketch_tests {
209 use super::*;
210
211 fn cube_and_sketch_history() -> String {
214 serde_json::json!({
215 "features": [
216 {
217 "type": "P.CU",
218 "inputParams": {
219 "id": "Box",
220 "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
221 "transform": {
222 "position": [0.0, 0.0, 0.0],
223 "rotationEuler": [0.0, 0.0, 0.0],
224 "scale": [1.0, 1.0, 1.0]
225 },
226 "boolean": { "targets": [], "operation": "NONE" }
227 },
228 "persistentData": {}
229 },
230 {
231 "type": "S",
232 "inputParams": { "id": "Sk" },
233 "persistentData": {
234 "basis": { "origin": [0, 0, 0], "x": [1, 0, 0], "y": [0, 1, 0], "z": [0, 0, 1] },
235 "sketch": {
236 "points": [
237 { "id": 0, "x": 0.0, "y": 0.0 },
238 { "id": 1, "x": 10.0, "y": 0.0 },
239 { "id": 2, "x": 10.0, "y": 6.0 },
240 { "id": 3, "x": 0.0, "y": 6.0 }
241 ],
242 "geometries": [
243 { "id": 10, "type": "line", "points": [0, 1] },
244 { "id": 11, "type": "line", "points": [1, 2] },
245 { "id": 12, "type": "line", "points": [2, 3] },
246 { "id": 13, "type": "line", "points": [3, 0] }
247 ],
248 "constraints": []
249 }
250 }
251 }
252 ]
253 })
254 .to_string()
255 }
256
257 fn has_group(engine: &EngineState, name: &str) -> bool {
258 engine.widgets.overlay_group_names().contains(&name)
259 }
260
261 fn sketch_sheet<'a>(
263 engine: &'a EngineState,
264 id: &str,
265 ) -> Option<&'a crate::scene::SolidDisplay> {
266 engine.scene.solid(id).filter(|solid| solid.is_sketch)
267 }
268
269 fn has_sketch_sheet(engine: &EngineState, id: &str) -> bool {
270 sketch_sheet(engine, id).is_some()
271 }
272
273 fn cube_and_open_sketch_history() -> String {
276 let mut history: serde_json::Value =
277 serde_json::from_str(&cube_and_sketch_history()).expect("history parses");
278 history["features"][1]["persistentData"]["sketch"] = serde_json::json!({
279 "points": [
280 { "id": 0, "x": 1.0, "y": 2.0 },
281 { "id": 1, "x": 7.0, "y": 4.0 }
282 ],
283 "geometries": [
284 { "id": 10, "type": "line", "points": [0, 1] }
285 ],
286 "constraints": []
287 });
288 history.to_string()
289 }
290
291 #[test]
296 fn open_sketch_renders_its_segments_without_a_sheet_face() {
297 let mut engine = EngineState::new();
298 engine.set_history_json(&cube_and_open_sketch_history()).unwrap();
299
300 let sheet = sketch_sheet(&engine, "Sk").expect("open sketch still displays");
301 assert!(sheet.is_sketch, "flagged as a sketch");
302 assert!(sheet.faces.is_empty(), "nothing closes, so there is no sheet face");
303 assert!(sheet.mesh.positions.is_empty(), "and no face mesh");
304 assert_eq!(sheet.edges.len(), 1, "the single line draws as one edge");
305 assert_eq!(sheet.edges[0].name, "Sk:G10", "named by its sketch geometry");
306 assert_eq!(sheet.vertices.len(), 2, "both endpoints draw");
307 assert!(!sheet.bbox.is_empty(), "the drawn segment gives the sketch an extent");
310
311 assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
313 }
314
315 #[test]
319 fn closed_sketch_draws_each_boundary_edge_once() {
320 let mut engine = EngineState::new();
321 engine.set_history_json(&cube_and_sketch_history()).unwrap();
322 let sheet = sketch_sheet(&engine, "Sk").expect("committed sketch sheet solid");
323 assert_eq!(sheet.edges.len(), 4, "four boundary edges, not eight");
324 let mut names: Vec<&str> = sheet.edges.iter().map(|e| e.name.as_str()).collect();
325 names.sort_unstable();
326 names.dedup();
327 assert_eq!(names.len(), 4, "each edge name appears once: {names:?}");
328 }
329
330 #[test]
331 fn committed_sketch_renders_and_lists_after_rerun() {
332 let mut engine = EngineState::new();
333 engine.set_history_json(&cube_and_sketch_history()).unwrap();
334
335 let sheet = sketch_sheet(&engine, "Sk").expect("committed sketch sheet solid");
339 assert!(sheet.is_sketch, "flagged as a sketch");
340 assert_eq!(sheet.faces.len(), 1, "one sheet face");
341 assert_eq!(sheet.edges.len(), 4, "four boundary edges");
342 assert_eq!(sheet.vertices.len(), 4, "four corner vertices");
343 assert!(!sheet.mesh.positions.is_empty(), "filled face mesh");
344
345 let solids: serde_json::Value =
347 serde_json::from_str(&engine.scene_entities_json()).unwrap();
348 assert_eq!(solids.as_array().unwrap().len(), 1);
349 assert_eq!(solids[0]["name"], "Box");
350
351 assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
353 let sketches: serde_json::Value =
354 serde_json::from_str(&engine.sketch_entities_json()).unwrap();
355 assert_eq!(sketches[0]["name"], "Sk");
356 assert_eq!(sketches[0]["visible"], true);
357 assert!(engine.sketch_visible("Sk"));
358 }
359
360 #[test]
361 fn rolling_back_before_sketch_clears_committed_sheet() {
362 let mut engine = EngineState::new();
363 engine.set_history_json(&cube_and_sketch_history()).unwrap();
364 assert!(has_sketch_sheet(&engine, "Sk"));
365
366 engine.roll_to(0);
369 assert!(!has_sketch_sheet(&engine, "Sk"), "rolled-back sketch sheet removed");
370 assert!(engine.committed_sketches().is_empty());
371
372 engine.roll_to(1);
374 assert!(has_sketch_sheet(&engine, "Sk"), "rolled-forward sketch re-shown");
375 assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
376 }
377
378 #[test]
379 fn set_sketch_visible_hides_and_restores() {
380 let mut engine = EngineState::new();
381 engine.set_history_json(&cube_and_sketch_history()).unwrap();
382 assert!(has_sketch_sheet(&engine, "Sk"));
383
384 engine.set_sketch_visible("Sk", false);
386 assert!(!has_sketch_sheet(&engine, "Sk"), "hidden sketch sheet removed");
387 assert!(!engine.sketch_visible("Sk"));
388 assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), false)]);
389 let sketches: serde_json::Value =
390 serde_json::from_str(&engine.sketch_entities_json()).unwrap();
391 assert_eq!(sketches[0]["visible"], false);
392
393 engine.set_sketch_visible("Sk", true);
395 assert!(has_sketch_sheet(&engine, "Sk"), "re-shown sketch sheet inserted");
396 assert!(engine.sketch_visible("Sk"));
397 }
398
399 #[test]
400 fn entering_sketch_mode_removes_committed_sheet_then_commit_readds() {
401 let mut engine = EngineState::new();
402 engine.set_history_json(&cube_and_sketch_history()).unwrap();
403 assert!(has_sketch_sheet(&engine, "Sk"));
404
405 engine.enter_sketch_mode("Sk").expect("enter");
408 assert!(engine.sketch_mode());
409 assert!(
410 !has_sketch_sheet(&engine, "Sk"),
411 "active sketch's committed sheet removed"
412 );
413 assert!(has_group(&engine, "sketch-geometry"), "live editing overlay fed");
414 assert!(engine.committed_sketches().is_empty());
416
417 engine.exit_sketch_mode(true);
420 assert!(!engine.sketch_mode());
421 assert!(
422 has_sketch_sheet(&engine, "Sk"),
423 "committed sketch reappears as a sheet after commit"
424 );
425 assert!(!has_group(&engine, "sketch-geometry"), "live editing overlay cleared");
426 assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
427 }
428
429 #[test]
434 fn sketch_sheet_info_reports_area_and_edge_length() {
435 let mut engine = EngineState::new();
436 engine.set_history_json(&cube_and_sketch_history()).unwrap();
437 let info: serde_json::Value =
438 serde_json::from_str(&engine.object_info_json("Sk")).unwrap();
439 assert_eq!(info["ok"], true, "sketch info must not error: {info}");
440 assert_eq!(info["kind"], "sketch");
441 assert!(info.get("volume").is_none(), "a sheet has no volume");
442 assert!((info["area"].as_f64().unwrap() - 60.0).abs() < 1e-3, "area {}", info["area"]);
443 assert!(
444 (info["edgeLengthTotal"].as_f64().unwrap() - 32.0).abs() < 1e-3,
445 "perimeter {}",
446 info["edgeLengthTotal"]
447 );
448 assert_eq!(info["creatingFeature"]["id"], "Sk");
449 assert_eq!(info["creatingFeature"]["type"], "S");
450 }
451
452 #[test]
455 fn sketch_sheet_edge_is_measurable() {
456 let mut engine = EngineState::new();
457 engine.set_history_json(&cube_and_sketch_history()).unwrap();
458 let edge_name = sketch_sheet(&engine, "Sk").unwrap().edges[0].name.clone();
459 assert!(!edge_name.is_empty(), "boundary edge is named");
460 let info: serde_json::Value =
461 serde_json::from_str(&engine.object_info_json(&edge_name)).unwrap();
462 assert_eq!(info["ok"], true, "edge info: {info}");
463 assert_eq!(info["kind"], "edge");
464 assert_eq!(info["solid"], "Sk");
465 let length = info["length"].as_f64().unwrap();
466 assert!(
467 (length - 10.0).abs() < 1e-3 || (length - 6.0).abs() < 1e-3,
468 "rectangle side length {length}"
469 );
470 }
471
472 #[test]
476 fn sketch_sheet_is_selectable_by_name() {
477 let mut engine = EngineState::new();
478 engine.set_history_json(&cube_and_sketch_history()).unwrap();
479 assert!(engine.select_by_name("solid", "Sk"));
480 let sel: serde_json::Value = serde_json::from_str(&engine.selection_json()).unwrap();
481 assert_eq!(sel["solids"][0], "Sk");
482 }
483
484 fn sketch_then_extrude_history(consume: bool) -> String {
489 serde_json::json!({
490 "features": [
491 {
492 "type": "S",
493 "inputParams": { "id": "Sk" },
494 "persistentData": {
495 "basis": { "origin": [0, 0, 0], "x": [1, 0, 0], "y": [0, 1, 0], "z": [0, 0, 1] },
496 "sketch": {
497 "points": [
498 { "id": 0, "x": 0.0, "y": 0.0 },
499 { "id": 1, "x": 10.0, "y": 0.0 },
500 { "id": 2, "x": 10.0, "y": 6.0 },
501 { "id": 3, "x": 0.0, "y": 6.0 }
502 ],
503 "geometries": [
504 { "id": 10, "type": "line", "points": [0, 1] },
505 { "id": 11, "type": "line", "points": [1, 2] },
506 { "id": 12, "type": "line", "points": [2, 3] },
507 { "id": 13, "type": "line", "points": [3, 0] }
508 ],
509 "constraints": []
510 }
511 }
512 },
513 {
514 "type": "E",
515 "inputParams": {
516 "id": "Ext",
517 "profile": "Sk",
518 "distance": 4.0,
519 "consumeProfileSketch": consume
520 },
521 "persistentData": {}
522 }
523 ]
524 })
525 .to_string()
526 }
527
528 #[test]
533 fn extrude_consumed_sketch_absent_from_scene() {
534 let mut engine = EngineState::new();
535 engine
536 .set_history_json(&sketch_then_extrude_history(true))
537 .unwrap();
538 assert!(
539 engine.committed_sketches().is_empty(),
540 "a consumed sketch is not listed: {:?}",
541 engine.committed_sketches()
542 );
543 assert!(
544 !has_sketch_sheet(&engine, "Sk"),
545 "a consumed sketch has no synthesized sheet"
546 );
547 assert!(engine.scene.solid("Ext").is_some(), "the extrude solid is present");
548 }
549
550 #[test]
553 fn extrude_kept_sketch_remains_in_scene() {
554 let mut engine = EngineState::new();
555 engine
556 .set_history_json(&sketch_then_extrude_history(false))
557 .unwrap();
558 assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
559 assert!(has_sketch_sheet(&engine, "Sk"), "a kept sketch still has a sheet");
560 }
561
562 #[test]
567 fn sketch_consumed_above_rollback_still_shows() {
568 let mut engine = EngineState::new();
569 engine
570 .set_history_json(&sketch_then_extrude_history(true))
571 .unwrap();
572 assert!(engine.committed_sketches().is_empty());
574 assert!(!has_sketch_sheet(&engine, "Sk"));
575
576 engine.roll_to(0);
578 assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
579 assert!(
580 has_sketch_sheet(&engine, "Sk"),
581 "sketch shows while its consumer is above the rollback"
582 );
583
584 engine.roll_to(1);
586 assert!(engine.committed_sketches().is_empty());
587 assert!(!has_sketch_sheet(&engine, "Sk"));
588 }
589}
590