feat: preserve adaptive sketch profiles and curved modeling workflows

This commit is contained in:
bherbruck committed 2026-10-11 09:06:25 -04:00
1 parent 4c4a0449a4
commit 4e595deff2
62 files changed
+7114 -362

No files matched your search

+176 -47
View File
@@ -7,7 +7,7 @@ use solvecraft_engine::Sel;
use solvecraft_engine::geom::{Vec2, Vec3};
use solvecraft_engine::render::{Camera, Mat4, Rgb};
use solvecraft_engine::sketch::CurveKind;
use solvecraft_engine::view::{colors, grid_step, sketch_consumed, sketch_lines};
use solvecraft_engine::view::{colors, grid_step, sketch_consumed, sketch_lines_with_sampler};
use std::hash::{Hash, Hasher};
use crate::gpu::{GpuScene, GpuTarget, SceneSlot, ViewportCallback};
@@ -36,6 +36,10 @@ pub struct ViewportState {
pub gpu: Option<GpuTarget>,
slot: SceneSlot,
key: u64,
sketch_slot: SceneSlot,
sketch_key: u64,
physical_scale: f32,
pub(crate) display: std::sync::Mutex<crate::sketch_display::DisplayCache>,
hl_slot: SceneSlot,
hl_key: u64,
cpu: Option<(u64, egui::TextureHandle)>,
@@ -112,6 +116,20 @@ pub enum Hit {
},
}
impl ViewportState {
pub fn pixels_per_point(&self) -> f32 {
if self.physical_scale > 0.0 { self.physical_scale } else { 1.0 }
}
#[cfg(test)]
pub(crate) fn set_pixels_per_point(&mut self, scale: f32) {
self.physical_scale = scale;
}
}
fn display_proj(app: &SolveApp) -> Proj {
projection(app, app.viewport.rect.unwrap_or_else(|| Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))))
}
/// World ↔ screen mapping for the current frame.
#[derive(Clone, Copy)]
pub struct Proj {
@@ -213,6 +231,7 @@ fn visible_sketches(app: &SolveApp) -> Vec<u64> {
fn scene_key(app: &SolveApp) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
app.session.revision.hash(&mut h);
(std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h);
app.ui.show_grid.hash(&mut h);
app.ui.visual_style.hash(&mut h);
app.ui.ground_shadow.hash(&mut h);
@@ -232,13 +251,22 @@ fn scene_key(app: &SolveApp) -> u64 {
app.tree.redefine.hash(&mut h);
crate::theme::is_dark().hash(&mut h);
app.session.active_sketch.hash(&mut h);
let (minor, _) = grid_step(app.cam.half_height());
minor.to_bits().hash(&mut h);
((app.cam.target.x / (minor * 5.0)).round() as i64).hash(&mut h);
((app.cam.target.y / (minor * 5.0)).round() as i64).hash(&mut h);
h.finish()
}
fn sketch_scene_key(app: &SolveApp, proj: &Proj) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
scene_key(app).hash(&mut h);
(std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h);
crate::sketch_display::projection_key(proj, app.viewport.pixels_per_point()).hash(&mut h);
h.finish()
}
#[cfg(test)]
pub(crate) fn display_scene_keys(app: &SolveApp, proj: &Proj) -> (u64, u64) {
(scene_key(app), sketch_scene_key(app, proj))
}
/// A sketch curve colour from the engine, in the current theme.
fn sketch_color(t: &Tokens, c: Rgb) -> [u8; 4] {
if c == colors::SKETCH {
@@ -258,23 +286,6 @@ fn build_scene(app: &SolveApp) -> GpuScene {
let s = &app.session;
// Bodies where their occurrences place them.
let st = s.world_state();
if app.ui.show_grid && !active_view(app).hide_grid {
let (minor, major) = grid_step(app.cam.half_height());
let ext = (app.cam.half_height() * 3.0 / major).ceil() * major;
// The ground plane: XY with Z up, XZ with Y up (laid out in the camera's Z-up frame).
let tf = app.cam.to_frame(app.cam.target);
let c = Vec3::new((tf.x / major).round() * major, (tf.y / major).round() * major, 0.0);
let w3 = |p: Vec3| app.cam.from_frame(p).to_f32();
let n = ((ext / minor) as i64).clamp(1, 300);
for i in -n..=n {
let t = i as f64 * minor;
let maj = (t / major).round() * major == t;
let col = if maj { c4(tk.grid_major) } else { c4(tk.grid) };
let w = if maj { 1.0 } else { 0.7 };
sc.under_line(w3(c + Vec3::new(t, -ext, 0.0)), w3(c + Vec3::new(t, ext, 0.0)), col, w);
sc.under_line(w3(c + Vec3::new(-ext, t, 0.0)), w3(c + Vec3::new(ext, t, 0.0)), col, w);
}
}
let active_bodies = s.active_occurrence_path.as_ref().and_then(|p| s.occurrence_bodies(p, true).ok());
for b in &st.bodies {
if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) {
@@ -351,14 +362,43 @@ fn build_scene(app: &SolveApp) -> GpuScene {
}
}
}
sc
}
/// A soft dark ellipse on the ground under the shown bodies (darkest in the middle).
fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene {
let tk = Tokens::get();
let mut sc = GpuScene::default();
let s = &app.session;
let st = s.world_state();
let mut fill_budget = 131_072;
if app.ui.show_grid && !active_view(app).hide_grid {
let (minor, major) = grid_step(app.cam.half_height());
let ext = (app.cam.half_height() * 3.0 / major).ceil() * major;
// The ground plane: XY with Z up, XZ with Y up (laid out in the camera's Z-up frame).
let tf = app.cam.to_frame(app.cam.target);
let c = Vec3::new((tf.x / major).round() * major, (tf.y / major).round() * major, 0.0);
let w3 = |p: Vec3| app.cam.from_frame(p).to_f32();
let n = ((ext / minor) as i64).clamp(1, 300);
for i in -n..=n {
let t = i as f64 * minor;
let maj = (t / major).round() * major == t;
let col = if maj { c4(tk.grid_major) } else { c4(tk.grid) };
let w = if maj { 1.0 } else { 0.7 };
sc.under_line(w3(c + Vec3::new(t, -ext, 0.0)), w3(c + Vec3::new(t, ext, 0.0)), col, w);
sc.under_line(w3(c + Vec3::new(-ext, t, 0.0)), w3(c + Vec3::new(ext, t, 0.0)), col, w);
}
}
for sid in visible_sketches(app) {
let Some(ss) = st.sketch(sid) else { continue };
let active = s.active_sketch == Some(sid);
// Closed profiles are shaded (lifted off a face they may lie on).
let lift = ss.plane.normal() * 0.01;
let profiles = if app.ui.hidden_profiles.contains(&sid) || ss.sketch.view.hide_profile { &[][..] } else { &ss.profiles[..] };
for p in profiles {
for tri in p.region.triangulate(0.05) {
for (index, _) in profiles.iter().enumerate() {
let display = crate::sketch_display::profile_region(app, sid, index, &ss.plane, proj);
for tri in display.as_ref().as_ref().map_or_else(Vec::new, |region| crate::sketch_display::fill_triangles(region, &mut fill_budget)) {
for q in tri {
sc.trans_tri((ss.plane.to_world(q) + lift).to_f32(), [0.0; 3], c4(tk.profile_fill));
}
@@ -366,7 +406,9 @@ fn build_scene(app: &SolveApp) -> GpuScene {
}
// Construction curves are dashed, centerlines dash-dot (lengths follow the grid).
let unit = grid_step(app.cam.half_height()).0;
for (pts, col, cons) in sketch_lines(&ss.sketch, &ss.plane, active, &ss.report.curve_determined) {
for (pts, col, cons) in sketch_lines_with_sampler(&ss.sketch, &ss.plane, active, &ss.report.curve_determined, |_, index, plane| {
crate::sketch_display::curve_sample(app, sid, index, plane, proj).parts.clone()
}) {
let segs = if !cons {
pts.windows(2).map(|w| (w[0], w[1])).collect()
} else if col == colors::SKETCH_CENTERLINE {
@@ -380,7 +422,7 @@ fn build_scene(app: &SolveApp) -> GpuScene {
}
}
let guide_unit = grid_step(app.cam.half_height()).0;
for points in crate::sketch_attachment_ui::guide_lines(app) {
for points in crate::sketch_attachment_ui::display_guide_lines(app, proj) {
for (a, b) in dashes(&points, &[guide_unit * 0.25, guide_unit * 0.15]) {
sc.line(a.to_f32(), b.to_f32(), c4(tk.warning), 1.5, true);
}
@@ -388,7 +430,6 @@ fn build_scene(app: &SolveApp) -> GpuScene {
sc
}
/// A soft dark ellipse on the ground under the shown bodies (darkest in the middle).
fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solvecraft_engine::doc::ModelState) {
// The bodies' box in the camera's Z-up frame (the ground is its XY plane).
let mut b = solvecraft_engine::geom::Aabb3::EMPTY;
@@ -638,6 +679,7 @@ fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
app.session.visibility.hidden_occurrences.hash(&mut h);
app.ui.perspective.hash(&mut h);
(proj.rect.width().to_bits(), proj.rect.height().to_bits()).hash(&mut h);
crate::sketch_display::projection_key(proj, app.viewport.pixels_per_point()).hash(&mut h);
let key = h.finish();
if let Some((k, hits)) = &app.viewport.last_pick
&& *k == key
@@ -684,8 +726,8 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
continue;
}
let straight = matches!(c.kind, CurveKind::Line { .. });
for seg in ss.sketch.segs(i) {
let pts: Vec<Pos2> = seg.polyline(0.05).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect();
for part in &crate::sketch_display::curve_sample(app, sid, i, &ss.plane, proj).parts {
let pts: Vec<Pos2> = part.iter().filter_map(|q| proj.to_screen(*q)).collect();
for w in pts.windows(2) {
let dd = seg_dist(pos, w[0], w[1]) - bonus;
if dd < 6.0 && bestc.as_ref().is_none_or(|(b, _)| dd < *b) {
@@ -1096,17 +1138,17 @@ pub(crate) fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index
let w = |q: solvecraft_engine::geom::Vec2| (plane.to_world(q) + lift).to_f32();
let col = c4(fill);
// In the translucent pass (after the grid and edges) so it covers them.
for [a, b, c] in p.region.triangulate(0.02) {
let proj = display_proj(app);
let display = crate::sketch_display::profile_region(app, sketch, index, &plane, &proj);
for [a, b, c] in display.as_ref().as_ref().map_or_else(Vec::new, |region| crate::sketch_display::fill_triangles(region, &mut 65_536)) {
sc.trans_tri(w(a), [0.0; 3], col);
sc.trans_tri(w(b), [0.0; 3], col);
sc.trans_tri(w(c), [0.0; 3], col);
}
for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) {
let poly = lp.polyline(0.02);
let n = poly.len();
for i in 0..n {
if let (Some(a), Some(b)) = (poly.get(i), poly.get((i + 1) % n)) {
sc.line(w(*a), w(*b), c4(edge), 2.0, false);
for part in crate::sketch_display::boundary_parts(lp, &plane, &proj, app.viewport.pixels_per_point()) {
for pair in part.windows(2) {
sc.line((pair[0] + lift).to_f32(), (pair[1] + lift).to_f32(), c4(edge), 2.0, false);
}
}
}
@@ -1140,6 +1182,7 @@ fn highlight_key(app: &SolveApp) -> u64 {
app.session.visibility.hidden_occurrences.hash(&mut h);
app.cam.half_height().to_bits().hash(&mut h);
app.cam.back().x.to_bits().hash(&mut h);
crate::sketch_display::projection_key(&display_proj(app), app.viewport.pixels_per_point()).hash(&mut h);
h.finish()
}
@@ -1258,10 +1301,9 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
if let Some(ss) = app.session.active_sketch.and_then(|sid| st.sketch(sid))
&& let Some(ci) = ss.sketch.curve_index(id)
{
for seg in ss.sketch.segs(ci) {
let pts = seg.polyline(0.02);
for pts in &crate::sketch_display::curve_sample(app, ss.feature, ci, &ss.plane, &display_proj(app)).parts {
for w in pts.windows(2) {
sc.line(ss.plane.to_world(w[0]).to_f32(), ss.plane.to_world(w[1]).to_f32(), c4(t.sel_edge), 3.5, true);
sc.line(w[0].to_f32(), w[1].to_f32(), c4(t.sel_edge), 3.5, true);
}
}
}
@@ -1540,7 +1582,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if app.viewport.orbit_snap.is_some() {
ui.ctx().request_repaint();
}
app.viewport.physical_scale = ui.ctx().pixels_per_point();
let proj = projection(app, rect);
if let Ok(mut cache) = app.viewport.display.lock() {
cache.prepare(app.session.world_state(), &proj, app.viewport.pixels_per_point());
}
// Suppress hover for the whole navigation gesture, including frames between pointer
// events. Testing delta made the highlight reappear whenever a held drag paused.
// Resolve it before rendering so the GPU scene and screen overlays use the same hit.
@@ -1579,6 +1625,14 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
app.viewport.key = key;
app.viewport.build_ms = crate::now_ms() - t0;
}
let sk = sketch_scene_key(app, &proj);
if sk != app.viewport.sketch_key {
let scene = build_sketch_scene(app, &proj);
if let Ok(mut slot) = app.viewport.sketch_slot.lock() {
*slot = Some(scene);
}
app.viewport.sketch_key = sk;
}
let hk = highlight_key(app);
if hk != app.viewport.hl_key {
let hl = build_highlight(app);
@@ -1591,6 +1645,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let cb = ViewportCallback {
key: app.viewport.key,
slot: app.viewport.slot.clone(),
sketch_key: app.viewport.sketch_key,
sketch_slot: app.viewport.sketch_slot.clone(),
hl_key: app.viewport.hl_key,
hl_slot: app.viewport.hl_slot.clone(),
pv_key: app.preview.key,
@@ -1954,8 +2010,20 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
if c.link.is_some() && ss.sketch.view.hide_projected {
continue;
}
let pts: Vec<Vec3> = ss.sketch.segs(i).iter().flat_map(|sg| sg.polyline(0.05)).map(|q| ss.plane.to_world(q)).collect();
if bx.takes(&to2(&pts)) {
let sampled = if bx.crossing {
crate::sketch_display::curve_sample(app, ss.feature, i, &ss.plane, proj)
} else {
std::sync::Arc::new(crate::sketch_display::curve_unclipped(&ss.sketch, i, &ss.plane, proj, app.viewport.pixels_per_point()))
};
let parts: Vec<Vec<Pos2>> = sampled.parts.iter().map(|part| to2(part)).collect();
let takes = if bx.crossing {
parts.iter().any(|part| bx.takes(part))
} else {
sampled.quality == solvecraft_engine::render::SamplingQuality::Complete
&& !parts.is_empty()
&& parts.iter().all(|part| bx.takes(part))
};
if takes {
out.push(Sel::SketchCurve { id: c.id.clone() });
}
}
@@ -2029,8 +2097,16 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
for sid in visible_sketches(app) {
let Some(ss) = st.sketch(sid) else { continue };
for (pi, p) in ss.profiles.iter().enumerate() {
let pts: Vec<Vec3> = p.region.outer.polyline(0.05).into_iter().map(|q| ss.plane.to_world(q)).collect();
if bx.takes(&to2(&pts)) {
let parts = crate::sketch_display::boundary_parts(&p.region.outer, &ss.plane, proj, app.viewport.pixels_per_point());
let takes = if bx.crossing {
parts.iter().any(|part| bx.takes(&to2(part)))
} else {
crate::sketch_display::profile_region(app, sid, pi, &ss.plane, proj).as_ref().as_ref().is_some_and(|display| {
let points: Vec<_> = display.outer.segs.iter().map(|segment| ss.plane.to_world(segment.start())).collect();
bx.takes(&to2(&points))
})
};
if takes {
out.push(Sel::Profile { sketch: sid, index: pi });
}
}
@@ -2089,8 +2165,8 @@ fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
if let Some(ss) = st.sketch(*sketch)
&& let Some(ci) = ss.sketch.curve_index(id)
{
for seg in ss.sketch.segs(ci) {
let pts: Vec<Pos2> = seg.polyline(0.05).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect();
for part in &crate::sketch_display::curve_sample(app, *sketch, ci, &ss.plane, proj).parts {
let pts: Vec<Pos2> = part.iter().filter_map(|q| proj.to_screen(*q)).collect();
painter.add(Shape::line(pts, Stroke::new(3.0, col)));
}
}
@@ -2131,7 +2207,7 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
};
if i == 0 {
painter.circle(sp, 3.5, Color32::from_rgb(240, 200, 60), Stroke::new(1.0, t.body_edge));
} else if virtual_corners.contains(&i) {
} else if virtual_corners.binary_search(&i).is_ok() {
// A virtual sharp corner: an open square, it is not on any drawn curve.
painter.rect_stroke(Rect::from_center_size(sp, vec2(6.0, 6.0)), 0.0, Stroke::new(1.2, c), egui::StrokeKind::Middle);
} else {
@@ -2157,14 +2233,18 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter,
return;
}
let mut h = std::collections::hash_map::DefaultHasher::new();
scene_key(app).hash(&mut h);
sketch_scene_key(app, proj).hash(&mut h);
for v in [proj.cam.yaw, proj.cam.pitch, proj.cam.distance, proj.cam.target.x, proj.cam.target.y, proj.cam.target.z] {
v.to_bits().hash(&mut h);
}
(rect.width() as u32, rect.height() as u32).hash(&mut h);
let key = h.finish();
if app.viewport.cpu.as_ref().is_none_or(|(k, _)| *k != key) {
let scene = solvecraft_engine::view::scene(&app.session, &proj.cam);
let mut budget = solvecraft_engine::render::SamplingBudget::new(131_072, 1_048_576);
let scene =
solvecraft_engine::view::scene_with_section_and_sketch_sampler(&app.session, &proj.cam, section_plane(app), |sk, index, plane| {
crate::sketch_display::curve_with_budget(sk, index, plane, proj, app.viewport.pixels_per_point(), &mut budget).parts
});
let w = (rect.width() as usize).clamp(16, 4096);
let hh = (rect.height() as usize).clamp(16, 4096);
let c = solvecraft_engine::render::render(&scene, &proj.cam, w, hh);
@@ -2526,6 +2606,55 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool {
mod tests {
use super::*;
#[test]
fn cpu_viewport_uses_section_preview_and_sketch_slice() {
fn pixels(h: &mut crate::scenario::Harness, cam: Camera) -> Vec<Color32> {
let rect = Rect::from_min_size(Pos2::ZERO, vec2(320.0, 240.0));
let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect };
h.app.viewport.no_pixels = false;
// Force a texture upload so this test reads the actual CPU rasterizer result.
h.app.viewport.cpu = None;
let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| {
cpu_render(&mut h.app, ui.ctx(), ui.painter(), rect, &proj);
});
let id = h.app.viewport.cpu.as_ref().unwrap().1.id();
let upload = out.textures_delta.set.get(&id).unwrap().iter().find(|delta| delta.pos.is_none()).unwrap();
let egui::ImageData::Color(image) = &upload.image;
assert_eq!(image.size, [320, 240]);
let pixels = image.pixels.clone();
out.textures_delta.clear();
pixels
}
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap();
let cam = solvecraft_engine::view::home_camera(&h.app.session);
let whole = pixels(&mut h, cam);
let before = h.app.session.doc.clone();
let undo = h.app.session.undo.len();
h.app.session.selection = vec![Sel::Plane { name: "XZ".into() }];
h.app.start("inspect.section");
let Some(crate::dialogs::Kind::Section { offset, .. }) = h.app.dialog.as_mut().map(|d| &mut d.kind) else { panic!("section dialog") };
*offset = "15 mm".into();
assert!(h.app.session.section.is_none());
let preview = pixels(&mut h, cam);
assert!(whole.iter().zip(&preview).filter(|(a, b)| a != b).count() > 100, "CPU viewport ignored uncommitted section preview");
assert!(std::sync::Arc::ptr_eq(&before, &h.app.session.doc));
assert_eq!(h.app.session.undo.len(), undo);
h.app.run("inspect.section", json!({"plane":"XZ","offset":15})).unwrap();
h.app.dialog = None;
assert_eq!(preview, pixels(&mut h, cam), "preview and committed section must rasterize identically");
h.app.session.section = None;
h.app.run("sketch.create", json!({"plane":{"origin":[0,0,10],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
let whole = pixels(&mut h, cam);
h.app.run("sketch.options", json!({"slice":true})).unwrap();
assert!(h.app.session.section.is_none());
let sliced = pixels(&mut h, cam);
assert!(whole.iter().zip(&sliced).filter(|(a, b)| a != b).count() > 100, "CPU viewport ignored active Sketch Slice");
h.app.run("sketch.options", json!({"slice":false})).unwrap();
assert_eq!(whole, pixels(&mut h, cam), "disabling Slice restores the whole-body pixels");
}
#[test]
fn timeline_modifiers_highlight_on_hover_and_click() {
for command in ["solid.fillet", "solid.chamfer"] {