graphic design tool
git clone https://git.lucas.co/cce-designer.git
fix: what is drawn over the scene is placed by the camera as it is
The 2D frame is painted before the scene is staged, and took its
projection from the stage pass of the frame before: the point numbers and
the handles trailed the geometry and its markers by a frame while the
camera moved, and stood off their points when it stopped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CLAUDE.md | 15 +++++++++++++
src/app.rs | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/main.rs | 42 +++++++++++++++++++++++++++++++++++
src/render.rs | 3 +++
4 files changed, 130 insertions(+)
diff --git a/CLAUDE.md b/CLAUDE.md
index f3f6ff6..b558b4e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2579,6 +2579,21 @@ the scene needs a text layer drawn ahead of the plates, which cce-ui does
not have; under the pane tint a blurred 10 px number would not be read in
any case. `a_point_number_under_a_plate_is_not_drawn` is the test.
+**What the 2D frame draws over the scene is placed by the camera as it
+is when the frame is painted** (`State::refresh_scene_view`, at the top of
+`collect_display_list`, since 2026-09-29). The runner paints the 2D frame
+and THEN stages the scene, and the stage pass was the one place
+`last_scene_mvp`, the pane's rect and the eye were set — so the numbers,
+a viewer state's handles and the scale readout were placed by the camera
+of the frame before. They trailed the geometry and its markers, which
+are meshes drawn by the frame's own matrix, by a frame whenever the
+camera moved, and stood a frame's move off their points once it stopped.
+`State::scene_view` is the pane and the view as they are now, for both.
+The dimming is asked for twice a frame that way and worked out once
+(`number_alpha_key`). Not in the traced mode, which keeps the view the
+raster pass last staged. `the_point_numbers_are_placed_by_the_camera_as_it_is`
+is the test.
+
**A scene rebuild works the dimming out itself**, for the view the
scene was last staged from (`last_scene_mvp`, `last_scene_eye`). The 2D
frame is painted BEFORE the stage pass, so a rebuild that only cleared
diff --git a/src/app.rs b/src/app.rs
index 92334c1..a30d34b 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2495,6 +2495,9 @@ pub struct State {
/// The raster scene's model-view-projection and the viewport pane rect in
/// LOGICAL px, cached at staging so the 2D pass can project 3D overlays.
pub last_scene_mvp: Option<Mat4>,
+ /// What the numbers' dimming was last worked out for: the view, the
+ /// geometry, the opacity, whether the fill is seen through, how many.
+ pub number_alpha_key: Option<([u32; 16], u64, u32, bool, usize)>,
/// The eye the scene was last staged for, in mesh space, beside the
/// matrix: what a scene rebuild dims the numbers by until the stage
/// pass has staged the new geometry.
@@ -2981,6 +2984,56 @@ impl State {
/// The Default Camera's eye hangs off its pivot, so its pivot is all
/// that moves. A camera node has its Pivot and Position rewritten, as
/// Frame All rewrites them.
+ /// The scene's pane and the view through it AS THEY ARE NOW: the pane's
+ /// rect in physical px, the projection and the view. What the stage
+ /// pass stages the scene by, and `None` where there is no scene to
+ /// stage — a detached window, a hidden viewport, a pane of no size.
+ pub(crate) fn scene_view(&self) -> Option<((u32, u32, u32, u32), Mat4, Mat4)> {
+ if self.is_detached_network || self.detached_pane.is_some() || !self.show_viewport {
+ return None;
+ }
+ let s = self.scale as f32;
+ let (mut sx, mut sy) = (0u32, (HEADER_H * s) as u32);
+ let (mut cw, mut ch) = ((self.width * s) as u32, (self.body_h() * s) as u32);
+ if self.square_viewport {
+ let side = cw.min(ch);
+ sx += (cw - side) / 2;
+ sy += (ch - side) / 2;
+ (cw, ch) = (side, side);
+ }
+ if cw == 0 || ch == 0 {
+ return None;
+ }
+ let (pos, rot, pivot) = self.active_camera_pose();
+ let (proj, view, model) = self.viewport().get_matrices(cw as f32 / ch as f32, Some(pos), Some(rot), Some(pivot));
+ Some(((sx, sy, cw, ch), proj, view * model))
+ }
+
+ /// Bring what the 2D frame projects by — `last_scene_mvp`, the pane's
+ /// rect, the eye — up to the camera as it is now, ahead of painting.
+ ///
+ /// The 2D frame is painted BEFORE the stage pass stages the scene, and
+ /// until 2026-09-29 the stage pass was the one place these were set: so
+ /// everything the 2D frame draws over the scene — the point, primitive
+ /// and vertex numbers, a viewer state's handles, the scale readout —
+ /// was placed by the camera of the frame BEFORE, and trailed the
+ /// geometry and its markers by a frame whenever the camera moved. When
+ /// it stopped they stood a frame's move off their points until
+ /// something else drew. Not in the traced mode, which keeps the view
+ /// the raster pass last staged, as it did.
+ pub(crate) fn refresh_scene_view(&mut self) {
+ if self.viewport().rt_mode {
+ return;
+ }
+ let Some(((sx, sy, cw, ch), proj, view)) = self.scene_view() else { return };
+ let s = (self.scale as f32).max(0.001);
+ let mvp = proj * view;
+ self.last_scene_mvp = Some(mvp);
+ self.last_scene_view_rect = (sx as f32 / s, sy as f32 / s, cw as f32 / s, ch as f32 / s);
+ self.last_scene_eye = view.inverse().transform_point3(Vec3::ZERO);
+ self.sync_point_number_alpha(mvp, self.last_scene_eye);
+ }
+
pub(crate) fn pan_camera_by(&mut self, dx_px: f32, dy_px: f32) {
let (pos, rot, pivot) = self.active_camera_pose();
let (_, view, _) = self.viewport().get_matrices(1.0, Some(pos), Some(rot), Some(pivot));
@@ -6544,6 +6597,22 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
/// the eye the stage pass is staging (`eye` in mesh space, the space of
/// the labels and the triangles).
pub(crate) fn sync_point_number_alpha(&mut self, mvp: Mat4, eye: Vec3) {
+ // Asked for the same view of the same scene twice in a frame — by
+ // the 2D paint and again by the stage pass — and worked out once.
+ let key = (
+ mvp.to_cols_array().map(f32::to_bits),
+ self.rt_geometry_version,
+ self.geo_opacity.to_bits(),
+ self.see_through_active(),
+ self.overlay_number_labels.len() + self.overlay_prim_labels.len() + self.overlay_vertex_labels.len(),
+ );
+ let worked_out = self.overlay_number_alpha.len() == self.overlay_number_labels.len()
+ && self.overlay_prim_alpha.len() == self.overlay_prim_labels.len()
+ && self.overlay_vertex_alpha.len() == self.overlay_vertex_labels.len();
+ if self.number_alpha_key == Some(key) && worked_out {
+ return;
+ }
+ self.number_alpha_key = Some(key);
// One pass over the three lists, so the mesh is binned once.
let (points_n, prims_n) = (self.overlay_number_labels.len(), self.overlay_prim_labels.len());
let at: Vec<[f32; 3]> = self
@@ -7081,6 +7150,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
.unwrap_or(cce_ui::units::Unit::Mm),
pick_cache: None,
last_scene_mvp: None,
+ number_alpha_key: None,
last_scene_eye: Vec3::ZERO,
last_scene_view_rect: (0.0, 0.0, 0.0, 0.0),
viewer_tool: None,
diff --git a/src/main.rs b/src/main.rs
index 9cff151..5ecd48f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2911,6 +2911,48 @@ mod tests {
let _ = fs::remove_dir_all(&dir);
}
+ /// What the 2D frame draws over the scene is placed by the camera as
+ /// it is when the frame is painted. The paint comes BEFORE the stage
+ /// pass, which was the one place the projection was kept: the numbers
+ /// trailed their points by a frame while the camera moved, and stood a
+ /// frame's move off them when it stopped.
+ #[test]
+ fn the_point_numbers_are_placed_by_the_camera_as_it_is() {
+ let mut state = State::new(false);
+ state.resize(1600.0, 900.0, 1.0);
+ state.rebuild_positions();
+ state.apply_layout();
+ state.set_active_camera("Default Camera");
+ state.show_point_numbers = true;
+ state.geo_opacity = 0.35;
+ state.show_occluded = true;
+ state.rebuild_scene_geometry();
+ let _ = state.collect_display_list();
+ let placed = |state: &State| -> Vec<(String, f32, f32)> {
+ state.point_number_labels().into_iter().map(|(t, x, y, ..)| (t, x, y)).collect()
+ };
+ let before = placed(&state);
+ assert!(!before.is_empty());
+
+ // The camera moves, and the next frame is painted — no stage pass
+ // between.
+ state.orbit_camera_by(90.0, 20.0);
+ state.pan_camera_by(30.0, -10.0);
+ let _ = state.collect_display_list();
+ let after = placed(&state);
+ assert_ne!(before, after, "the numbers stood where the last frame had them");
+ // And they stand where the scene will be drawn: by the view the
+ // stage pass is about to stage.
+ let (_, proj, view) = state.scene_view().expect("a scene");
+ assert_eq!(state.last_scene_mvp, Some(proj * view));
+ let alphas = state.overlay_number_alpha.clone();
+ assert_eq!(alphas.len(), state.overlay_number_labels.len(), "and are dimmed for that view");
+ // Asked again for the same view, the dimming is not worked out again.
+ let key = state.number_alpha_key;
+ state.sync_point_number_alpha(proj * view, state.last_scene_eye);
+ assert_eq!((state.number_alpha_key, &state.overlay_number_alpha), (key, &alphas));
+ }
+
/// A pan slides the camera across its own view: what is at the pivot
/// follows the pointer px for px, the view turns nowhere, and a camera
/// node's Pivot and Position move together. Middle-drag, shift and the
diff --git a/src/render.rs b/src/render.rs
index 1638e27..cceb6a4 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -93,6 +93,9 @@ impl State {
/// cache are gone). Draw order is the hand-maintained slot order the vertex path
/// used; the circular network pane rides `PaintItem::clip_circle`.
pub(crate) fn collect_display_list(&mut self) -> DisplayList {
+ // What is drawn over the scene is placed by the camera as it is
+ // NOW, not as the stage pass last saw it.
+ self.refresh_scene_view();
// Refresh popover registration: the engine's text-occlusion clamp
// reads `ui_context.active_popovers` to keep underlying text from
// bleeding through an open popup's plate. The legacy render_widget