Big feature update by Codex
ci / check (push) Failing after 5s

This commit is contained in:
2026-09-10 09:17:04 +02:00
parent 7d0eeab940
commit c0d74cbf2d
28 changed files with 877 additions and 53 deletions
+5 -8
View File
@@ -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:
+70 -8
View File
@@ -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,
+2
View File
@@ -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,
+26 -2
View File
@@ -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 {
+93 -2
View File
@@ -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"
);
}
}
+28 -23
View File
@@ -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 => {
+20 -1
View File
@@ -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;
+119
View File
@@ -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
);
});
}
+63
View File
@@ -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
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 259 KiB

+36 -1
View File
@@ -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) {
+235
View File
@@ -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()
}
+12 -3
View File
@@ -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 {
+11 -4
View File
@@ -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| {
+108
View File
@@ -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"
);
}
+7 -1
View File
@@ -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
+6
View File
@@ -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" },
+6
View File
@@ -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" },
+6
View File
@@ -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"
+6
View File
@@ -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"
+3
View File
@@ -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",
+3
View File
@@ -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" },
+3
View File
@@ -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"
+3
View File
@@ -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" },