Skip to main content

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