1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
use super::*;
impl EngineState {
/// Frame the whole scene (used right after the first history feed).
pub fn zoom_to_fit(&mut self) {
self.camera.zoom_to_fit(&self.scene.bbox(), 1.15);
self.dirty = true;
}
// --- Sizing -----------------------------------------------------------
/// Update the CSS viewport size (used by all camera math). The physical
/// framebuffer size + DPR are the presentation shell's concern.
pub fn resize(&mut self, css_width: f64, css_height: f64) {
self.camera.width = css_width.max(1.0);
self.camera.height = css_height.max(1.0);
self.dirty = true;
}
// --- Pointer / wheel ingestion (R22) ----------------------------------
pub fn pointer_down(&mut self, x: f64, y: f64, button: i32) -> bool {
// Sketch camera lock: while locked, the view is held flat-on to the sketch
// plane, so a LEFT press must NOT drive the camera at all — neither orbit
// (which would tilt off the plane) NOR pan. Suppressing it keeps left-drag
// free for sketch interaction and leaves pan on right/middle. Modeling mode
// and the UNLOCKED sketch view (where left orbits) are unaffected.
if self.sketch_mode()
&& self.sketch_camera_locked
&& button == crate::controls::BUTTON_LEFT
{
return false;
}
self.controls.pointer_down(x, y, button)
}
pub fn pointer_move(&mut self, x: f64, y: f64) -> bool {
let changed = self.controls.pointer_move(&mut self.camera, x, y);
if changed {
self.dirty = true;
}
changed
}
pub fn pointer_up(&mut self) -> bool {
self.controls.pointer_up()
}
pub fn wheel(&mut self, delta_y: f64, cursor: Option<[f64; 2]>) -> bool {
let changed = self.controls.wheel(&mut self.camera, delta_y, cursor);
if changed {
self.dirty = true;
}
changed
}
pub fn set_controls_enabled(&mut self, enabled: bool) {
self.controls.enabled = enabled;
}
// --- Camera commands (R21) --------------------------------------------
pub fn toggle_projection(&mut self) -> &'static str {
let kind = self.camera.toggle_projection();
self.dirty = true;
kind
}
pub fn set_projection(&mut self, kind: &str) {
let is_persp = matches!(self.camera.projection, crate::view::Projection::Perspective { .. });
let want_persp = kind.to_ascii_lowercase().starts_with("pers");
if is_persp != want_persp {
self.camera.toggle_projection();
self.dirty = true;
}
}
pub fn standard_view(&mut self, name: &str) -> bool {
let ok = self.camera.standard_view(name);
if ok {
self.camera.zoom_to_fit(&self.scene.bbox(), 1.15);
self.dirty = true;
}
ok
}
pub fn camera_state_json(&self) -> String {
self.camera.state_json()
}
pub fn apply_camera_state_json(&mut self, json: &str) -> Result<(), String> {
self.camera.apply_state_json(json)?;
self.dirty = true;
Ok(())
}
pub fn world_per_pixel(&self) -> f64 {
self.camera.world_per_pixel()
}
// --- World → screen (R25) ---------------------------------------------
/// Project world points to CSS-pixel screen coords for host anchoring. Input
/// is `[[x,y,z], …]`; output `[[sx, sy, depth, inFront], …]` where inFront
/// is 1 when the point is in front of the eye plane.
pub fn world_to_screen_json(&self, points_json: &str) -> Result<String, String> {
let points: Vec<[f64; 3]> = serde_json::from_str(points_json)
.map_err(|error| format!("world_to_screen points parse: {error}"))?;
let out: Vec<[f64; 4]> = points
.into_iter()
.map(|p| {
let (sx, sy, depth) = self.camera.project(p);
[sx, sy, depth, if depth > 0.0 { 1.0 } else { 0.0 }]
})
.collect();
Ok(serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string()))
}
/// The camera matrices for the host overlays' per-frame world→screen /
/// screen→world hot path: `{ viewProj:[16], viewProjInverse:[16],
/// viewport:[w,h] }`. Both matrices are column-major (index =
/// `col*4 + row`); `viewProj` maps world → wgpu clip
/// (x,y in −1..1, z in 0..1) and `viewport` is the CSS-pixel size. This lets
/// dimensions + sketch drop the compat mirror camera and read the engine's
/// own view-projection directly (see `world_to_screen_json` for one-shots).
pub fn camera_matrices_json(&self) -> String {
let view_proj = self.camera.view_proj_flat();
let view_proj_inverse = self.camera.view_proj_inverse_flat();
serde_json::json!({
"viewProj": view_proj,
"viewProjInverse": view_proj_inverse,
"viewport": [self.camera.width, self.camera.height],
})
.to_string()
}
// --- Picking (R23/R24) ------------------------------------------------
}
impl EngineState {
/// Build this frame's overlay-widget geometry, or None when nothing is
/// enabled (skips the overlay passes entirely).
pub fn build_widget_overlay(&self) -> Option<WidgetOverlay> {
if !self.widgets.any_visible() {
return None;
}
Some(self.widgets.build_overlay(&gizmo_camera(&self.camera)))
}
pub fn set_datums_json(&mut self, json: &str) -> Result<(), String> {
self.widgets.set_datums_json(json)?;
self.dirty = true;
Ok(())
}
/// Feed the general overlay geometry channel (`set_overlay`): arbitrary named
/// tri/line/point groups (feature-dialog previews and other display-only
/// geometry), drawn in the widget overlay pass.
pub fn set_overlay_json(&mut self, json: &str) -> Result<(), String> {
self.widgets.set_overlay_json(json)?;
self.dirty = true;
Ok(())
}
pub fn set_dimensions_json(&mut self, json: &str) -> Result<(), String> {
self.widgets.set_dimensions_json(json)?;
self.dirty = true;
Ok(())
}
pub fn set_transform_json(&mut self, json: &str) -> Result<(), String> {
self.widgets.set_transform_json(json)?;
self.dirty = true;
Ok(())
}
pub fn set_viewcube_enabled(&mut self, enabled: bool) {
self.widgets.set_viewcube_enabled(enabled);
self.dirty = true;
}
/// The ViewCube corner rect `{x,y,w,h}` (CSS px) so the host can decide
/// whether to forward a pointer event.
pub fn viewcube_rect_json(&self) -> String {
let r = self.widgets.viewcube_rect(&gizmo_camera(&self.camera));
serde_json::json!({ "x": r[0], "y": r[1], "w": r[2], "h": r[3] }).to_string()
}
/// Update the ViewCube hover from cube-local pixels; returns whether it
/// changed (a hover-out is `(None)` with local coords outside).
pub fn viewcube_hover(&mut self, local_x: f64, local_y: f64) -> bool {
let cam = gizmo_camera(&self.camera);
let handle = self.widgets.viewcube_hit(&cam, local_x as f32, local_y as f32);
let changed = self.widgets.set_viewcube_hover(handle);
if changed {
self.dirty = true;
}
changed
}
pub fn viewcube_clear_hover(&mut self) -> bool {
let changed = self.widgets.set_viewcube_hover(None);
if changed {
self.dirty = true;
}
changed
}
/// Click the ViewCube at cube-local pixels: snap the shared camera to the
/// region's standard view (keeping the current pivot distance). Returns
/// true if a region was hit.
pub fn viewcube_click(&mut self, local_x: f64, local_y: f64) -> bool {
let cam = gizmo_camera(&self.camera);
let Some(handle) = self.widgets.viewcube_hit(&cam, local_x as f32, local_y as f32) else {
return false;
};
// Navigation arrows apply a RELATIVE camera rotation (orbit / roll) to
// the current view instead of snapping to an absolute standard view.
if brep_gizmos::view_cube::ViewCube::is_arrow(handle) {
self.apply_viewcube_arrow(handle);
self.dirty = true;
return true;
}
let (dir, fallback_up) = self.widgets.viewcube_target(handle);
// Minimal-rotation snap: keep the current roll by projecting the current
// up onto the plane perpendicular to the new view direction, so the
// camera reorients by the smallest angle instead of snapping to a fixed
// world up (which could spin/flip the model).
let dirf = [dir[0] as f64, dir[1] as f64, dir[2] as f64];
let cu = self.camera.up;
let d = cu[0] * dirf[0] + cu[1] * dirf[1] + cu[2] * dirf[2];
let proj = [
cu[0] - dirf[0] * d,
cu[1] - dirf[1] * d,
cu[2] - dirf[2] * d,
];
let len = (proj[0] * proj[0] + proj[1] * proj[1] + proj[2] * proj[2]).sqrt();
let up = if len > 1e-4 {
[
(proj[0] / len) as f32,
(proj[1] / len) as f32,
(proj[2] / len) as f32,
]
} else {
fallback_up
};
self.apply_look_direction(dir, up);
self.dirty = true;
true
}
/// Reorient the camera to look along `dir` (world eye→target) with `up`,
/// preserving the current pivot distance.
pub(super) fn apply_look_direction(&mut self, dir: [f32; 3], up: [f32; 3]) {
let dir = [dir[0] as f64, dir[1] as f64, dir[2] as f64];
let dist = self.camera.distance();
self.camera.eye = [
self.camera.target[0] - dir[0] * dist,
self.camera.target[1] - dir[1] * dist,
self.camera.target[2] - dir[2] * dist,
];
self.camera.up = [up[0] as f64, up[1] as f64, up[2] as f64];
}
/// Apply a ViewCube navigation-arrow rotation to the CURRENT camera (a
/// relative 90° orbit / roll), keeping the pivot (`target`) fixed. The
/// rotation axes are the camera's own screen axes so the motion follows the
/// on-screen arrow direction: the eye moves toward the pan arrow it points
/// at, and the roll arcs spin the up vector about the view direction.
fn apply_viewcube_arrow(&mut self, handle: u32) {
use crate::view::{add3, rotate3, sub3};
use brep_gizmos::view_cube::ViewCube;
// World-space screen axes of the current view: right, up, forward(eye→target).
let (right, up_axis, fwd) = self.camera.basis();
let target = self.camera.target;
let rel = sub3(self.camera.eye, target); // eye relative to pivot
let q = std::f64::consts::FRAC_PI_2; // 90° per click
match handle {
// Orbit about the screen-up axis; eye moves toward the arrow side.
ViewCube::ARROW_RIGHT => {
self.camera.eye = add3(target, rotate3(rel, up_axis, q));
}
ViewCube::ARROW_LEFT => {
self.camera.eye = add3(target, rotate3(rel, up_axis, -q));
}
// Orbit about the screen-right axis; carry the up vector along so the
// view stays upright (eye moves toward the arrow side).
ViewCube::ARROW_UP => {
self.camera.eye = add3(target, rotate3(rel, right, -q));
self.camera.up = rotate3(self.camera.up, right, -q);
}
ViewCube::ARROW_DOWN => {
self.camera.eye = add3(target, rotate3(rel, right, q));
self.camera.up = rotate3(self.camera.up, right, q);
}
// Roll about the view direction; only the up vector changes.
ViewCube::ROLL_CCW => {
self.camera.up = rotate3(self.camera.up, fwd, q);
}
ViewCube::ROLL_CW => {
self.camera.up = rotate3(self.camera.up, fwd, -q);
}
_ => {}
}
}
/// Pick the datum plane/axis under a screen pixel; returns its name (empty
/// when none). The host merges this with solid picking into SelectionFilter.
pub fn datum_pick(&self, x: f64, y: f64) -> String {
self.widgets
.datum_pick(&gizmo_camera(&self.camera), x as f32, y as f32)
.unwrap_or_default()
}
/// Update the transform-gizmo hover from a screen pixel; returns the handle
/// under the pointer (0 = none). Marks dirty when the highlight changed.
pub fn transform_hover(&mut self, x: f64, y: f64) -> u32 {
let cam = gizmo_camera(&self.camera);
let handle = self.widgets.transform_hit(&cam, x as f32, y as f32);
if self.widgets.set_transform_hover(handle) {
self.dirty = true;
}
handle
}
/// The transform-gizmo handle under a screen pixel (0 = none) — the host
/// echoes it back to start a drag.
pub fn transform_pick(&self, x: f64, y: f64) -> u32 {
self.widgets.transform_hit(&gizmo_camera(&self.camera), x as f32, y as f32)
}
/// Compute a transform drag (frame-space + world delta) as JSON for the
/// feature-edit commit. Marks the handle active for the highlight.
pub fn transform_drag(
&mut self,
handle: u32,
sx: f64,
sy: f64,
cx: f64,
cy: f64,
) -> String {
let cam = gizmo_camera(&self.camera);
self.widgets.set_transform_active(handle);
self.dirty = true;
self.widgets
.transform_drag_json(&cam, handle, sx as f32, sy as f32, cx as f32, cy as f32)
}
pub fn transform_drag_end(&mut self) {
self.widgets.set_transform_active(0);
self.dirty = true;
}
/// Per-dimension label placement: `[{id, anchor:[x,y,z],
/// screen:[sx,sy,inFront]}]` — the host pins each text label at `screen`.
pub fn dimension_anchors_json(&self) -> String {
let anchors = self.widgets.dimension_anchors(&gizmo_camera(&self.camera));
let out: Vec<serde_json::Value> = anchors
.into_iter()
.map(|(id, p)| {
let (sx, sy, depth) = self.camera.project([p[0] as f64, p[1] as f64, p[2] as f64]);
serde_json::json!({
"id": id,
"anchor": p,
"screen": [sx, sy, if depth > 0.0 { 1.0 } else { 0.0 }],
})
})
.collect();
serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
}
// --- Undo / redo (engine-owned) ---------------------------------------
//
// The model is engine-owned, so its undo history lives in the engine core
// too: `History` holds the stacks and snapshots itself BEFORE each model
// mutation (edit / add / delete / reorder), while roll-to-step is view state
// and is NOT snapshotted. The UI only TRIGGERS these; it never holds a stack.
}