brep_gizmos/lib.rs
1//! In-scene gizmos and overlay widgets — the shared CONTRACT every
2//! gizmo builds against, plus the math it needs and a CPU rasterizer for
3//! headless demos.
4//!
5//! Design: a gizmo is pure geometry + hit-testing. It consumes a [`GizmoCamera`]
6//! (the render engine's view state, mirrored here with no wgpu/kernel dep) and
7//! emits an [`Overlay`] of colored line segments and triangles in WORLD space,
8//! which the render engine's overlay pass draws over the shaded solids. Where a
9//! gizmo is interactive it also answers [`Gizmo::hit`] (which handle is under a
10//! screen point) and, for draggables, a drag lifecycle in its own frame.
11//!
12//! The crate has no renderer or kernel dependency. The render engine converts
13//! [`Overlay`] geometry into its overlay vertex buffers.
14//!
15//! Conventions (shared with the engine):
16//! - Right-handed world space, **+Z up** (the app convention).
17//! - Column-major 4x4 matrices, world → wgpu clip (z in 0..1) — byte-identical
18//! to `brep-render`'s `Camera::view_proj`.
19//! - Screen: origin top-left, x right, y DOWN, in CSS pixels (device-pixel
20//! ratio handled by the caller when it forwards viewport size).
21//! - Screen-constant gizmo sizing uses [`GizmoCamera::world_per_pixel`].
22
23pub mod hit_region;
24pub mod math;
25pub mod raster;
26
27// Gizmo / overlay-widget modules (each built against the contract above).
28pub mod curve_display;
29pub mod datum;
30pub mod dimension;
31pub mod transform;
32pub mod view_cube;
33
34pub use math::{Ray, Vec3};
35
36/// The engine camera state a gizmo needs, mirrored with no engine dependency.
37/// Construct from the engine's live camera each frame at integration time.
38#[derive(Debug, Clone, Copy)]
39pub struct GizmoCamera {
40 /// Column-major world→clip (z in 0..1), identical to the engine's
41 /// `Camera::view_proj`.
42 pub view_proj: [[f32; 4]; 4],
43 /// Camera eye in world space.
44 pub eye: Vec3,
45 /// Normalized world-space view direction (eye → scene).
46 pub forward: Vec3,
47 /// The camera's TRUE world-space up (the second basis vector its view
48 /// matrix is built from). The engine's camera is a free arcball — its up
49 /// rolls arbitrarily — so widgets that must mirror the camera's exact
50 /// orientation (the ViewCube) consume THIS, never a forward-derived
51 /// heuristic. Ideally unit and perpendicular to `forward`; consumers
52 /// re-orthonormalize defensively.
53 pub up: Vec3,
54 /// Viewport size in CSS pixels (width, height).
55 pub viewport: [f32; 2],
56 /// True for an orthographic projection (world_per_pixel is then constant
57 /// in depth); false for perspective.
58 pub orthographic: bool,
59}
60
61impl GizmoCamera {
62 /// Project a world point to screen pixels (top-left origin, y down).
63 /// Returns `None` when the point is behind the camera / on the clip plane.
64 pub fn world_to_screen(&self, p: Vec3) -> Option<[f32; 2]> {
65 let clip = mat_mul_point(&self.view_proj, p);
66 let w = clip[3];
67 if w <= 1e-6 {
68 return None;
69 }
70 let ndc_x = clip[0] / w;
71 let ndc_y = clip[1] / w;
72 Some([
73 (ndc_x * 0.5 + 0.5) * self.viewport[0],
74 (0.5 - ndc_y * 0.5) * self.viewport[1],
75 ])
76 }
77
78 /// World distance that projects to one CSS pixel at `at` (screen-constant
79 /// sizing — a gizmo scales handles so they stay a fixed pixel size). For an
80 /// orthographic camera this is independent of `at`.
81 pub fn world_per_pixel(&self, at: Vec3) -> f32 {
82 // Numerically: how far in world space (perpendicular to view) maps to
83 // one pixel of NDC-to-screen. Nudge `at` by a small world delta along a
84 // screen-horizontal axis and measure the screen displacement.
85 let right = self.screen_right(at);
86 let base = match self.world_to_screen(at) {
87 Some(s) => s,
88 None => return 1.0,
89 };
90 let delta = 1.0_f32; // 1 world unit probe
91 let moved = match self.world_to_screen(at.add(right.scale(delta))) {
92 Some(s) => s,
93 None => return 1.0,
94 };
95 let px = ((moved[0] - base[0]).powi(2) + (moved[1] - base[1]).powi(2)).sqrt();
96 if px <= 1e-6 {
97 1.0
98 } else {
99 delta / px
100 }
101 }
102
103 /// The view-space depth of a world point: the signed distance along the
104 /// forward view axis from the eye. Positive in front of the eye plane. The
105 /// screen-space region builder ([`crate::hit_region`]) keys the perspective
106 /// front-clip off this — an orthographic camera renders behind-eye-plane
107 /// geometry, so it is never clipped there.
108 pub fn view_depth(&self, p: Vec3) -> f32 {
109 p.sub(self.eye).dot(self.forward)
110 }
111
112 /// A world-space axis that is horizontal on screen at `at` (view right).
113 pub fn screen_right(&self, _at: Vec3) -> Vec3 {
114 // View right = normalize(cross(forward, up)). App up is +Z; fall back to
115 // +Y if forward is near-vertical.
116 let up = if self.forward.z.abs() > 0.9 {
117 Vec3::new(0.0, 1.0, 0.0)
118 } else {
119 Vec3::new(0.0, 0.0, 1.0)
120 };
121 self.forward.cross(up).normalized()
122 }
123
124 /// A pick ray from a screen point (top-left origin, y down) into the scene.
125 /// Perspective: origin at eye. Orthographic: origin on the near plane at the
126 /// pixel, direction = forward. Requires the inverse view_proj, computed here.
127 pub fn ray_from_screen(&self, x: f32, y: f32) -> Ray {
128 let ndc_x = (x / self.viewport[0]) * 2.0 - 1.0;
129 let ndc_y = 1.0 - (y / self.viewport[1]) * 2.0;
130 let inv = mat_inverse(&self.view_proj);
131 // Unproject near (z=0) and far (z=1) clip points.
132 let near = mat_unproject(&inv, ndc_x, ndc_y, 0.0);
133 let far = mat_unproject(&inv, ndc_x, ndc_y, 1.0);
134 let dir = far.sub(near).normalized();
135 if self.orthographic {
136 Ray { origin: near, dir }
137 } else {
138 Ray {
139 origin: self.eye,
140 dir,
141 }
142 }
143 }
144}
145
146/// The gizmo camera projects handle points to viewport-local px for the shared
147/// screen-space region builder, so a gizmo's hit-test + its debug outline share
148/// ONE projection (see [`crate::hit_region`]).
149impl crate::hit_region::RegionCamera for GizmoCamera {
150 fn is_orthographic(&self) -> bool {
151 self.orthographic
152 }
153 fn depth(&self, p: [f64; 3]) -> f64 {
154 self.view_depth(Vec3::new(p[0] as f32, p[1] as f32, p[2] as f32)) as f64
155 }
156 fn project_px(&self, p: [f64; 3]) -> Option<[f32; 2]> {
157 self.world_to_screen(Vec3::new(p[0] as f32, p[1] as f32, p[2] as f32))
158 }
159}
160
161/// One line-segment vertex in the overlay (world position + linear-space RGBA).
162#[derive(Debug, Clone, Copy)]
163pub struct LineVertex {
164 pub pos: [f32; 3],
165 pub color: [f32; 4],
166}
167
168/// One triangle vertex in the overlay (world position, normal, linear RGBA).
169#[derive(Debug, Clone, Copy)]
170pub struct TriVertex {
171 pub pos: [f32; 3],
172 pub normal: [f32; 3],
173 pub color: [f32; 4],
174}
175
176/// Accumulated overlay geometry a gizmo emits for the engine's overlay pass.
177/// `lines` are screen-constant-width segments (pairs); `tris` are shaded/flat
178/// triangles (triples). Both are WORLD space.
179#[derive(Debug, Clone, Default)]
180pub struct Overlay {
181 pub lines: Vec<LineVertex>,
182 pub tris: Vec<TriVertex>,
183}
184
185impl Overlay {
186 pub fn new() -> Self {
187 Self::default()
188 }
189
190 /// Append a colored world-space segment.
191 pub fn line(&mut self, a: Vec3, b: Vec3, color: [f32; 4]) {
192 self.lines.push(LineVertex {
193 pos: a.into(),
194 color,
195 });
196 self.lines.push(LineVertex {
197 pos: b.into(),
198 color,
199 });
200 }
201
202 /// Append a flat-colored world-space triangle (normal auto-computed).
203 pub fn tri(&mut self, a: Vec3, b: Vec3, c: Vec3, color: [f32; 4]) {
204 let n = b.sub(a).cross(c.sub(a)).normalized();
205 for p in [a, b, c] {
206 self.tris.push(TriVertex {
207 pos: p.into(),
208 normal: n.into(),
209 color,
210 });
211 }
212 }
213
214 pub fn extend(&mut self, other: &Overlay) {
215 self.lines.extend_from_slice(&other.lines);
216 self.tris.extend_from_slice(&other.tris);
217 }
218}
219
220/// A gizmo handle id — an opaque token a gizmo returns from `hit` and the host
221/// interaction layer echoes back to start a drag. `0` conventionally means
222/// "the body / no specific handle".
223pub type HandleId = u32;
224
225/// The contract every gizmo implements.
226pub trait Gizmo {
227 /// Emit this gizmo's overlay geometry for the given camera. `hovered` is the
228 /// handle currently under the pointer (for highlight), `active` the handle
229 /// being dragged (if any).
230 fn geometry(&self, camera: &GizmoCamera, hovered: Option<HandleId>, active: Option<HandleId>)
231 -> Overlay;
232
233 /// Which handle (if any) is under `screen` (top-left origin, y down).
234 fn hit(&self, camera: &GizmoCamera, screen: [f32; 2]) -> Option<HandleId>;
235}
236
237// --- matrix helpers (column-major, matching the engine) --------------------
238
239/// `m * [p.x, p.y, p.z, 1]` → `[x, y, z, w]` (column-major m).
240pub fn mat_mul_point(m: &[[f32; 4]; 4], p: Vec3) -> [f32; 4] {
241 let v = [p.x, p.y, p.z, 1.0];
242 let mut out = [0.0f32; 4];
243 for row in 0..4 {
244 let mut sum = 0.0;
245 for k in 0..4 {
246 sum += m[k][row] * v[k];
247 }
248 out[row] = sum;
249 }
250 out
251}
252
253/// Unproject an NDC point at clip depth `z` through an inverse view_proj.
254fn mat_unproject(inv: &[[f32; 4]; 4], ndc_x: f32, ndc_y: f32, z: f32) -> Vec3 {
255 let clip = [ndc_x, ndc_y, z, 1.0];
256 let mut out = [0.0f32; 4];
257 for row in 0..4 {
258 let mut sum = 0.0;
259 for k in 0..4 {
260 sum += inv[k][row] * clip[k];
261 }
262 out[row] = sum;
263 }
264 let w = if out[3].abs() < 1e-9 { 1.0 } else { out[3] };
265 Vec3::new(out[0] / w, out[1] / w, out[2] / w)
266}
267
268/// General 4x4 inverse (column-major), via cofactors. Adequate for camera
269/// matrices (well-conditioned); returns identity on a singular matrix.
270pub fn mat_inverse(m: &[[f32; 4]; 4]) -> [[f32; 4]; 4] {
271 // Flatten column-major into row-major indexing a[r][c] = m[c][r].
272 let a = |r: usize, c: usize| m[c][r] as f64;
273 let mut inv = [[0.0f64; 4]; 4];
274 // Standard adjugate/determinant inverse.
275 let m00 = a(0, 0); let m01 = a(0, 1); let m02 = a(0, 2); let m03 = a(0, 3);
276 let m10 = a(1, 0); let m11 = a(1, 1); let m12 = a(1, 2); let m13 = a(1, 3);
277 let m20 = a(2, 0); let m21 = a(2, 1); let m22 = a(2, 2); let m23 = a(2, 3);
278 let m30 = a(3, 0); let m31 = a(3, 1); let m32 = a(3, 2); let m33 = a(3, 3);
279
280 let b00 = m00 * m11 - m01 * m10;
281 let b01 = m00 * m12 - m02 * m10;
282 let b02 = m00 * m13 - m03 * m10;
283 let b03 = m01 * m12 - m02 * m11;
284 let b04 = m01 * m13 - m03 * m11;
285 let b05 = m02 * m13 - m03 * m12;
286 let b06 = m20 * m31 - m21 * m30;
287 let b07 = m20 * m32 - m22 * m30;
288 let b08 = m20 * m33 - m23 * m30;
289 let b09 = m21 * m32 - m22 * m31;
290 let b10 = m21 * m33 - m23 * m31;
291 let b11 = m22 * m33 - m23 * m32;
292
293 let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
294 if det.abs() < 1e-18 {
295 return identity();
296 }
297 let inv_det = 1.0 / det;
298
299 let r = [
300 [
301 (m11 * b11 - m12 * b10 + m13 * b09) * inv_det,
302 (m02 * b10 - m01 * b11 - m03 * b09) * inv_det,
303 (m31 * b05 - m32 * b04 + m33 * b03) * inv_det,
304 (m22 * b04 - m21 * b05 - m23 * b03) * inv_det,
305 ],
306 [
307 (m12 * b08 - m10 * b11 - m13 * b07) * inv_det,
308 (m00 * b11 - m02 * b08 + m03 * b07) * inv_det,
309 (m32 * b02 - m30 * b05 - m33 * b01) * inv_det,
310 (m20 * b05 - m22 * b02 + m23 * b01) * inv_det,
311 ],
312 [
313 (m10 * b10 - m11 * b08 + m13 * b06) * inv_det,
314 (m01 * b08 - m00 * b10 - m03 * b06) * inv_det,
315 (m30 * b04 - m31 * b02 + m33 * b00) * inv_det,
316 (m21 * b02 - m20 * b04 - m23 * b00) * inv_det,
317 ],
318 [
319 (m11 * b07 - m10 * b09 - m12 * b06) * inv_det,
320 (m00 * b09 - m01 * b07 + m02 * b06) * inv_det,
321 (m31 * b01 - m30 * b03 - m32 * b00) * inv_det,
322 (m20 * b03 - m21 * b01 + m22 * b00) * inv_det,
323 ],
324 ];
325 // r is row-major inverse; store back column-major (out[c][r]).
326 for row in 0..4 {
327 for col in 0..4 {
328 inv[col][row] = r[row][col];
329 }
330 }
331 let mut out = [[0.0f32; 4]; 4];
332 for c in 0..4 {
333 for rr in 0..4 {
334 out[c][rr] = inv[c][rr] as f32;
335 }
336 }
337 out
338}
339
340fn identity() -> [[f32; 4]; 4] {
341 [
342 [1.0, 0.0, 0.0, 0.0],
343 [0.0, 1.0, 0.0, 0.0],
344 [0.0, 0.0, 1.0, 0.0],
345 [0.0, 0.0, 0.0, 1.0],
346 ]
347}
348
349// BREP private tests: b40000a715c49b2a