@@ -21,16 +21,13 @@ façade.
|
||||
## Running it
|
||||
|
||||
```sh
|
||||
WINIT_UNIX_BACKEND=x11 cargo run --release -p vernier-app
|
||||
cargo run --release -p vernier-app
|
||||
```
|
||||
|
||||
**The `WINIT_UNIX_BACKEND=x11` is not optional if anything other than a human
|
||||
will touch the window.** Under a native Wayland backend the window is
|
||||
*invisible to `xdotool`* — no window id, no synthetic clicks, no screenshots —
|
||||
so every automated or scripted GUI check silently has nothing to drive. It
|
||||
looks exactly like the app failing to start. Set it and the window is an X11
|
||||
client under XWayland, which those tools can see. Launching by hand for your own
|
||||
use works either way; set it anyway so the two cases do not diverge.
|
||||
Use the native desktop backend for normal use (Wayland on a Wayland session).
|
||||
The driven-GUI harness runs offscreen and does not need X11 or `xdotool`.
|
||||
Only set `WINIT_UNIX_BACKEND=x11` when specifically diagnosing XWayland behavior;
|
||||
it is no longer a prerequisite for automated testing.
|
||||
|
||||
Headless checks need no display at all:
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ use crate::camera::{
|
||||
use crate::cut;
|
||||
use crate::edit::{Edit, edit_for, silent_refusal_readout};
|
||||
use crate::graphics::{Graphics, gpu_parts, viewport_px};
|
||||
use crate::preview::{gizmo_frame, live_after_refusal_filter};
|
||||
use crate::preview::gizmo_frame;
|
||||
use crate::scene::{FaceMeta, Scene};
|
||||
use crate::sessions::add_loft_section;
|
||||
|
||||
@@ -637,10 +637,7 @@ impl VernierApp {
|
||||
// frame, so the round trip is coalesced rather than one per
|
||||
// event. `None` outside sketch mode and off the plane: the
|
||||
// preview then asks nothing at all.
|
||||
self.hover_at = match (self.sketch_mode, self.input.cursor) {
|
||||
(Some(frame), Some((cx, cy))) => self.sketch_uv(cx, cy, frame),
|
||||
_ => None,
|
||||
};
|
||||
self.refresh_drawing_hover();
|
||||
let height = crate::graphics::viewport_px(self.graphics.as_ref(), self.offscreen.as_ref())
|
||||
.map_or(1.0, |[_, height]| f64::from(height.max(1)));
|
||||
kamera_drag(
|
||||
@@ -653,6 +650,14 @@ impl VernierApp {
|
||||
);
|
||||
}
|
||||
|
||||
/// Mouse hover uses the same plane projection in the desktop and driver.
|
||||
pub(crate) fn refresh_drawing_hover(&mut self) {
|
||||
self.hover_at = match (self.sketch_mode, self.input.cursor) {
|
||||
(Some(frame), Some((cx, cy))) => self.sketch_uv(cx, cy, frame),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
|
||||
/// A viewport click while sketching: pixel -> ray -> the sketch plane.
|
||||
///
|
||||
/// The caller `sketch_click` was written for and did not have. Everything
|
||||
@@ -944,6 +949,15 @@ impl VernierApp {
|
||||
raw_input: egui::RawInput,
|
||||
viewport: [f32; 2],
|
||||
) -> egui::FullOutput {
|
||||
// Projections feed egui, whose coordinates are logical points.
|
||||
// The camera aspect is unchanged by this uniform scale.
|
||||
let scale = raw_input
|
||||
.viewports
|
||||
.get(&egui::ViewportId::ROOT)
|
||||
.and_then(|v| v.native_pixels_per_point)
|
||||
.unwrap_or(1.0)
|
||||
* self.egui_ctx.zoom_factor();
|
||||
let viewport = [viewport[0] / scale, viewport[1] / scale];
|
||||
// Push/pull gizmo anchor: exactly one planar face picked.
|
||||
let aspect = f64::from(viewport[0]) / f64::from(viewport[1]);
|
||||
let view_proj = self.camera.view_proj(aspect);
|
||||
@@ -1055,8 +1069,6 @@ impl VernierApp {
|
||||
let theme = self.shell.theme;
|
||||
let mut dimension_click = None;
|
||||
let output = self.egui_ctx.run_ui(raw_input, |ui| {
|
||||
response = shell::show(ui, &self.scene, &mut self.shell);
|
||||
dimension_click = shell::dimension_overlay(ui, theme, &dimensions, picked_dimension);
|
||||
if let Some((face, anchor, tip)) = gizmo {
|
||||
let drag = shell::gizmo_overlay(ui, anchor, tip);
|
||||
if let Some(mm) = drag.live_mm {
|
||||
@@ -1066,7 +1078,10 @@ impl VernierApp {
|
||||
// release, so it read as a stale, untrustworthy
|
||||
// number for the whole gesture.
|
||||
self.shell.distance_mm = mm;
|
||||
live = live_after_refusal_filter(face, Some(mm));
|
||||
if self.shell.card.action == Some(Action::PushPull) {
|
||||
self.shell.card.buffers = vec![format!("{mm:.2}")];
|
||||
}
|
||||
live = Some((face, mm));
|
||||
}
|
||||
if let Some(mm) = drag.committed_mm {
|
||||
self.shell.distance_mm = mm;
|
||||
@@ -1074,6 +1089,50 @@ impl VernierApp {
|
||||
}
|
||||
released = drag.released;
|
||||
}
|
||||
response = shell::show(ui, &self.scene, &mut self.shell);
|
||||
if ui.ctx().is_pointer_over_egui() {
|
||||
self.hover_at = None;
|
||||
}
|
||||
dimension_click = shell::dimension_overlay(ui, theme, &dimensions, picked_dimension);
|
||||
if let (Some(source), Some(at), Some(frame)) =
|
||||
(&self.scene.drawing, self.hover_at, self.sketch_mode)
|
||||
&& let Some(preview) = source.preview(at, self.shell.tool, self.shell.polygon_sides)
|
||||
{
|
||||
let project = |uv| {
|
||||
vernier_ui::gizmo::project(&view_proj, frame.to_world(uv), viewport)
|
||||
.map(|p| egui::pos2(p[0], p[1]))
|
||||
};
|
||||
let color = self.shell.theme.accent;
|
||||
for path in &preview.paths {
|
||||
for pair in path.windows(2) {
|
||||
if let (Some(a), Some(b)) = (project(pair[0]), project(pair[1])) {
|
||||
ui.painter()
|
||||
.line_segment([a, b], egui::Stroke::new(1.5, color));
|
||||
}
|
||||
}
|
||||
}
|
||||
for guide in &preview.guides {
|
||||
if let (Some(a), Some(b)) = (project(guide[0]), project(guide[1])) {
|
||||
ui.painter().line_segment(
|
||||
[a, b],
|
||||
egui::Stroke::new(1.0, color.gamma_multiply(0.45)),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(at) = project(preview.at) {
|
||||
ui.painter()
|
||||
.circle_stroke(at, 5.0, egui::Stroke::new(1.5, color));
|
||||
if !preview.snap.is_empty() {
|
||||
ui.painter().text(
|
||||
at + egui::vec2(10.0, -14.0),
|
||||
egui::Align2::LEFT_BOTTOM,
|
||||
preview.snap,
|
||||
egui::FontId::monospace(12.0),
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// AFTER the gizmo, so a value card or a handle is never
|
||||
// painted over by a glyph — the marks are the least important
|
||||
// thing on screen and are the last thing drawn.
|
||||
@@ -1523,8 +1582,10 @@ impl ApplicationHandler for VernierApp {
|
||||
// stop highlighting rather than keep the last edge lit.
|
||||
if consumed {
|
||||
self.hover = None;
|
||||
self.hover_at = None;
|
||||
} else {
|
||||
self.refresh_hover(x, y);
|
||||
self.refresh_drawing_hover();
|
||||
}
|
||||
// M3 lane B: THE CUT PREVIEW FOLLOWS THE CURSOR, and only
|
||||
// under a cut tool. It is a read-only round trip — the
|
||||
@@ -1570,6 +1631,7 @@ impl ApplicationHandler for VernierApp {
|
||||
self.handle_key(event_loop, &event);
|
||||
}
|
||||
WindowEvent::Focused(false) | WindowEvent::CursorLeft { .. } => {
|
||||
self.hover_at = None;
|
||||
let modifiers = self.input.modifiers;
|
||||
self.input = Input {
|
||||
modifiers,
|
||||
|
||||
@@ -441,6 +441,8 @@ pub(crate) enum Edit {
|
||||
plane: NewSketchPlane,
|
||||
},
|
||||
/// Push/pull the picked planar face.
|
||||
/// Replace a provisional push/pull without publishing the intermediate undo.
|
||||
ReplacePushPull { face: u64, distance: f64 },
|
||||
PushPull {
|
||||
/// Kernel-space face id.
|
||||
face: u64,
|
||||
|
||||
@@ -229,12 +229,25 @@ impl Graphics {
|
||||
alpha_mode = ?config.alpha_mode,
|
||||
"surface configured (after any env override)"
|
||||
);
|
||||
// The 3D pass keeps its surface format; egui blends coverage in
|
||||
// gamma space. A second view changes the blend domain, not the pixels.
|
||||
if adapter
|
||||
.get_downlevel_capabilities()
|
||||
.flags
|
||||
.contains(wgpu::DownlevelFlags::VIEW_FORMATS)
|
||||
{
|
||||
config.view_formats = vec![config.format.remove_srgb_suffix()];
|
||||
}
|
||||
surface.configure(&device, &config);
|
||||
let renderer = Renderer::new(&device, config.format);
|
||||
let depth = Renderer::create_depth(&device, config.width, config.height);
|
||||
let egui_renderer = egui_wgpu::Renderer::new(
|
||||
&device,
|
||||
config.format,
|
||||
config
|
||||
.view_formats
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(config.format),
|
||||
egui_wgpu::RendererOptions::default(),
|
||||
);
|
||||
Ok(Self {
|
||||
@@ -516,6 +529,17 @@ impl Graphics {
|
||||
scene,
|
||||
);
|
||||
|
||||
// egui expects coverage blending in gamma space (dark text on paper).
|
||||
let ui_view = frame.texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
format: Some(
|
||||
self.config
|
||||
.view_formats
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(self.config.format),
|
||||
),
|
||||
..Default::default()
|
||||
});
|
||||
// The shell, composited over the scene.
|
||||
let primitives = egui_ctx.tessellate(shapes, pixels_per_point);
|
||||
let descriptor = egui_wgpu::ScreenDescriptor {
|
||||
@@ -540,7 +564,7 @@ impl Graphics {
|
||||
.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("vernier-egui"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
view: &ui_view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
|
||||
@@ -235,7 +235,7 @@ impl Headless {
|
||||
// decision on a real display, and here it is pure noise in a diff.
|
||||
let egui_wgpu = egui_wgpu::Renderer::new(
|
||||
offscreen.device(),
|
||||
offscreen.format(),
|
||||
offscreen.ui_format(),
|
||||
egui_wgpu::RendererOptions::PREDICTABLE,
|
||||
);
|
||||
let adapter_name = offscreen.adapter_name().to_owned();
|
||||
@@ -506,7 +506,7 @@ impl Headless {
|
||||
.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("vernier-headless-egui"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: offscreen.colour_view(),
|
||||
view: offscreen.ui_view(),
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
@@ -1089,6 +1089,7 @@ impl Headless {
|
||||
// was written to avoid.
|
||||
self.app.hover_ids_size = [self.width, self.height];
|
||||
self.app.refresh_hover(f64::from(x), f64::from(y));
|
||||
self.app.refresh_drawing_hover();
|
||||
}
|
||||
|
||||
/// The pointer position the harness last set.
|
||||
@@ -1128,3 +1129,93 @@ impl Headless {
|
||||
self.app.pending_jobs
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod compositing_tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a GPU; run explicitly with the driven rendering checks"]
|
||||
fn ui_half_coverage_blends_in_gamma_space() {
|
||||
vernier_ui::testing::driven("UI coverage pixels", || {
|
||||
let mut app = Headless::empty(64, 64).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let output = ctx.run_ui(
|
||||
egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(
|
||||
egui::Pos2::ZERO,
|
||||
egui::vec2(64.0, 64.0),
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
|ui| {
|
||||
ui.painter().rect_filled(
|
||||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(64.0, 64.0)),
|
||||
0.0,
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
ui.painter().rect_filled(
|
||||
egui::Rect::from_min_max(egui::pos2(16.0, 16.0), egui::pos2(48.0, 48.0)),
|
||||
0.0,
|
||||
egui::Color32::from_black_alpha(128),
|
||||
);
|
||||
},
|
||||
);
|
||||
let primitives = ctx.tessellate(output.shapes.clone(), output.pixels_per_point);
|
||||
let pixels = app
|
||||
.paint(
|
||||
[[0.0; 4]; 4],
|
||||
SceneDraw::default(),
|
||||
&primitives,
|
||||
1.0,
|
||||
true,
|
||||
&output.textures_delta,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
output.drop_without_applying_deltas();
|
||||
let value = pixels[(32 * 64 + 32) * 4];
|
||||
assert!(
|
||||
(i16::from(value) - 128).abs() <= 1,
|
||||
"half-covered UI pixel is {value}, expected 128"
|
||||
);
|
||||
assert_eq!(pixels[(8 * 64 + 8) * 4], 255, "opaque background control");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hover_motion_tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
#[test]
|
||||
#[ignore = "requires a GPU; run with the driven rendering checks"]
|
||||
fn a_plain_pointer_move_updates_the_sketch_preview() {
|
||||
let at = vernier_ui::testing::driven("pointer hover wiring", || {
|
||||
let mut headless = Headless::empty(1600, 1000).unwrap();
|
||||
headless.app.submit(Edit::NewSketch {
|
||||
plane: crate::edit::NewSketchPlane::World(vernier_doc::PrincipalPlane::Xy),
|
||||
});
|
||||
headless.wait_idle(Duration::from_secs(5)).unwrap();
|
||||
headless.set_cursor(900.0, 600.0);
|
||||
let at = headless.app.hover_at;
|
||||
headless.set_cursor(30.0, 55.0);
|
||||
headless
|
||||
.step_events(
|
||||
vec![egui::Event::PointerMoved(egui::pos2(30.0, 55.0))],
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
(at, headless.app.hover_at)
|
||||
});
|
||||
assert!(
|
||||
at.0.is_some(),
|
||||
"mouse motion must reach sketch hover without a held button"
|
||||
);
|
||||
assert!(
|
||||
at.1.is_none(),
|
||||
"leaving the viewport must clear drawing feedback"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,16 +53,8 @@ pub(crate) fn preview_step(
|
||||
})
|
||||
}
|
||||
|
||||
/// The render loop's own filter on a live drag distance (card f94ebbff,
|
||||
/// CRITICAL, cybersec's finding 1/2): the refusal band is never offered
|
||||
/// to [`gizmo_frame`] as a candidate preview step. The press frame's
|
||||
/// distance is ALWAYS `0.0` (the drag accumulator starts at zero), which
|
||||
/// the kernel always refuses, so without this every single drag's first
|
||||
/// preview step was doomed before it reached `gizmo_frame` at all. Same
|
||||
/// threshold `gizmo_overlay` already applies to `committed_mm`; kept
|
||||
/// separate rather than moved into `gizmo_overlay` because the render
|
||||
/// loop's readout must track every live frame UNFILTERED, only submission
|
||||
/// is gated.
|
||||
/// Excludes the kernel's zero-distance refusal band from submission.
|
||||
/// The frame decision still receives zero so it can retract a standing preview.
|
||||
pub(crate) fn live_after_refusal_filter(face: u64, live_mm: Option<f64>) -> Option<(u64, f64)> {
|
||||
live_mm.filter(|mm| mm.abs() > 1e-6).map(|mm| (face, mm))
|
||||
}
|
||||
@@ -98,9 +90,8 @@ pub(crate) fn live_after_refusal_filter(face: u64, live_mm: Option<f64>) -> Opti
|
||||
/// actually carries. `handle` already guards this one level down
|
||||
/// (main.rs:948-953, "undoing here would silently retract the user's
|
||||
/// *previous* feature instead") -- this function reintroduces the exact
|
||||
/// same hazard a level up, with no such key, unless callers filter the
|
||||
/// refusal band out of `live` before it ever reaches here (the render
|
||||
/// loop does, through [`live_after_refusal_filter`]).
|
||||
/// same hazard a level up, with no such key, unless the refusal band is filtered before submission. This function
|
||||
/// also retracts a standing preview when the pointer returns to zero.
|
||||
pub(crate) fn gizmo_frame(
|
||||
live: Option<(u64, f64)>,
|
||||
committed: Option<(u64, f64)>,
|
||||
@@ -124,6 +115,15 @@ pub(crate) fn gizmo_frame(
|
||||
if (released || !gizmo_present) && preview.is_some() {
|
||||
return GizmoFrame::Retire;
|
||||
}
|
||||
if let Some((face, mm)) = live
|
||||
&& live_after_refusal_filter(face, Some(mm)).is_none()
|
||||
{
|
||||
return if preview.is_some() && pending_jobs == 0 {
|
||||
GizmoFrame::Retire
|
||||
} else {
|
||||
GizmoFrame::Idle
|
||||
};
|
||||
}
|
||||
match preview_step(live, pending_jobs, preview) {
|
||||
Some(step) => GizmoFrame::Preview(step),
|
||||
None => GizmoFrame::Idle,
|
||||
@@ -165,12 +165,16 @@ impl VernierApp {
|
||||
match frame {
|
||||
GizmoFrame::Idle => {}
|
||||
GizmoFrame::Preview(step) => {
|
||||
if step.undo_first {
|
||||
self.submit(Edit::UndoRedo { back: true });
|
||||
}
|
||||
self.submit(Edit::PushPull {
|
||||
face: step.face,
|
||||
distance: step.distance,
|
||||
self.submit(if step.undo_first {
|
||||
Edit::ReplacePushPull {
|
||||
face: step.face,
|
||||
distance: step.distance,
|
||||
}
|
||||
} else {
|
||||
Edit::PushPull {
|
||||
face: step.face,
|
||||
distance: step.distance,
|
||||
}
|
||||
});
|
||||
self.preview = Some((step.face, step.distance));
|
||||
}
|
||||
@@ -187,10 +191,11 @@ impl VernierApp {
|
||||
// not on top of a provisional one — Worker preserves
|
||||
// submission order, so this is correctly sequenced even if
|
||||
// the preview's own result has not come back yet.
|
||||
if undo_first {
|
||||
self.submit(Edit::UndoRedo { back: true });
|
||||
}
|
||||
self.submit(Edit::PushPull { face, distance });
|
||||
self.submit(if undo_first {
|
||||
Edit::ReplacePushPull { face, distance }
|
||||
} else {
|
||||
Edit::PushPull { face, distance }
|
||||
});
|
||||
self.preview = None;
|
||||
}
|
||||
GizmoFrame::Retire => {
|
||||
|
||||
@@ -312,6 +312,12 @@ impl DocumentServer {
|
||||
/// edit is undone (the document allocator stays advanced — ids are
|
||||
/// monotonic by design) and reported, never left half-applied.
|
||||
pub(crate) fn handle(&mut self, edit: Edit) -> Scene {
|
||||
let edit = if let Edit::ReplacePushPull { face, distance } = edit {
|
||||
let _ = self.handle(Edit::UndoRedo { back: true });
|
||||
Edit::PushPull { face, distance }
|
||||
} else {
|
||||
edit
|
||||
};
|
||||
let mut readout = None;
|
||||
let mut error = None;
|
||||
let was_unsaved = self.unsaved;
|
||||
@@ -679,6 +685,18 @@ impl DocumentServer {
|
||||
unsaved: self.unsaved,
|
||||
warnings,
|
||||
proposals: self.hover_proposals.clone(),
|
||||
drawing: self.sketch_active.and_then(|id| {
|
||||
let FeaturePayload::Sketch(data) = &self.document.features().get(&id)?.payload
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
Some(vernier_ui::hover::DrawingState {
|
||||
sketch: data.clone(),
|
||||
tool: self.pending_tool,
|
||||
pending: self.pending_points.clone(),
|
||||
line_end: self.sketch_pending,
|
||||
})
|
||||
}),
|
||||
cut_preview,
|
||||
// THE COMPILE'S OWN FRAME for the sketch being drawn into,
|
||||
// so the viewport and the extrude cannot disagree about
|
||||
@@ -725,6 +743,7 @@ impl DocumentServer {
|
||||
self.last_drag = None;
|
||||
self.extra_commands = 0;
|
||||
match edit {
|
||||
Edit::ReplacePushPull { .. } => Err(MISROUTED.to_owned()),
|
||||
// The exports run AFTER the compile, in `handle`, because that
|
||||
// is where the body is. Here they are read-only.
|
||||
Edit::Recompute
|
||||
@@ -924,4 +943,4 @@ impl DocumentServer {
|
||||
/// It is in MILLIMETRES, so it is generous zoomed in and tight zoomed out.
|
||||
/// Deriving it from the camera would fix that and is not free: the snap runs
|
||||
/// on the server, which has no camera. Recorded rather than hidden.
|
||||
pub(crate) const DRAW_SNAP_MM: f64 = 2.0;
|
||||
pub(crate) const DRAW_SNAP_MM: f64 = vernier_ui::hover::DRAW_SNAP_MM;
|
||||
|
||||
@@ -141,3 +141,122 @@ fn a_frame_with_nothing_picked_leaves_the_value_card_unanchored() {
|
||||
});
|
||||
assert!(!anchored, "a card anchored to a face nobody picked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_hover_paints_a_live_snap_label_without_another_worker_job() {
|
||||
vernier_ui::testing::driven("live sketch feedback", || {
|
||||
let mut server = DocumentServer::new();
|
||||
server.handle(Edit::NewSketch {
|
||||
plane: crate::edit::NewSketchPlane::World(vernier_doc::PrincipalPlane::Xy),
|
||||
});
|
||||
server.handle(Edit::SketchDraw {
|
||||
at: [0.0, 0.0],
|
||||
tool: SketchTool::Line,
|
||||
infer: false,
|
||||
sides: 6,
|
||||
});
|
||||
let scene = server.handle(Edit::Recompute);
|
||||
let worker =
|
||||
Worker::spawn("drawing-frame", move |edit| server.handle(edit), || {}).unwrap();
|
||||
let mut app = VernierApp::new(worker);
|
||||
app.apply_scene(scene);
|
||||
app.shell.tool = SketchTool::Line;
|
||||
app.hover_at = Some([15.3, 0.2]);
|
||||
let mut output = app.run_shell_frame(raw_input([1600.0, 1000.0], 0), [1600.0, 1000.0]);
|
||||
let shapes = format!("{:?}", output.shapes);
|
||||
output.textures_delta.clear();
|
||||
assert!(
|
||||
shapes.contains("horizontal"),
|
||||
"the live frame must identify its snap before a hover round trip"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gizmo_coordinates_are_logical_points_on_a_scaled_display() {
|
||||
vernier_ui::testing::driven("scaled overlay", || {
|
||||
let (mut server, _) = built_server();
|
||||
let scene = server.handle(Edit::Recompute);
|
||||
let face = scene.faces.iter().find(|f| f.normal.is_some()).unwrap().raw;
|
||||
let worker = Worker::spawn("scale-frame", move |edit| server.handle(edit), || {}).unwrap();
|
||||
let mut app = VernierApp::new(worker);
|
||||
app.apply_scene(scene);
|
||||
app.shell.picked_faces = vec![face];
|
||||
app.shell.picked_planar = true;
|
||||
let size = [1600.0, 1000.0];
|
||||
let expected = crate::preview::gizmo_anchor(
|
||||
&[face],
|
||||
false,
|
||||
&app.faces,
|
||||
&app.camera.view_proj(1.6),
|
||||
[800.0, 500.0],
|
||||
)
|
||||
.unwrap()
|
||||
.1;
|
||||
let mut input = raw_input([800.0, 500.0], 0);
|
||||
input
|
||||
.viewports
|
||||
.entry(egui::ViewportId::ROOT)
|
||||
.or_default()
|
||||
.native_pixels_per_point = Some(2.0);
|
||||
let output = app.run_shell_frame(input, size);
|
||||
output.drop_without_applying_deltas();
|
||||
assert_eq!(app.shell.card.anchor, Some(expected));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_updates_the_visible_card_buffer() {
|
||||
vernier_ui::testing::driven("live value card", || {
|
||||
let (mut server, _) = built_server();
|
||||
let scene = server.handle(Edit::Recompute);
|
||||
let face = scene.faces.iter().find(|f| f.normal.is_some()).unwrap().raw;
|
||||
let worker = Worker::spawn("value-frame", move |edit| server.handle(edit), || {}).unwrap();
|
||||
let mut app = VernierApp::new(worker);
|
||||
app.apply_scene(scene);
|
||||
app.shell.picked_faces = vec![face];
|
||||
app.shell.picked_planar = true;
|
||||
let size = [1600.0, 1000.0];
|
||||
let (_, a, b) = crate::preview::gizmo_anchor(
|
||||
&[face],
|
||||
false,
|
||||
&app.faces,
|
||||
&app.camera.view_proj(1.6),
|
||||
size,
|
||||
)
|
||||
.unwrap();
|
||||
let direction = (egui::pos2(b[0], b[1]) - egui::pos2(a[0], a[1])).normalized();
|
||||
let handle = egui::pos2(a[0], a[1]) + direction * 60.0;
|
||||
for i in 0..5 {
|
||||
let mut input = raw_input(size, i);
|
||||
if i == 2 {
|
||||
input.events = vec![
|
||||
egui::Event::PointerMoved(handle),
|
||||
egui::Event::PointerButton {
|
||||
pos: handle,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: true,
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
];
|
||||
}
|
||||
if i >= 3 {
|
||||
input.events = vec![egui::Event::PointerMoved(
|
||||
handle + direction * (15.0 * (i - 2) as f32),
|
||||
)];
|
||||
}
|
||||
app.run_shell_frame(input, size)
|
||||
.drop_without_applying_deltas();
|
||||
}
|
||||
assert!(
|
||||
app.shell.distance_mm.abs() > 0.1,
|
||||
"positive control: a real drag occurred"
|
||||
);
|
||||
let displayed = app.shell.card.buffers[0].parse::<f64>().unwrap();
|
||||
assert!(
|
||||
(displayed - app.shell.distance_mm).abs() < 0.01,
|
||||
"visible {displayed} differs from live {}",
|
||||
app.shell.distance_mm
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -671,3 +671,66 @@ fn a_sketch_being_drawn_on_suppresses_the_faces_gizmo() {
|
||||
"the gizmo must still name the face it belongs to"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_a_push_pull_preview_publishes_only_one_scene() {
|
||||
let (mut server, _) = built_server();
|
||||
let scene = server.handle(Edit::Recompute);
|
||||
let meta = scene.faces.iter().find(|f| f.normal.is_some()).unwrap();
|
||||
let face = meta.raw;
|
||||
let origin = meta.centroid;
|
||||
let normal = meta.normal.unwrap();
|
||||
let baseline_features = scene.view.timeline.len();
|
||||
assert!(
|
||||
server
|
||||
.handle(Edit::PushPull {
|
||||
face,
|
||||
distance: 1.0
|
||||
})
|
||||
.error
|
||||
.is_none()
|
||||
);
|
||||
let worker = Worker::spawn("atomic-preview", move |edit| server.handle(edit), || {}).unwrap();
|
||||
let mut app = VernierApp::new(worker);
|
||||
app.preview = Some((face, 1.0));
|
||||
app.apply_gizmo_frame(crate::preview::GizmoFrame::Preview(
|
||||
crate::preview::PreviewStep {
|
||||
undo_first: true,
|
||||
face,
|
||||
distance: 3.0,
|
||||
},
|
||||
));
|
||||
assert_eq!(
|
||||
app.pending_jobs, 1,
|
||||
"an intermediate undo scene causes visible flicker"
|
||||
);
|
||||
let scene = app
|
||||
.worker
|
||||
.bounded()
|
||||
.recv_timeout(std::time::Duration::from_secs(5))
|
||||
.unwrap();
|
||||
assert!(scene.error.is_none(), "{:?}", scene.error);
|
||||
assert_eq!(scene.view.timeline.len(), baseline_features + 1);
|
||||
let moved = scene.faces.iter().find(|f| f.raw == face).unwrap();
|
||||
let distance: f64 = (0..3)
|
||||
.map(|i| (moved.centroid[i] - origin[i]) * normal[i])
|
||||
.sum();
|
||||
assert!(
|
||||
(distance - 3.0).abs() < 1e-9,
|
||||
"the one published scene must contain the replacement, not the undo or a cumulative extrusion"
|
||||
);
|
||||
assert!(app.worker.try_recv().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returning_to_zero_retracts_the_preview_before_mouse_release() {
|
||||
use crate::preview::{GizmoFrame, gizmo_frame};
|
||||
assert_eq!(
|
||||
gizmo_frame(Some((7, 0.0)), None, false, true, 0, Some((7, 3.0))),
|
||||
GizmoFrame::Retire
|
||||
);
|
||||
assert_eq!(
|
||||
gizmo_frame(Some((7, 0.0)), None, false, true, 0, None),
|
||||
GizmoFrame::Idle
|
||||
);
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 259 KiB |
@@ -35,6 +35,7 @@ struct Gpu {
|
||||
/// Which adapter wgpu actually chose. Kept because a golden threshold
|
||||
/// means nothing unless the report says which rasterizer produced it.
|
||||
name: String,
|
||||
view_formats: bool,
|
||||
}
|
||||
|
||||
fn gpu() -> Result<Gpu, RenderError> {
|
||||
@@ -51,6 +52,10 @@ fn gpu() -> Result<Gpu, RenderError> {
|
||||
device,
|
||||
queue,
|
||||
name,
|
||||
view_formats: adapter
|
||||
.get_downlevel_capabilities()
|
||||
.flags
|
||||
.contains(wgpu::DownlevelFlags::VIEW_FORMATS),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -133,6 +138,7 @@ fn read_texture(
|
||||
}
|
||||
|
||||
fn create_target(gpu: &Gpu, format: wgpu::TextureFormat, width: u32, height: u32) -> wgpu::Texture {
|
||||
let aliases = [format.remove_srgb_suffix()];
|
||||
gpu.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("vernier-offscreen-target"),
|
||||
size: wgpu::Extent3d {
|
||||
@@ -145,7 +151,11 @@ fn create_target(gpu: &Gpu, format: wgpu::TextureFormat, width: u32, height: u32
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
view_formats: if gpu.view_formats && format.is_srgb() {
|
||||
&aliases
|
||||
} else {
|
||||
&[]
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -403,6 +413,8 @@ pub struct Offscreen {
|
||||
renderer: Renderer,
|
||||
colour: wgpu::Texture,
|
||||
colour_view: wgpu::TextureView,
|
||||
ui_view: wgpu::TextureView,
|
||||
ui_format: wgpu::TextureFormat,
|
||||
depth: wgpu::TextureView,
|
||||
width: u32,
|
||||
height: u32,
|
||||
@@ -421,6 +433,15 @@ impl Offscreen {
|
||||
let gpu = gpu()?;
|
||||
let colour = create_target(&gpu, COLOUR_FORMAT, width, height);
|
||||
let colour_view = colour.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let ui_format = if gpu.view_formats {
|
||||
COLOUR_FORMAT.remove_srgb_suffix()
|
||||
} else {
|
||||
COLOUR_FORMAT
|
||||
};
|
||||
let ui_view = colour.create_view(&wgpu::TextureViewDescriptor {
|
||||
format: Some(ui_format),
|
||||
..Default::default()
|
||||
});
|
||||
let depth = Renderer::create_depth(&gpu.device, width, height);
|
||||
let renderer = Renderer::new(&gpu.device, COLOUR_FORMAT);
|
||||
Ok(Self {
|
||||
@@ -429,6 +450,8 @@ impl Offscreen {
|
||||
renderer,
|
||||
colour,
|
||||
colour_view,
|
||||
ui_view,
|
||||
ui_format,
|
||||
depth,
|
||||
width,
|
||||
height,
|
||||
@@ -462,6 +485,18 @@ impl Offscreen {
|
||||
COLOUR_FORMAT
|
||||
}
|
||||
|
||||
/// UI blend format; legacy GL adapters without view aliases retain sRGB.
|
||||
#[must_use]
|
||||
pub const fn ui_format(&self) -> wgpu::TextureFormat {
|
||||
self.ui_format
|
||||
}
|
||||
|
||||
/// Gamma-space attachment for egui's coverage blending, over the same pixels.
|
||||
#[must_use]
|
||||
pub const fn ui_view(&self) -> &wgpu::TextureView {
|
||||
&self.ui_view
|
||||
}
|
||||
|
||||
/// Target size in pixels, as `(width, height)`.
|
||||
#[must_use]
|
||||
pub const fn size(&self) -> (u32, u32) {
|
||||
|
||||
@@ -24,6 +24,7 @@ pub struct CutPreview {
|
||||
///
|
||||
/// Present even when `refusal` is: it is the hovered point itself in
|
||||
/// that case, so the sentence has somewhere to hang.
|
||||
/// Cursor destination in sketch coordinates.
|
||||
pub at: [f64; 2],
|
||||
/// Where the moving endpoint comes FROM, drawn as a ghost. `None` for
|
||||
/// a split, which moves nothing, and for a refusal, which does
|
||||
@@ -126,3 +127,237 @@ mod tests {
|
||||
assert_eq!(mid(marks[1]), [10.0, 20.0]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable drawing state sent by the document worker. Mouse motion can
|
||||
/// preview the next click without waiting for another kernel evaluation.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DrawingState {
|
||||
/// Geometry used by the next drawing command.
|
||||
pub sketch: vernier_doc::SketchData,
|
||||
/// Tool that owns the pending clicks.
|
||||
pub tool: Option<crate::shell::SketchTool>,
|
||||
/// Previously clicked points for multi-click tools.
|
||||
pub pending: Vec<vernier_doc::EntityId>,
|
||||
/// Endpoint of the current line chain.
|
||||
pub line_end: Option<vernier_doc::EntityId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// Geometry and snap feedback for the next click.
|
||||
pub struct DrawingPreview {
|
||||
/// Sampled paths in sketch coordinates.
|
||||
pub paths: Vec<Vec<[f64; 2]>>,
|
||||
/// Cursor destination in sketch coordinates.
|
||||
pub at: [f64; 2],
|
||||
/// Human-readable snap kind, empty when unsnapped.
|
||||
pub snap: &'static str,
|
||||
/// Alignment guides to existing geometry.
|
||||
pub guides: Vec<[[f64; 2]; 2]>,
|
||||
}
|
||||
|
||||
impl DrawingState {
|
||||
/// Predicts the next click without mutating the document.
|
||||
pub fn preview(
|
||||
&self,
|
||||
at: [f64; 2],
|
||||
tool: crate::shell::SketchTool,
|
||||
sides: u32,
|
||||
) -> Option<DrawingPreview> {
|
||||
use crate::shell::SketchTool as T;
|
||||
use vernier_doc::{CurveDraw as C, PointRef as P};
|
||||
if matches!(tool, T::Select | T::Trim | T::Split | T::TangentArc) {
|
||||
return None;
|
||||
}
|
||||
let pending = if self.tool == Some(tool) {
|
||||
self.pending.as_slice()
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
let line_end = if self.tool == Some(tool) {
|
||||
self.line_end
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let radial = matches!(tool, T::Circle | T::Polygon) && !pending.is_empty()
|
||||
|| matches!(tool, T::Slot | T::ThreePointArc) && pending.len() == 2;
|
||||
let snap = crate::snap::snap_point(
|
||||
at,
|
||||
&self.sketch,
|
||||
None,
|
||||
&crate::snap::SnapSettings {
|
||||
tolerance_mm: if radial { 0.0 } else { DRAW_SNAP_MM },
|
||||
grid_mm: if radial { 0.0 } else { 1.0 },
|
||||
},
|
||||
);
|
||||
let at = snap.position;
|
||||
let mut preview = DrawingPreview {
|
||||
paths: vec![],
|
||||
at,
|
||||
snap: if radial {
|
||||
""
|
||||
} else if snap.point.is_some() {
|
||||
"point"
|
||||
} else if snap.align_h.is_some() && snap.align_v.is_some() {
|
||||
"horizontal + vertical"
|
||||
} else if snap.align_h.is_some() {
|
||||
"horizontal"
|
||||
} else if snap.align_v.is_some() {
|
||||
"vertical"
|
||||
} else if snap.grid {
|
||||
"grid"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
guides: vec![],
|
||||
};
|
||||
for id in [snap.align_h, snap.align_v].into_iter().flatten() {
|
||||
if let Some(&p) = self.sketch.points.get(&id) {
|
||||
preview.guides.push([p, at]);
|
||||
}
|
||||
}
|
||||
let first = pending
|
||||
.first()
|
||||
.and_then(|id| self.sketch.points.get(id).map(|&p| (*id, p)));
|
||||
let second = pending
|
||||
.get(1)
|
||||
.and_then(|id| self.sketch.points.get(id).map(|&p| (*id, p)));
|
||||
let primitive = match (tool, first, second) {
|
||||
(T::Rectangle, Some((id, p)), _) if crate::primitives::is_drawable_rectangle(p, at) => {
|
||||
Some(crate::primitives::rectangle(P::Existing(id), p, at))
|
||||
}
|
||||
(T::CentreRectangle, Some((id, p)), _)
|
||||
if crate::primitives::is_drawable_centre_rectangle(p, at) =>
|
||||
{
|
||||
Some(crate::primitives::centre_rectangle(P::Existing(id), p, at))
|
||||
}
|
||||
(T::Polygon, Some((id, p)), _)
|
||||
if crate::primitives::is_drawable_polygon(p, at, sides) =>
|
||||
{
|
||||
Some(crate::primitives::polygon(P::Existing(id), p, at, sides))
|
||||
}
|
||||
(T::Slot, Some((a, p)), Some((b, q)))
|
||||
if crate::primitives::is_drawable_slot(p, q, at) =>
|
||||
{
|
||||
Some(crate::primitives::slot(
|
||||
P::Existing(a),
|
||||
P::Existing(b),
|
||||
p,
|
||||
q,
|
||||
at,
|
||||
))
|
||||
}
|
||||
(T::ThreePointArc, Some((a, p)), Some((b, q))) => {
|
||||
crate::primitives::three_point_arc(P::Existing(a), p, P::Existing(b), q, at)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(draw) = primitive {
|
||||
let point = |r: P| match r {
|
||||
P::Existing(id) => self.sketch.points.get(&id).copied(),
|
||||
P::New(i) => draw.points.get(i).copied(),
|
||||
};
|
||||
for curve in &draw.curves {
|
||||
match *curve {
|
||||
C::Line { start, end } => {
|
||||
if let (Some(a), Some(b)) = (point(start), point(end)) {
|
||||
preview.paths.push(vec![a, b]);
|
||||
}
|
||||
}
|
||||
C::Arc { center, start, end } => {
|
||||
if let (Some(c), Some(a), Some(b)) =
|
||||
(point(center), point(start), point(end))
|
||||
{
|
||||
preview.paths.push(arc_path(c, a, b));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match (tool, first, second) {
|
||||
(T::Line, _, _) => {
|
||||
if let Some(p) = line_end.and_then(|id| self.sketch.points.get(&id)) {
|
||||
preview.paths.push(vec![*p, at]);
|
||||
}
|
||||
}
|
||||
(T::Circle, Some((_, p)), _) => {
|
||||
let radius = (at[0] - p[0]).hypot(at[1] - p[1]);
|
||||
preview.paths.push(
|
||||
(0..=96)
|
||||
.map(|i| {
|
||||
let a = f64::from(i) * std::f64::consts::TAU / 96.0;
|
||||
[p[0] + radius * a.cos(), p[1] + radius * a.sin()]
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
preview.paths.push(vec![p, at]);
|
||||
}
|
||||
(T::Arc, Some((_, c)), Some((_, a))) => preview.paths.push(arc_path(c, a, at)),
|
||||
(T::Spline, _, _) => {
|
||||
let mut points: Vec<_> = pending
|
||||
.iter()
|
||||
.filter_map(|id| self.sketch.points.get(id).copied())
|
||||
.collect();
|
||||
points.push(at);
|
||||
preview.paths.push(points);
|
||||
}
|
||||
(_, Some((_, p)), _) => preview.paths.push(vec![p, at]),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(preview)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod drawing_tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
use super::*;
|
||||
use crate::shell::SketchTool;
|
||||
#[test]
|
||||
fn a_line_preview_ends_at_the_same_snap_as_the_next_click() {
|
||||
let mut doc = vernier_doc::Document::new();
|
||||
let created = doc
|
||||
.execute(&vernier_doc::AddSketchFeature {
|
||||
name: "preview".into(),
|
||||
plane: Default::default(),
|
||||
points: vec![[0.0, 0.0], [20.0, 10.0]],
|
||||
curves: vec![],
|
||||
constraints: vec![],
|
||||
})
|
||||
.unwrap()
|
||||
.created;
|
||||
let vernier_doc::FeaturePayload::Sketch(sketch) =
|
||||
doc.features()[&created[0]].payload.clone()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
let state = DrawingState {
|
||||
sketch,
|
||||
tool: Some(SketchTool::Line),
|
||||
pending: vec![],
|
||||
line_end: Some(created[1]),
|
||||
};
|
||||
let preview = state
|
||||
.preview([20.4, 10.2], SketchTool::Line, 6)
|
||||
.expect("live line and snap target");
|
||||
assert_eq!(preview.paths, vec![vec![[0.0, 0.0], [20.0, 10.0]]]);
|
||||
assert_eq!(preview.snap, "point");
|
||||
assert!(state.preview([20.4, 10.2], SketchTool::Select, 6).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture radius shared by the drawing command and its preview, in mm.
|
||||
pub const DRAW_SNAP_MM: f64 = 2.0;
|
||||
|
||||
fn arc_path(c: [f64; 2], a: [f64; 2], b: [f64; 2]) -> Vec<[f64; 2]> {
|
||||
let start = (a[1] - c[1]).atan2(a[0] - c[0]);
|
||||
let sweep = ((b[1] - c[1]).atan2(b[0] - c[0]) - start).rem_euclid(std::f64::consts::TAU);
|
||||
let radius = (a[0] - c[0]).hypot(a[1] - c[1]);
|
||||
(0..=64)
|
||||
.map(|i| {
|
||||
let angle = start + sweep * f64::from(i) / 64.0;
|
||||
[c[0] + radius * angle.cos(), c[1] + radius * angle.sin()]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -43,7 +43,10 @@ pub fn gizmo_overlay(ui: &mut egui::Ui, anchor_px: [f32; 2], tip_px: [f32; 2]) -
|
||||
let rect = egui::Rect::from_center_size(handle, egui::vec2(24.0, 24.0));
|
||||
let response = ui.interact(rect, id, egui::Sense::drag());
|
||||
if response.drag_started() {
|
||||
ui.data_mut(|data| data.insert_temp(id, egui::Vec2::ZERO));
|
||||
ui.data_mut(|data| {
|
||||
data.insert_temp(id, egui::Vec2::ZERO);
|
||||
data.insert_temp(id.with("axis"), (anchor_px, tip_px));
|
||||
});
|
||||
}
|
||||
if response.dragged() || response.drag_stopped() {
|
||||
// READ THE DELTA BEFORE TAKING THE LOCK, and keep it out here.
|
||||
@@ -59,9 +62,15 @@ pub fn gizmo_overlay(ui: &mut egui::Ui, anchor_px: [f32; 2], tip_px: [f32; 2]) -
|
||||
data.insert_temp(id, total);
|
||||
total
|
||||
});
|
||||
let mm = crate::gizmo::screen_axis_drag_mm(anchor_px, tip_px, [total.x, total.y]);
|
||||
let (start, tip) = ui
|
||||
.data(|data| data.get_temp::<([f32; 2], [f32; 2])>(id.with("axis")))
|
||||
.unwrap_or((anchor_px, tip_px));
|
||||
let mm = crate::gizmo::screen_axis_drag_mm(start, tip, [total.x, total.y]);
|
||||
if response.drag_stopped() {
|
||||
ui.data_mut(|data| data.remove::<egui::Vec2>(id));
|
||||
ui.data_mut(|data| {
|
||||
data.remove::<egui::Vec2>(id);
|
||||
data.remove::<([f32; 2], [f32; 2])>(id.with("axis"));
|
||||
});
|
||||
out.committed_mm = mm.filter(|mm| mm.abs() > 1e-6);
|
||||
out.released = true;
|
||||
} else {
|
||||
|
||||
@@ -192,6 +192,8 @@ pub struct SceneView {
|
||||
/// Empty is the ordinary case: most of a sketch infers nothing, and a
|
||||
/// hover outside sketch mode infers nothing at all.
|
||||
pub proposals: Vec<vernier_doc::InferredMark>,
|
||||
/// Drawing snapshot for immediate mouse-move feedback.
|
||||
pub drawing: Option<crate::hover::DrawingState>,
|
||||
/// What a trim or split click at the hovered point WOULD do (M3 lane
|
||||
/// B), including the refusal it would raise.
|
||||
///
|
||||
@@ -1740,7 +1742,7 @@ impl Default for ShellState {
|
||||
section_offset_mm: 0.0,
|
||||
section_flip: false,
|
||||
readout: String::new(),
|
||||
distance_mm: 2.0,
|
||||
distance_mm: 0.0,
|
||||
radius_mm: 1.0,
|
||||
chamfer: false,
|
||||
thickness_mm: 2.0,
|
||||
@@ -2729,7 +2731,7 @@ pub fn show(ui: &mut egui::Ui, view: &SceneView, state: &mut ShellState) -> Shel
|
||||
// styling bug rather than an ordering one. Status first of the two, so it
|
||||
// ends up bottom-most.
|
||||
title_bar(ui, view, state, theme, &mut response);
|
||||
tab_strip(ui, state, theme);
|
||||
tab_strip(ui, state, theme, workspace_for(state, view));
|
||||
ribbon::ribbon(ui, view, state, theme, selection, &mut response);
|
||||
ribbon::status_line(ui, view, state, theme);
|
||||
ribbon::history_strip(ui, view, state, theme, &mut response);
|
||||
@@ -3063,7 +3065,12 @@ fn document_buttons(
|
||||
}
|
||||
|
||||
/// The workspace tab strip, and the note that explains its behaviour.
|
||||
fn tab_strip(ui: &mut egui::Ui, state: &mut ShellState, theme: ShellTheme) {
|
||||
fn tab_strip(
|
||||
ui: &mut egui::Ui,
|
||||
state: &mut ShellState,
|
||||
theme: ShellTheme,
|
||||
following: registry::Workspace,
|
||||
) {
|
||||
let frame = egui::Frame::NONE
|
||||
.fill(theme::PAPER)
|
||||
.inner_margin(egui::Margin::symmetric(16, 0));
|
||||
@@ -3080,7 +3087,7 @@ fn tab_strip(ui: &mut egui::Ui, state: &mut ShellState, theme: ShellTheme) {
|
||||
// CLICKING A TAB PINS IT. Otherwise the next selection
|
||||
// would snatch it back, and the click would read as
|
||||
// broken rather than as overridden.
|
||||
state.workspace_pinned = true;
|
||||
state.workspace_pinned = *workspace != following;
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
|
||||
@@ -18,6 +18,7 @@ fn view() -> SceneView {
|
||||
closed_profile: None,
|
||||
unsaved: false,
|
||||
proposals: Vec::new(),
|
||||
drawing: None,
|
||||
cut_preview: None,
|
||||
timeline: vec![
|
||||
TimelineRow {
|
||||
@@ -1985,3 +1986,110 @@ fn opening_a_sketch_drops_a_standing_aim() {
|
||||
surfaced as a card nobody pressed when the sketch closed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_profile_waits_for_an_explicit_extrude_request() {
|
||||
let mut state = ShellState {
|
||||
sketching: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(card_showing(&state, Selection::Profile).is_none());
|
||||
state.card.arm(Action::Extrude);
|
||||
assert_eq!(
|
||||
card_showing(&state, Selection::Profile).and_then(|c| c.action()),
|
||||
Some(Action::Extrude)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_push_pull_starts_at_zero() {
|
||||
assert_eq!(ShellState::default().distance_mm, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_geometry_does_not_change_the_drag_measurement() {
|
||||
driven("stable drag axis", || {
|
||||
let ctx = shell_ctx();
|
||||
let handle = egui::pos2(160.0, 100.0);
|
||||
let events = [
|
||||
vec![],
|
||||
vec![
|
||||
egui::Event::PointerMoved(handle),
|
||||
egui::Event::PointerButton {
|
||||
pos: handle,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: true,
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
],
|
||||
vec![egui::Event::PointerMoved(handle + egui::vec2(20.0, 0.0))],
|
||||
vec![egui::Event::PointerMoved(handle + egui::vec2(40.0, 0.0))],
|
||||
];
|
||||
let mut last = None;
|
||||
for (i, events) in events.into_iter().enumerate() {
|
||||
let tip = if i == 3 {
|
||||
[300.0, 100.0]
|
||||
} else {
|
||||
[200.0, 100.0]
|
||||
};
|
||||
ctx.run_ui(
|
||||
egui::RawInput {
|
||||
events,
|
||||
..frame_input()
|
||||
},
|
||||
|ui| {
|
||||
last = gizmo_overlay(ui, [100.0, 100.0], tip).live_mm;
|
||||
},
|
||||
)
|
||||
.drop_without_applying_deltas();
|
||||
}
|
||||
assert_eq!(
|
||||
last,
|
||||
Some(0.4),
|
||||
"the same 40px movement must use the original 100px/mm axis"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_the_current_workspace_releases_the_tab_pin() {
|
||||
let pinned = driven("release tab pin", || {
|
||||
let ctx = shell_ctx();
|
||||
let mut state = ShellState {
|
||||
sketching: true,
|
||||
workspace: registry::Workspace::Solid,
|
||||
workspace_pinned: true,
|
||||
..Default::default()
|
||||
};
|
||||
let view = SceneView::default();
|
||||
let at = egui::pos2(35.0, 58.0);
|
||||
for i in 0..4 {
|
||||
let mut input = frame_input();
|
||||
if i >= 2 {
|
||||
input.events = vec![
|
||||
egui::Event::PointerMoved(at),
|
||||
egui::Event::PointerButton {
|
||||
pos: at,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: i == 2,
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
];
|
||||
}
|
||||
ctx.run_ui(input, |ui| {
|
||||
show(ui, &view, &mut state);
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
}
|
||||
(state.workspace, state.workspace_pinned)
|
||||
});
|
||||
assert_eq!(
|
||||
pinned.0,
|
||||
registry::Workspace::Sketch,
|
||||
"positive control: tab was clicked"
|
||||
);
|
||||
assert!(
|
||||
!pinned.1,
|
||||
"clicking the workspace the selection wants must release the pin as its caption promises"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,7 +94,13 @@ pub fn card_showing(
|
||||
command.takes_input()
|
||||
&& registry::readiness_for(command, selection) == registry::Readiness::Ready
|
||||
})
|
||||
.or_else(|| card_for(selection))
|
||||
.or_else(|| {
|
||||
if state.sketching && matches!(selection, Selection::Profile) {
|
||||
None
|
||||
} else {
|
||||
card_for(selection)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a ribbon press on `command` AIMS the card at it rather than
|
||||
|
||||
@@ -179,3 +179,9 @@ and cargo run -q -p vernier-drive -- scripts/drive/m4-edge-pick.json \
|
||||
--out target/drive/m4-edge-pick --require-adapter RADV
|
||||
and cargo run -q -p vernier-drive -- scripts/drive/m4-cutaway.json \
|
||||
--out target/drive/m4-cutaway --require-adapter RADV
|
||||
|
||||
# Live sketch geometry, snap label, deliberate extrusion, and exported volume.
|
||||
and cargo run -q -p vernier-drive -- scripts/drive/ux-sketch-feedback.json \
|
||||
--out target/drive/ux-sketch-feedback --require-adapter RADV
|
||||
and cargo test -q -p vernier-app ui_half_coverage -- --ignored
|
||||
and cargo test -q -p vernier-app a_plain_pointer_move -- --ignored
|
||||
|
||||
@@ -294,6 +294,9 @@
|
||||
{ "step": "wait_idle" },
|
||||
{ "step": "expect_selection", "is": "profile" },
|
||||
|
||||
{ "step": "click", "at": "tab:solid" },
|
||||
{ "step": "click", "at": "ribbon:extrude" },
|
||||
{ "step": "click", "at": "tab:sketch" },
|
||||
{ "step": "click", "at": "card:height" },
|
||||
{ "step": "key", "key": "ctrl+a" },
|
||||
{ "step": "type", "text": "20.0" },
|
||||
@@ -351,6 +354,9 @@
|
||||
{ "step": "click", "at": "world:33.93,12.49,2" },
|
||||
{ "step": "wait_idle" },
|
||||
{ "step": "expect_selection", "is": "profile" },
|
||||
{ "step": "click", "at": "tab:solid" },
|
||||
{ "step": "click", "at": "ribbon:extrude" },
|
||||
{ "step": "click", "at": "tab:sketch" },
|
||||
{ "step": "click", "at": "card:height" },
|
||||
{ "step": "key", "key": "ctrl+a" },
|
||||
{ "step": "type", "text": "4.0" },
|
||||
|
||||
@@ -294,6 +294,9 @@
|
||||
{ "step": "wait_idle" },
|
||||
{ "step": "expect_selection", "is": "profile" },
|
||||
|
||||
{ "step": "click", "at": "tab:solid" },
|
||||
{ "step": "click", "at": "ribbon:extrude" },
|
||||
{ "step": "click", "at": "tab:sketch" },
|
||||
{ "step": "click", "at": "card:height" },
|
||||
{ "step": "key", "key": "ctrl+a" },
|
||||
{ "step": "type", "text": "20.0" },
|
||||
@@ -351,6 +354,9 @@
|
||||
{ "step": "click", "at": "world:33.93,12.49,2" },
|
||||
{ "step": "wait_idle" },
|
||||
{ "step": "expect_selection", "is": "profile" },
|
||||
{ "step": "click", "at": "tab:solid" },
|
||||
{ "step": "click", "at": "ribbon:extrude" },
|
||||
{ "step": "click", "at": "tab:sketch" },
|
||||
{ "step": "click", "at": "card:height" },
|
||||
{ "step": "key", "key": "ctrl+a" },
|
||||
{ "step": "type", "text": "4.0" },
|
||||
|
||||
@@ -350,6 +350,9 @@
|
||||
"step": "expect_selection",
|
||||
"is": "profile"
|
||||
},
|
||||
{ "step": "click", "at": "tab:solid" },
|
||||
{ "step": "click", "at": "ribbon:extrude" },
|
||||
{ "step": "click", "at": "tab:sketch" },
|
||||
{
|
||||
"step": "click",
|
||||
"at": "card:height"
|
||||
@@ -1121,6 +1124,9 @@
|
||||
"step": "expect_selection",
|
||||
"is": "profile"
|
||||
},
|
||||
{ "step": "click", "at": "tab:solid" },
|
||||
{ "step": "click", "at": "ribbon:extrude" },
|
||||
{ "step": "click", "at": "tab:sketch" },
|
||||
{
|
||||
"step": "click",
|
||||
"at": "chip:cut"
|
||||
|
||||
@@ -350,6 +350,9 @@
|
||||
"step": "expect_selection",
|
||||
"is": "profile"
|
||||
},
|
||||
{ "step": "click", "at": "tab:solid" },
|
||||
{ "step": "click", "at": "ribbon:extrude" },
|
||||
{ "step": "click", "at": "tab:sketch" },
|
||||
{
|
||||
"step": "click",
|
||||
"at": "card:height"
|
||||
@@ -1121,6 +1124,9 @@
|
||||
"step": "expect_selection",
|
||||
"is": "profile"
|
||||
},
|
||||
{ "step": "click", "at": "tab:solid" },
|
||||
{ "step": "click", "at": "ribbon:extrude" },
|
||||
{ "step": "click", "at": "tab:sketch" },
|
||||
{
|
||||
"step": "click",
|
||||
"at": "chip:cut"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"name": "m1-smoke-push-pull",
|
||||
"notes": [
|
||||
"2026-09-09: PNG rebaselined for gamma-space UI coverage blending. The",
|
||||
"GPU regression measures half-covered black on white as 127 instead of 187.",
|
||||
"The earlier GL drift measurements below are historical; this golden is RADV-only.",
|
||||
"THE M1 SMOKE: pick the starter block's top face in the real shell, type a",
|
||||
"distance into the value card, commit it, and assert the exported geometry.",
|
||||
"Every number below is DERIVED here because JSON has no comments and a",
|
||||
|
||||
@@ -57,6 +57,9 @@
|
||||
{ "step": "wait_idle" },
|
||||
{ "step": "expect_no_error" },
|
||||
|
||||
{ "step": "click", "at": "tab:solid" },
|
||||
{ "step": "click", "at": "ribbon:extrude" },
|
||||
{ "step": "click", "at": "tab:sketch" },
|
||||
{ "step": "click", "at": "card:height" },
|
||||
{ "step": "key", "key": "ctrl+a" },
|
||||
{ "step": "type", "text": "5.0" },
|
||||
|
||||
@@ -234,6 +234,9 @@
|
||||
"step": "expect_selection",
|
||||
"is": "profile"
|
||||
},
|
||||
{ "step": "click", "at": "tab:solid" },
|
||||
{ "step": "click", "at": "ribbon:extrude" },
|
||||
{ "step": "click", "at": "tab:sketch" },
|
||||
{
|
||||
"step": "click",
|
||||
"at": "card:height"
|
||||
|
||||
@@ -124,6 +124,9 @@
|
||||
{ "step": "expect_no_error" },
|
||||
{ "step": "expect_selection", "is": "profile" },
|
||||
|
||||
{ "step": "click", "at": "tab:solid" },
|
||||
{ "step": "click", "at": "ribbon:extrude" },
|
||||
{ "step": "click", "at": "tab:sketch" },
|
||||
{ "step": "click", "at": "card:height" },
|
||||
{ "step": "key", "key": "ctrl+a" },
|
||||
{ "step": "type", "text": "20.0" },
|
||||
|
||||