1use super::*;
2use super::sketch_edit_ops::is_plain_number_literal;
3use brep_gizmos::hit_region::{point_region, segment_region, HitShape};
4
5const FEATURE_DIM_OVERLAY: &str = "feature-dim-leaders";
7
8#[derive(Clone, Debug)]
13enum DimRole {
14 Arrow(String),
15 Origin,
16}
17
18impl EngineState {
19 pub fn gizmo_mode(&self) -> &'static str {
22 match self.transform_gizmo.mode {
23 GizmoMode::None => "none",
24 GizmoMode::Transform => "transform",
25 GizmoMode::Dimension => "dimension",
26 }
27 }
28
29 pub fn dimension_armed_for(&self, feature_id: &str) -> bool {
32 matches!(self.transform_gizmo.mode, GizmoMode::Dimension)
33 && self.transform_gizmo.feature_id.as_deref() == Some(feature_id)
34 }
35
36 pub fn dimension_armed_feature(&self) -> String {
38 if matches!(self.transform_gizmo.mode, GizmoMode::Dimension) {
39 self.transform_gizmo.feature_id.clone().unwrap_or_default()
40 } else {
41 String::new()
42 }
43 }
44
45 pub fn arm_dimension(&mut self, feature_id: &str) {
48 self.component_move_reset();
50 self.transform_gizmo.feature_id = Some(feature_id.to_string());
51 self.transform_gizmo.mode = GizmoMode::Dimension;
52 self.transform_gizmo.drag = None;
53 let _ = self.widgets.set_transform_json("null");
55 self.refresh_feature_dimension_overlay();
56 self.dirty = true;
57 }
58
59 pub fn transform_center_pick(&self, x: f64, y: f64) -> bool {
75 matches!(self.transform_gizmo.mode, GizmoMode::Transform)
76 && self.transform_pick(x, y) == brep_gizmos::transform::HANDLE_CENTER
77 }
78
79 pub fn dimension_origin_pick(&self, x: f64, y: f64) -> bool {
91 let p = [x as f32, y as f32];
96 self.dimension_hit_regions()
97 .into_iter()
98 .any(|(role, shape)| matches!(role, DimRole::Origin) && shape.contains(p))
99 }
100
101 pub fn dimension_arrow_pick(&self, x: f64, y: f64) -> Option<String> {
111 let p = [x as f32, y as f32];
120 let mut best: Option<(f32, String)> = None;
121 for (role, shape) in self.dimension_hit_regions() {
122 if let DimRole::Arrow(key) = role {
123 let d = shape.spine_distance(p);
124 if d <= shape.radius() && best.as_ref().map(|(bd, _)| d < *bd).unwrap_or(true) {
125 best = Some((d, key));
126 }
127 }
128 }
129 best.map(|(_, key)| key)
130 }
131
132 fn dimension_hit_regions(&self) -> Vec<(DimRole, HitShape)> {
148 if !matches!(self.transform_gizmo.mode, GizmoMode::Dimension) {
149 return Vec::new();
150 }
151 let feature = self.dimension_armed_feature();
152 if feature.is_empty() {
153 return Vec::new();
154 }
155 let wpp = self.camera.world_per_pixel();
156 let arrow_px = crate::feature_dimensions::ARROW_HANDLE_HIT_RAD_PX as f32;
157 let origin_px = (crate::feature_dimensions::ORIGIN_SPHERE_RAD_PX + 2.0) as f32;
158 let cam = &self.camera;
159 let mut out: Vec<(DimRole, HitShape)> = Vec::new();
160 let mut origins: Vec<[f64; 3]> = Vec::new();
161 for ann in self.feature_dimension_annotations(&feature) {
162 match ann.kind {
163 crate::feature_dimensions::FeatureDimKind::Linear => {
164 if let Some(shape) = segment_region(cam, ann.point_a, ann.point_b, arrow_px) {
165 out.push((DimRole::Arrow(ann.field_key.clone()), shape));
166 }
167 push_origin_region(&mut out, &mut origins, cam, ann.point_a, origin_px);
168 }
169 crate::feature_dimensions::FeatureDimKind::Angular => {
170 let handle = crate::feature_dimensions::arrow_handle_point(&ann, wpp);
171 if let Some(shape) = point_region(cam, handle, arrow_px) {
172 out.push((DimRole::Arrow(ann.field_key.clone()), shape));
173 }
174 push_origin_region(&mut out, &mut origins, cam, ann.center, origin_px);
175 }
176 }
177 }
178 out
179 }
180
181 pub fn dimension_hit_areas_json(&self) -> String {
190 let regions = self.dimension_hit_regions();
191 super::camera_widgets::hit_shapes_json(regions.iter().map(|(_, shape)| shape))
192 }
193
194 pub fn toggle_to_dimension(&mut self) {
198 let feature = self.transform_armed_feature();
199 if !feature.is_empty() && self.feature_dimension_annotations_json(&feature) != "[]" {
200 self.arm_dimension(&feature);
201 }
202 }
203
204 pub fn toggle_to_transform(&mut self) {
208 let feature = self.dimension_armed_feature();
209 if feature.is_empty() {
210 return;
211 }
212 if let Some(index) = self.history.index_of(&feature) {
217 if self.history.feature_type(index).as_deref() == Some("P") {
218 return;
219 }
220 }
221 self.arm_transform(&feature);
222 }
223
224 fn feature_dimension_annotations(
228 &self,
229 feature_id: &str,
230 ) -> Vec<crate::feature_dimensions::FeatureDimAnnotation> {
231 let Some(index) = self.history.index_of(feature_id) else {
232 return Vec::new();
233 };
234 let Some(feature_type) = self.history.feature_type(index) else {
235 return Vec::new();
236 };
237 let Some(params) = self.history.feature_params(index) else {
238 return Vec::new();
239 };
240 let resolved = self.resolve_param_expressions(¶ms);
241 let refs = self.feature_dimension_refs(&feature_type, ¶ms);
245 crate::feature_dimensions::build_annotations_with_refs(&feature_type, &resolved, &refs)
246 }
247
248 fn feature_dimension_refs(
259 &self,
260 feature_type: &str,
261 params: &serde_json::Value,
262 ) -> crate::feature_dimensions::ResolvedRefs {
263 let mut refs = crate::feature_dimensions::ResolvedRefs::default();
264 match feature_type {
265 "E" => {
266 if let Some((center, normal)) = self.lookup_profile_plane(params.get("profile")) {
267 refs.profile_center = Some(center);
268 refs.profile_normal = Some(normal);
269 }
270 }
271 "R" => {
272 if let Some((center, normal)) = self.lookup_profile_plane(params.get("profile")) {
273 refs.profile_center = Some(center);
274 refs.profile_normal = Some(normal);
275 }
276 if let Some((point, dir)) = self.lookup_axis_line(params.get("axis")) {
277 refs.axis_point = Some(point);
278 refs.axis_dir = Some(dir);
279 }
280 }
281 "P" => {
282 if let Some(id) = params.get("id").and_then(|v| v.as_str()) {
285 if let Some((_, frame)) =
286 self.construction_frames.iter().find(|(name, _)| name == id)
287 {
288 refs.plane_origin = Some(vec3_to_arr(frame.origin));
289 refs.plane_normal = Some(fd_normalize3(vec3_to_arr(frame.z_axis)));
290 refs.plane_dim_length = Some(self.camera.world_per_pixel() * 48.0);
292 }
293 }
294 }
295 _ => {}
296 }
297 refs
298 }
299
300 fn lookup_profile_plane(
312 &self,
313 profile_param: Option<&serde_json::Value>,
314 ) -> Option<([f64; 3], [f64; 3])> {
315 if let Some(profile) = self.lookup_sketch_profile(profile_param) {
316 return Some((sketch_profile_centroid(profile), vec3_to_arr(profile.z_axis)));
317 }
318 let name = first_reference_name(profile_param?)?;
319 self.scene.face_plane_world(&name)
320 }
321
322 fn lookup_sketch_profile(
325 &self,
326 profile_param: Option<&serde_json::Value>,
327 ) -> Option<&brep_kernel::SketchProfile> {
328 let name = first_reference_name(profile_param?)?;
329 let base = name
335 .strip_suffix(":FACE")
336 .or_else(|| name.strip_suffix(":PROFILE"))
337 .unwrap_or(&name);
338 self.sketch_profiles
339 .iter()
340 .find(|(id, _)| id == &name || id == base)
341 .map(|(_, profile)| profile)
342 }
343
344 fn lookup_axis_line(
348 &self,
349 axis_param: Option<&serde_json::Value>,
350 ) -> Option<([f64; 3], [f64; 3])> {
351 let name = first_reference_name(axis_param?)?;
352 if let Some((_, axis)) = self.sketch_axes.iter().find(|(id, _)| id == &name) {
353 let dir = fd_normalize3(vec3_to_arr(axis.direction));
354 return Some((vec3_to_arr(axis.point), dir));
355 }
356 let poly = self.scene.edge_polyline_world(&name)?;
358 let first = *poly.first()?;
359 let last = *poly.last()?;
360 let dir = fd_sub3(last, first);
361 if fd_norm3(dir) < 1e-9 {
362 return None;
363 }
364 Some((first, fd_normalize3(dir)))
365 }
366
367 fn resolve_param_expressions(&self, params: &serde_json::Value) -> serde_json::Value {
373 let expressions = self.history.expressions();
374 let configurator = self.history.configurator();
375 let mut out = params.clone();
376 if let Some(object) = out.as_object_mut() {
377 for value in object.values_mut() {
378 if let Some(source) = value.as_str() {
379 if let Ok(number) =
380 brep_kernel::eval_expression(&expressions, &configurator, source)
381 {
382 if number.is_finite() {
383 *value = serde_json::json!(number);
384 }
385 }
386 }
387 }
388 }
389 out
390 }
391
392 pub fn feature_dimension_annotations_json(&self, feature_id: &str) -> String {
397 use crate::feature_dimensions::FeatureDimKind;
398 let annotations = self.feature_dimension_annotations(feature_id);
399 let wpp = self.camera.world_per_pixel();
400 let out: Vec<serde_json::Value> = annotations
401 .iter()
402 .map(|a| {
403 let (kind, mid) = match a.kind {
408 FeatureDimKind::Linear => ("linear", a.midpoint()),
409 FeatureDimKind::Angular => {
410 ("angular", crate::feature_dimensions::angular_chip_anchor(a, wpp))
411 }
412 };
413 serde_json::json!({
414 "fieldKey": a.field_key,
415 "pointA": a.point_a,
416 "pointB": a.point_b,
417 "value": a.value,
418 "label": a.label,
419 "mid": mid,
420 "kind": kind,
421 })
422 })
423 .collect();
424 serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
425 }
426
427 pub fn feature_dimension_state_json(&self) -> String {
430 let feature = self.dimension_armed_feature();
431 let annotations: serde_json::Value = if feature.is_empty() {
432 serde_json::json!([])
433 } else {
434 serde_json::from_str(&self.feature_dimension_annotations_json(&feature))
435 .unwrap_or_else(|_| serde_json::json!([]))
436 };
437 serde_json::json!({
438 "mode": self.gizmo_mode(),
439 "feature": feature,
440 "annotations": annotations,
441 })
442 .to_string()
443 }
444
445 fn feature_dimension_overlay_json(&self) -> String {
449 let feature = self.dimension_armed_feature();
450 let annotations = if feature.is_empty() {
451 Vec::new()
452 } else {
453 self.feature_dimension_annotations(&feature)
454 };
455 let (positions, colors) = crate::feature_dimensions::leaders_buffers(
456 &annotations,
457 self.camera.world_per_pixel(),
458 );
459 serde_json::json!({
460 "groups": [
461 {
462 "name": FEATURE_DIM_OVERLAY,
463 "renderOrder": 10003,
464 "tris": { "positions": positions, "colors": colors },
465 }
466 ]
467 })
468 .to_string()
469 }
470
471 pub fn refresh_feature_dimension_overlay(&mut self) {
477 let json = self.feature_dimension_overlay_json();
478 let _ = self.set_overlay_json(&json);
479 let wpp = self.camera.world_per_pixel();
480 self.feature_dim_overlay_wpp = if wpp > 0.0 { wpp } else { f64::MIN_POSITIVE };
481 }
482
483 pub(super) fn clear_feature_dimension_overlay(&mut self) {
486 let _ = self.set_overlay_json(&serde_json::json!({
487 "groups": [ { "name": FEATURE_DIM_OVERLAY } ]
488 }).to_string());
489 self.feature_dim_overlay_wpp = 0.0;
490 }
491
492 pub(super) fn ensure_feature_dimension_overlay_current(&mut self) {
508 if !matches!(self.transform_gizmo.mode, GizmoMode::Dimension)
509 || self.dimension_armed_feature().is_empty()
510 {
511 self.feature_dim_overlay_wpp = 0.0;
512 return;
513 }
514 let wpp = self.camera.world_per_pixel();
515 if super::overlay_wpp_stale(self.feature_dim_overlay_wpp, wpp) {
516 self.refresh_feature_dimension_overlay();
517 }
518 }
519
520 pub fn feature_dimension_drag(&mut self, feature_id: &str, field_key: &str, x: f64, y: f64) {
527 let annotations = self.feature_dimension_annotations(feature_id);
528 let Some(annotation) = annotations.iter().find(|a| a.field_key == field_key) else {
529 return;
530 };
531 if annotation.kind == crate::feature_dimensions::FeatureDimKind::Angular {
532 if let Some(degrees) = self.angular_drag_degrees(annotation, x, y) {
533 self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(degrees));
534 self.refresh_feature_dimension_overlay();
537 }
538 return;
539 }
540 let a = annotation.point_a;
541 let b = annotation.point_b;
542 let axis = fd_sub3(b, a);
543 let len = fd_norm3(axis);
544 if len < 1e-9 {
545 return;
546 }
547 let dir = [axis[0] / len, axis[1] / len, axis[2] / len];
548 let ray = self.camera.pick_ray(x, y);
549 let ray_dir = fd_normalize3(ray.dir);
550 let Some(t_world) = closest_t_on_axis(a, dir, ray.origin, ray_dir) else {
551 return;
552 };
553 let scale_recip = if annotation.value.abs() > 1e-9 && len > 1e-9 {
558 annotation.value / len
559 } else {
560 1.0
561 };
562 let raw = t_world * scale_recip;
567 let new_value = if raw >= 0.0 { raw.max(1e-4) } else { raw.min(-1e-4) };
568 self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(new_value));
569 self.refresh_feature_dimension_overlay();
572 }
573
574 pub(super) fn angular_drag_degrees(
586 &self,
587 ann: &crate::feature_dimensions::FeatureDimAnnotation,
588 x: f64,
589 y: f64,
590 ) -> Option<f64> {
591 let radius = crate::feature_dimensions::ANGLE_ARC_RAD_PX * self.camera.world_per_pixel();
592 if radius <= 1e-9 {
593 return None;
594 }
595 let current = ann.value;
601 let combined = |screen_err: f64, deg: f64| -> f64 {
602 let d = deg - current;
603 screen_err + 1e-6 * d * d
604 };
605 let mut best_deg = current;
606 let mut best_err = f64::INFINITY;
607 let mut deg = -360.0;
609 while deg <= 360.0 {
610 if let Some(err) = self.angle_arc_end_err(ann, radius, deg, x, y) {
611 let err = combined(err, deg);
612 if err < best_err {
613 best_err = err;
614 best_deg = deg;
615 }
616 }
617 deg += 2.0;
618 }
619 if !best_err.is_finite() {
620 return None;
621 }
622 let center = best_deg;
624 let mut deg = center - 2.0;
625 while deg <= center + 2.0 {
626 if (-360.0..=360.0).contains(°) {
627 if let Some(err) = self.angle_arc_end_err(ann, radius, deg, x, y) {
628 let err = combined(err, deg);
629 if err < best_err {
630 best_err = err;
631 best_deg = deg;
632 }
633 }
634 }
635 deg += 0.25;
636 }
637 let clamped = best_deg.round().clamp(-360.0, 360.0);
638 let floored = if clamped.abs() < 0.1 {
639 if clamped < 0.0 { -0.1 } else { 0.1 }
640 } else {
641 clamped
642 };
643 Some(floored)
644 }
645
646 fn angle_arc_end_err(
650 &self,
651 ann: &crate::feature_dimensions::FeatureDimAnnotation,
652 radius: f64,
653 deg: f64,
654 x: f64,
655 y: f64,
656 ) -> Option<f64> {
657 let dir = fd_normalize3(crate::feature_dimensions::rotate_about_axis(
658 ann.ref_dir,
659 ann.axis,
660 deg.to_radians(),
661 ));
662 let p = [
663 ann.center[0] + dir[0] * radius,
664 ann.center[1] + dir[1] * radius,
665 ann.center[2] + dir[2] * radius,
666 ];
667 let (sx, sy, depth) = self.camera.project(p);
668 if depth <= 0.0 {
669 return None;
670 }
671 Some((sx - x) * (sx - x) + (sy - y) * (sy - y))
672 }
673
674 pub fn feature_dimension_set_value(&mut self, feature_id: &str, field_key: &str, input: &str) {
681 let trimmed = input.trim();
682 if trimmed.is_empty() {
683 return;
684 }
685 if is_plain_number_literal(trimmed) {
686 let Ok(number) = trimmed.parse::<f64>() else {
687 return;
688 };
689 if !number.is_finite() {
690 return;
691 }
692 self.write_feature_dimension_param(feature_id, field_key, serde_json::json!(number));
693 } else {
694 let expressions = self.history.expressions();
696 let configurator = self.history.configurator();
697 match brep_kernel::eval_expression(&expressions, &configurator, trimmed) {
698 Ok(number) if number.is_finite() => {
699 self.write_feature_dimension_param(
700 feature_id,
701 field_key,
702 serde_json::Value::String(trimmed.to_string()),
703 );
704 }
705 _ => {}
706 }
707 }
708 }
709
710 fn write_feature_dimension_param(
714 &mut self,
715 feature_id: &str,
716 field_key: &str,
717 value: serde_json::Value,
718 ) {
719 let Some(index) = self.history.index_of(feature_id) else {
720 return;
721 };
722 let mut params = self
723 .history
724 .feature_params(index)
725 .unwrap_or_else(|| serde_json::json!({}));
726 let Some(object) = params.as_object_mut() else {
727 return;
728 };
729 object.insert(field_key.to_string(), value);
730 let _ = self.update_feature_params(feature_id, ¶ms.to_string());
731 }
732}
733
734fn fd_sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
737 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
738}
739
740fn fd_dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
741 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
742}
743
744fn fd_norm3(v: [f64; 3]) -> f64 {
745 fd_dot3(v, v).sqrt()
746}
747
748fn fd_normalize3(v: [f64; 3]) -> [f64; 3] {
749 let n = fd_norm3(v);
750 if n < 1e-12 {
751 [0.0, 0.0, 1.0]
752 } else {
753 [v[0] / n, v[1] / n, v[2] / n]
754 }
755}
756
757fn push_origin_region(
762 out: &mut Vec<(DimRole, HitShape)>,
763 seen: &mut Vec<[f64; 3]>,
764 cam: &crate::view::ViewCamera,
765 p: [f64; 3],
766 px: f32,
767) {
768 if seen.iter().any(|o| fd_norm3(fd_sub3(*o, p)) < 1e-6) {
769 return;
770 }
771 seen.push(p);
772 if let Some(shape) = point_region(cam, p, px) {
773 out.push((DimRole::Origin, shape));
774 }
775}
776
777pub(super) fn closest_t_on_axis(
782 a: [f64; 3],
783 dir: [f64; 3],
784 ray_o: [f64; 3],
785 ray_d: [f64; 3],
786) -> Option<f64> {
787 let w0 = fd_sub3(a, ray_o);
788 let b = fd_dot3(dir, ray_d);
789 let d = fd_dot3(dir, w0);
790 let e = fd_dot3(ray_d, w0);
791 let denom = 1.0 - b * b;
792 if denom.abs() < 1e-9 {
793 return None;
794 }
795 Some((b * e - d) / denom)
796}
797
798fn vec3_to_arr(v: brep_kernel::Vec3) -> [f64; 3] {
799 [v.x, v.y, v.z]
800}
801
802fn first_reference_name(value: &serde_json::Value) -> Option<String> {
806 match value {
807 serde_json::Value::String(text) => {
808 let trimmed = text.trim();
809 (!trimmed.is_empty()).then(|| trimmed.to_string())
810 }
811 serde_json::Value::Object(map) => map
812 .get("name")
813 .and_then(|v| v.as_str())
814 .map(|s| s.trim().to_string())
815 .filter(|s| !s.is_empty()),
816 serde_json::Value::Array(items) => items.iter().find_map(first_reference_name),
817 _ => None,
818 }
819}
820
821fn sketch_profile_centroid(profile: &brep_kernel::SketchProfile) -> [f64; 3] {
827 let mut sum = [0.0f64; 3];
828 let mut count = 0usize;
829 if let Some(outer) = profile.regions.first().and_then(|region| region.first()) {
830 for curve in &outer.curves {
831 if let Ok(domain) = curve.domain() {
832 if let Ok(point) = curve.evaluate(domain[0]) {
833 sum[0] += point.x;
834 sum[1] += point.y;
835 sum[2] += point.z;
836 count += 1;
837 }
838 }
839 }
840 }
841 if count > 0 {
842 [sum[0] / count as f64, sum[1] / count as f64, sum[2] / count as f64]
843 } else {
844 vec3_to_arr(profile.origin)
845 }
846}
847
848#[cfg(test)]
849mod feature_dimension_tests {
850 use super::*;
851
852 fn primitive_request(feature_type: &str, id: &str, params: serde_json::Value) -> String {
855 let mut input = params.as_object().cloned().unwrap_or_default();
856 input.insert("id".into(), serde_json::json!(id));
857 input.insert(
858 "transform".into(),
859 serde_json::json!({
860 "position": [0.0, 0.0, 0.0],
861 "rotationEuler": [0.0, 0.0, 0.0],
862 "scale": [1.0, 1.0, 1.0]
863 }),
864 );
865 input.insert(
866 "boolean".into(),
867 serde_json::json!({ "targets": [], "operation": "NONE" }),
868 );
869 serde_json::json!({
870 "expressions": "",
871 "configurator": {},
872 "features": [{
873 "type": feature_type,
874 "inputParams": input,
875 "persistentData": {}
876 }]
877 })
878 .to_string()
879 }
880
881 fn cube_engine() -> EngineState {
882 let mut state = EngineState::new();
883 state
884 .set_history_json(&primitive_request(
885 "P.CU",
886 "Box",
887 serde_json::json!({ "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0 }),
888 ))
889 .unwrap();
890 state
891 }
892
893 #[test]
894 fn annotations_json_for_a_cube_has_three_linear_dims() {
895 let state = cube_engine();
896 let json: serde_json::Value =
897 serde_json::from_str(&state.feature_dimension_annotations_json("Box")).unwrap();
898 let arr = json.as_array().unwrap();
899 assert_eq!(arr.len(), 3);
900 assert_eq!(arr[0]["fieldKey"], "sizeX");
901 assert_eq!(arr[0]["value"].as_f64().unwrap(), 10.0);
902 assert!(arr[0]["pointA"].is_array() && arr[0]["pointB"].is_array());
903 assert!(arr[0]["mid"].is_array());
904 }
905
906 #[test]
907 fn annotations_json_for_a_cylinder_has_radius_and_height() {
908 let mut state = EngineState::new();
909 state
910 .set_history_json(&primitive_request(
911 "P.CY",
912 "Cyl",
913 serde_json::json!({ "radius": 4.0, "height": 12.0 }),
914 ))
915 .unwrap();
916 let json: serde_json::Value =
917 serde_json::from_str(&state.feature_dimension_annotations_json("Cyl")).unwrap();
918 let arr = json.as_array().unwrap();
919 assert_eq!(arr.len(), 2);
920 assert_eq!(arr[0]["fieldKey"], "radius");
921 assert_eq!(arr[1]["fieldKey"], "height");
922 }
923
924 #[test]
933 fn every_transformable_primitive_auto_arms_a_dimension_gizmo() {
934 let cases: [(&str, serde_json::Value); 6] = [
935 ("P.CU", serde_json::json!({ "sizeX": 10.0, "sizeY": 20.0, "sizeZ": 30.0 })),
936 ("P.CY", serde_json::json!({ "radius": 4.0, "height": 12.0 })),
937 ("P.CO", serde_json::json!({ "radiusBottom": 4.0, "radiusTop": 2.0, "height": 10.0 })),
938 ("P.S", serde_json::json!({ "radius": 5.0 })),
939 ("P.PY", serde_json::json!({ "baseSideLength": 6.0, "height": 8.0 })),
940 ("P.T", serde_json::json!({ "majorRadius": 6.0, "tubeRadius": 1.5, "arc": 120.0 })),
941 ];
942 for (ty, params) in cases {
943 let mut state = EngineState::new();
944 state
945 .set_history_json(&primitive_request(ty, "Feat", params))
946 .unwrap_or_else(|e| panic!("{ty} builds: {e}"));
947 assert_ne!(
948 state.feature_dimension_annotations_json("Feat"),
949 "[]",
950 "{ty} must expose a dimension gizmo so expanding it auto-arms one"
951 );
952 state.arm_dimension("Feat");
954 assert_eq!(state.gizmo_mode(), "dimension", "{ty}");
955 }
956 }
957
958 #[test]
959 fn annotations_json_empty_for_unknown_feature() {
960 let state = cube_engine();
961 assert_eq!(state.feature_dimension_annotations_json("nope"), "[]");
962 }
963
964 #[test]
965 fn set_value_updates_the_param_and_reruns() {
966 let mut state = cube_engine();
967 state.arm_dimension("Box");
968 state.feature_dimension_set_value("Box", "sizeX", "25");
969 let index = state.history.index_of("Box").unwrap();
970 let params = state.history.feature_params(index).unwrap();
971 assert_eq!(params["sizeX"].as_f64().unwrap(), 25.0);
972 let json: serde_json::Value =
974 serde_json::from_str(&state.feature_dimension_annotations_json("Box")).unwrap();
975 assert_eq!(json[0]["value"].as_f64().unwrap(), 25.0);
976 }
977
978 #[test]
979 fn set_value_stores_expression_string_when_not_a_literal() {
980 let mut state = cube_engine();
981 state.set_expressions("w = 7;");
983 state.feature_dimension_set_value("Box", "sizeX", "w * 2");
984 let index = state.history.index_of("Box").unwrap();
985 let params = state.history.feature_params(index).unwrap();
986 assert_eq!(params["sizeX"].as_str().unwrap(), "w * 2");
988 let json: serde_json::Value =
990 serde_json::from_str(&state.feature_dimension_annotations_json("Box")).unwrap();
991 assert_eq!(json[0]["value"].as_f64().unwrap(), 14.0);
992 }
993
994 #[test]
995 fn set_value_rejects_a_bad_expression() {
996 let mut state = cube_engine();
997 state.feature_dimension_set_value("Box", "sizeX", "this is not valid");
998 let index = state.history.index_of("Box").unwrap();
999 let params = state.history.feature_params(index).unwrap();
1000 assert_eq!(params["sizeX"].as_f64().unwrap(), 10.0);
1002 }
1003
1004 #[test]
1005 fn origin_sphere_and_center_sphere_toggle_the_two_modes() {
1006 let mut state = cube_engine();
1010 state.resize(800.0, 600.0);
1011 state.camera.eye = [0.0, 0.0, 40.0];
1012 state.camera.target = [0.0, 0.0, 0.0];
1013 state.camera.up = [0.0, 1.0, 0.0];
1014 state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1015
1016 state.arm_dimension("Box");
1018 assert_eq!(state.gizmo_mode(), "dimension");
1019
1020 let anns: serde_json::Value =
1023 serde_json::from_str(&state.feature_dimension_annotations_json("Box")).unwrap();
1024 let pa = &anns[0]["pointA"];
1025 let origin = [
1026 pa[0].as_f64().unwrap(),
1027 pa[1].as_f64().unwrap(),
1028 pa[2].as_f64().unwrap(),
1029 ];
1030 let (ox, oy, depth) = state.camera.project(origin);
1031 assert!(depth > 0.0, "origin in front of camera");
1032
1033 assert!(state.dimension_origin_pick(ox, oy), "pick hits the origin sphere");
1036 assert!(
1037 !state.transform_center_pick(ox, oy),
1038 "no transform-center pick while in dimension mode"
1039 );
1040 state.toggle_to_transform();
1041 assert_eq!(state.gizmo_mode(), "transform");
1042
1043 let (cx, cy) = state.transform_gizmo_anchor().expect("transform anchor");
1046 assert!(state.transform_center_pick(cx, cy), "pick hits the center handle");
1047 assert!(
1048 !state.dimension_origin_pick(cx, cy),
1049 "no dimension-origin pick while in transform mode"
1050 );
1051 state.toggle_to_dimension();
1052 assert_eq!(state.gizmo_mode(), "dimension");
1053 }
1054
1055 #[test]
1062 fn plane_arms_the_offset_dim_and_never_toggles_to_transform() {
1063 let request = serde_json::json!({
1067 "expressions": "",
1068 "configurator": {},
1069 "features": [{
1070 "type": "P",
1071 "inputParams": { "id": "Pl", "orientation": "XY", "offset_distance": 3.0 },
1072 "persistentData": {}
1073 }]
1074 })
1075 .to_string();
1076 let mut state = EngineState::new();
1077 state.set_history_json(&request).expect("plane builds");
1078
1079 let anns = state.feature_dimension_annotations("Pl");
1081 assert_eq!(anns.len(), 1, "plane emits its offset dim: {anns:?}");
1082 assert_eq!(anns[0].field_key, "offset_distance");
1083
1084 state.arm_dimension("Pl");
1085 assert_eq!(state.gizmo_mode(), "dimension");
1086 state.toggle_to_transform();
1088 assert_eq!(state.gizmo_mode(), "dimension", "a plane never gets a transform gizmo");
1089 }
1090
1091 #[test]
1092 fn angular_center_sphere_toggles_a_revolve_to_transform_and_back() {
1093 let request = serde_json::json!({
1097 "expressions": "",
1098 "configurator": {},
1099 "features": [
1100 {
1101 "type": "S",
1102 "inputParams": { "id": "Sk" },
1103 "persistentData": {
1104 "basis": { "origin": [0,0,0], "x": [1,0,0], "y": [0,1,0], "z": [0,0,1] },
1105 "sketch": {
1106 "points": [
1107 {"id":1,"x":2.0,"y":0.0}, {"id":2,"x":4.0,"y":0.0},
1108 {"id":3,"x":4.0,"y":3.0}, {"id":4,"x":2.0,"y":3.0},
1109 {"id":5,"x":0.0,"y":0.0}, {"id":6,"x":0.0,"y":1.0}
1110 ],
1111 "geometries": [
1112 {"id":10,"type":"line","points":[1,2]},
1113 {"id":11,"type":"line","points":[2,3]},
1114 {"id":12,"type":"line","points":[3,4]},
1115 {"id":13,"type":"line","points":[4,1]},
1116 {"id":20,"type":"line","points":[5,6],"construction":true}
1117 ],
1118 "constraints": []
1119 }
1120 }
1121 },
1122 {
1123 "type": "R",
1124 "inputParams": { "id": "Rev", "profile": "Sk", "axis": "Sk:G20", "angle": 90.0 },
1125 "persistentData": {}
1126 }
1127 ]
1128 })
1129 .to_string();
1130
1131 let mut state = EngineState::new();
1132 state.set_history_json(&request).expect("revolve builds");
1133 state.resize(800.0, 600.0);
1134 state.camera.eye = [0.0, 0.0, 40.0];
1135 state.camera.target = [0.0, 0.0, 0.0];
1136 state.camera.up = [0.0, 1.0, 0.0];
1137 state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1138
1139 let anns = state.feature_dimension_annotations("Rev");
1141 assert_eq!(anns.len(), 1, "revolve emits one (angular) dim: {anns:?}");
1142 assert_eq!(anns[0].kind, crate::feature_dimensions::FeatureDimKind::Angular);
1143 let center = anns[0].center;
1144
1145 state.arm_dimension("Rev");
1147 assert_eq!(state.gizmo_mode(), "dimension");
1148
1149 let (cx, cy, depth) = state.camera.project(center);
1151 assert!(depth > 0.0, "arc center in front of the camera");
1152 assert!(
1153 state.dimension_origin_pick(cx, cy),
1154 "pick hits the angular center sphere"
1155 );
1156 state.toggle_to_transform();
1157 assert_eq!(state.gizmo_mode(), "transform");
1158
1159 let (tx, ty) = state.transform_gizmo_anchor().expect("transform anchor");
1161 assert!(
1162 state.transform_center_pick(tx, ty),
1163 "pick hits the transform center handle"
1164 );
1165 state.toggle_to_dimension();
1166 assert_eq!(state.gizmo_mode(), "dimension");
1167 assert!(
1168 state.dimension_armed_for("Rev"),
1169 "round trip lands back on the revolve's dimension arrows"
1170 );
1171 }
1172
1173 fn revolve_engine(angle: f64) -> EngineState {
1179 revolve_engine_with_profile("Sk", angle)
1180 }
1181
1182 fn revolve_engine_with_profile(profile: &str, angle: f64) -> EngineState {
1186 let request = serde_json::json!({
1187 "expressions": "",
1188 "configurator": {},
1189 "features": [
1190 {
1191 "type": "S",
1192 "inputParams": { "id": "Sk" },
1193 "persistentData": {
1194 "basis": { "origin": [0,0,0], "x": [1,0,0], "y": [0,1,0], "z": [0,0,1] },
1195 "sketch": {
1196 "points": [
1197 {"id":1,"x":2.0,"y":0.0}, {"id":2,"x":4.0,"y":0.0},
1198 {"id":3,"x":4.0,"y":3.0}, {"id":4,"x":2.0,"y":3.0},
1199 {"id":5,"x":0.0,"y":0.0}, {"id":6,"x":0.0,"y":1.0}
1200 ],
1201 "geometries": [
1202 {"id":10,"type":"line","points":[1,2]},
1203 {"id":11,"type":"line","points":[2,3]},
1204 {"id":12,"type":"line","points":[3,4]},
1205 {"id":13,"type":"line","points":[4,1]},
1206 {"id":20,"type":"line","points":[5,6],"construction":true}
1207 ],
1208 "constraints": []
1209 }
1210 }
1211 },
1212 {
1213 "type": "R",
1214 "inputParams": { "id": "Rev", "profile": profile, "axis": "Sk:G20", "angle": angle },
1215 "persistentData": {}
1216 }
1217 ]
1218 })
1219 .to_string();
1220
1221 let mut state = EngineState::new();
1222 state.set_history_json(&request).expect("revolve builds");
1223 state.resize(800.0, 600.0);
1224 state.camera.eye = [0.0, 40.0, 0.0];
1229 state.camera.target = [0.0, 1.5, 0.0];
1230 state.camera.up = [0.0, 0.0, 1.0];
1231 state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1232 state
1233 }
1234
1235 #[test]
1244 fn revolve_angle_json_is_angular_and_drag_writes_the_swept_degrees() {
1245 let mut state = revolve_engine(90.0);
1246
1247 let json: serde_json::Value =
1250 serde_json::from_str(&state.feature_dimension_annotations_json("Rev")).unwrap();
1251 let arr = json.as_array().unwrap();
1252 assert_eq!(arr.len(), 1, "revolve emits one dim: {arr:?}");
1253 assert_eq!(arr[0]["fieldKey"], "angle");
1254 assert_eq!(arr[0]["kind"], "angular");
1255 assert!((arr[0]["value"].as_f64().unwrap() - 90.0).abs() < 1e-9);
1256 assert!(arr[0]["mid"].is_array(), "angular chip anchors on the arc");
1257
1258 state.arm_dimension("Rev");
1261 let ann = state
1262 .feature_dimension_annotations("Rev")
1263 .into_iter()
1264 .find(|a| a.field_key == "angle")
1265 .expect("angle dim");
1266 assert_eq!(ann.kind, crate::feature_dimensions::FeatureDimKind::Angular);
1267
1268 let handle =
1270 crate::feature_dimensions::arrow_handle_point(&ann, state.world_per_pixel());
1271 let (hx, hy, hdepth) = state.camera.project(handle);
1272 assert!(hdepth > 0.0, "handle in front of the camera");
1273 assert_eq!(
1274 state.dimension_arrow_pick(hx, hy).as_deref(),
1275 Some("angle"),
1276 "pick at the arc arrowhead grabs the angle handle"
1277 );
1278
1279 let target = 200.0_f64;
1282 let radius = crate::feature_dimensions::ANGLE_ARC_RAD_PX * state.world_per_pixel();
1283 let dir = crate::feature_dimensions::rotate_about_axis(
1284 ann.ref_dir,
1285 ann.axis,
1286 target.to_radians(),
1287 );
1288 let world = [
1289 ann.center[0] + dir[0] * radius,
1290 ann.center[1] + dir[1] * radius,
1291 ann.center[2] + dir[2] * radius,
1292 ];
1293 let (tx, ty, tdepth) = state.camera.project(world);
1294 assert!(tdepth > 0.0, "drag target in front of the camera");
1295 state.feature_dimension_drag("Rev", "angle", tx, ty);
1296
1297 let after = state.feature_dimension_annotations("Rev");
1298 let new_angle = after.iter().find(|a| a.field_key == "angle").expect("angle dim");
1299 assert!(
1300 (new_angle.value - target).abs() < 2.0,
1301 "drag should sweep the angle to ~{target}°, got {}",
1302 new_angle.value
1303 );
1304 }
1305
1306 #[test]
1314 fn revolve_face_alias_profile_still_arms_the_angle_gizmo() {
1315 let state = revolve_engine_with_profile("Sk:FACE", 90.0);
1316 let json: serde_json::Value =
1317 serde_json::from_str(&state.feature_dimension_annotations_json("Rev")).unwrap();
1318 let arr = json.as_array().unwrap();
1319 assert_eq!(
1320 arr.len(),
1321 1,
1322 "the `:FACE`-aliased revolve profile must still emit its angular dim: {arr:?}"
1323 );
1324 assert_eq!(arr[0]["fieldKey"], "angle");
1325 assert_eq!(arr[0]["kind"], "angular");
1326 assert_eq!(arr[0]["value"].as_f64().unwrap(), 90.0);
1327 }
1328
1329 #[test]
1336 fn dimension_hit_areas_are_screen_regions_matching_the_picks() {
1337 let mut state = cube_engine();
1338 state.resize(800.0, 600.0);
1339 state.camera.eye = [30.0, 20.0, 40.0];
1343 state.camera.target = [0.0, 0.0, 0.0];
1344 state.camera.up = [0.0, 1.0, 0.0];
1345 state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1346
1347 assert_eq!(state.dimension_hit_areas_json(), "[]");
1349 state.arm_transform("Box");
1350 assert_eq!(state.dimension_hit_areas_json(), "[]");
1351
1352 state.arm_dimension("Box");
1353 let areas: Vec<serde_json::Value> =
1354 serde_json::from_str(&state.dimension_hit_areas_json()).unwrap();
1355 let anns = state.feature_dimension_annotations("Box");
1356
1357 let close2 = |v: &serde_json::Value, want: (f64, f64)| {
1358 (v[0].as_f64().unwrap() - want.0).abs() < 1e-3
1359 && (v[1].as_f64().unwrap() - want.1).abs() < 1e-3
1360 };
1361
1362 let capsules: Vec<&serde_json::Value> =
1365 areas.iter().filter(|a| a["kind"] == "capsule").collect();
1366 assert_eq!(capsules.len(), 3);
1367 assert_eq!(capsules.len(), anns.len());
1368 for (cap, ann) in capsules.iter().zip(anns.iter()) {
1369 let r = cap["r"].as_f64().unwrap();
1370 assert!(
1371 (r - crate::feature_dimensions::ARROW_HANDLE_HIT_RAD_PX).abs() < 1e-6,
1372 "r {r} must equal ARROW_HANDLE_HIT_RAD_PX"
1373 );
1374 let (ax, ay, _) = state.camera.project(ann.point_a);
1375 let (bx, by, _) = state.camera.project(ann.point_b);
1376 assert!(close2(&cap["a"], (ax, ay)), "leader a: {:?} vs {ax},{ay}", cap["a"]);
1377 assert!(close2(&cap["b"], (bx, by)), "leader b: {:?} vs {bx},{by}", cap["b"]);
1378 }
1379
1380 let circles: Vec<&serde_json::Value> =
1383 areas.iter().filter(|a| a["kind"] == "circle").collect();
1384 assert_eq!(circles.len(), 1, "cube dims share one origin sphere");
1385 let want_r = crate::feature_dimensions::ORIGIN_SPHERE_RAD_PX + 2.0;
1386 assert!((circles[0]["r"].as_f64().unwrap() - want_r).abs() < 1e-6);
1387 let (ox, oy, _) = state.camera.project(anns[0].point_a);
1388 assert!(close2(&circles[0]["c"], (ox, oy)), "origin circle off point_a");
1389
1390 for ann in &anns {
1394 let mid = ann.midpoint();
1395 let (mx, my, _) = state.camera.project(mid);
1396 assert_eq!(
1397 state.dimension_arrow_pick(mx, my).as_deref(),
1398 Some(ann.field_key.as_str()),
1399 "cursor on the {} leader midpoint grabs it",
1400 ann.field_key
1401 );
1402 }
1403 assert!(state.dimension_origin_pick(ox, oy), "cursor on the origin circle toggles");
1404 }
1405
1406 #[test]
1407 fn closest_t_on_axis_projects_a_perpendicular_ray() {
1408 let t = closest_t_on_axis(
1411 [0.0, 0.0, 0.0],
1412 [1.0, 0.0, 0.0],
1413 [7.0, 5.0, 0.0],
1414 [0.0, -1.0, 0.0],
1415 )
1416 .unwrap();
1417 assert!((t - 7.0).abs() < 1e-9, "t = {t}");
1418 }
1419
1420 #[test]
1421 fn closest_t_on_axis_none_when_parallel() {
1422 assert!(closest_t_on_axis(
1423 [0.0, 0.0, 0.0],
1424 [1.0, 0.0, 0.0],
1425 [0.0, 5.0, 0.0],
1426 [1.0, 0.0, 0.0],
1427 )
1428 .is_none());
1429 }
1430
1431 fn torus_engine(arc: f64) -> EngineState {
1434 let mut state = EngineState::new();
1435 state
1436 .set_history_json(&primitive_request(
1437 "P.T",
1438 "Tor",
1439 serde_json::json!({ "majorRadius": 6.0, "tubeRadius": 1.5, "arc": arc }),
1440 ))
1441 .unwrap();
1442 state
1443 }
1444
1445 #[test]
1446 fn annotations_json_for_a_torus_has_two_linear_and_one_angular() {
1447 let state = torus_engine(120.0);
1448 let json: serde_json::Value =
1449 serde_json::from_str(&state.feature_dimension_annotations_json("Tor")).unwrap();
1450 let arr = json.as_array().unwrap();
1451 assert_eq!(arr.len(), 3);
1452 assert_eq!(arr[0]["fieldKey"], "majorRadius");
1453 assert_eq!(arr[0]["kind"], "linear");
1454 assert_eq!(arr[1]["fieldKey"], "tubeRadius");
1455 assert_eq!(arr[1]["kind"], "linear");
1456 assert_eq!(arr[2]["fieldKey"], "arc");
1459 assert_eq!(arr[2]["kind"], "angular");
1460 assert_eq!(arr[2]["value"].as_f64().unwrap(), 120.0);
1461 assert!(arr[2]["mid"].is_array());
1462 }
1463
1464 #[test]
1465 fn dragging_a_torus_arc_writes_the_swept_degrees() {
1466 let mut state = torus_engine(90.0);
1467 state.arm_dimension("Tor");
1468 let anns = state.feature_dimension_annotations("Tor");
1469 let arc = anns
1470 .iter()
1471 .find(|a| a.field_key == "arc")
1472 .expect("arc dim")
1473 .clone();
1474 assert_eq!(arc.kind, crate::feature_dimensions::FeatureDimKind::Angular);
1475
1476 let target = 210.0_f64;
1479 let radius = crate::feature_dimensions::ANGLE_ARC_RAD_PX * state.world_per_pixel();
1480 let dir = crate::feature_dimensions::rotate_about_axis(
1481 arc.ref_dir,
1482 arc.axis,
1483 target.to_radians(),
1484 );
1485 let world = [
1486 arc.center[0] + dir[0] * radius,
1487 arc.center[1] + dir[1] * radius,
1488 arc.center[2] + dir[2] * radius,
1489 ];
1490 let (sx, sy, depth) = state.camera.project(world);
1491 assert!(depth > 0.0, "arc-end must project in front of the camera");
1492
1493 state.feature_dimension_drag("Tor", "arc", sx, sy);
1494
1495 let after = state.feature_dimension_annotations("Tor");
1496 let new_arc = after.iter().find(|a| a.field_key == "arc").expect("arc dim");
1497 assert!(
1498 (new_arc.value - target).abs() < 2.0,
1499 "drag should sweep the arc to ~{target}°, got {}",
1500 new_arc.value
1501 );
1502 }
1503
1504 #[test]
1507 fn dimension_arrow_pick_and_drag_edits_the_param_live() {
1508 let mut state = cube_engine();
1509 state.resize(800.0, 600.0);
1510 state.camera.eye = [0.0, 0.0, 40.0]; state.camera.target = [0.0, 0.0, 0.0];
1512 state.camera.up = [0.0, 1.0, 0.0];
1513 state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1514
1515 state.arm_transform("Box");
1517 assert!(
1518 state.dimension_arrow_pick(400.0, 300.0).is_none(),
1519 "no arrow pick in transform mode"
1520 );
1521
1522 state.arm_dimension("Box");
1523 assert_eq!(state.gizmo_mode(), "dimension");
1524
1525 let ann = state
1528 .feature_dimension_annotations("Box")
1529 .into_iter()
1530 .find(|a| a.field_key == "sizeX")
1531 .expect("sizeX dim");
1532 let a = ann.point_a;
1533 let b = ann.point_b;
1534 let (bx, by, depth) = state.camera.project(b);
1535 assert!(depth > 0.0, "arrowhead in front of the camera");
1536 assert_eq!(
1537 state.dimension_arrow_pick(bx, by).as_deref(),
1538 Some("sizeX"),
1539 "pick at the arrowhead grabs sizeX"
1540 );
1541 assert!(state.dimension_arrow_pick(10.0, 10.0).is_none(), "empty space → no arrow");
1543
1544 let len = {
1547 let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
1548 (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
1549 };
1550 let dir = [(b[0] - a[0]) / len, (b[1] - a[1]) / len, (b[2] - a[2]) / len];
1551 let target_len = 16.0_f64;
1552 let world = [
1553 a[0] + dir[0] * target_len,
1554 a[1] + dir[1] * target_len,
1555 a[2] + dir[2] * target_len,
1556 ];
1557 let (wx, wy, wdepth) = state.camera.project(world);
1558 assert!(wdepth > 0.0, "drag target in front of the camera");
1559
1560 let before = state
1561 .history
1562 .feature_params(state.history.index_of("Box").unwrap())
1563 .unwrap()["sizeX"]
1564 .as_f64()
1565 .unwrap();
1566 state.feature_dimension_drag("Box", "sizeX", wx, wy);
1567 let after = state
1568 .history
1569 .feature_params(state.history.index_of("Box").unwrap())
1570 .unwrap()["sizeX"]
1571 .as_f64()
1572 .unwrap();
1573
1574 let expected = target_len * (ann.value / len);
1578 assert!(after > before, "sizeX grew: {before} → {after}");
1579 assert!(
1580 (after - expected).abs() < 0.5,
1581 "drag set sizeX to ~{expected}, got {after}"
1582 );
1583 }
1584
1585 #[test]
1593 fn linear_leader_with_point_b_behind_the_eye_is_grabbable_on_its_visible_shaft() {
1594 let mut state = cube_engine();
1595 state.resize(800.0, 600.0);
1596 state.arm_dimension("Box");
1597
1598 let anns = state.feature_dimension_annotations("Box");
1603 let ann = anns.iter().find(|a| a.field_key == "sizeY").expect("sizeY dim").clone();
1604 let a = ann.point_a;
1605 let b = ann.point_b;
1606 let dir = fd_normalize3(fd_sub3(b, a));
1607 let l = fd_norm3(fd_sub3(b, a));
1608 let mut off = [0.0f64; 3];
1613 for other in anns.iter().filter(|a| a.field_key != "sizeY") {
1614 let d = fd_normalize3(fd_sub3(other.point_b, other.point_a));
1615 off = [off[0] + d[0], off[1] + d[1], off[2] + d[2]];
1616 }
1617 let off_dot = fd_dot3(off, dir);
1618 let perp = fd_normalize3([
1619 off[0] - dir[0] * off_dot,
1620 off[1] - dir[1] * off_dot,
1621 off[2] - dir[2] * off_dot,
1622 ]);
1623 let mid = [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5, (a[2] + b[2]) * 0.5];
1624 let d_off = l * 0.25; let eye = [
1626 mid[0] + perp[0] * d_off,
1627 mid[1] + perp[1] * d_off,
1628 mid[2] + perp[2] * d_off,
1629 ];
1630 let forward = fd_normalize3(fd_sub3(a, eye)); state.camera.eye = eye;
1632 state.camera.target = [eye[0] + forward[0], eye[1] + forward[1], eye[2] + forward[2]];
1633 state.camera.up = if forward[2].abs() < 0.9 { [0.0, 0.0, 1.0] } else { [0.0, 1.0, 0.0] };
1634 state.camera.projection =
1635 crate::view::Projection::Orthographic { half_height: l.max(1.0) };
1636
1637 assert!(
1639 state.camera.view_depth(b) < 0.0,
1640 "point_b must be behind the eye: {}",
1641 state.camera.view_depth(b)
1642 );
1643 assert!(state.camera.view_depth(a) > 0.0, "point_a in front");
1644
1645 let (bx, by, bdepth) = state.camera.project(b);
1648 assert!(bdepth < 0.0, "the projected far-end depth the old skip rejected");
1649 assert_eq!(
1650 state.dimension_arrow_pick(bx, by).as_deref(),
1651 Some("sizeY"),
1652 "the leader is grabbable at its visible far end"
1653 );
1654
1655 let (mx, my, _) = state.camera.project(mid);
1657 assert_eq!(
1658 state.dimension_arrow_pick(mx, my).as_deref(),
1659 Some("sizeY"),
1660 "the leader is grabbable on its shaft"
1661 );
1662 }
1663
1664 #[test]
1676 fn ensure_overlays_current_rebakes_the_dimension_gizmo_on_zoom_without_looping() {
1677 let mut state = cube_engine();
1678 state.resize(800.0, 600.0);
1679 state.camera.eye = [30.0, 20.0, 40.0];
1680 state.camera.target = [0.0, 0.0, 0.0];
1681 state.camera.up = [0.0, 1.0, 0.0];
1682 state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1683 state.arm_dimension("Box");
1684
1685 state.dirty = false;
1687 state.ensure_overlays_current();
1688 assert!(!state.dirty, "a quiet frame must not re-bake (no dirty loop)");
1689
1690 state.camera.projection = crate::view::Projection::Orthographic { half_height: 40.0 };
1692 state.ensure_overlays_current();
1693 assert!(state.dirty, "a zoom change re-bakes the screen-constant sizing");
1694
1695 state.dirty = false;
1699 state.ensure_overlays_current();
1700 state.ensure_overlays_current();
1701 state.ensure_overlays_current();
1702 assert!(!state.dirty, "the re-bake settles — no per-frame re-bake loop");
1703
1704 state.resize(800.0, 300.0);
1707 state.dirty = false;
1708 state.ensure_overlays_current();
1709 assert!(state.dirty, "a viewport resize changes world-per-pixel → re-bake");
1710
1711 state.dirty = false;
1715 state.camera.eye = [40.0, 20.0, 30.0]; state.ensure_overlays_current();
1717 assert!(!state.dirty, "orbit needs no re-bake (world-space geometry)");
1718 state.camera.eye = [35.0, 25.0, 45.0];
1719 state.camera.target = [5.0, 5.0, 5.0]; state.ensure_overlays_current();
1721 assert!(!state.dirty, "pan needs no re-bake (world-space geometry)");
1722 }
1723
1724 #[test]
1732 fn zoom_rescales_the_drawn_angular_arc_and_keeps_the_grab_circle_on_it() {
1733 let mut state = revolve_engine(90.0);
1734 state.arm_dimension("Rev");
1735 assert_eq!(
1736 state.widgets.overlay_group_names(),
1737 vec!["feature-dim-leaders"],
1738 "only the dimension gizmo is drawn, so the overlay bbox is its own"
1739 );
1740
1741 let extent = |s: &EngineState| {
1742 let b = s.widgets.overlay_groups_bbox();
1743 let (min, max) = (b.min, b.max);
1744 (max[0] - min[0]).max(max[1] - min[1]).max(max[2] - min[2])
1745 };
1746 let before = extent(&state);
1747 assert!(before > 0.0, "the arc is drawn");
1748
1749 let hit_center = |s: &EngineState| {
1751 let areas: Vec<serde_json::Value> =
1752 serde_json::from_str(&s.dimension_hit_areas_json()).unwrap();
1753 let c = areas
1754 .iter()
1755 .find(|a| a["kind"] == "circle" && a["r"].as_f64().unwrap() > 10.0)
1756 .expect("the arc sweep-end grab circle")
1757 .clone();
1758 [c["c"][0].as_f64().unwrap(), c["c"][1].as_f64().unwrap()]
1759 };
1760 let wpp_baked = state.camera.world_per_pixel();
1761 let drawn_handle = |s: &EngineState, wpp: f64| {
1762 let ann = s.feature_dimension_annotations("Rev");
1763 let ann = ann.iter().find(|a| a.field_key == "angle").unwrap();
1764 let p = crate::feature_dimensions::arrow_handle_point(ann, wpp);
1765 let (x, y, _) = s.camera.project(p);
1766 [x, y]
1767 };
1768 let at_arm = drawn_handle(&state, wpp_baked);
1769 let hit_at_arm = hit_center(&state);
1770 assert!(
1771 (at_arm[0] - hit_at_arm[0]).hypot(at_arm[1] - hit_at_arm[1]) < 1.0,
1772 "at arm the grab circle sits on the drawn handle"
1773 );
1774
1775 state.camera.projection = crate::view::Projection::Orthographic { half_height: 40.0 };
1777 state.ensure_overlays_current();
1778
1779 let after = extent(&state);
1781 assert!(
1782 (after / before - 2.0).abs() < 0.05,
1783 "the drawn arc must scale with the zoom: {before} → {after}"
1784 );
1785 let wpp_now = state.camera.world_per_pixel();
1788 let drawn_now = drawn_handle(&state, wpp_now);
1789 let hit_now = hit_center(&state);
1790 assert!(
1791 (drawn_now[0] - hit_now[0]).hypot(drawn_now[1] - hit_now[1]) < 1.0,
1792 "after the zoom the grab circle still sits on the drawn handle"
1793 );
1794 let stale = drawn_handle(&state, wpp_baked);
1796 assert!(
1797 (stale[0] - hit_now[0]).hypot(stale[1] - hit_now[1]) > 20.0,
1798 "the OLD-zoom handle is nowhere near the live grab circle"
1799 );
1800 }
1801
1802 #[test]
1807 fn every_zoom_path_rebakes_the_dimension_gizmo() {
1808 type Zoom = (&'static str, fn(&mut EngineState));
1809 let paths: Vec<Zoom> = vec![
1810 ("wheel", |s| {
1811 assert!(s.wheel(-240.0, None), "the wheel zooms");
1812 }),
1813 ("zoom-to-fit", |s| s.zoom_to_fit()),
1814 ("standard view (View menu — it zoom-to-fits)", |s| {
1815 assert!(s.standard_view("FRONT"));
1816 }),
1817 ("viewport resize", |s| s.resize(400.0, 200.0)),
1818 ("projection half-height", |s| {
1819 s.camera.projection = crate::view::Projection::Orthographic { half_height: 7.0 };
1820 }),
1821 ];
1822 for (name, apply) in paths {
1823 let mut state = cube_engine();
1824 state.resize(800.0, 600.0);
1825 state.camera.eye = [30.0, 20.0, 40.0];
1826 state.camera.target = [0.0, 0.0, 0.0];
1827 state.camera.up = [0.0, 1.0, 0.0];
1828 state.camera.projection =
1829 crate::view::Projection::Orthographic { half_height: 200.0 };
1830 state.arm_dimension("Box");
1831 let baked = state.feature_dim_overlay_wpp;
1832 assert!(baked > 0.0, "{name}: armed → baked");
1833
1834 apply(&mut state);
1835 let live = state.camera.world_per_pixel();
1836 assert!(
1837 super::overlay_wpp_stale(baked, live),
1838 "{name} must move world-per-pixel ({baked} → {live})"
1839 );
1840 state.ensure_overlays_current();
1841 assert!(
1842 (state.feature_dim_overlay_wpp - live).abs() < live * 1e-9,
1843 "{name}: the gizmo is re-baked at the LIVE zoom"
1844 );
1845 }
1846 }
1847
1848 #[test]
1861 fn pan_orbit_viewcube_and_projection_toggle_need_no_rebake() {
1862 let mut state = cube_engine();
1863 state.resize(800.0, 600.0);
1864 state.camera.eye = [30.0, 20.0, 40.0];
1865 state.camera.target = [0.0, 0.0, 0.0];
1866 state.camera.up = [0.0, 1.0, 0.0];
1867 state.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1868 state.arm_dimension("Box");
1869 let baked = state.feature_dim_overlay_wpp;
1870
1871 state.apply_look_direction([0.0, 0.0, 1.0], [0.0, 1.0, 0.0]);
1873 state.camera.eye = [35.0, 25.0, 45.0];
1875 state.camera.target = [5.0, 5.0, 5.0];
1876 assert_eq!(state.toggle_projection(), "perspective");
1878
1879 let live = state.camera.world_per_pixel();
1880 assert!(
1881 !super::overlay_wpp_stale(baked, live),
1882 "pan/orbit/ViewCube/projection-toggle hold world-per-pixel ({baked} → {live})"
1883 );
1884 state.dirty = false;
1885 state.ensure_overlays_current();
1886 assert!(!state.dirty, "none of these must re-bake");
1887
1888 let anns = state.feature_dimension_annotations("Box");
1891 for ann in &anns {
1892 let (mx, my, _) = state.camera.project(ann.midpoint());
1893 assert_eq!(
1894 state.dimension_arrow_pick(mx, my).as_deref(),
1895 Some(ann.field_key.as_str()),
1896 "after pan+orbit+toggle the {} leader still grabs on its shaft",
1897 ann.field_key
1898 );
1899 }
1900 }
1901
1902 fn revolve_end_cap_and_boundary_edge() -> (String, String) {
1909 let state = revolve_engine(90.0);
1910 let solid = state.scene.solid("Rev").expect("the revolve solid is resident");
1911 let cap = solid
1912 .faces
1913 .iter()
1914 .map(|face| face.name.clone())
1915 .find(|name| name.ends_with("_END"))
1916 .expect("the revolve names its END cap");
1917 let edge = solid
1918 .edges
1919 .iter()
1920 .map(|edge| edge.name.clone())
1921 .find(|name| name.split('|').any(|side| side == cap))
1922 .expect("the END cap has a named boundary edge");
1923 (cap, edge)
1924 }
1925
1926 fn engine_with_extra_feature(feature: serde_json::Value) -> EngineState {
1929 let request = serde_json::json!({
1930 "expressions": "",
1931 "configurator": {},
1932 "features": [
1933 {
1934 "type": "S",
1935 "inputParams": { "id": "Sk" },
1936 "persistentData": {
1937 "basis": { "origin": [0,0,0], "x": [1,0,0], "y": [0,1,0], "z": [0,0,1] },
1938 "sketch": {
1939 "points": [
1940 {"id":1,"x":2.0,"y":0.0}, {"id":2,"x":4.0,"y":0.0},
1941 {"id":3,"x":4.0,"y":3.0}, {"id":4,"x":2.0,"y":3.0},
1942 {"id":5,"x":0.0,"y":0.0}, {"id":6,"x":0.0,"y":1.0}
1943 ],
1944 "geometries": [
1945 {"id":10,"type":"line","points":[1,2]},
1946 {"id":11,"type":"line","points":[2,3]},
1947 {"id":12,"type":"line","points":[3,4]},
1948 {"id":13,"type":"line","points":[4,1]},
1949 {"id":20,"type":"line","points":[5,6],"construction":true}
1950 ],
1951 "constraints": []
1952 }
1953 }
1954 },
1955 {
1956 "type": "R",
1957 "inputParams": { "id": "Rev", "profile": "Sk", "axis": "Sk:G20", "angle": 90.0 },
1958 "persistentData": {}
1959 },
1960 feature
1961 ]
1962 })
1963 .to_string();
1964 let mut state = EngineState::new();
1965 state.set_history_json(&request).expect("the face-profile feature builds");
1966 state
1967 }
1968
1969 fn fd_cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
1970 [
1971 a[1] * b[2] - a[2] * b[1],
1972 a[2] * b[0] - a[0] * b[2],
1973 a[0] * b[1] - a[1] * b[0],
1974 ]
1975 }
1976
1977 #[test]
1985 fn revolve_off_a_resident_face_profile_emits_its_angle_dim() {
1986 let (cap, edge) = revolve_end_cap_and_boundary_edge();
1987 let state = engine_with_extra_feature(serde_json::json!({
1988 "type": "R",
1989 "inputParams": { "id": "Rev2", "profile": cap, "axis": edge, "angle": 120.0 },
1990 "persistentData": {}
1991 }));
1992 assert!(state.scene.solid("Rev2").is_some(), "the face-profile revolve built a solid");
1993
1994 let anns = state.feature_dimension_annotations("Rev2");
1995 assert_eq!(anns.len(), 1, "a face-profile revolve emits its angle dim: {anns:?}");
1996 let ann = &anns[0];
1997 assert_eq!(ann.kind, crate::feature_dimensions::FeatureDimKind::Angular);
1998 assert_eq!(ann.field_key, "angle");
1999 assert!((ann.value - 120.0).abs() < 1e-9, "value is the angle: {}", ann.value);
2000
2001 let poly = state.scene.edge_polyline_world(&edge).expect("axis edge polyline");
2003 let (a, b) = (poly[0], *poly.last().unwrap());
2004 let edge_dir = fd_normalize3(fd_sub3(b, a));
2005 let along = fd_dot3(fd_sub3(ann.center, a), edge_dir);
2006 let foot = [
2007 a[0] + edge_dir[0] * along,
2008 a[1] + edge_dir[1] * along,
2009 a[2] + edge_dir[2] * along,
2010 ];
2011 assert!(
2012 fd_norm3(fd_sub3(ann.center, foot)) < 1e-6,
2013 "arc center {:?} lies on the axis edge {a:?}→{b:?}",
2014 ann.center
2015 );
2016 assert!(
2017 fd_dot3(ann.axis, edge_dir).abs() > 1.0 - 1e-9,
2018 "arc axis {:?} is the edge direction {edge_dir:?}",
2019 ann.axis
2020 );
2021 assert!(fd_dot3(ann.ref_dir, ann.axis).abs() < 1e-9, "zero reference ⟂ axis");
2022
2023 let tangent = fd_cross3(ann.axis, ann.ref_dir);
2027 let (_, cap_normal) = state.scene.face_plane_world(&cap).expect("cap plane");
2028 assert!(
2029 fd_dot3(tangent, cap_normal) > 0.5,
2030 "the arc sweeps toward the cap's outward normal {cap_normal:?}: tangent {tangent:?}"
2031 );
2032 let bbox = &state.scene.solid("Rev2").unwrap().bbox;
2033 let bbox_center = [
2034 (bbox.min[0] + bbox.max[0]) * 0.5,
2035 (bbox.min[1] + bbox.max[1]) * 0.5,
2036 (bbox.min[2] + bbox.max[2]) * 0.5,
2037 ];
2038 assert!(
2039 fd_dot3(tangent, fd_sub3(bbox_center, ann.center)) > 0.5,
2040 "the built solid lies on the arc's sweep side: bbox center {bbox_center:?}"
2041 );
2042
2043 let json: serde_json::Value =
2046 serde_json::from_str(&state.feature_dimension_annotations_json("Rev2")).unwrap();
2047 let arr = json.as_array().unwrap();
2048 assert_eq!(arr.len(), 1, "one angular dim in JSON: {arr:?}");
2049 assert_eq!(arr[0]["fieldKey"], "angle");
2050 assert_eq!(arr[0]["kind"], "angular");
2051 }
2052
2053 #[test]
2058 fn extrude_off_a_resident_face_profile_emits_its_distance_dims() {
2059 let (cap, _) = revolve_end_cap_and_boundary_edge();
2060 let state = engine_with_extra_feature(serde_json::json!({
2061 "type": "E",
2062 "inputParams": { "id": "Ext", "profile": cap, "distance": 5.0 },
2063 "persistentData": {}
2064 }));
2065 assert!(state.scene.solid("Ext").is_some(), "the face-profile extrude built a solid");
2066
2067 let anns = state.feature_dimension_annotations("Ext");
2068 assert_eq!(anns.len(), 2, "a face-profile extrude emits distance + distanceBack: {anns:?}");
2069 assert_eq!(anns[0].field_key, "distance");
2070 assert_eq!(anns[1].field_key, "distanceBack");
2071 let (centroid, normal) = state.scene.face_plane_world(&cap).expect("cap plane");
2072 assert!(
2073 fd_norm3(fd_sub3(anns[0].point_a, centroid)) < 1e-9,
2074 "anchored at the face centroid {centroid:?}: {:?}",
2075 anns[0].point_a
2076 );
2077 let leg = fd_sub3(anns[0].point_b, anns[0].point_a);
2078 assert!((fd_norm3(leg) - 5.0).abs() < 1e-9, "the D leg is `distance` long: {leg:?}");
2079 assert!(
2080 fd_dot3(fd_normalize3(leg), normal) > 1.0 - 1e-9,
2081 "the D leg runs along the outward normal {normal:?}: {leg:?}"
2082 );
2083 let bbox = &state.scene.solid("Ext").unwrap().bbox;
2086 let corners = (0..8).map(|k| {
2087 [
2088 if k & 1 == 0 { bbox.min[0] } else { bbox.max[0] },
2089 if k & 2 == 0 { bbox.min[1] } else { bbox.max[1] },
2090 if k & 4 == 0 { bbox.min[2] } else { bbox.max[2] },
2091 ]
2092 });
2093 let heights: Vec<f64> = corners.map(|c| fd_dot3(fd_sub3(c, centroid), normal)).collect();
2094 let top = heights.iter().cloned().fold(f64::MIN, f64::max);
2095 let bottom = heights.iter().cloned().fold(f64::MAX, f64::min);
2096 assert!((top - 5.0).abs() < 1e-3, "solid reaches distance 5 along the normal: {top}");
2097 assert!(bottom.abs() < 1e-3, "solid starts at the cap plane: {bottom}");
2098 }
2099
2100 #[test]
2105 fn face_plane_world_of_a_cube_face_is_its_center_with_the_outward_normal() {
2106 let state = cube_engine();
2107 let solid = state.scene.solid("Box").expect("cube resident");
2108 let size = [10.0, 20.0, 30.0];
2109 let mid = [5.0, 10.0, 15.0];
2110 let named: Vec<&str> = solid
2111 .faces
2112 .iter()
2113 .map(|face| face.name.as_str())
2114 .filter(|name| !name.is_empty())
2115 .collect();
2116 assert_eq!(named.len(), 6, "six named cube faces: {named:?}");
2117 for name in named {
2118 let (centroid, normal) = state
2119 .scene
2120 .face_plane_world(name)
2121 .unwrap_or_else(|| panic!("face {name} has a plane"));
2122 assert!((fd_norm3(normal) - 1.0).abs() < 1e-9, "{name}: unit normal {normal:?}");
2123 let axis = (0..3)
2124 .find(|&k| normal[k].abs() > 1.0 - 1e-6)
2125 .unwrap_or_else(|| panic!("{name}: axis-aligned normal {normal:?}"));
2126 let expected_axis_coord = if normal[axis] > 0.0 { size[axis] } else { 0.0 };
2127 for k in 0..3 {
2128 let expected = if k == axis { expected_axis_coord } else { mid[k] };
2129 assert!(
2130 (centroid[k] - expected).abs() < 1e-4,
2131 "{name}: centroid {centroid:?} is the face center"
2132 );
2133 }
2134 assert!(
2135 fd_dot3(normal, fd_sub3(centroid, mid)) > 0.0,
2136 "{name}: normal {normal:?} points outward from {centroid:?}"
2137 );
2138 }
2139 assert!(state.scene.face_plane_world("Box:NOPE").is_none());
2140 }
2141}