Compare commits

...
Author SHA1 Message Date
bherbruck 069ba1223f Release 0.10.1 2026-10-14 12:58:23 +00:00
bherbruckandClaude Opus 5.5 4c09fa5355 Hovered and selected faces light solidly again over GPU-drawn bodies (#1064)
Since the model's mesh went back to the reference mesher (#940), the GPU viewport drew
opaque bodies from their display plans while hover and selection lit faces with the
reference mesh's triangles. Two triangulations of the same face at equal depth fought, so
a hovered face shimmered in wedges instead of lighting whole.

Faces lit over a body drawn from its plan now use the plan's own triangles
(ModelBody::plan_mesh, evaluated once on the CPU), and the highlight pass gets a small
depth bias toward the eye so f32 rounding between the GPU and CPU evaluations of the same
triangles cannot speckle it either.

Test: every face of a box with a pocket and a round boss, hovered and rendered offscreen
through the viewport's real pipelines on the GPU path, lights the same pixels as on the
CPU path (skipped without a GPU adapter).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 12:56:47 +00:00
5 changed files with 177 additions and 37 deletions

No files matched your search

Generated
+14 -14
View File
@@ -3281,7 +3281,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"eframe",
"egui",
@@ -3299,7 +3299,7 @@ dependencies = [
[[package]]
name = "solidcraft-cli"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -3308,7 +3308,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"log",
"serde",
@@ -3323,7 +3323,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"log",
"serde",
@@ -3339,7 +3339,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"serde",
"serde_json",
@@ -3348,7 +3348,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"log",
"pollster 0.4.0",
@@ -3360,7 +3360,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"quick-xml",
"serde",
@@ -3375,7 +3375,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"log",
"rayon",
@@ -3393,7 +3393,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -3401,7 +3401,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"image",
"serde",
@@ -3411,7 +3411,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"serde",
"serde_json",
@@ -3422,7 +3422,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-egui"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"egui",
"egui-wgpu",
@@ -3439,7 +3439,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"eframe",
"egui",
@@ -4862,7 +4862,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"serde_json",
"sha2",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.10.0"
version = "0.10.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+12
View File
@@ -31,6 +31,9 @@ pub struct ModelBody {
/// The display plan (none when the body has no plan: a mesh body, or one the planner
/// refuses), shared like the mesh. A GPU viewport draws it.
plan: Arc<OnceLock<Option<Arc<TessPlan>>>>,
/// The plan evaluated on the CPU (none without a plan), shared like the mesh: the triangles a
/// GPU viewport draws, so a highlight over a face lies exactly on it.
plan_mesh: Arc<OnceLock<Option<Arc<Mesh>>>>,
/// Identity of this body's content for the evaluation cache: the key of the evaluation
/// that produced it (kept while later features pass it through unchanged).
key: u64,
@@ -59,6 +62,7 @@ impl ModelBody {
feature,
mesh: Arc::new(OnceLock::new()),
plan: Arc::new(OnceLock::new()),
plan_mesh: Arc::new(OnceLock::new()),
key: 0,
names: Default::default(),
made: Default::default(),
@@ -98,6 +102,14 @@ impl ModelBody {
.clone()
}
/// The display plan's mesh, evaluated on the CPU once: the same triangles a GPU viewport
/// evaluates and draws for this body. Highlights over a face drawn from the plan use it, since
/// the reference mesh ([`ModelBody::mesh`]) triangulates the face differently and the two
/// would fight at equal depth. `None` when the body has no plan.
pub fn plan_mesh(&self) -> Option<Arc<Mesh>> {
self.plan_mesh.get_or_init(|| self.display_plan().and_then(|plan| self.body.display_mesh_of(&plan).ok()).map(Arc::new)).clone()
}
/// The display mesh's chord tolerance: 1/1000 of the body's size.
fn display_tol(&self) -> f64 {
(self.body.size() * 1e-3).max(1e-3)
+31 -4
View File
@@ -642,7 +642,7 @@ impl Resources {
access_bgl,
access_bind,
access_version: 0,
tri_hl: pipeline("sc_tris_hl", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
tri_hl: pipeline("sc_tris_hl", "vs_tri", "fs_tri", tri_layout(), highlight_depth(t.depth), alpha),
trans: pipeline("sc_trans", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
glass_far: glass("sc_glass_far", wgpu::Face::Front),
glass_near: glass("sc_glass_near", wgpu::Face::Back),
@@ -662,6 +662,24 @@ impl Resources {
}
}
/// Depth state of faces lit over the model (hover, selection): tested, not written, and pulled a
/// little toward the eye. They lie on the model's own faces, but the GPU evaluates a body's plan
/// in f32 while the highlight's copy of the same triangles is evaluated on the CPU, so the two
/// depths differ by rounding and an unbiased equal test lights the face in speckles and wedges.
/// The bias is a few depth steps plus the face's own slope: far less than any real gap.
fn highlight_depth(format: Option<wgpu::TextureFormat>) -> Option<wgpu::DepthStencilState> {
format.map(|format| wgpu::DepthStencilState {
format,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: HIGHLIGHT_BIAS,
})
}
/// See [`highlight_depth`]. Negative: toward the eye (depth grows away from it).
const HIGHLIGHT_BIAS: wgpu::DepthBiasState = wgpu::DepthBiasState { constant: -8, slope_scale: -1.0, clamp: 0.0 };
fn upload(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize) -> Option<Batch> {
upload_with(device, label, bytes, stride, wgpu::BufferUsages::VERTEX)
}
@@ -895,8 +913,17 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
/// Render `scene` offscreen on a device of its own, through the viewport's real resources and
/// frame callback (`prepare`, then `paint`), and read the pixels back: straight RGBA, `size` ×
/// `size`. `None` without a GPU adapter. With `tess`, planned bodies are evaluated on the GPU.
/// `highlight` is drawn as the viewport's highlight scene (hover and selection).
#[cfg(all(test, not(target_arch = "wasm32")))]
pub(crate) fn render_offscreen(scene: GpuScene, view_proj: [[f32; 4]; 4], back: [f32; 3], eye: [f32; 4], size: u32, tess: bool) -> Option<Vec<u8>> {
pub(crate) fn render_offscreen(
scene: GpuScene,
highlight: Option<GpuScene>,
view_proj: [[f32; 4]; 4],
back: [f32; 3],
eye: [f32; 4],
size: u32,
tess: bool,
) -> Option<Vec<u8>> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
@@ -915,8 +942,8 @@ pub(crate) fn render_offscreen(scene: GpuScene, view_proj: [[f32; 4]; 4], back:
slot: Arc::new(Mutex::new(Some(scene))),
sketch_key: 0,
sketch_slot: none(),
hl_key: 0,
hl_slot: none(),
hl_key: u64::from(highlight.is_some()),
hl_slot: Arc::new(Mutex::new(highlight)),
pv_key: 0,
pv_slot: none(),
view_proj,
+119 -18
View File
@@ -305,6 +305,51 @@ fn sketch_color(t: &Tokens, c: Rgb) -> [u8; 4] {
}
}
/// With a component active, bodies outside it are drawn faded.
fn body_faded(app: &SolveApp, b: &solidcraft_engine::doc::ModelBody, active_bodies: &Option<Vec<String>>) -> bool {
let s = &app.session;
if let Some(names) = active_bodies {
!names.contains(&b.name)
} else {
s.active_component != 0 && !s.doc.component_within(b.component, s.active_component)
}
}
/// The display plan the model scene draws `b` from on the GPU, or `None` when its faces are built
/// here from the reference mesh. Opaque shaded bodies (`opaque`: not faded, and no see-through
/// body or face) are drawn from their plans where the device evaluates them; faded and
/// see-through ones (sorted and blended per triangle) are built here.
fn drawn_plan(
app: &SolveApp,
b: &solidcraft_engine::doc::ModelBody,
opaque: bool,
) -> Option<std::sync::Arc<solidcraft_engine::geom::tess::TessPlan>> {
let shaded = app.ui.visual_style != 2;
(shaded && opaque && app.viewport.gpu.is_some_and(|g| g.tess))
.then(|| b.display_plan())
.flatten()
.filter(|plan| plan.vertex_count() <= solidcraft_gpu::MAX_BATCH)
}
/// The mesh whose triangles the model scene shows for `b`'s faces: the plan's (evaluated on the
/// CPU, the same triangles the GPU draws) or the reference mesh. A face highlighted over the model
/// must use the same triangles: another triangulation of the same face fights it at equal depth
/// and shimmers.
fn shown_mesh(
app: &SolveApp,
b: &solidcraft_engine::doc::ModelBody,
active_bodies: &Option<Vec<String>>,
) -> std::sync::Arc<solidcraft_engine::geom::Mesh> {
let doc = &app.session.doc;
let opaque =
|| !body_faded(app, b, active_bodies) && doc.model_body_opacity(b) >= 0.999 && doc.face_colors(b).iter().all(|(_, l)| l.opacity >= 0.999);
(app.viewport.gpu.is_some_and(|g| g.tess) && opaque())
.then(|| drawn_plan(app, b, true))
.flatten()
.and_then(|_| b.plan_mesh())
.unwrap_or_else(|| b.mesh())
}
fn build_scene(app: &SolveApp) -> GpuScene {
let tk = Tokens::get();
let mut sc = GpuScene::default();
@@ -316,12 +361,7 @@ fn build_scene(app: &SolveApp) -> GpuScene {
if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) {
continue;
}
// With a component active, bodies outside it are drawn faded.
let faded = if let Some(names) = &active_bodies {
!names.contains(&b.name)
} else {
s.active_component != 0 && !s.doc.component_within(b.component, s.active_component)
};
let faded = body_faded(app, b, &active_bodies);
let base = body_color(&s.doc, b).unwrap_or(tk.body);
let col = if faded { c4(base.gamma_multiply(0.35)) } else { c4(base) };
let m = b.mesh();
@@ -338,13 +378,8 @@ fn build_scene(app: &SolveApp) -> GpuScene {
let opacity = s.doc.model_body_opacity(b);
let face_looks: std::collections::HashMap<usize, solidcraft_engine::doc::appearance::Look> =
if shaded { s.doc.face_colors(b).into_iter().collect() } else { Default::default() };
// Opaque bodies are drawn from their display plans on the GPU where the device evaluates
// them; faded and see-through ones (sorted and blended per triangle) are built here.
let opaque = !faded && opacity >= 0.999 && face_looks.values().all(|l| l.opacity >= 0.999);
let planned = (shaded && opaque && app.viewport.gpu.is_some_and(|g| g.tess))
.then(|| b.display_plan())
.flatten()
.filter(|plan| plan.vertex_count() <= solidcraft_gpu::MAX_BATCH);
let planned = drawn_plan(app, b, opaque);
let on_gpu = planned.is_some();
if let Some(plan) = planned {
let face_colors = (0..b.body.face_count())
@@ -1400,6 +1435,10 @@ fn highlight_key(app: &SolveApp) -> u64 {
format!("{:?}", app.viewport.hover).hash(&mut h);
app.viewport.hover_feature.hash(&mut h);
app.viewport.hover_bodies.hash(&mut h);
// Which mesh lights a face follows how the model draws its body (see `shown_mesh`).
app.session.active_component.hash(&mut h);
app.session.active_occurrence_path.hash(&mut h);
app.ui.visual_style.hash(&mut h);
app.ui.pick_bodies.hash(&mut h);
crate::theme::is_dark().hash(&mut h);
app.preview.replaced.hash(&mut h);
@@ -1418,6 +1457,10 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
let t = Tokens::get();
let mut sc = GpuScene::default();
let st = app.session.world_state();
let s = &app.session;
let active_bodies = s.active_occurrence_path.as_ref().and_then(|p| s.occurrence_bodies(p, true).ok());
// Faces lit over the model use the triangles the model shows them with.
let shown = |b: &solidcraft_engine::doc::ModelBody| shown_mesh(app, b, &active_bodies);
let mut sel = Vec::new();
for item in app.highlighted() {
for piece in displayed_selection(app, &item) {
@@ -1491,7 +1534,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
Sel::Face { body, index, .. } => {
// A live preview stands in for the body: its old faces would fight with it.
if let Some(b) = st.body(body).filter(|_| app.session.body_visible(body) && !app.preview.replaced.contains(body)) {
face_tris(&mut sc, &b.mesh(), *index, c4(t.sel_face), false);
face_tris(&mut sc, &shown(b), *index, c4(t.sel_face), false);
hidden_face_outline(&mut sc, &b.mesh(), *index, t.sel_edge);
}
}
@@ -1499,7 +1542,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
if let Some(b) = st.body(name)
&& app.session.body_visible(name)
{
let m = b.mesh();
let m = shown(b);
for f in 0..b.body.face_count() {
face_tris(&mut sc, &m, f, c4(t.body_selected), true);
}
@@ -1573,11 +1616,12 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
let k = t.hover_face_lift;
let c = body_rgb(app, &b.name);
let m = b.mesh();
let lit = shown(b);
for &f in &faces {
if !sel.iter().any(|x| {
matches!(x, Sel::Face { body, index, .. } if body == &b.name && *index == f) || matches!(x, Sel::Body { name } if name == &b.name)
}) {
face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
face_tris(&mut sc, &lit, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
}
}
for (ei, e) in m.edges.iter().enumerate() {
@@ -1593,7 +1637,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
if let Some(b) = st.body(body) {
let k = t.hover_face_lift;
let c = body_rgb(app, body);
let m = b.mesh();
let m = shown(b);
for f in 0..b.body.face_count() {
face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
}
@@ -1608,8 +1652,9 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
let c = body_rgb(app, body);
let m = b.mesh();
let patch = m.surface_patch(*index);
let lit = shown(b);
for &f in &patch {
face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
face_tris(&mut sc, &lit, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
}
for (e, _) in m.edge_faces.iter().enumerate().filter(|(_, fs)| fs.iter().any(|f| patch.contains(&(*f as usize)))) {
if let Some(p) = m.edges.get(e)
@@ -3272,7 +3317,7 @@ mod tests {
let e = proj.cam.eye().to_f32();
let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }];
let planned = scene.planned.len();
(planned, scene.tris.len(), crate::gpu::render_offscreen(scene, proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, tess))
(planned, scene.tris.len(), crate::gpu::render_offscreen(scene, None, proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, tess))
};
let (planned, gpu_tris, gpu) = render(true);
let (none, cpu_tris, cpu) = render(false);
@@ -3290,6 +3335,62 @@ mod tests {
assert!(differ * 100 < drawn, "{differ} of {drawn} model pixels differ");
}
/// A hovered face lights solidly over a body drawn from its display plan on the GPU: the
/// highlight uses the plan's triangles, not the reference mesh's other triangulation of the
/// same face, which fought the model at equal depth and shimmered in wedges. Every face of a
/// box with a pocket and a round boss, hovered, changes the same pixels as on the CPU path (where model and
/// highlight share the reference mesh). Skipped without a GPU adapter.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_hovered_face_lights_whole_over_a_body_drawn_from_its_plan() {
let mut s = solidcraft_engine::Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 40, "height": 40, "corner": [0, 0, 0], "operation": "new"})).unwrap();
s.execute("solid.box", &json!({"length": 16, "width": 16, "height": 20, "corner": [12, 12, 20], "operation": "cut"})).unwrap();
s.execute("solid.cylinder", &json!({"base": [33, 33, 40], "diameter": 10, "height": 10, "operation": "join"})).unwrap();
let mut app = SolveApp::new(s, crate::Services::default());
app.viewport.no_pixels = true;
app.fit_view();
const SIZE: u32 = 256;
let rect = Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32));
let body = app.session.world_state().bodies[0].clone();
let faces = body.body.face_count();
let mut render = |tess: bool, hover: Option<usize>| {
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess });
app.viewport.hover = hover.map(|index| Hit::Face { body: body.name.clone(), index, point: Vec3::ZERO });
let (scene, highlight) = (build_scene(&app), build_highlight(&app));
assert_eq!(!scene.planned.is_empty(), tess, "the box is drawn from its plan exactly on the GPU path");
let proj = projection(&app, rect);
let e = proj.cam.eye().to_f32();
let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }];
crate::gpu::render_offscreen(scene, Some(highlight), proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, tess)
};
let lit = |plain: &[u8], hovered: &[u8]| -> Vec<bool> { plain.chunks(4).zip(hovered.chunks(4)).map(|(a, b)| a != b).collect() };
let (Some(gpu_plain), Some(cpu_plain)) = (render(true, None), render(false, None)) else {
eprintln!("skipping: no GPU adapter");
return;
};
let mut checked = 0;
for f in 0..faces {
let (Some(gpu), Some(cpu)) = (render(true, Some(f)), render(false, Some(f))) else { return };
let (on_gpu, on_cpu) = (lit(&gpu_plain, &gpu), lit(&cpu_plain, &cpu));
let area = on_cpu.iter().filter(|x| **x).count();
if area < 400 {
continue; // facing away or hidden
}
// Inside the face, two pixels in from its outline: a curved face's outline follows
// its own facets, which the plan and the reference mesh cut differently.
let n = SIZE as i32;
let inside = |i: usize| {
let (x, y) = (i as i32 % n, i as i32 / n);
(-2..=2).all(|dy| (-2..=2).all(|dx| on_cpu.get(((y + dy).clamp(0, n - 1) * n + (x + dx).clamp(0, n - 1)) as usize) == Some(&true)))
};
let missed = (0..on_cpu.len()).filter(|&i| inside(i) && !on_gpu[i]).count();
assert!(missed * 200 < area, "face {f}: {missed} of its {area} hovered pixels do not light (the highlight fights the model)");
checked += 1;
}
assert!(checked >= 3, "{checked} faces seen lit");
}
#[test]
fn construction_drawing_and_hover_overlays_preserve_visible_gaps() {
fn length(shape: &Shape) -> f32 {