use crate::style::parse_css_hex;
use crate::view::{Projection, ViewCamera};
use brep_gizmos::datum::{DatumAxis, DatumPlane};
use brep_gizmos::transform::{DragDelta, TransformGizmo};
use brep_gizmos::view_cube::ViewCube;
use brep_gizmos::{
curve_display, datum, dimension, Gizmo, GizmoCamera, HandleId, LineVertex, Overlay, TriVertex,
Vec3,
};
use serde_json::Value;
pub fn gizmo_camera(view: &ViewCamera) -> GizmoCamera {
let resolved = view.resolve();
let (_, up, forward) = view.basis();
GizmoCamera {
view_proj: resolved.view_proj,
eye: v3f(view.eye),
forward: v3f(forward),
up: v3f(up),
viewport: [view.width as f32, view.height as f32],
orthographic: matches!(view.projection, Projection::Orthographic { .. }),
}
}
fn v3f(a: [f64; 3]) -> Vec3 {
Vec3::new(a[0] as f32, a[1] as f32, a[2] as f32)
}
fn vec3_of(v: &Value) -> Option<Vec3> {
let a = v.as_array()?;
Some(Vec3::new(
a.first()?.as_f64()? as f32,
a.get(1)?.as_f64()? as f32,
a.get(2)?.as_f64()? as f32,
))
}
fn color_of(v: Option<&Value>, default: [f32; 4]) -> [f32; 4] {
match v.and_then(|v| v.as_str()).and_then(parse_css_hex) {
Some(rgb) => [rgb[0], rgb[1], rgb[2], 1.0],
None => default,
}
}
fn flag(v: &Value, key: &str) -> bool {
v.get(key).and_then(|b| b.as_bool()).unwrap_or(false)
}
fn f32_array(v: Option<&Value>) -> Vec<f32> {
v.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|x| x.as_f64().map(|f| f as f32)).collect())
.unwrap_or_default()
}
fn rgb_at(colors: &[f32], i: usize) -> [f32; 4] {
let base = i * 3;
if base + 2 < colors.len() {
[colors[base], colors[base + 1], colors[base + 2], 1.0]
} else if colors.len() >= 3 {
let n = colors.len();
[colors[n - 3], colors[n - 2], colors[n - 1], 1.0]
} else {
[1.0, 1.0, 1.0, 1.0]
}
}
fn pos_at(positions: &[f32], i: usize) -> Vec3 {
let base = i * 3;
if base + 2 < positions.len() {
Vec3::new(positions[base], positions[base + 1], positions[base + 2])
} else {
Vec3::ZERO
}
}
fn push_point_quad(ov: &mut Overlay, center: Vec3, color: [f32; 4], size_px: f32, cam: &GizmoCamera) {
let half = 0.5 * size_px.max(1.0) * cam.world_per_pixel(center);
let right = cam.screen_right(center);
let up = right.cross(cam.forward).normalized();
let rx = right.scale(half);
let uy = up.scale(half);
let normal: [f32; 3] = cam.forward.scale(-1.0).into();
let a = center.sub(rx).sub(uy);
let b = center.add(rx).sub(uy);
let c = center.add(rx).add(uy);
let d = center.sub(rx).add(uy);
for p in [a, b, c, a, c, d] {
ov.tris.push(TriVertex { pos: p.into(), normal, color });
}
}
fn brighten(c: [f32; 4]) -> [f32; 4] {
[
(c[0] * 1.35 + 0.1).min(1.0),
(c[1] * 1.35 + 0.1).min(1.0),
(c[2] * 1.35 + 0.1).min(1.0),
c[3],
]
}
const DEFAULT_AXIS_COLOR: [f32; 4] = [0.72, 0.74, 0.80, 1.0];
struct PlaneW {
name: String,
origin: Vec3,
x: Vec3,
y: Vec3,
size: Option<f32>,
color: [f32; 4],
hot: bool,
}
struct AxisW {
name: String,
point: Vec3,
direction: Vec3,
length: f32,
color: [f32; 4],
hot: bool,
}
struct FrameW {
origin: Vec3,
x: Vec3,
y: Vec3,
z: Vec3,
px: f32,
}
struct CurveW {
points: Vec<Vec3>,
closed: bool,
color: [f32; 4],
}
struct OverlayPointW {
center: Vec3,
color: [f32; 4],
}
struct OverlayGroupW {
name: String,
render_order: i32,
tris: Vec<TriVertex>,
lines: Vec<LineVertex>,
points: Vec<OverlayPointW>,
point_size: f32,
}
const DEFAULT_OVERLAY_POINT_PX: f32 = 6.0;
impl OverlayGroupW {
fn from_json(g: &Value) -> Self {
let name = g.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
let render_order = g.get("renderOrder").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
let mut tris = Vec::new();
if let Some(t) = g.get("tris") {
let positions = f32_array(t.get("positions"));
let colors = f32_array(t.get("colors"));
let normals = f32_array(t.get("normals"));
let vcount = positions.len() / 3;
let tri_count = vcount / 3;
for tri in 0..tri_count {
let vi = [tri * 3, tri * 3 + 1, tri * 3 + 2];
let p = [pos_at(&positions, vi[0]), pos_at(&positions, vi[1]), pos_at(&positions, vi[2])];
let face_n = p[1].sub(p[0]).cross(p[2].sub(p[0])).normalized();
for k in 0..3 {
let idx = vi[k];
let normal: [f32; 3] = if normals.len() >= (idx + 1) * 3 {
[normals[idx * 3], normals[idx * 3 + 1], normals[idx * 3 + 2]]
} else {
face_n.into()
};
tris.push(TriVertex {
pos: p[k].into(),
normal,
color: rgb_at(&colors, idx),
});
}
}
}
let mut lines = Vec::new();
if let Some(l) = g.get("lines") {
let positions = f32_array(l.get("positions"));
let colors = f32_array(l.get("colors"));
let vcount = positions.len() / 3;
let seg_count = vcount / 2;
for seg in 0..seg_count {
let a = seg * 2;
let b = seg * 2 + 1;
lines.push(LineVertex { pos: pos_at(&positions, a).into(), color: rgb_at(&colors, a) });
lines.push(LineVertex { pos: pos_at(&positions, b).into(), color: rgb_at(&colors, b) });
}
}
let mut points = Vec::new();
let mut point_size = DEFAULT_OVERLAY_POINT_PX;
if let Some(pt) = g.get("points") {
let positions = f32_array(pt.get("positions"));
let colors = f32_array(pt.get("colors"));
point_size = pt.get("size").and_then(|v| v.as_f64()).unwrap_or(DEFAULT_OVERLAY_POINT_PX as f64) as f32;
let count = positions.len() / 3;
for i in 0..count {
points.push(OverlayPointW { center: pos_at(&positions, i), color: rgb_at(&colors, i) });
}
}
Self { name, render_order, tris, lines, points, point_size }
}
fn is_empty(&self) -> bool {
self.tris.is_empty() && self.lines.is_empty() && self.points.is_empty()
}
}
enum DimW {
Linear {
id: String,
a: Vec3,
b: Vec3,
offset_dir: Vec3,
offset: f32,
color: [f32; 4],
},
Angular {
id: String,
vertex: Vec3,
dir_a: Vec3,
dir_b: Vec3,
radius: f32,
color: [f32; 4],
},
Radial {
id: String,
center: Vec3,
point: Vec3,
color: [f32; 4],
},
}
pub struct WidgetRegistry {
pub viewcube_enabled: bool,
viewcube: ViewCube,
viewcube_hover: Option<HandleId>,
planes: Vec<PlaneW>,
axes: Vec<AxisW>,
frames: Vec<FrameW>,
curves: Vec<CurveW>,
dims: Vec<DimW>,
overlay_groups: Vec<OverlayGroupW>,
transform: Option<TransformGizmo>,
transform_hover: Option<HandleId>,
transform_active: Option<HandleId>,
}
impl Default for WidgetRegistry {
fn default() -> Self {
Self {
viewcube_enabled: false,
viewcube: ViewCube::new(),
viewcube_hover: None,
planes: Vec::new(),
axes: Vec::new(),
frames: Vec::new(),
curves: Vec::new(),
dims: Vec::new(),
overlay_groups: Vec::new(),
transform: None,
transform_hover: None,
transform_active: None,
}
}
}
pub struct ViewCubeFrame {
pub overlay: Overlay,
pub view_proj: [[f32; 4]; 4],
pub forward: [f32; 3],
pub rect_css: [f32; 4],
}
pub struct WidgetOverlay {
pub main: Overlay,
pub viewcube: Option<ViewCubeFrame>,
}
impl WidgetOverlay {
pub fn is_empty(&self) -> bool {
self.main.lines.is_empty() && self.main.tris.is_empty() && self.viewcube.is_none()
}
}
impl WidgetRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn has_main_overlay(&self) -> bool {
!self.planes.is_empty()
|| !self.axes.is_empty()
|| !self.frames.is_empty()
|| !self.curves.is_empty()
|| !self.dims.is_empty()
|| !self.overlay_groups.is_empty()
|| self.transform.is_some()
}
pub fn overlay_groups_bbox(&self) -> crate::camera::Aabb {
let mut bbox = crate::camera::Aabb::empty();
for group in &self.overlay_groups {
for v in &group.tris {
bbox.expand([v.pos[0] as f64, v.pos[1] as f64, v.pos[2] as f64]);
}
for v in &group.lines {
bbox.expand([v.pos[0] as f64, v.pos[1] as f64, v.pos[2] as f64]);
}
for point in &group.points {
let c: [f32; 3] = point.center.into();
bbox.expand([c[0] as f64, c[1] as f64, c[2] as f64]);
}
}
bbox
}
pub fn set_datums_json(&mut self, json: &str) -> Result<(), String> {
let value: Value =
serde_json::from_str(json).map_err(|e| format!("datums parse: {e}"))?;
self.planes.clear();
self.axes.clear();
self.frames.clear();
self.curves.clear();
if let Some(list) = value.get("planes").and_then(|v| v.as_array()) {
for p in list {
let (Some(origin), Some(x), Some(y)) = (
p.get("origin").and_then(vec3_of),
p.get("x").and_then(vec3_of),
p.get("y").and_then(vec3_of),
) else {
continue;
};
self.planes.push(PlaneW {
name: p.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
origin,
x,
y,
size: p.get("size").and_then(|v| v.as_f64()).map(|s| s as f32),
color: color_of(p.get("color"), datum::PLANE_COLOR),
hot: flag(p, "selected") || flag(p, "hovered"),
});
}
}
if let Some(list) = value.get("axes").and_then(|v| v.as_array()) {
for a in list {
let (Some(point), Some(direction)) = (
a.get("point").and_then(vec3_of),
a.get("direction").and_then(vec3_of),
) else {
continue;
};
self.axes.push(AxisW {
name: a.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
point,
direction,
length: a.get("length").and_then(|v| v.as_f64()).unwrap_or(10.0) as f32,
color: color_of(a.get("color"), DEFAULT_AXIS_COLOR),
hot: flag(a, "selected") || flag(a, "hovered"),
});
}
}
if let Some(list) = value.get("frames").and_then(|v| v.as_array()) {
for f in list {
let (Some(origin), Some(x), Some(y), Some(z)) = (
f.get("origin").and_then(vec3_of),
f.get("x").and_then(vec3_of),
f.get("y").and_then(vec3_of),
f.get("z").and_then(vec3_of),
) else {
continue;
};
self.frames.push(FrameW {
origin,
x,
y,
z,
px: f.get("px").and_then(|v| v.as_f64()).unwrap_or(datum::DEFAULT_FRAME_PX as f64)
as f32,
});
}
}
if let Some(list) = value.get("curves").and_then(|v| v.as_array()) {
for c in list {
let points: Vec<Vec3> = c
.get("points")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(vec3_of).collect())
.unwrap_or_default();
if points.len() < 2 {
continue;
}
let mut color = color_of(c.get("color"), curve_display::CURVE_COLOR);
if flag(c, "selected") || flag(c, "hovered") {
color = brighten(color);
}
self.curves.push(CurveW {
points,
closed: flag(c, "closed"),
color,
});
}
}
Ok(())
}
pub fn set_overlay_json(&mut self, json: &str) -> Result<(), String> {
let value: Value =
serde_json::from_str(json).map_err(|e| format!("overlay parse: {e}"))?;
let Some(list) = value.get("groups").and_then(|v| v.as_array()) else {
self.overlay_groups.clear();
return Ok(());
};
if list.is_empty() {
self.overlay_groups.clear();
return Ok(());
}
for g in list {
let group = OverlayGroupW::from_json(g);
self.overlay_groups.retain(|x| x.name != group.name);
if !group.is_empty() {
self.overlay_groups.push(group);
}
}
Ok(())
}
pub fn overlay_group_names(&self) -> Vec<&str> {
self.overlay_groups.iter().map(|g| g.name.as_str()).collect()
}
pub fn datum_plane_names(&self) -> Vec<(&str, bool)> {
self.planes.iter().map(|p| (p.name.as_str(), p.hot)).collect()
}
pub fn set_dimensions_json(&mut self, json: &str) -> Result<(), String> {
let value: Value =
serde_json::from_str(json).map_err(|e| format!("dimensions parse: {e}"))?;
self.dims.clear();
let Some(list) = value.as_array() else {
return Ok(());
};
for d in list {
let id = d.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
let color = color_of(d.get("color"), dimension::DIMENSION_COLOR);
match d.get("type").and_then(|v| v.as_str()) {
Some("linear") => {
if let (Some(a), Some(b), Some(offset_dir)) = (
d.get("a").and_then(vec3_of),
d.get("b").and_then(vec3_of),
d.get("offsetDir").and_then(vec3_of),
) {
self.dims.push(DimW::Linear {
id,
a,
b,
offset_dir,
offset: d.get("offset").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32,
color,
});
}
}
Some("angular") => {
if let (Some(vertex), Some(dir_a), Some(dir_b)) = (
d.get("vertex").and_then(vec3_of),
d.get("dirA").and_then(vec3_of),
d.get("dirB").and_then(vec3_of),
) {
self.dims.push(DimW::Angular {
id,
vertex,
dir_a,
dir_b,
radius: d.get("radius").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32,
color,
});
}
}
Some("radial") => {
if let (Some(center), Some(point)) = (
d.get("center").and_then(vec3_of),
d.get("pointOnCircle").and_then(vec3_of),
) {
self.dims.push(DimW::Radial {
id,
center,
point,
color,
});
}
}
_ => {}
}
}
Ok(())
}
pub fn set_transform_json(&mut self, json: &str) -> Result<(), String> {
let value: Value =
serde_json::from_str(json).map_err(|e| format!("transform parse: {e}"))?;
if value.is_null() {
self.transform = None;
self.transform_hover = None;
self.transform_active = None;
return Ok(());
}
let origin = value.get("origin").and_then(vec3_of).unwrap_or(Vec3::ZERO);
let x = value.get("x").and_then(vec3_of).unwrap_or(Vec3::X);
let y = value.get("y").and_then(vec3_of).unwrap_or(Vec3::Y);
let z = value.get("z").and_then(vec3_of).unwrap_or(Vec3::Z);
let mut gz = TransformGizmo::default();
gz.set_frame(origin, x, y, z);
gz.show_center = value
.get("showCenter")
.and_then(|v| v.as_bool())
.unwrap_or(true);
self.transform = Some(gz);
Ok(())
}
pub fn set_viewcube_enabled(&mut self, enabled: bool) {
self.viewcube_enabled = enabled;
if !enabled {
self.viewcube_hover = None;
}
}
pub fn build_main_overlay(&self, cam: &GizmoCamera) -> Overlay {
let mut ov = Overlay::new();
for p in &self.planes {
let color = if p.hot { brighten(p.color) } else { p.color };
let plane = match p.size {
Some(size) => datum::datum_plane(p.origin, p.x, p.y, size, color),
None => datum::datum_plane_screen(
p.origin,
p.x,
p.y,
datum::DEFAULT_PLANE_SCREEN_PX,
color,
cam,
),
};
ov.extend(&plane);
}
for a in &self.axes {
let color = if a.hot { brighten(a.color) } else { a.color };
ov.extend(&datum::datum_axis(a.point, a.direction, a.length, color));
}
for f in &self.frames {
ov.extend(&datum::datum_frame(f.origin, f.x, f.y, f.z, f.px, cam));
}
for c in &self.curves {
ov.extend(&curve_display::polyline_display(&c.points, c.color, c.closed));
}
for d in &self.dims {
ov.extend(&self.build_dim(d, cam).overlay);
}
if let Some(gz) = &self.transform {
ov.extend(&gz.geometry(cam, self.transform_hover, self.transform_active));
}
let mut order: Vec<&OverlayGroupW> = self.overlay_groups.iter().collect();
order.sort_by_key(|g| g.render_order);
for g in order {
ov.tris.extend_from_slice(&g.tris);
ov.lines.extend_from_slice(&g.lines);
for p in &g.points {
push_point_quad(&mut ov, p.center, p.color, g.point_size, cam);
}
}
ov
}
fn build_dim(&self, d: &DimW, cam: &GizmoCamera) -> dimension::DimensionAnnotation {
match d {
DimW::Linear {
a,
b,
offset_dir,
offset,
color,
..
} => dimension::linear_dimension_colored(*a, *b, *offset_dir, *offset, cam, *color),
DimW::Angular {
vertex,
dir_a,
dir_b,
radius,
color,
..
} => dimension::angular_dimension_colored(*vertex, *dir_a, *dir_b, *radius, cam, *color),
DimW::Radial {
center,
point,
color,
..
} => dimension::radial_dimension_colored(*center, *point, cam, *color),
}
}
pub fn dimension_anchors(&self, cam: &GizmoCamera) -> Vec<(String, [f32; 3])> {
self.dims
.iter()
.map(|d| {
let id = match d {
DimW::Linear { id, .. } | DimW::Angular { id, .. } | DimW::Radial { id, .. } => {
id.clone()
}
};
(id, self.build_dim(d, cam).label_anchor.into())
})
.collect()
}
pub fn any_visible(&self) -> bool {
self.has_main_overlay() || self.viewcube_enabled
}
pub fn build_overlay(&self, cam: &GizmoCamera) -> WidgetOverlay {
WidgetOverlay {
main: self.build_main_overlay(cam),
viewcube: self.build_viewcube(cam),
}
}
pub fn build_viewcube(&self, cam: &GizmoCamera) -> Option<ViewCubeFrame> {
if !self.viewcube_enabled {
return None;
}
let overlay = self.viewcube.geometry(cam, self.viewcube_hover, None);
let mini = self.viewcube.mini_camera(cam);
Some(ViewCubeFrame {
overlay,
view_proj: mini.view_proj,
forward: mini.forward.into(),
rect_css: self.viewcube.sub_rect(cam.viewport),
})
}
pub fn viewcube_rect(&self, cam: &GizmoCamera) -> [f32; 4] {
self.viewcube.sub_rect(cam.viewport)
}
pub fn viewcube_hit(&self, cam: &GizmoCamera, local_x: f32, local_y: f32) -> Option<HandleId> {
if !self.viewcube_enabled {
return None;
}
self.viewcube.hit(cam, [local_x, local_y])
}
pub fn set_viewcube_hover(&mut self, handle: Option<HandleId>) -> bool {
if self.viewcube_hover != handle {
self.viewcube_hover = handle;
true
} else {
false
}
}
pub fn viewcube_target(&self, handle: HandleId) -> ([f32; 3], [f32; 3]) {
(
ViewCube::target_view(handle).into(),
ViewCube::target_up(handle).into(),
)
}
pub fn datum_pick(&self, cam: &GizmoCamera, x: f32, y: f32) -> Option<String> {
for a in &self.axes {
let gz = DatumAxis {
point: a.point,
direction: a.direction,
length: a.length,
color: a.color,
handle: 1,
};
if gz.hit(cam, [x, y]).is_some() && !a.name.is_empty() {
return Some(a.name.clone());
}
}
for p in &self.planes {
let gz = DatumPlane {
origin: p.origin,
x_axis: p.x,
y_axis: p.y,
size: p.size,
color: p.color,
handle: 1,
};
if gz.hit(cam, [x, y]).is_some() && !p.name.is_empty() {
return Some(p.name.clone());
}
}
None
}
pub fn has_transform(&self) -> bool {
self.transform.is_some()
}
pub fn transform_origin(&self) -> Option<[f32; 3]> {
self.transform.as_ref().map(|gz| [gz.origin.x, gz.origin.y, gz.origin.z])
}
pub fn transform_hit(&self, cam: &GizmoCamera, x: f32, y: f32) -> HandleId {
self.transform
.as_ref()
.and_then(|gz| gz.hit(cam, [x, y]))
.unwrap_or(0)
}
pub fn set_transform_hover(&mut self, handle: HandleId) -> bool {
let handle = (handle != 0).then_some(handle);
if self.transform_hover != handle {
self.transform_hover = handle;
true
} else {
false
}
}
pub fn set_transform_active(&mut self, handle: HandleId) {
self.transform_active = (handle != 0).then_some(handle);
}
pub fn transform_drag_json(
&self,
cam: &GizmoCamera,
handle: HandleId,
sx: f32,
sy: f32,
cx: f32,
cy: f32,
) -> String {
let Some(gz) = &self.transform else {
return "{\"kind\":\"none\"}".to_string();
};
drag_delta_json(gz, cam, handle, sx, sy, cx, cy)
}
pub fn transform_drag_json_with_frame(
&self,
cam: &GizmoCamera,
frame_json: &str,
handle: HandleId,
sx: f32,
sy: f32,
cx: f32,
cy: f32,
) -> String {
let value: Value = match serde_json::from_str(frame_json) {
Ok(v) => v,
Err(_) => return "{\"kind\":\"none\"}".to_string(),
};
let origin = value.get("origin").and_then(vec3_of).unwrap_or(Vec3::ZERO);
let x = value.get("x").and_then(vec3_of).unwrap_or(Vec3::X);
let y = value.get("y").and_then(vec3_of).unwrap_or(Vec3::Y);
let z = value.get("z").and_then(vec3_of).unwrap_or(Vec3::Z);
let mut gz = TransformGizmo::default();
gz.set_frame(origin, x, y, z);
drag_delta_json(&gz, cam, handle, sx, sy, cx, cy)
}
}
fn drag_delta_json(
gz: &TransformGizmo,
cam: &GizmoCamera,
handle: HandleId,
sx: f32,
sy: f32,
cx: f32,
cy: f32,
) -> String {
let start = cam.ray_from_screen(sx, sy);
let current = cam.ray_from_screen(cx, cy);
match gz.drag_delta(cam, handle, start, current) {
DragDelta::Translate(v) => {
let world = gz.ex.scale(v.x).add(gz.ey.scale(v.y)).add(gz.ez.scale(v.z));
serde_json::json!({
"kind": "translate",
"local": [v.x, v.y, v.z],
"world": [world.x, world.y, world.z],
})
.to_string()
}
DragDelta::Rotate { axis_index, radians } => {
let axis = gz.axis(axis_index);
serde_json::json!({
"kind": "rotate",
"axisIndex": axis_index,
"axisWorld": [axis.x, axis.y, axis.z],
"radians": radians,
})
.to_string()
}
DragDelta::None => "{\"kind\":\"none\"}".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::view::ViewCamera;
fn cam() -> ViewCamera {
let mut c = ViewCamera::default();
c.width = 800.0;
c.height = 600.0;
c.eye = [0.0, 0.0, 40.0];
c.target = [0.0, 0.0, 0.0];
c.up = [0.0, 1.0, 0.0];
c.projection = Projection::Orthographic { half_height: 20.0 };
c
}
#[test]
fn datums_build_overlay_and_pick() {
let mut reg = WidgetRegistry::new();
reg.set_datums_json(
r#"{"planes":[{"name":"P1","origin":[0,0,0],"x":[1,0,0],"y":[0,1,0],"size":10.0}],
"axes":[{"name":"A1","point":[-5,0,0],"direction":[1,0,0],"length":10.0}]}"#,
)
.unwrap();
assert!(reg.has_main_overlay());
let gc = gizmo_camera(&cam());
let ov = reg.build_main_overlay(&gc);
assert!(!ov.tris.is_empty(), "plane fill tris");
assert!(!ov.lines.is_empty(), "plane border + axis lines");
let hit = reg.datum_pick(&gc, 400.0, 300.0);
assert!(hit.is_some(), "center pixel picks a datum");
}
#[test]
fn transform_feeds_and_drags() {
let mut reg = WidgetRegistry::new();
reg.set_transform_json(r#"{"origin":[0,0,0],"x":[1,0,0],"y":[0,1,0],"z":[0,0,1]}"#)
.unwrap();
assert!(reg.has_transform());
let gc = gizmo_camera(&cam());
let out = reg.transform_drag_json(
&gc,
brep_gizmos::transform::HANDLE_AXIS_X,
400.0,
300.0,
440.0,
300.0,
);
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["kind"], "translate");
assert!(v["world"][0].as_f64().unwrap() > 0.0, "world dx > 0: {out}");
reg.set_transform_json("null").unwrap();
assert!(!reg.has_transform());
}
#[test]
fn viewcube_frame_and_snap() {
let mut reg = WidgetRegistry::new();
assert!(reg.build_viewcube(&gizmo_camera(&cam())).is_none());
reg.set_viewcube_enabled(true);
let gc = gizmo_camera(&cam());
let frame = reg.build_viewcube(&gc).expect("enabled");
assert!(!frame.overlay.tris.is_empty());
let [x, _y, w, h] = reg.viewcube_rect(&gc);
assert!(x > 600.0 && (w - 135.0).abs() < 1e-3 && (h - 135.0).abs() < 1e-3);
if let Some(handle) = reg.viewcube_hit(&gc, w / 2.0, h / 2.0) {
let (dir, _up) = reg.viewcube_target(handle);
assert!((dir[0].powi(2) + dir[1].powi(2) + dir[2].powi(2) - 1.0).abs() < 1e-3);
}
}
#[test]
fn viewcube_orientation_matches_camera_for_standard_and_rolled_poses() {
use brep_gizmos::view_cube::ViewCube;
let mut poses: Vec<(String, ViewCamera)> = Vec::new();
for name in ["FRONT", "TOP", "RIGHT", "ISO"] {
let mut c = cam();
assert!(c.standard_view(name), "{name}");
poses.push((name.to_string(), c));
}
let mut rolled = cam();
rolled.standard_view("FRONT");
let (_, _, fwd) = rolled.basis();
rolled.up = crate::view::rotate3(rolled.up, fwd, 30f64.to_radians());
poses.push(("FRONT+30°roll".to_string(), rolled));
let cube = ViewCube::new();
for (label, view) in &poses {
let gc = gizmo_camera(view);
let mini = cube.mini_camera(&gc);
for (axis, world) in [
("X", [1.0f64, 0.0, 0.0]),
("Y", [0.0, 1.0, 0.0]),
("Z", [0.0, 0.0, 1.0]),
] {
let (ox, oy, _) = view.project([0.0, 0.0, 0.0]);
let (ax, ay, _) = view.project(world);
let dm = [ax - ox, ay - oy];
let c0 = mini.world_to_screen(Vec3::ZERO).expect("origin visible");
let c1 = mini
.world_to_screen(Vec3::new(world[0] as f32 * 0.4, world[1] as f32 * 0.4, world[2] as f32 * 0.4))
.expect("axis tip visible");
let dc = [(c1[0] - c0[0]) as f64, (c1[1] - c0[1]) as f64];
let lm = (dm[0] * dm[0] + dm[1] * dm[1]).sqrt();
let lc = (dc[0] * dc[0] + dc[1] * dc[1]).sqrt();
if lm < 1e-3 || lc < 1e-3 {
assert!(
lm < 1.0 && lc < 1.0,
"{label}/{axis}: axis vanishes in one camera only (main {lm}, cube {lc})"
);
continue;
}
let dot = (dm[0] * dc[0] + dm[1] * dc[1]) / (lm * lc);
assert!(
dot > 0.999,
"{label}: world {axis} axis points a different way on the cube \
(main dir {dm:?}, cube dir {dc:?}, dot {dot})"
);
}
}
}
#[test]
fn viewcube_click_targets_round_trip_standard_views() {
use brep_gizmos::view_cube::ViewCube;
let mut reg = WidgetRegistry::new();
reg.set_viewcube_enabled(true);
let cube_faces = [
("FRONT", ViewCube::FRONT),
("BACK", ViewCube::BACK),
("RIGHT", ViewCube::RIGHT),
("LEFT", ViewCube::LEFT),
("TOP", ViewCube::TOP),
("BOTTOM", ViewCube::BOTTOM),
];
for (name, want) in cube_faces {
let mut view = cam();
assert!(view.standard_view(name));
let gc = gizmo_camera(&view);
let [_, _, w, h] = reg.viewcube_rect(&gc);
let hit = reg.viewcube_hit(&gc, w / 2.0, h / 2.0);
assert_eq!(hit, Some(want), "{name}: center hit");
let (dir, up) = reg.viewcube_target(want);
let (_, _, fwd) = view.basis();
for k in 0..3 {
assert!((dir[k] as f64 - fwd[k]).abs() < 1e-6, "{name}: dir");
assert!((up[k] as f64 - view.up[k]).abs() < 1e-6, "{name}: up");
}
}
let mut view = cam();
view.standard_view("TOP");
let gc = gizmo_camera(&view);
let [_, _, w, h] = reg.viewcube_rect(&gc);
let hit = reg.viewcube_hit(&gc, w / 2.0, h * 0.66).expect("hit the cube");
assert!(
ViewCube::region_name(hit).contains("FRONT"),
"TOP view: below-center click must reach toward +Z/FRONT, got {}",
ViewCube::region_name(hit)
);
}
#[test]
fn overlay_groups_build_upsert_and_clear() {
let mut reg = WidgetRegistry::new();
reg.set_overlay_json(
r#"{"groups":[{"name":"prev","renderOrder":2,
"tris":{"positions":[0,0,0, 1,0,0, 0,1,0],"colors":[1,0,0, 0,1,0, 0,0,1]},
"lines":{"positions":[0,0,0, 2,2,2],"colors":[1,1,0, 1,1,0]},
"points":{"positions":[3,3,3],"colors":[0,1,1],"size":8}}]}"#,
)
.unwrap();
assert!(reg.has_main_overlay());
let gc = gizmo_camera(&cam());
let ov = reg.build_main_overlay(&gc);
assert_eq!(ov.tris.len(), 3 + 6, "tri verts (triangle + point quad)");
assert_eq!(ov.lines.len(), 2, "one line segment (a pair)");
assert_eq!(ov.tris[0].color, [1.0, 0.0, 0.0, 1.0]);
reg.set_overlay_json(
r#"{"groups":[{"name":"prev","lines":{"positions":[0,0,0, 1,1,1],"colors":[1,1,1, 1,1,1]}}]}"#,
)
.unwrap();
let ov = reg.build_main_overlay(&gc);
assert!(ov.tris.is_empty(), "upsert replaced geometry (no tris)");
assert_eq!(ov.lines.len(), 2);
reg.set_overlay_json(r#"{"groups":[{"name":"prev"}]}"#).unwrap();
assert!(!reg.has_main_overlay(), "empty group removed the only overlay");
reg.set_overlay_json(r#"{"groups":[{"name":"a","lines":{"positions":[0,0,0,1,0,0],"colors":[1,1,1,1,1,1]}}]}"#).unwrap();
assert!(reg.has_main_overlay());
reg.set_overlay_json(r#"{"groups":[]}"#).unwrap();
assert!(!reg.has_main_overlay(), "empty groups cleared all");
}
#[test]
fn overlay_groups_bbox_covers_pushed_geometry() {
let mut reg = WidgetRegistry::new();
assert!(reg.overlay_groups_bbox().is_empty(), "no groups -> empty bbox");
reg.set_overlay_json(
r#"{"groups":[{"name":"sk",
"lines":{"positions":[-5,-5,-5, 10,20,30],"colors":[1,1,1, 1,1,1]},
"points":{"positions":[100,0,-2],"colors":[0,1,0],"size":8}}]}"#,
)
.unwrap();
let bbox = reg.overlay_groups_bbox();
assert!(bbox.min[0] <= -5.0 && bbox.min[1] <= -5.0 && bbox.min[2] <= -5.0, "min {:?}", bbox.min);
assert!(bbox.max[0] >= 100.0 && bbox.max[1] >= 20.0 && bbox.max[2] >= 30.0, "max {:?}", bbox.max);
}
#[test]
fn dimensions_build_and_expose_anchors() {
let mut reg = WidgetRegistry::new();
reg.set_dimensions_json(
r#"[{"id":"d1","type":"linear","a":[-3,0,0],"b":[3,0,0],"offsetDir":[0,-1,0],"offset":2.0}]"#,
)
.unwrap();
let gc = gizmo_camera(&cam());
let ov = reg.build_main_overlay(&gc);
assert!(!ov.lines.is_empty() && !ov.tris.is_empty());
let anchors = reg.dimension_anchors(&gc);
assert_eq!(anchors.len(), 1);
assert_eq!(anchors[0].0, "d1");
}
}