use crate::{GizmoCamera, Overlay, Vec3};
pub const ARROW_LEN_PX: f32 = 14.0;
pub const ARROW_HALF_WIDTH_PX: f32 = 5.0;
pub const ARROW_CONE_SEGMENTS: usize = 12;
pub const DEFAULT_ANGLE_RADIUS_PX: f32 = 120.0;
pub const MIN_ANGLE_RADIUS_PX: f32 = 40.0;
pub const ANGLE_EXT_PX: f32 = 18.0;
pub const LABEL_GAP_PX: f32 = 10.0;
pub const DIMENSION_COLOR: [f32; 4] = [1.0, 0.72, 0.30, 1.0];
#[derive(Debug, Clone)]
pub struct DimensionAnnotation {
pub overlay: Overlay,
pub label_anchor: Vec3,
}
fn rotate_about_axis(v: Vec3, axis: Vec3, angle: f32) -> Vec3 {
let (s, c) = angle.sin_cos();
v.scale(c)
.add(axis.cross(v).scale(s))
.add(axis.scale(axis.dot(v) * (1.0 - c)))
}
fn arrowhead(
ov: &mut Overlay,
apex: Vec3,
dir: Vec3,
len: f32,
radius: f32,
color: [f32; 4],
) {
let axis = dir.normalized();
if axis.length() < 1e-9 || len <= 0.0 || radius <= 0.0 {
return;
}
let base = apex.sub(axis.scale(len));
let u = axis.any_perp();
let v = axis.cross(u).normalized();
let ring = |k: usize| -> Vec3 {
let ang = (k as f32 / ARROW_CONE_SEGMENTS as f32) * std::f32::consts::TAU;
base.add(u.scale(ang.cos() * radius))
.add(v.scale(ang.sin() * radius))
};
let mut prev = ring(0);
for k in 1..=ARROW_CONE_SEGMENTS {
let cur = ring(k);
ov.tri(apex, prev, cur, color); ov.tri(base, cur, prev, color); prev = cur;
}
}
pub fn linear_dimension(
a: Vec3,
b: Vec3,
offset_dir: Vec3,
offset_dist: f32,
camera: &GizmoCamera,
) -> DimensionAnnotation {
linear_dimension_colored(a, b, offset_dir, offset_dist, camera, DIMENSION_COLOR)
}
pub fn linear_dimension_colored(
a: Vec3,
b: Vec3,
offset_dir: Vec3,
offset_dist: f32,
camera: &GizmoCamera,
color: [f32; 4],
) -> DimensionAnnotation {
let mut ov = Overlay::new();
let dir_raw = b.sub(a);
let len_ab = dir_raw.length();
let dir = if len_ab < 1e-9 {
Vec3::X
} else {
dir_raw.scale(1.0 / len_ab)
};
let mut od = offset_dir.sub(dir.scale(offset_dir.dot(dir)));
if od.length() < 1e-9 {
od = dir.any_perp();
}
od = od.normalized();
let da = a.add(od.scale(offset_dist));
let db = b.add(od.scale(offset_dist));
ov.line(a, da, color);
ov.line(b, db, color);
ov.line(da, db, color);
let wpp_a = camera.world_per_pixel(da);
let wpp_b = camera.world_per_pixel(db);
arrowhead(
&mut ov,
da,
dir.scale(-1.0),
ARROW_LEN_PX * wpp_a,
ARROW_HALF_WIDTH_PX * wpp_a,
color,
);
arrowhead(
&mut ov,
db,
dir,
ARROW_LEN_PX * wpp_b,
ARROW_HALF_WIDTH_PX * wpp_b,
color,
);
let label_anchor = da.lerp(db, 0.5);
DimensionAnnotation {
overlay: ov,
label_anchor,
}
}
pub fn angular_arc_points(vertex: Vec3, dir_a: Vec3, dir_b: Vec3, radius: f32) -> Vec<Vec3> {
let a = dir_a.normalized();
let b = dir_b.normalized();
let mut normal = a.cross(b);
if normal.length() < 1e-9 {
return Vec::new();
}
normal = normal.normalized();
let sweep = a.dot(b).clamp(-1.0, 1.0).acos();
let steps = (((sweep / (5.0_f32).to_radians()).ceil()) as usize).max(8);
let mut out = Vec::with_capacity(steps + 1);
for i in 0..=steps {
let t = i as f32 / steps as f32;
let d = rotate_about_axis(a, normal, sweep * t);
out.push(vertex.add(d.scale(radius)));
}
out
}
pub fn angular_dimension(
vertex: Vec3,
dir_a: Vec3,
dir_b: Vec3,
radius: f32,
camera: &GizmoCamera,
) -> DimensionAnnotation {
angular_dimension_colored(vertex, dir_a, dir_b, radius, camera, DIMENSION_COLOR)
}
pub fn angular_dimension_colored(
vertex: Vec3,
dir_a: Vec3,
dir_b: Vec3,
radius: f32,
camera: &GizmoCamera,
color: [f32; 4],
) -> DimensionAnnotation {
let mut ov = Overlay::new();
let a = dir_a.normalized();
let b = dir_b.normalized();
let wpp = camera.world_per_pixel(vertex);
let mut r = if radius > 1e-6 {
radius
} else {
DEFAULT_ANGLE_RADIUS_PX * wpp
};
r = r.max(MIN_ANGLE_RADIUS_PX * wpp);
let mut normal = a.cross(b);
if normal.length() < 1e-9 {
return DimensionAnnotation {
overlay: ov,
label_anchor: vertex.add(a.scale(r)),
};
}
normal = normal.normalized();
let sweep = a.dot(b).clamp(-1.0, 1.0).acos();
let pts = angular_arc_points(vertex, a, b, r);
for w in pts.windows(2) {
ov.line(w[0], w[1], color);
}
let ext = r + ANGLE_EXT_PX * wpp;
ov.line(vertex, vertex.add(a.scale(ext)), color);
ov.line(vertex, vertex.add(b.scale(ext)), color);
if pts.len() >= 2 {
let n = pts.len();
let start_dir = pts[0].sub(pts[1]).normalized();
arrowhead(
&mut ov,
pts[0],
start_dir,
ARROW_LEN_PX * wpp,
ARROW_HALF_WIDTH_PX * wpp,
color,
);
let end_dir = pts[n - 1].sub(pts[n - 2]).normalized();
arrowhead(
&mut ov,
pts[n - 1],
end_dir,
ARROW_LEN_PX * wpp,
ARROW_HALF_WIDTH_PX * wpp,
color,
);
}
let mid_dir = rotate_about_axis(a, normal, sweep * 0.5);
let label_anchor = vertex.add(mid_dir.scale(r));
DimensionAnnotation {
overlay: ov,
label_anchor,
}
}
pub fn radial_dimension(
center: Vec3,
point_on_circle: Vec3,
camera: &GizmoCamera,
) -> DimensionAnnotation {
radial_dimension_colored(center, point_on_circle, camera, DIMENSION_COLOR)
}
pub fn radial_dimension_colored(
center: Vec3,
point_on_circle: Vec3,
camera: &GizmoCamera,
color: [f32; 4],
) -> DimensionAnnotation {
let mut ov = Overlay::new();
let radial = point_on_circle.sub(center);
let dist = radial.length();
let dir = if dist < 1e-9 {
Vec3::X
} else {
radial.scale(1.0 / dist)
};
let wpp = camera.world_per_pixel(point_on_circle);
ov.line(center, point_on_circle, color);
arrowhead(
&mut ov,
point_on_circle,
dir,
ARROW_LEN_PX * wpp,
ARROW_HALF_WIDTH_PX * wpp,
color,
);
let gap = LABEL_GAP_PX * wpp;
let anchor = point_on_circle.add(dir.scale(gap.max(0.0)));
ov.line(point_on_circle, anchor, color);
DimensionAnnotation {
overlay: ov,
label_anchor: anchor,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cam(vp_px: f32) -> GizmoCamera {
let view_proj =
crate::raster::test_view_proj([0.0, 0.0, 20.0], [0.0, 0.0, 0.0], vp_px, vp_px);
GizmoCamera {
view_proj,
eye: Vec3::new(0.0, 0.0, 20.0),
forward: Vec3::new(0.0, 0.0, -1.0),
up: Vec3::Y,
viewport: [vp_px, vp_px],
orthographic: true,
}
}
fn approx(a: Vec3, b: Vec3, tol: f32) -> bool {
a.sub(b).length() < tol
}
const PER_CONE_VERTS: usize = 6 * ARROW_CONE_SEGMENTS;
fn arrowhead_len(ann: &DimensionAnnotation, i: usize) -> f32 {
let base_idx = i * PER_CONE_VERTS;
let apex = Vec3::from(ann.overlay.tris[base_idx].pos);
let base_center = Vec3::from(ann.overlay.tris[base_idx + 3].pos);
apex.sub(base_center).length()
}
#[test]
fn linear_emits_extensions_dimension_line_and_two_arrowheads() {
let c = cam(200.0);
let a = Vec3::new(-3.0, 0.0, 0.0);
let b = Vec3::new(3.0, 0.0, 0.0);
let ann = linear_dimension(a, b, Vec3::new(0.0, -1.0, 0.0), 2.0, &c);
assert_eq!(ann.overlay.lines.len(), 6, "expected 3 line segments");
assert_eq!(
ann.overlay.tris.len(),
2 * PER_CONE_VERTS,
"expected 2 arrowhead cones"
);
assert!(
approx(ann.label_anchor, Vec3::new(0.0, -2.0, 0.0), 1e-4),
"label anchor {:?} should be dimension-line midpoint",
ann.label_anchor
);
}
#[test]
fn linear_arrowhead_apexes_sit_on_the_dimension_line_ends() {
let c = cam(200.0);
let a = Vec3::new(-3.0, 0.0, 0.0);
let b = Vec3::new(3.0, 0.0, 0.0);
let ann = linear_dimension(a, b, Vec3::new(0.0, -1.0, 0.0), 2.0, &c);
let apex0 = Vec3::from(ann.overlay.tris[0].pos);
let apex1 = Vec3::from(ann.overlay.tris[PER_CONE_VERTS].pos);
assert!(approx(apex0, Vec3::new(-3.0, -2.0, 0.0), 1e-4), "{:?}", apex0);
assert!(approx(apex1, Vec3::new(3.0, -2.0, 0.0), 1e-4), "{:?}", apex1);
}
#[test]
fn arrowheads_are_screen_constant_across_two_zooms() {
let a = Vec3::new(-3.0, 0.0, 0.0);
let b = Vec3::new(3.0, 0.0, 0.0);
let od = Vec3::new(0.0, -1.0, 0.0);
let c1 = cam(100.0);
let c2 = cam(200.0);
let ann1 = linear_dimension(a, b, od, 2.0, &c1);
let ann2 = linear_dimension(a, b, od, 2.0, &c2);
let apex = Vec3::new(-3.0, -2.0, 0.0);
let px1 = arrowhead_len(&ann1, 0) / c1.world_per_pixel(apex);
let px2 = arrowhead_len(&ann2, 0) / c2.world_per_pixel(apex);
assert!((px1 - ARROW_LEN_PX).abs() < 0.5, "px1 {px1}");
assert!((px2 - ARROW_LEN_PX).abs() < 0.5, "px2 {px2}");
assert!((px1 - px2).abs() < 0.5, "pixel sizes differ: {px1} vs {px2}");
assert!(
arrowhead_len(&ann1, 0) > arrowhead_len(&ann2, 0) * 1.5,
"zoomed-out arrowhead should be larger in world units"
);
}
#[test]
fn angular_arc_has_the_right_sweep() {
let v = Vec3::ZERO;
let a = Vec3::new(1.0, 0.0, 0.0);
let b = Vec3::new(0.0, 1.0, 0.0); let pts = angular_arc_points(v, a, b, 3.0);
assert!(pts.len() >= 9, "arc should be sampled");
let d0 = pts[0].sub(v).normalized();
let dn = pts[pts.len() - 1].sub(v).normalized();
let sweep = d0.dot(dn).clamp(-1.0, 1.0).acos();
assert!(
(sweep - std::f32::consts::FRAC_PI_2).abs() < 1e-2,
"sweep {} should be 90 deg",
sweep.to_degrees()
);
assert!(approx(pts[0], Vec3::new(3.0, 0.0, 0.0), 1e-3));
assert!(approx(pts[pts.len() - 1], Vec3::new(0.0, 3.0, 0.0), 1e-3));
}
#[test]
fn angular_emits_arc_witness_lines_and_two_arrowheads() {
let c = cam(200.0);
let v = Vec3::ZERO;
let a = Vec3::new(1.0, 0.0, 0.0);
let b = Vec3::new(0.0, 1.0, 0.0);
let ann = angular_dimension(v, a, b, 3.0, &c);
assert!(ann.overlay.lines.len() >= (10) * 2, "arc+witness line count");
assert_eq!(
ann.overlay.tris.len(),
2 * PER_CONE_VERTS,
"expected 2 arrowhead cones"
);
let expect = Vec3::new(1.0, 1.0, 0.0).normalized().scale(3.0);
assert!(
approx(ann.label_anchor, expect, 1e-3),
"angular label anchor {:?} vs {:?}",
ann.label_anchor,
expect
);
}
#[test]
fn radial_emits_leader_and_one_arrowhead() {
let c = cam(200.0);
let center = Vec3::ZERO;
let pc = Vec3::new(4.0, 0.0, 0.0);
let ann = radial_dimension(center, pc, &c);
assert_eq!(ann.overlay.lines.len(), 4, "expected 2 leader segments");
assert_eq!(ann.overlay.tris.len(), PER_CONE_VERTS, "expected 1 arrowhead cone");
assert!(ann.label_anchor.x > 4.0, "anchor {:?}", ann.label_anchor);
assert!(ann.label_anchor.sub(Vec3::new(4.0, 0.0, 0.0)).length() < 1.0);
}
#[test]
fn parallel_rays_yield_no_arc() {
let c = cam(200.0);
let ann = angular_dimension(
Vec3::ZERO,
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
2.0,
&c,
);
assert!(ann.overlay.tris.is_empty());
assert!(angular_arc_points(Vec3::ZERO, Vec3::X, Vec3::X, 2.0).is_empty());
}
}