1use super::*;
2use super::sketch_edit_ops::is_plain_number_literal;
3
4const FEATURE_DIM_OVERLAY: &str = "feature-dim-leaders";
6
7impl EngineState {
8 pub fn gizmo_mode(&self) -> &'static str {
11 match self.transform_gizmo.mode {
12 GizmoMode::None => "none",
13 GizmoMode::Transform => "transform",
14 GizmoMode::Dimension => "dimension",
15 }
16 }
17
18 pub fn dimension_armed_for(&self, feature_id: &str) -> bool {
21 matches!(self.transform_gizmo.mode, GizmoMode::Dimension)
22 && self.transform_gizmo.feature_id.as_deref() == Some(feature_id)
23 }
24
25 pub fn dimension_armed_feature(&self) -> String {
27 if matches!(self.transform_gizmo.mode, GizmoMode::Dimension) {
28 self.transform_gizmo.feature_id.clone().unwrap_or_default()
29 } else {
30 String::new()
31 }
32 }
33
34 pub fn arm_dimension(&mut self, feature_id: &str) {
37 self.transform_gizmo.feature_id = Some(feature_id.to_string());
38 self.transform_gizmo.mode = GizmoMode::Dimension;
39 self.transform_gizmo.drag = None;
40 let _ = self.widgets.set_transform_json("null");
42 self.refresh_feature_dimension_overlay();
43 self.dirty = true;
44 }
45
46 pub fn transform_center_pick(&self, x: f64, y: f64) -> bool {
62 matches!(self.transform_gizmo.mode, GizmoMode::Transform)
63 && self.transform_pick(x, y) == brep_gizmos::transform::HANDLE_CENTER
64 }
65
66 pub fn dimension_origin_pick(&self, x: f64, y: f64) -> bool {
78 if !matches!(self.transform_gizmo.mode, GizmoMode::Dimension) {
79 return false;
80 }
81 let feature = self.dimension_armed_feature();
82 if feature.is_empty() {
83 return false;
84 }
85 let hit_r = crate::feature_dimensions::ORIGIN_SPHERE_RAD_PX + 2.0;
86 let hit_r2 = hit_r * hit_r;
87 for ann in self.feature_dimension_annotations(&feature) {
88 let target = match ann.kind {
92 crate::feature_dimensions::FeatureDimKind::Linear => ann.point_a,
93 crate::feature_dimensions::FeatureDimKind::Angular => ann.center,
94 };
95 let (sx, sy, depth) = self.camera.project(target);
96 if depth <= 0.0 {
97 continue; }
99 let (dx, dy) = (sx - x, sy - y);
100 if dx * dx + dy * dy <= hit_r2 {
101 return true;
102 }
103 }
104 false
105 }
106
107 pub fn dimension_arrow_pick(&self, x: f64, y: f64) -> Option<String> {
117 if !matches!(self.transform_gizmo.mode, GizmoMode::Dimension) {
118 return None;
119 }
120 let feature = self.dimension_armed_feature();
121 if feature.is_empty() {
122 return None;
123 }
124 let wpp = self.camera.world_per_pixel();
125 let hit_r = crate::feature_dimensions::ARROW_HANDLE_HIT_RAD_PX;
126 let hit_r2 = hit_r * hit_r;
127 let mut best: Option<(f64, String)> = None;
128 for ann in self.feature_dimension_annotations(&feature) {
129 let tip = crate::feature_dimensions::arrow_handle_point(&ann, wpp);
130 let (sx, sy, depth) = self.camera.project(tip);
131 if depth <= 0.0 {
132 continue; }
134 let (dx, dy) = (sx - x, sy - y);
135 let d2 = dx * dx + dy * dy;
136 if d2 <= hit_r2 && best.as_ref().map(|(bd, _)| d2 < *bd).unwrap_or(true) {
137 best = Some((d2, ann.field_key.clone()));
138 }
139 }
140 best.map(|(_, key)| key)
141 }
142
143 pub fn toggle_to_dimension(&mut self) {
147 let feature = self.transform_armed_feature();
148 if !feature.is_empty() {
149 self.arm_dimension(&feature);
150 }
151 }
152
153 pub fn toggle_to_transform(&mut self) {
157 let feature = self.dimension_armed_feature();
158 if !feature.is_empty() {
159 self.arm_transform(&feature);
160 }
161 }
162
163 fn feature_dimension_annotations(
167 &self,
168 feature_id: &str,
169 ) -> Vec<crate::feature_dimensions::FeatureDimAnnotation> {
170 let Some(index) = self.history.index_of(feature_id) else {
171 return Vec::new();
172 };
173 let Some(feature_type) = self.history.feature_type(index) else {
174 return Vec::new();
175 };
176 let Some(params) = self.history.feature_params(index) else {
177 return Vec::new();
178 };
179 let resolved = self.resolve_param_expressions(¶ms);
180 let refs = self.feature_dimension_refs(&feature_type, ¶ms);
184 crate::feature_dimensions::build_annotations_with_refs(&feature_type, &resolved, &refs)
185 }
186
187 fn feature_dimension_refs(
196 &self,
197 feature_type: &str,
198 params: &serde_json::Value,
199 ) -> crate::feature_dimensions::ResolvedRefs {
200 let mut refs = crate::feature_dimensions::ResolvedRefs::default();
201 match feature_type {
202 "E" => {
203 if let Some(profile) = self.lookup_sketch_profile(params.get("profile")) {
204 refs.profile_center = Some(sketch_profile_centroid(profile));
205 refs.profile_normal = Some(vec3_to_arr(profile.z_axis));
206 }
207 }
208 "R" => {
209 if let Some(profile) = self.lookup_sketch_profile(params.get("profile")) {
210 refs.profile_center = Some(sketch_profile_centroid(profile));
211 refs.profile_normal = Some(vec3_to_arr(profile.z_axis));
212 }
213 if let Some((point, dir)) = self.lookup_axis_line(params.get("axis")) {
214 refs.axis_point = Some(point);
215 refs.axis_dir = Some(dir);
216 }
217 }
218 _ => {}
219 }
220 refs
221 }
222
223 fn lookup_sketch_profile(
226 &self,
227 profile_param: Option<&serde_json::Value>,
228 ) -> Option<&brep_kernel::SketchProfile> {
229 let name = first_reference_name(profile_param?)?;
230 let base = name
236 .strip_suffix(":FACE")
237 .or_else(|| name.strip_suffix(":PROFILE"))
238 .unwrap_or(&name);
239 self.sketch_profiles
240 .iter()
241 .find(|(id, _)| id == &name || id == base)
242 .map(|(_, profile)| profile)
243 }
244
245 fn lookup_axis_line(
249 &self,
250 axis_param: Option<&serde_json::Value>,
251 ) -> Option<([f64; 3], [f64; 3])> {
252 let name = first_reference_name(axis_param?)?;
253 if let Some((_, axis)) = self.sketch_axes.iter().find(|(id, _)| id == &name) {
254 let dir = fd_normalize3(vec3_to_arr(axis.direction));
255 return Some((vec3_to_arr(axis.point), dir));
256 }
257 let poly = self.scene.edge_polyline_world(&name)?;
259 let first = *poly.first()?;
260 let last = *poly.last()?;
261 let dir = fd_sub3(last, first);
262 if fd_norm3(dir) < 1e-9 {
263 return None;
264 }
265 Some((first, fd_normalize3(dir)))
266 }
267
268 fn resolve_param_expressions(&self, params: &serde_json::Value) -> serde_json::Value {
274 let expressions = self.history.expressions();
275 let configurator = self.history.configurator();
276 let mut out = params.clone();
277 if let Some(object) = out.as_object_mut() {
278 for value in object.values_mut() {
279 if let Some(source) = value.as_str() {
280 if let Ok(number) =
281 brep_kernel::eval_expression(&expressions, &configurator, source)
282 {
283 if number.is_finite() {
284 *value = serde_json::json!(number);
285 }
286 }
287 }
288 }
289 }
290 out
291 }
292
293 pub fn feature_dimension_annotations_json(&self, feature_id: &str) -> String {
298 use crate::feature_dimensions::FeatureDimKind;
299 let annotations = self.feature_dimension_annotations(feature_id);
300 let wpp = self.camera.world_per_pixel();
301 let out: Vec<serde_json::Value> = annotations
302 .iter()
303 .map(|a| {
304 let (kind, mid) = match a.kind {
309 FeatureDimKind::Linear => ("linear", a.midpoint()),
310 FeatureDimKind::Angular => {
311 ("angular", crate::feature_dimensions::angular_chip_anchor(a, wpp))
312 }
313 };
314 serde_json::json!({
315 "fieldKey": a.field_key,
316 "pointA": a.point_a,
317 "pointB": a.point_b,
318 "value": a.value,
319 "label": a.label,
320 "mid": mid,
321 "kind": kind,
322 })
323 })
324 .collect();
325 serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
326 }
327
328 pub fn feature_dimension_state_json(&self) -> String {
331 let feature = self.dimension_armed_feature();
332 let annotations: serde_json::Value = if feature.is_empty() {
333 serde_json::json!([])
334 } else {
335 serde_json::from_str(&self.feature_dimension_annotations_json(&feature))
336 .unwrap_or_else(|_| serde_json::json!([]))
337 };
338 serde_json::json!({
339 "mode": self.gizmo_mode(),
340 "feature": feature,
341 "annotations": annotations,
342 })
343 .to_string()
344 }
345
346 fn feature_dimension_overlay_json(&self) -> String {
350 let feature = self.dimension_armed_feature();
351 let annotations = if feature.is_empty() {
352 Vec::new()
353 } else {
354 self.feature_dimension_annotations(&feature)
355 };
356 let (positions, colors) = crate::feature_dimensions::leaders_buffers(
357 &annotations,
358 self.camera.world_per_pixel(),
359 );
360 serde_json::json!({
361 "groups": [
362 {
363 "name": FEATURE_DIM_OVERLAY,
364 "renderOrder": 10003,
365 "tris": { "positions": positions, "colors": colors },
366 }
367 ]
368 })
369 .to_string()
370 }
371
372 pub fn refresh_feature_dimension_overlay(&mut self) {
375 let json = self.feature_dimension_overlay_json();
376 let _ = self.set_overlay_json(&json);
377 }
378
379 pub(super) fn clear_feature_dimension_overlay(&mut self) {
382 let _ = self.set_overlay_json(&serde_json::json!({
383 "groups": [ { "name": FEATURE_DIM_OVERLAY } ]
384 }).to_string());
385 }
386
387 pub fn feature_dimension_drag(&mut self, feature_id: &str, field_key: &str, x: f64, y: f64) {
394 let annotations = self.feature_dimension_annotations(feature_id);
395 let Some(annotation) = annotations.iter().find(|a| a.field_key == field_key) else {
396 return;
397 };
398 if annotation.kind == crate::feature_dimensions::FeatureDimKind::Angular {
399 if let Some(degrees) = self.angular_drag_degrees(annotation, x, y) {
400 self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(degrees));
401 self.refresh_feature_dimension_overlay();
404 }
405 return;
406 }
407 let a = annotation.point_a;
408 let b = annotation.point_b;
409 let axis = fd_sub3(b, a);
410 let len = fd_norm3(axis);
411 if len < 1e-9 {
412 return;
413 }
414 let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
415 let ray = self.camera.pick_ray(x, y);
416 let ray_dir = fd_normalize3(ray.dir);
417 let Some(t_world) = closest_t_on_axis(a, dir, ray.origin, ray_dir) else {
418 return;
419 };
420 let scale_recip = if annotation.value.abs() > 1e-9 && len > 1e-9 {
425 annotation.value / len
426 } else {
427 1.0
428 };
429 let raw = t_world * scale_recip;
434 let new_value = if raw >= 0.0 { raw.max(1e-4) } else { raw.min(-1e-4) };
435 self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(new_value));
436 self.refresh_feature_dimension_overlay();
439 }
440
441 fn angular_drag_degrees(
449 &self,
450 ann: &crate::feature_dimensions::FeatureDimAnnotation,
451 x: f64,
452 y: f64,
453 ) -> Option<f64> {
454 let radius = crate::feature_dimensions::ANGLE_ARC_RAD_PX * self.camera.world_per_pixel();
455 if radius <= 1e-9 {
456 return None;
457 }
458 let current = ann.value;
464 let combined = |screen_err: f64, deg: f64| -> f64 {
465 let d = deg - current;
466 screen_err + 1e-6 * d * d
467 };
468 let mut best_deg = current;
469 let mut best_err = f64::INFINITY;
470 let mut deg = -360.0;
472 while deg <= 360.0 {
473 if let Some(err) = self.angle_arc_end_err(ann, radius, deg, x, y) {
474 let err = combined(err, deg);
475 if err < best_err {
476 best_err = err;
477 best_deg = deg;
478 }
479 }
480 deg += 2.0;
481 }
482 if !best_err.is_finite() {
483 return None;
484 }
485 let center = best_deg;
487 let mut deg = center - 2.0;
488 while deg <= center + 2.0 {
489 if (-360.0..=360.0).contains(°) {
490 if let Some(err) = self.angle_arc_end_err(ann, radius, deg, x, y) {
491 let err = combined(err, deg);
492 if err < best_err {
493 best_err = err;
494 best_deg = deg;
495 }
496 }
497 }
498 deg += 0.25;
499 }
500 let clamped = best_deg.round().clamp(-360.0, 360.0);
501 let floored = if clamped.abs() < 0.1 {
502 if clamped < 0.0 { -0.1 } else { 0.1 }
503 } else {
504 clamped
505 };
506 Some(floored)
507 }
508
509 fn angle_arc_end_err(
513 &self,
514 ann: &crate::feature_dimensions::FeatureDimAnnotation,
515 radius: f64,
516 deg: f64,
517 x: f64,
518 y: f64,
519 ) -> Option<f64> {
520 let dir = fd_normalize3(crate::feature_dimensions::rotate_about_axis(
521 ann.ref_dir,
522 ann.axis,
523 deg.to_radians(),
524 ));
525 let p = [
526 ann.center[0] + dir[0] * radius,
527 ann.center[1] + dir[1] * radius,
528 ann.center[2] + dir[2] * radius,
529 ];
530 let (sx, sy, depth) = self.camera.project(p);
531 if depth <= 0.0 {
532 return None;
533 }
534 Some((sx - x) * (sx - x) + (sy - y) * (sy - y))
535 }
536
537 pub fn feature_dimension_set_value(&mut self, feature_id: &str, field_key: &str, input: &str) {
544 let trimmed = input.trim();
545 if trimmed.is_empty() {
546 return;
547 }
548 if is_plain_number_literal(trimmed) {
549 let Ok(number) = trimmed.parse::<f64>() else {
550 return;
551 };
552 if !number.is_finite() {
553 return;
554 }
555 self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(number));
556 } else {
557 let expressions = self.history.expressions();
559 let configurator = self.history.configurator();
560 match brep_kernel::eval_expression(&expressions, &configurator, trimmed) {
561 Ok(number) if number.is_finite() => {
562 self.write_feature_dimension_param(
563 feature_id,
564 field_key,
565 serde_json::Value::String(trimmed.to_string()),
566 );
567 }
568 _ => {}
569 }
570 }
571 }
572
573 fn write_feature_dimension_param(
577 &mut self,
578 feature_id: &str,
579 field_key: &str,
580 value: serde_json::Value,
581 ) {
582 let Some(index) = self.history.index_of(feature_id) else {
583 return;
584 };
585 let mut params = self
586 .history
587 .feature_params(index)
588 .unwrap_or_else(|| serde_json::json!({}));
589 let Some(object) = params.as_object_mut() else {
590 return;
591 };
592 object.insert(field_key.to_string(), value);
593 let _ = self.update_feature_params(feature_id, ¶ms.to_string());
594 }
595}
596
597fn fd_sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
600 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
601}
602
603fn fd_dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
604 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
605}
606
607fn fd_norm3(v: [f64; 3]) -> f64 {
608 fd_dot3(v, v).sqrt()
609}
610
611fn fd_normalize3(v: [f64; 3]) -> [f64; 3] {
612 let n = fd_norm3(v);
613 if n < 1e-12 {
614 [0.0, 0.0, 1.0]
615 } else {
616 [v[0] / n, v[1] / n, v[2] / n]
617 }
618}
619
620fn closest_t_on_axis(
625 a: [f64; 3],
626 dir: [f64; 3],
627 ray_o: [f64; 3],
628 ray_d: [f64; 3],
629) -> Option<f64> {
630 let w0 = fd_sub3(a, ray_o);
631 let b = fd_dot3(dir, ray_d);
632 let d = fd_dot3(dir, w0);
633 let e = fd_dot3(ray_d, w0);
634 let denom = 1.0 - b * b;
635 if denom.abs() < 1e-9 {
636 return None;
637 }
638 Some((b * e - d) / denom)
639}
640
641fn vec3_to_arr(v: brep_kernel::Vec3) -> [f64; 3] {
642 [v.x, v.y, v.z]
643}
644
645fn first_reference_name(value: &serde_json::Value) -> Option<String> {
649 match value {
650 serde_json::Value::String(text) => {
651 let trimmed = text.trim();
652 (!trimmed.is_empty()).then(|| trimmed.to_string())
653 }
654 serde_json::Value::Object(map) => map
655 .get("name")
656 .and_then(|v| v.as_str())
657 .map(|s| s.trim().to_string())
658 .filter(|s| !s.is_empty()),
659 serde_json::Value::Array(items) => items.iter().find_map(first_reference_name),
660 _ => None,
661 }
662}
663
664fn sketch_profile_centroid(profile: &brep_kernel::SketchProfile) -> [f64; 3] {
670 let mut sum = [0.0f64; 3];
671 let mut count = 0usize;
672 if let Some(outer) = profile.regions.first().and_then(|region| region.first()) {
673 for curve in &outer.curves {
674 if let Ok(domain) = curve.domain() {
675 if let Ok(point) = curve.evaluate(domain[0]) {
676 sum[0] += point.x;
677 sum[1] += point.y;
678 sum[2] += point.z;
679 count += 1;
680 }
681 }
682 }
683 }
684 if count > 0 {
685 [sum[0] / count as f64, sum[1] / count as f64, sum[2] / count as f64]
686 } else {
687 vec3_to_arr(profile.origin)
688 }
689}
690
691#[cfg(test)]
692mod feature_dimension_tests {
693 use super::*;
694
695 fn primitive_request(feature_type: &str, id: &str, params: serde_json::Value) -> String {
698 let mut input = params.as_object().cloned().unwrap_or_default();
699 input.insert("id".into(), serde_json::json!(id));
700 input.insert(
701 "transform".into(),
702 serde_json::json!({
703 "position": [0.0, 0.0, 0.0],
704 "rotationEuler": [0.0, 0.0, 0.0],
705 "scale": [1.0, 1.0, 1.0]
706 }),
707 );
708 input.insert(
709 "boolean".into(),
710 serde_json::json!({ "targets": [], "operation": "NONE" }),
711 );
712 serde_json::json!({
713 "expressions": "",
714 "configurator": {},
715 "features": [{
716 "type": feature_type,
717 "inputParams": input,
718 "persistentData": {}
719 }]
720 })
721 .to_string()
722 }
723
724 fn cube_engine() -> EngineState {
725 let mut state = EngineState::new();
726 state
727 .set_history_json(&primitive_request(
728 "P.CU",
729 "Box",
730 serde_json::json!({ "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0 }),
731 ))
732 .unwrap();
733 state
734 }
735
736 #[test]
737 fn annotations_json_for_a_cube_has_three_linear_dims() {
738 let state = cube_engine();
739 let json: serde_json::Value =
740 serde_json::from_str(&state.feature_dimension_annotations_json("Box")).unwrap();
741 let arr = json.as_array().unwrap();
742 assert_eq!(arr.len(), 3);
743 assert_eq!(arr[0]["fieldKey"], "sizeX");
744 assert_eq!(arr[0]["value"].as_f64().unwrap(), 10.0);
745 assert!(arr[0]["pointA"].is_array() && arr[0]["pointB"].is_array());
746 assert!(arr[0]["mid"].is_array());
747 }
748
749 #[test]
750 fn annotations_json_for_a_cylinder_has_radius_and_height() {
751 let mut state = EngineState::new();
752 state
753 .set_history_json(&primitive_request(
754 "P.CY",
755 "Cyl",
756 serde_json::json!({ "radius": 4.0, "height": 12.0 }),
757 ))
758 .unwrap();
759 let json: serde_json::Value =
760 serde_json::from_str(&state.feature_dimension_annotations_json("Cyl")).unwrap();
761 let arr = json.as_array().unwrap();
762 assert_eq!(arr.len(), 2);
763 assert_eq!(arr[0]["fieldKey"], "radius");
764 assert_eq!(arr[1]["fieldKey"], "height");
765 }
766
767 #[test]
776 fn every_transformable_primitive_auto_arms_a_dimension_gizmo() {
777 let cases: [(&str, serde_json::Value); 6] = [
778 ("P.CU", serde_json::json!({ "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0 })),
779 ("P.CY", serde_json::json!({ "radius": 4.0, "height": 12.0 })),
780 ("P.CO", serde_json::json!({ "radiusBottom": 4.0, "radiusTop": 2.0, "height": 10.0 })),
781 ("P.S", serde_json::json!({ "radius": 5.0 })),
782 ("P.PY", serde_json::json!({ "baseSideLength": 6.0, "height": 8.0 })),
783 ("P.T", serde_json::json!({ "majorRadius": 6.0, "tubeRadius": 1.5, "arc": 120.0 })),
784 ];
785 for (ty, params) in cases {
786 let mut state = EngineState::new();
787 state
788 .set_history_json(&primitive_request(ty, "Feat", params))
789 .unwrap_or_else(|e| panic!("{ty} builds: {e}"));
790 assert_ne!(
791 state.feature_dimension_annotations_json("Feat"),
792 "[]",
793 "{ty} must expose a dimension gizmo so expanding it auto-arms one"
794 );
795 state.arm_dimension("Feat");
797 assert_eq!(state.gizmo_mode(), "dimension", "{ty}");
798 }
799 }
800
801 #[test]
802 fn annotations_json_empty_for_unknown_feature() {
803 let state = cube_engine();
804 assert_eq!(state.feature_dimension_annotations_json("nope"), "[]");
805 }
806
807 #[test]
808 fn set_value_updates_the_param_and_reruns() {
809 let mut state = cube_engine();
810 state.arm_dimension("Box");
811 state.feature_dimension_set_value("Box", "sizeX", "25");
812 let index = state.history.index_of("Box").unwrap();
813 let params = state.history.feature_params(index).unwrap();
814 assert_eq!(params["sizeX"].as_f64().unwrap(), 25.0);
815 let json: serde_json::Value =
817 serde_json::from_str(&state.feature_dimension_annotations_json("Box")).unwrap();
818 assert_eq!(json[0]["value"].as_f64().unwrap(), 25.0);
819 }
820
821 #[test]
822 fn set_value_stores_expression_string_when_not_a_literal() {
823 let mut state = cube_engine();
824 state.set_expressions("w = 7;");
826 state.feature_dimension_set_value("Box", "sizeX", "w * 2");
827 let index = state.history.index_of("Box").unwrap();
828 let params = state.history.feature_params(index).unwrap();
829 assert_eq!(params["sizeX"].as_str().unwrap(), "w * 2");
831 let json: serde_json::Value =
833 serde_json::from_str(&state.feature_dimension_annotations_json("Box")).unwrap();
834 assert_eq!(json[0]["value"].as_f64().unwrap(), 14.0);
835 }
836
837 #[test]
838 fn set_value_rejects_a_bad_expression() {
839 let mut state = cube_engine();
840 state.feature_dimension_set_value("Box", "sizeX", "this is not valid");
841 let index = state.history.index_of("Box").unwrap();
842 let params = state.history.feature_params(index).unwrap();
843 assert_eq!(params["sizeX"].as_f64().unwrap(), 10.0);
845 }
846
847 #[test]
848 fn origin_sphere_and_center_sphere_toggle_the_two_modes() {
849 let mut state = cube_engine();
853 state.resize(800.0, 600.0);
854 state.camera.eye = [0.0, 0.0, 40.0];
855 state.camera.target = [0.0, 0.0, 0.0];
856 state.camera.up = [0.0, 1.0, 0.0];
857 state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
858
859 state.arm_dimension("Box");
861 assert_eq!(state.gizmo_mode(), "dimension");
862
863 let anns: serde_json::Value =
866 serde_json::from_str(&state.feature_dimension_annotations_json("Box")).unwrap();
867 let pa = &anns[0]["pointA"];
868 let origin = [
869 pa[0].as_f64().unwrap(),
870 pa[1].as_f64().unwrap(),
871 pa[2].as_f64().unwrap(),
872 ];
873 let (ox, oy, depth) = state.camera.project(origin);
874 assert!(depth > 0.0, "origin in front of camera");
875
876 assert!(state.dimension_origin_pick(ox, oy), "pick hits the origin sphere");
879 assert!(
880 !state.transform_center_pick(ox, oy),
881 "no transform-center pick while in dimension mode"
882 );
883 state.toggle_to_transform();
884 assert_eq!(state.gizmo_mode(), "transform");
885
886 let (cx, cy) = state.transform_gizmo_anchor().expect("transform anchor");
889 assert!(state.transform_center_pick(cx, cy), "pick hits the center handle");
890 assert!(
891 !state.dimension_origin_pick(cx, cy),
892 "no dimension-origin pick while in transform mode"
893 );
894 state.toggle_to_dimension();
895 assert_eq!(state.gizmo_mode(), "dimension");
896 }
897
898 #[test]
905 fn angular_center_sphere_toggles_a_revolve_to_transform_and_back() {
906 let request = serde_json::json!({
910 "expressions": "",
911 "configurator": {},
912 "features": [
913 {
914 "type": "S",
915 "inputParams": { "id": "Sk" },
916 "persistentData": {
917 "basis": { "origin": [0,0,0], "x": [1,0,0], "y": [0,1,0], "z": [0,0,1] },
918 "sketch": {
919 "points": [
920 {"id":1,"x":2.0,"y":0.0}, {"id":2,"x":4.0,"y":0.0},
921 {"id":3,"x":4.0,"y":3.0}, {"id":4,"x":2.0,"y":3.0},
922 {"id":5,"x":0.0,"y":0.0}, {"id":6,"x":0.0,"y":1.0}
923 ],
924 "geometries": [
925 {"id":10,"type":"line","points":[1,2]},
926 {"id":11,"type":"line","points":[2,3]},
927 {"id":12,"type":"line","points":[3,4]},
928 {"id":13,"type":"line","points":[4,1]},
929 {"id":20,"type":"line","points":[5,6],"construction":true}
930 ],
931 "constraints": []
932 }
933 }
934 },
935 {
936 "type": "R",
937 "inputParams": { "id": "Rev", "profile": "Sk", "axis": "Sk:G20", "angle": 90.0 },
938 "persistentData": {}
939 }
940 ]
941 })
942 .to_string();
943
944 let mut state = EngineState::new();
945 state.set_history_json(&request).expect("revolve builds");
946 state.resize(800.0, 600.0);
947 state.camera.eye = [0.0, 0.0, 40.0];
948 state.camera.target = [0.0, 0.0, 0.0];
949 state.camera.up = [0.0, 1.0, 0.0];
950 state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
951
952 let anns = state.feature_dimension_annotations("Rev");
954 assert_eq!(anns.len(), 1, "revolve emits one (angular) dim: {anns:?}");
955 assert_eq!(anns[0].kind, crate::feature_dimensions::FeatureDimKind::Angular);
956 let center = anns[0].center;
957
958 state.arm_dimension("Rev");
960 assert_eq!(state.gizmo_mode(), "dimension");
961
962 let (cx, cy, depth) = state.camera.project(center);
964 assert!(depth > 0.0, "arc center in front of the camera");
965 assert!(
966 state.dimension_origin_pick(cx, cy),
967 "pick hits the angular center sphere"
968 );
969 state.toggle_to_transform();
970 assert_eq!(state.gizmo_mode(), "transform");
971
972 let (tx, ty) = state.transform_gizmo_anchor().expect("transform anchor");
974 assert!(
975 state.transform_center_pick(tx, ty),
976 "pick hits the transform center handle"
977 );
978 state.toggle_to_dimension();
979 assert_eq!(state.gizmo_mode(), "dimension");
980 assert!(
981 state.dimension_armed_for("Rev"),
982 "round trip lands back on the revolve's dimension arrows"
983 );
984 }
985
986 fn revolve_engine(angle: f64) -> EngineState {
992 revolve_engine_with_profile("Sk", angle)
993 }
994
995 fn revolve_engine_with_profile(profile: &str, angle: f64) -> EngineState {
999 let request = serde_json::json!({
1000 "expressions": "",
1001 "configurator": {},
1002 "features": [
1003 {
1004 "type": "S",
1005 "inputParams": { "id": "Sk" },
1006 "persistentData": {
1007 "basis": { "origin": [0,0,0], "x": [1,0,0], "y": [0,1,0], "z": [0,0,1] },
1008 "sketch": {
1009 "points": [
1010 {"id":1,"x":2.0,"y":0.0}, {"id":2,"x":4.0,"y":0.0},
1011 {"id":3,"x":4.0,"y":3.0}, {"id":4,"x":2.0,"y":3.0},
1012 {"id":5,"x":0.0,"y":0.0}, {"id":6,"x":0.0,"y":1.0}
1013 ],
1014 "geometries": [
1015 {"id":10,"type":"line","points":[1,2]},
1016 {"id":11,"type":"line","points":[2,3]},
1017 {"id":12,"type":"line","points":[3,4]},
1018 {"id":13,"type":"line","points":[4,1]},
1019 {"id":20,"type":"line","points":[5,6],"construction":true}
1020 ],
1021 "constraints": []
1022 }
1023 }
1024 },
1025 {
1026 "type": "R",
1027 "inputParams": { "id": "Rev", "profile": profile, "axis": "Sk:G20", "angle": angle },
1028 "persistentData": {}
1029 }
1030 ]
1031 })
1032 .to_string();
1033
1034 let mut state = EngineState::new();
1035 state.set_history_json(&request).expect("revolve builds");
1036 state.resize(800.0, 600.0);
1037 state.camera.eye = [0.0, 40.0, 0.0];
1042 state.camera.target = [0.0, 1.5, 0.0];
1043 state.camera.up = [0.0, 0.0, 1.0];
1044 state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1045 state
1046 }
1047
1048 #[test]
1057 fn revolve_angle_json_is_angular_and_drag_writes_the_swept_degrees() {
1058 let mut state = revolve_engine(90.0);
1059
1060 let json: serde_json::Value =
1063 serde_json::from_str(&state.feature_dimension_annotations_json("Rev")).unwrap();
1064 let arr = json.as_array().unwrap();
1065 assert_eq!(arr.len(), 1, "revolve emits one dim: {arr:?}");
1066 assert_eq!(arr[0]["fieldKey"], "angle");
1067 assert_eq!(arr[0]["kind"], "angular");
1068 assert!((arr[0]["value"].as_f64().unwrap() - 90.0).abs() < 1e-9);
1069 assert!(arr[0]["mid"].is_array(), "angular chip anchors on the arc");
1070
1071 state.arm_dimension("Rev");
1074 let ann = state
1075 .feature_dimension_annotations("Rev")
1076 .into_iter()
1077 .find(|a| a.field_key == "angle")
1078 .expect("angle dim");
1079 assert_eq!(ann.kind, crate::feature_dimensions::FeatureDimKind::Angular);
1080
1081 let handle =
1083 crate::feature_dimensions::arrow_handle_point(&ann, state.world_per_pixel());
1084 let (hx, hy, hdepth) = state.camera.project(handle);
1085 assert!(hdepth > 0.0, "handle in front of the camera");
1086 assert_eq!(
1087 state.dimension_arrow_pick(hx, hy).as_deref(),
1088 Some("angle"),
1089 "pick at the arc arrowhead grabs the angle handle"
1090 );
1091
1092 let target = 200.0_f64;
1095 let radius = crate::feature_dimensions::ANGLE_ARC_RAD_PX * state.world_per_pixel();
1096 let dir = crate::feature_dimensions::rotate_about_axis(
1097 ann.ref_dir,
1098 ann.axis,
1099 target.to_radians(),
1100 );
1101 let world = [
1102 ann.center[0] + dir[0] * radius,
1103 ann.center[1] + dir[1] * radius,
1104 ann.center[2] + dir[2] * radius,
1105 ];
1106 let (tx, ty, tdepth) = state.camera.project(world);
1107 assert!(tdepth > 0.0, "drag target in front of the camera");
1108 state.feature_dimension_drag("Rev", "angle", tx, ty);
1109
1110 let after = state.feature_dimension_annotations("Rev");
1111 let new_angle = after.iter().find(|a| a.field_key == "angle").expect("angle dim");
1112 assert!(
1113 (new_angle.value - target).abs() < 2.0,
1114 "drag should sweep the angle to ~{target}°, got {}",
1115 new_angle.value
1116 );
1117 }
1118
1119 #[test]
1127 fn revolve_face_alias_profile_still_arms_the_angle_gizmo() {
1128 let state = revolve_engine_with_profile("Sk:FACE", 90.0);
1129 let json: serde_json::Value =
1130 serde_json::from_str(&state.feature_dimension_annotations_json("Rev")).unwrap();
1131 let arr = json.as_array().unwrap();
1132 assert_eq!(
1133 arr.len(),
1134 1,
1135 "the `:FACE`-aliased revolve profile must still emit its angular dim: {arr:?}"
1136 );
1137 assert_eq!(arr[0]["fieldKey"], "angle");
1138 assert_eq!(arr[0]["kind"], "angular");
1139 assert_eq!(arr[0]["value"].as_f64().unwrap(), 90.0);
1140 }
1141
1142 #[test]
1143 fn closest_t_on_axis_projects_a_perpendicular_ray() {
1144 let t = closest_t_on_axis(
1147 [0.0, 0.0, 0.0],
1148 [1.0, 0.0, 0.0],
1149 [7.0, 5.0, 0.0],
1150 [0.0, -1.0, 0.0],
1151 )
1152 .unwrap();
1153 assert!((t - 7.0).abs() < 1e-9, "t = {t}");
1154 }
1155
1156 #[test]
1157 fn closest_t_on_axis_none_when_parallel() {
1158 assert!(closest_t_on_axis(
1159 [0.0, 0.0, 0.0],
1160 [1.0, 0.0, 0.0],
1161 [0.0, 5.0, 0.0],
1162 [1.0, 0.0, 0.0],
1163 )
1164 .is_none());
1165 }
1166
1167 fn torus_engine(arc: f64) -> EngineState {
1170 let mut state = EngineState::new();
1171 state
1172 .set_history_json(&primitive_request(
1173 "P.T",
1174 "Tor",
1175 serde_json::json!({ "majorRadius": 6.0, "tubeRadius": 1.5, "arc": arc }),
1176 ))
1177 .unwrap();
1178 state
1179 }
1180
1181 #[test]
1182 fn annotations_json_for_a_torus_has_two_linear_and_one_angular() {
1183 let state = torus_engine(120.0);
1184 let json: serde_json::Value =
1185 serde_json::from_str(&state.feature_dimension_annotations_json("Tor")).unwrap();
1186 let arr = json.as_array().unwrap();
1187 assert_eq!(arr.len(), 3);
1188 assert_eq!(arr[0]["fieldKey"], "majorRadius");
1189 assert_eq!(arr[0]["kind"], "linear");
1190 assert_eq!(arr[1]["fieldKey"], "tubeRadius");
1191 assert_eq!(arr[1]["kind"], "linear");
1192 assert_eq!(arr[2]["fieldKey"], "arc");
1195 assert_eq!(arr[2]["kind"], "angular");
1196 assert_eq!(arr[2]["value"].as_f64().unwrap(), 120.0);
1197 assert!(arr[2]["mid"].is_array());
1198 }
1199
1200 #[test]
1201 fn dragging_a_torus_arc_writes_the_swept_degrees() {
1202 let mut state = torus_engine(90.0);
1203 state.arm_dimension("Tor");
1204 let anns = state.feature_dimension_annotations("Tor");
1205 let arc = anns
1206 .iter()
1207 .find(|a| a.field_key == "arc")
1208 .expect("arc dim")
1209 .clone();
1210 assert_eq!(arc.kind, crate::feature_dimensions::FeatureDimKind::Angular);
1211
1212 let target = 210.0_f64;
1215 let radius = crate::feature_dimensions::ANGLE_ARC_RAD_PX * state.world_per_pixel();
1216 let dir = crate::feature_dimensions::rotate_about_axis(
1217 arc.ref_dir,
1218 arc.axis,
1219 target.to_radians(),
1220 );
1221 let world = [
1222 arc.center[0] + dir[0] * radius,
1223 arc.center[1] + dir[1] * radius,
1224 arc.center[2] + dir[2] * radius,
1225 ];
1226 let (sx, sy, depth) = state.camera.project(world);
1227 assert!(depth > 0.0, "arc-end must project in front of the camera");
1228
1229 state.feature_dimension_drag("Tor", "arc", sx, sy);
1230
1231 let after = state.feature_dimension_annotations("Tor");
1232 let new_arc = after.iter().find(|a| a.field_key == "arc").expect("arc dim");
1233 assert!(
1234 (new_arc.value - target).abs() < 2.0,
1235 "drag should sweep the arc to ~{target}°, got {}",
1236 new_arc.value
1237 );
1238 }
1239
1240 #[test]
1243 fn dimension_arrow_pick_and_drag_edits_the_param_live() {
1244 let mut state = cube_engine();
1245 state.resize(800.0, 600.0);
1246 state.camera.eye = [0.0, 0.0, 40.0]; state.camera.target = [0.0, 0.0, 0.0];
1248 state.camera.up = [0.0, 1.0, 0.0];
1249 state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1250
1251 state.arm_transform("Box");
1253 assert!(
1254 state.dimension_arrow_pick(400.0, 300.0).is_none(),
1255 "no arrow pick in transform mode"
1256 );
1257
1258 state.arm_dimension("Box");
1259 assert_eq!(state.gizmo_mode(), "dimension");
1260
1261 let ann = state
1264 .feature_dimension_annotations("Box")
1265 .into_iter()
1266 .find(|a| a.field_key == "sizeX")
1267 .expect("sizeX dim");
1268 let a = ann.point_a;
1269 let b = ann.point_b;
1270 let (bx, by, depth) = state.camera.project(b);
1271 assert!(depth > 0.0, "arrowhead in front of the camera");
1272 assert_eq!(
1273 state.dimension_arrow_pick(bx, by).as_deref(),
1274 Some("sizeX"),
1275 "pick at the arrowhead grabs sizeX"
1276 );
1277 assert!(state.dimension_arrow_pick(10.0, 10.0).is_none(), "empty space → no arrow");
1279
1280 let len = {
1283 let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
1284 (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
1285 };
1286 let dir = [(b[0] - a[0]) / len, (b[1] - a[1]) / len, (b[2] - a[2]) / len];
1287 let target_len = 16.0_f64;
1288 let world = [
1289 a[0] + dir[0] * target_len,
1290 a[1] + dir[1] * target_len,
1291 a[2] + dir[2] * target_len,
1292 ];
1293 let (wx, wy, wdepth) = state.camera.project(world);
1294 assert!(wdepth > 0.0, "drag target in front of the camera");
1295
1296 let before = state
1297 .history
1298 .feature_params(state.history.index_of("Box").unwrap())
1299 .unwrap()["sizeX"]
1300 .as_f64()
1301 .unwrap();
1302 state.feature_dimension_drag("Box", "sizeX", wx, wy);
1303 let after = state
1304 .history
1305 .feature_params(state.history.index_of("Box").unwrap())
1306 .unwrap()["sizeX"]
1307 .as_f64()
1308 .unwrap();
1309
1310 let expected = target_len * (ann.value / len);
1314 assert!(after > before, "sizeX grew: {before} → {after}");
1315 assert!(
1316 (after - expected).abs() < 0.5,
1317 "drag set sizeX to ~{expected}, got {after}"
1318 );
1319 }
1320}
1321