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

+4 -7
View File
@@ -2923,16 +2923,13 @@ pub(crate) fn profile_indices(ss: &solvecraft_engine::doc::SolvedSketch, sel: &P
match sel {
ProfileSel::All => (0..ps.len()).collect(),
ProfileSel::Indices { indices } => indices.iter().copied().filter(|i| *i < ps.len()).collect(),
ProfileSel::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. } => {
solvecraft_engine::doc::profile_selection_indices(ss, sel).unwrap_or_default()
}
ProfileSel::Curves { loops } => loops
.iter()
.filter_map(|l| {
let mut want: Vec<&String> = l.iter().collect();
want.sort();
ps.iter().position(|p| {
let mut have: Vec<&String> = p.outer_curves.iter().collect();
have.sort();
have == want
})
solvecraft_engine::doc::profile_selection_indices(ss, &ProfileSel::Curves { loops: vec![l.clone()] }).ok()?.first().copied()
})
.collect(),
ProfileSel::Points { points } => points.iter().filter_map(|q| ps.iter().position(|p| p.region.contains(*q))).collect(),
+11 -1
View File
@@ -744,6 +744,7 @@ pub fn overlay(painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool {
// A pocket retains material below (or above) the cut. Dashed contours and a depth
// annotation distinguish it from the solid through-cut boundaries.
let pockets: Vec<&Value> = v["pockets"].as_array().into_iter().flatten().collect();
let mut pocket_annotations: Vec<(Pos2, Vec<String>)> = Vec::new();
for pocket in &pockets {
let outer: Vec<(f64, f64)> = pocket["outline"].as_array().into_iter().flatten().filter_map(read).collect();
for contour in std::iter::once(&pocket["outline"]).chain(pocket["cutouts"].as_array().into_iter().flatten()) {
@@ -757,10 +758,19 @@ pub fn overlay(painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool {
let bounds = outer.iter().map(|p| to(*p)).fold(Rect::NOTHING, |r, p| r.union(Rect::from_min_max(p, p)));
let anchor = bounds.center_top() - vec2(0.0, 6.0);
if let Some(label) = pocket_label(pocket) {
painter.text(anchor, Align2::CENTER_BOTTOM, label, FontId::proportional(12.0), t.accent);
if let Some((_, labels)) = pocket_annotations.iter_mut().find(|(at, _)| at.distance(anchor) < 1.0) {
if !labels.contains(&label) {
labels.push(label);
}
} else {
pocket_annotations.push((anchor, vec![label]));
}
}
}
}
for (anchor, labels) in pocket_annotations {
painter.text(anchor, Align2::CENTER_BOTTOM, labels.join("\n"), FontId::proportional(12.0), t.accent);
}
for b in v["bends"].as_array().into_iter().flatten() {
let (Some(a), Some(e)) = (read(&b["start"]), read(&b["end"])) else { continue };
let col = if b["direction"] == "up" { up_col } else { down_col };
+37
View File
@@ -218,6 +218,43 @@ fn flat_pattern_islands_fit_before_the_actual_command_panel() {
);
}
#[test]
fn coincident_top_and_bottom_pocket_depths_share_a_readable_annotation() {
let mut app = plate();
app.run("sheet.flange", json!({"sketch":"Base"})).unwrap();
app.run("sketch.create", json!({"plane":"XY","name":"Retain"})).unwrap();
app.run("sketch.rectangle.two_point", json!({"p0":[20,20],"p1":[30,30]})).unwrap();
app.run("sketch.finish", json!({})).unwrap();
app.run("solid.extrude", json!({"sketch":"Retain","distance":1,"start_offset":0.5,"operation":"intersect"})).unwrap();
let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap();
assert_eq!(flat["cutouts"], json!([]));
assert_eq!(flat["pockets"].as_array().unwrap().len(), 2);
let doc = app.session.doc.to_json();
app.start("sheet.flat_pattern");
let mut dialog = app.dialog.clone().unwrap();
let Kind::Sheet(Sm::FlatPattern { data, .. }) = &mut dialog.kind else { panic!("flat dialog") };
*data = Some(Ok(flat));
let ctx = egui::Context::default();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("coincident-pocket-test")));
assert!(overlay(&painter, Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)), &dialog));
});
output.textures_delta.clear();
let labels: Vec<_> = output
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
egui::Shape::Text(text) if text.galley.text().contains("pocket:") => Some(text.galley.text()),
_ => None,
})
.collect();
assert_eq!(labels.len(), 1, "coincident depths must not overwrite one another");
assert!(labels[0].contains("Top pocket: 1 mm deep"));
assert!(labels[0].contains("Bottom pocket: 0.5 mm deep"));
assert!(labels[0].contains('\n'));
assert_eq!(app.session.doc.to_json(), doc);
}
#[test]
fn pocket_export_button_uses_the_picker_and_preserves_depth_layers() {
let mut h = crate::scenario::Harness::new();
+10 -1
View File
@@ -121,6 +121,8 @@ const IMG_SIZE: usize = 24;
pub struct ViewportCallback {
pub key: u64,
pub slot: SceneSlot,
pub sketch_key: u64,
pub sketch_slot: SceneSlot,
/// The highlight scene and its key.
pub hl_key: u64,
pub hl_slot: SceneSlot,
@@ -230,6 +232,7 @@ struct Resources {
bind: wgpu::BindGroup,
linear_out: bool,
model: Batches,
sketch: Batches,
highlight: Batches,
preview: Batches,
image: wgpu::RenderPipeline,
@@ -828,6 +831,7 @@ impl Resources {
bind,
linear_out: t.format.is_srgb(),
model: Batches::default(),
sketch: Batches::default(),
highlight: Batches::default(),
preview: Batches::default(),
}
@@ -893,6 +897,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
) -> Vec<wgpu::CommandBuffer> {
let Some(res) = resources.get_mut::<Resources>() else { return Vec::new() };
res.model.take(device, self.key, &self.slot);
res.sketch.take(device, self.sketch_key, &self.sketch_slot);
res.highlight.take(device, self.hl_key, &self.hl_slot);
res.preview.take(device, self.pv_key, &self.pv_slot);
res.model.sort_glass(queue, self.back);
@@ -977,8 +982,9 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
pass.draw(0..6, 0..b.count);
}
};
let (m, h, pv) = (&res.model, &res.highlight, &res.preview);
let (m, sk, h, pv) = (&res.model, &res.sketch, &res.highlight, &res.preview);
lines(pass, &res.overlay, &m.under);
lines(pass, &res.overlay, &sk.under);
pass.set_bind_group(1, &res.access_bind, &[]);
let cut = res.tri_cut.as_ref().filter(|_| self.clip[..3].iter().map(|c| c * c).sum::<f32>() > 0.25);
match cut {
@@ -996,6 +1002,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
}
tris(pass, &res.tri_hl, &h.tris);
lines(pass, &res.line, &m.lines);
lines(pass, &res.line, &sk.lines);
lines(pass, &res.line_hidden, &m.hidden);
lines(pass, &res.line_hidden, &h.hidden);
lines(pass, &res.line, &pv.lines);
@@ -1012,9 +1019,11 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
tris(pass, &res.glass_far, &m.glass);
tris(pass, &res.glass_near, &m.glass);
tris(pass, &res.trans, &m.trans);
tris(pass, &res.trans, &sk.trans);
tris(pass, &res.trans, &h.trans);
tris(pass, &res.xray, &pv.xray);
lines(pass, &res.overlay, &m.overlays);
lines(pass, &res.overlay, &sk.overlays);
lines(pass, &res.overlay, &h.overlays);
}
}
+3
View File
@@ -83,6 +83,9 @@ pub(crate) mod sketch_attachment_ui;
#[cfg(test)]
mod sketch_attachment_ui_tests;
pub mod sketch_dims;
pub(crate) mod sketch_display;
#[cfg(test)]
mod sketch_display_ui_tests;
#[cfg(test)]
mod sketch_edit_tests;
pub mod sketch_palette;
@@ -267,3 +267,11 @@ pub fn guide_lines(app: &SolveApp) -> Vec<Vec<Vec3>> {
.map(|segment| segment.polyline(0.05).into_iter().map(|point| plane.to_world(point)).collect())
.collect()
}
pub fn display_guide_lines(app: &SolveApp, proj: &crate::viewport::Proj) -> Vec<Vec<Vec3>> {
let Some((plane, sketch)) = lost_guide(app) else { return Vec::new() };
let mut budget = solvecraft_engine::render::SamplingBudget::new(65_536, 262_144);
(0..sketch.curves.len())
.flat_map(|index| crate::sketch_display::curve_with_budget(sketch, index, &plane, proj, app.viewport.pixels_per_point(), &mut budget).parts)
.collect()
}
+243
View File
@@ -0,0 +1,243 @@
//! Display-only sampling. CAD curves, profiles and constraints are never changed here.
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use solvecraft_engine::doc::ModelState;
use solvecraft_engine::geom::{Loop2, Plane, Region2, Vec2, Vec3};
use solvecraft_engine::render::{PixelCurveView, SampledPath, SamplingBudget, SamplingQuality, sample_segment, sample_segment_unclipped};
use solvecraft_engine::sketch::Sketch;
use crate::SolveApp;
use crate::viewport::Proj;
pub fn projection_key(proj: &Proj, ppp: f32) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
for n in proj.vp.0.iter().flatten() {
n.to_bits().hash(&mut h);
}
for n in [proj.rect.left(), proj.rect.top(), proj.rect.width(), proj.rect.height(), ppp] {
n.to_bits().hash(&mut h);
}
h.finish()
}
pub fn pixel_view(proj: &Proj, ppp: f32) -> PixelCurveView {
PixelCurveView { vp: proj.vp, width_px: f64::from(proj.rect.width() * ppp), height_px: f64::from(proj.rect.height() * ppp) }
}
pub fn curve_with_budget(sketch: &Sketch, index: usize, plane: &Plane, proj: &Proj, ppp: f32, budget: &mut SamplingBudget) -> SampledPath {
let segments = sketch.exact_segs(index).unwrap_or_else(|| sketch.segs(index));
let mut local = SamplingBudget::new(budget.remaining_vertices.min(4096), budget.remaining_nodes.min(65_536));
let initial = local.clone();
let mut out = SampledPath { parts: Vec::new(), quality: SamplingQuality::Complete, vertex_count: 0 };
for segment in segments {
let sampled = sample_segment(&segment, plane, &pixel_view(proj, ppp), &mut local);
if sampled.quality != SamplingQuality::Complete {
out.quality = sampled.quality;
}
out.vertex_count += sampled.vertex_count;
out.parts.extend(sampled.parts);
}
budget.remaining_vertices -= initial.remaining_vertices - local.remaining_vertices;
budget.remaining_nodes -= initial.remaining_nodes - local.remaining_nodes;
out
}
pub fn curve_unclipped(sketch: &Sketch, index: usize, plane: &Plane, proj: &Proj, ppp: f32) -> SampledPath {
let mut budget = SamplingBudget::new(4096, 65_536);
let mut out = SampledPath { parts: Vec::new(), quality: SamplingQuality::Complete, vertex_count: 0 };
for segment in sketch.exact_segs(index).unwrap_or_else(|| sketch.segs(index)) {
let sampled = sample_segment_unclipped(&segment, plane, &pixel_view(proj, ppp), &mut budget);
if sampled.quality != SamplingQuality::Complete {
out.quality = sampled.quality;
}
out.vertex_count += sampled.vertex_count;
out.parts.extend(sampled.parts);
}
out
}
#[cfg(test)]
pub fn curve_parts(sketch: &Sketch, index: usize, plane: &Plane, proj: &Proj, ppp: f32) -> Vec<Vec<Vec3>> {
curve_with_budget(sketch, index, plane, proj, ppp, &mut SamplingBudget::new(4096, 65_536)).parts
}
/// A closed display region made from complete, unclipped adaptive boundaries. Near-plane
/// clipping or an exhausted budget suppresses the fill instead of inventing closing edges.
#[cfg(test)]
pub fn display_region(region: &Region2, plane: &Plane, proj: &Proj, ppp: f32) -> Option<Region2> {
let mut budget = SamplingBudget::new(4096, 65_536);
display_region_with_budget(region, plane, proj, ppp, &mut budget)
}
fn display_region_with_budget(region: &Region2, plane: &Plane, proj: &Proj, ppp: f32, shared: &mut SamplingBudget) -> Option<Region2> {
let mut budget = SamplingBudget::new(shared.remaining_vertices.min(4096), shared.remaining_nodes.min(65_536));
let initial = budget.clone();
let result = (|| {
let mut boundary = |loop_: &Loop2| -> Option<Loop2> {
let mut points: Vec<Vec2> = Vec::new();
for segment in &loop_.segs {
let sampled = sample_segment_unclipped(segment, plane, &pixel_view(proj, ppp), &mut budget);
if sampled.quality != SamplingQuality::Complete || sampled.parts.len() != 1 {
return None;
}
let part = sampled.parts.first()?;
// Both exact endpoints must survive; a clipped boundary cannot close a region.
if part.first()?.dist(plane.to_world(segment.start())) > 1e-8 || part.last()?.dist(plane.to_world(segment.end())) > 1e-8 {
return None;
}
for point in part {
let point = plane.to_local(*point);
if points.last().is_none_or(|last| last.dist(point) > 1e-12) {
points.push(point);
}
}
}
if points.len() < 3 || points.first()?.dist(*points.last()?) > 1e-8 {
return None;
}
points.pop();
Some(Loop2::polygon(&points))
};
let outer = boundary(&region.outer)?;
let holes = region.holes.iter().map(&mut boundary).collect::<Option<Vec<_>>>()?;
Some(Region2 { outer, holes })
})();
shared.remaining_vertices -= initial.remaining_vertices - budget.remaining_vertices;
shared.remaining_nodes -= initial.remaining_nodes - budget.remaining_nodes;
result
}
#[cfg(test)]
pub fn region_triangles(region: &Region2, plane: &Plane, proj: &Proj, ppp: f32) -> Vec<[Vec2; 3]> {
display_region(region, plane, proj, ppp).map_or_else(Vec::new, |display| fill_triangles(&display, &mut 65_536))
}
/// Bound the slab triangulator's allocation before calling it. Every slab can emit
/// at most one triangle per edge; complex fills are omitted rather than cut into holes.
pub fn fill_triangles(display: &Region2, budget: &mut usize) -> Vec<[Vec2; 3]> {
let mut heights = Vec::new();
let mut edges = 0usize;
for loop_ in std::iter::once(&display.outer).chain(&display.holes) {
for segment in &loop_.segs {
let (a, b) = (segment.start(), segment.end());
if (a.y - b.y).abs() > 1e-12 {
edges += 1;
heights.extend([a.y, b.y]);
}
}
}
heights.sort_by(f64::total_cmp);
heights.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
if edges.saturating_mul(heights.len().saturating_sub(1)) > *budget {
return Vec::new();
}
let triangles = display.triangulate(1.0);
*budget = budget.saturating_sub(triangles.len());
triangles
}
pub fn boundary_parts(loop_: &Loop2, plane: &Plane, proj: &Proj, ppp: f32) -> Vec<Vec<Vec3>> {
let mut budget = SamplingBudget::new(4096, 65_536);
loop_.segs.iter().flat_map(|segment| sample_segment(segment, plane, &pixel_view(proj, ppp), &mut budget).parts).collect()
}
pub struct DisplayCache {
state: Option<Arc<ModelState>>,
view: u64,
curves: HashMap<(u64, usize, [u64; 9]), Arc<SampledPath>>,
profiles: HashMap<(u64, usize, [u64; 9]), Arc<Option<Region2>>>,
budget: SamplingBudget,
}
impl Default for DisplayCache {
fn default() -> Self {
Self { state: None, view: 0, curves: HashMap::new(), profiles: HashMap::new(), budget: SamplingBudget::new(131_072, 1_048_576) }
}
}
impl DisplayCache {
#[cfg(test)]
pub fn len(&self) -> usize {
self.curves.len()
}
pub fn prepare(&mut self, state: Arc<ModelState>, proj: &Proj, ppp: f32) {
self.reset(&state, proj, ppp);
}
fn reset(&mut self, state: &Arc<ModelState>, proj: &Proj, ppp: f32) {
let view = projection_key(proj, ppp);
if view != self.view || self.state.as_ref().is_none_or(|old| !Arc::ptr_eq(old, state)) {
self.curves.clear();
self.profiles.clear();
self.budget = SamplingBudget::new(131_072, 1_048_576);
self.view = view;
self.state = Some(Arc::clone(state));
}
}
fn curve(&mut self, state: Arc<ModelState>, sketch: u64, index: usize, plane: &Plane, proj: &Proj, ppp: f32) -> Arc<SampledPath> {
self.reset(&state, proj, ppp);
let plane_key =
[plane.origin.x, plane.origin.y, plane.origin.z, plane.x.x, plane.x.y, plane.x.z, plane.y.x, plane.y.y, plane.y.z].map(f64::to_bits);
let key = (sketch, index, plane_key);
if !self.curves.contains_key(&key) && (self.curves.len() >= 4096 || self.budget.remaining_vertices == 0 || self.budget.remaining_nodes == 0) {
return Arc::new(SampledPath { parts: Vec::new(), quality: SamplingQuality::BudgetLimited, vertex_count: 0 });
}
self.curves
.entry(key)
.or_insert_with(|| {
Arc::new(state.sketch(sketch).map_or_else(
|| SampledPath { parts: Vec::new(), quality: SamplingQuality::Invalid, vertex_count: 0 },
|ss| curve_with_budget(&ss.sketch, index, plane, proj, ppp, &mut self.budget),
))
})
.clone()
}
fn profile(&mut self, state: Arc<ModelState>, sketch: u64, index: usize, plane: &Plane, proj: &Proj, ppp: f32) -> Arc<Option<Region2>> {
self.reset(&state, proj, ppp);
let plane_key =
[plane.origin.x, plane.origin.y, plane.origin.z, plane.x.x, plane.x.y, plane.x.z, plane.y.x, plane.y.y, plane.y.z].map(f64::to_bits);
let key = (sketch, index, plane_key);
if !self.profiles.contains_key(&key)
&& (self.profiles.len() >= 1024 || self.budget.remaining_vertices == 0 || self.budget.remaining_nodes == 0)
{
return Arc::new(None);
}
self.profiles
.entry(key)
.or_insert_with(|| {
Arc::new(
state
.sketch(sketch)
.and_then(|ss| ss.profiles.get(index))
.and_then(|profile| display_region_with_budget(&profile.region, plane, proj, ppp, &mut self.budget)),
)
})
.clone()
}
}
pub fn profile_region(app: &SolveApp, sketch: u64, index: usize, plane: &Plane, proj: &Proj) -> Arc<Option<Region2>> {
let state = app.session.world_state();
let ppp = app.viewport.pixels_per_point();
match app.viewport.display.lock() {
Ok(mut cache) => cache.profile(state, sketch, index, plane, proj, ppp),
Err(_) => Arc::new(None),
}
}
pub fn curve_sample(app: &SolveApp, sketch: u64, index: usize, plane: &Plane, proj: &Proj) -> Arc<SampledPath> {
let state = app.session.world_state();
let ppp = app.viewport.pixels_per_point();
match app.viewport.display.lock() {
Ok(mut cache) => cache.curve(state, sketch, index, plane, proj, ppp),
Err(_) => Arc::new(state.sketch(sketch).map_or_else(
|| SampledPath { parts: Vec::new(), quality: SamplingQuality::Invalid, vertex_count: 0 },
|ss| curve_with_budget(&ss.sketch, index, plane, proj, ppp, &mut SamplingBudget::new(4096, 65_536)),
)),
}
}
@@ -0,0 +1,122 @@
use super::{Tool, preview_curve};
use crate::scenario::Harness;
use crate::viewport::Proj;
use serde_json::{Value, json};
use solvecraft_engine::geom::{Plane, Vec2, Vec3};
use solvecraft_engine::render::{Camera, Mat4};
use solvecraft_engine::sketch::Sketch;
fn tool(command: &str, points: &[[f64; 2]]) -> Tool {
let mut tool = Tool::for_command(command).unwrap();
tool.pts = points.iter().map(|p| (Vec2::new(p[0], p[1]), None)).collect();
tool
}
fn spans(sk: &Sketch) -> Vec<solvecraft_engine::geom::Seg2> {
sk.exact_segs(0).unwrap_or_else(|| sk.segs(0))
}
fn projection(scale: f64) -> Proj {
Proj {
cam: Camera::default(),
vp: Mat4([[scale / 700.0, 0.0, 0.0, 0.0], [0.0, scale / 450.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.5, 1.0]]),
rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)),
}
}
fn screen_error(paths: &[Vec<Vec3>], point: Vec3, proj: &Proj, ppp: f32) -> f32 {
let point = proj.to_screen(point).unwrap();
paths
.iter()
.flat_map(|part| part.windows(2))
.map(|pair| {
let a = proj.to_screen(pair[0]).unwrap();
let b = proj.to_screen(pair[1]).unwrap();
let ab = b - a;
let t = if ab.length_sq() > 0.0 { ((point - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 };
point.distance(a + ab * t) * ppp
})
.fold(f32::INFINITY, f32::min)
}
#[test]
fn pixel_preview_temporary_curves_match_committed_geometry_and_sampling() {
let cases: Vec<(&str, Vec<[f64; 2]>, [f64; 2], Option<f64>, Value)> = vec![
("sketch.circle.center", vec![[0., 0.]], [1., 0.], None, json!({"center":[0,0],"radius":1})),
("sketch.circle.two_point", vec![[-1., 0.]], [1., 0.], None, json!({"p0":[-1,0],"p1":[1,0]})),
("sketch.circle.three_point", vec![[1., 0.], [-1., 0.]], [0., 1.], None, json!({"p0":[1,0],"p1":[-1,0],"p2":[0,1]})),
("sketch.arc.three_point", vec![[1., 0.], [-1., 0.]], [0., -1.], None, json!({"start":[1,0],"end":[-1,0],"through":[0,-1]})),
(
"sketch.arc.center_point",
vec![[0., 0.], [1., 0.]],
[0., -1.],
Some(-std::f64::consts::FRAC_PI_2),
json!({"center":[0,0],"start":[1,0],"sweep":-90}),
),
("sketch.ellipse", vec![[0., 0.], [1., 0.]], [0., 0.05], None, json!({"center":[0,0],"major":[1,0],"minor":[0,0.05]})),
("sketch.spline.fit_point", vec![[0., 0.], [0.3, 0.8], [0.6, -0.8]], [1., 0.], None, json!({"points":[[0,0],[0.3,0.8],[0.6,-0.8],[1,0]]})),
(
"sketch.spline.control_point_5",
vec![[0., 0.], [0.2, 0.9], [0.4, -0.9], [0.6, 0.9], [0.8, -0.9]],
[1., 0.],
None,
json!({"points":[[0,0],[0.2,0.9],[0.4,-0.9],[0.6,0.9],[0.8,-0.9],[1,0]]}),
),
("sketch.spline.fit_point", vec![[0., 0.], [1., 0.], [0.5, 1.]], [0., 0.], None, json!({"points":[[0,0],[1,0],[0.5,1]],"periodic":true})),
];
for (command, points, hover, sweep, params) in cases {
let tool = tool(command, &points);
let preview = preview_curve(&tool, Vec2::new(hover[0], hover[1]), sweep).unwrap();
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run(command, params).unwrap();
let state = h.app.session.world_state();
let committed = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
let a = spans(&preview);
let b = spans(committed);
assert_eq!(a.len(), b.len(), "{command}");
for (a, b) in a.iter().zip(&b) {
for i in 0..=50 {
assert!(a.point_at(i as f64 / 50.0).dist(b.point_at(i as f64 / 50.0)) < 1e-8, "{command}");
}
}
for ppp in [1.0, 1.5, 2.0] {
for scale in [2.0, 20.0, 220.0, 1000.0] {
let proj = projection(scale / f64::from(ppp));
let temporary = crate::sketch_display::curve_parts(&preview, 0, &Plane::XY, &proj, ppp);
let permanent = crate::sketch_display::curve_parts(committed, 0, &Plane::XY, &proj, ppp);
assert_eq!(temporary, permanent, "{command}");
assert!(temporary.iter().map(Vec::len).sum::<usize>() <= 65_536);
let mut visible = 0;
for segment in &a {
for i in 0..=300 {
let point = Plane::XY.to_world(segment.point_at(i as f64 / 300.0));
if !proj.rect.contains(proj.to_screen(point).unwrap()) {
continue;
}
visible += 1;
let error = screen_error(&temporary, point, &proj, ppp);
assert!(error <= 1.0, "{command}, scale{scale}, ppp{ppp}: {error}");
}
}
if scale <= 220.0 {
assert_eq!(visible, a.len() * 301, "the low/normal zoom fixture must test the entire curve: {command}");
}
}
}
}
}
#[test]
fn pixel_preview_fully_offscreen_circle_is_clipped_without_false_connections() {
let curve = preview_curve(&tool("sketch.circle.center", &[[0., 0.]]), Vec2::X, None).unwrap();
let proj = projection(1000.0);
// The viewport half diagonal is about 832px: a 1000px circle never enters it.
assert!((proj.rect.width() * 0.5).hypot(proj.rect.height() * 0.5) < 1000.0);
assert!(crate::sketch_display::curve_parts(&curve, 0, &Plane::XY, &proj, 1.0).is_empty());
}
#[test]
fn pixel_preview_rejects_degenerate_and_unbounded_input_without_changing_document() {
assert!(preview_curve(&tool("sketch.circle.center", &[[0., 0.]]), Vec2::new(f64::NAN, 0.), None).is_none());
assert!(preview_curve(&tool("sketch.arc.three_point", &[[0., 0.], [1., 0.]]), Vec2::new(2., 0.), None).is_none());
let points = vec![[0., 0.]; 500];
assert!(preview_curve(&tool("sketch.spline.fit_point", &points), Vec2::X, None).is_none());
}
@@ -0,0 +1,286 @@
//! Independent physical-pixel and interaction proofs for display-only sampling.
use crate::scenario::Harness;
use crate::sketch_display::{curve_sample, projection_key};
use crate::viewport::{Hit, Proj, pick, projection};
use serde_json::json;
use solvecraft_engine::geom::{Plane, Vec2, Vec3};
use solvecraft_engine::render::{SampledPath, SamplingQuality};
use std::sync::Arc;
fn circle(h: &mut Harness) -> u64 {
h.app.ui.show_origin = false;
h.app.run("parameters.add", json!({"name":"foreground_diameter","expression":"2 mm"})).unwrap();
h.app.run("sketch.create", json!({"plane":"XY","name":"PixelCircle"})).unwrap();
h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":1})).unwrap();
h.app.run("sketch.dimension", json!({"entities":["c1"],"type":"diameter","value":"foreground_diameter"})).unwrap();
h.settle();
h.step(&json!({"call":"ui.view","params":{"view":"top"}})).unwrap();
h.step(&json!({"call":"ui.view","params":{"view":"fit"}})).unwrap();
h.settle();
h.app.session.active_sketch.unwrap()
}
fn segment_distance(p: egui::Pos2, a: egui::Pos2, b: egui::Pos2) -> f64 {
let dx = f64::from(b.x - a.x);
let dy = f64::from(b.y - a.y);
let px = f64::from(p.x - a.x);
let py = f64::from(p.y - a.y);
let length = dx * dx + dy * dy;
let t = if length > 0.0 { ((px * dx + py * dy) / length).clamp(0.0, 1.0) } else { 0.0 };
(px - t * dx).hypot(py - t * dy)
}
fn dense_circle_error(path: &SampledPath, plane: &Plane, proj: &Proj, radius: f64, ppp: f32) -> f64 {
assert_eq!(path.quality, SamplingQuality::Complete);
assert!(path.vertex_count <= 4096);
let lines: Vec<_> =
path.parts.iter().flat_map(|part| part.windows(2)).map(|pair| (proj.to_screen(pair[0]).unwrap(), proj.to_screen(pair[1]).unwrap())).collect();
assert!(!lines.is_empty());
(0..8192)
.map(|i| {
let angle = i as f64 * std::f64::consts::TAU / 8192.0;
let p = proj.to_screen(plane.to_world(Vec2::new(radius * angle.cos(), radius * angle.sin()))).unwrap();
lines.iter().map(|&(a, b)| segment_distance(p, a, b)).fold(f64::INFINITY, f64::min) * f64::from(ppp)
})
.fold(0.0, f64::max)
}
#[test]
fn pixel_circle_fit_exact_half_chord_hover_click_and_model_invariance() {
let mut h = Harness::new();
let sid = circle(&mut h);
let baseline = h.app.session.doc.to_json();
let history = (h.app.session.undo.len(), h.app.session.redo.len());
let state = h.app.session.world_state();
let ss = state.sketch(sid).unwrap();
let plane = ss.plane;
let index = ss.sketch.curve_index("c1").unwrap();
// Ordinary circles already expose exact analytic Arc spans through segs;
// exact_segs is the supplemental freeform extractor.
let area: f64 = ss.sketch.segs(index).iter().map(|s| s.area_term()).sum();
assert!((area.abs() - std::f64::consts::PI).abs() < 1e-10);
let proj = projection(&h.app, h.app.viewport.rect.unwrap());
let radius_px = proj.to_screen(plane.to_world(Vec2::new(1.0, 0.0))).unwrap().distance(proj.to_screen(plane.origin).unwrap())
* h.app.viewport.pixels_per_point();
assert!(radius_px > 180.0, "fixture must expose the old R1 Fit sag: {radius_px}");
let sampled = curve_sample(&h.app, sid, index, &plane, &proj);
assert!(dense_circle_error(&sampled, &plane, &proj, 1.0, h.app.viewport.pixels_per_point()) <= 1.0);
// Midpoints of the old ten-chord pick outline. No centre/point handle is nearby.
for k in [0, 2, 5, 7] {
let angle = (2 * k + 1) as f64 * std::f64::consts::PI / 10.0;
let world = plane.to_world(Vec2::new(angle.cos(), angle.sin()));
let screen = proj.to_screen(world).unwrap();
assert!(pick(&h.app, &proj, screen).iter().any(|hit| matches!(hit, Hit::SketchCurve { sketch, id, .. } if *sketch == sid && id == "c1")));
h.step(&json!({"move":{"world":[world.x,world.y,world.z]}})).unwrap();
assert!(
matches!(&h.app.viewport.hover, Some(Hit::SketchCurve{sketch,id,..}) if *sketch == sid && id == "c1"),
"hover: {:?}",
h.app.viewport.hover
);
h.step(&json!({"click":{"world":[world.x,world.y,world.z]}})).unwrap();
assert!(h.app.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::SketchCurve{id} if id == "c1")));
h.app.session.selection.clear();
}
assert_eq!(h.app.session.doc.to_json(), baseline, "display and selection must not alter stored geometry, constraints or expressions");
assert_eq!((h.app.session.undo.len(), h.app.session.redo.len()), history);
}
#[test]
fn pixel_cache_full_view_resize_and_hidpi_invalidate_within_same_grid_bucket() {
let mut h = Harness::new();
h.app.run("solid.box", json!({"corner":[-0.1,-0.1,-0.2],"length":0.2,"width":0.2,"height":0.2,"body_name":"CachedStock"})).unwrap();
let sid = circle(&mut h);
let baseline = h.app.session.doc.to_json();
let history = (h.app.session.undo.len(), h.app.session.redo.len());
let state = h.app.session.world_state();
assert_eq!(state.bodies.len(), 1, "body batch invariance needs an actual mesh");
let ss = state.sketch(sid).unwrap();
let index = ss.sketch.curve_index("c1").unwrap();
let plane = ss.plane;
let first = projection(&h.app, h.app.viewport.rect.unwrap());
let keys = crate::viewport::display_scene_keys(&h.app, &first);
let a = curve_sample(&h.app, sid, index, &plane, &first);
assert!(Arc::ptr_eq(&a, &curve_sample(&h.app, sid, index, &plane, &first)));
let grid = solvecraft_engine::view::grid_step(h.app.cam.half_height());
// Find a real zoom change which keeps the complete grid bucket unchanged.
let distance = h.app.cam.distance;
for factor in [0.8, 0.9, 1.1, 1.2] {
h.app.cam.distance = distance * factor;
if solvecraft_engine::view::grid_step(h.app.cam.half_height()) == grid {
break;
}
}
assert_eq!(solvecraft_engine::view::grid_step(h.app.cam.half_height()), grid);
assert_ne!(h.app.cam.distance, distance);
let zoom = projection(&h.app, first.rect);
let zoom_keys = crate::viewport::display_scene_keys(&h.app, &zoom);
assert_eq!(keys.0, zoom_keys.0, "sketch zoom must not invalidate the body GPU batch");
assert_ne!(keys.1, zoom_keys.1);
assert_ne!(projection_key(&first, 1.0), projection_key(&zoom, 1.0));
let b = curve_sample(&h.app, sid, index, &plane, &zoom);
assert!(!Arc::ptr_eq(&a, &b), "full VP must invalidate even when grid spacing is unchanged");
assert!(dense_circle_error(&b, &plane, &zoom, 1.0, 1.0) <= 1.0);
let resized = projection(&h.app, egui::Rect::from_min_size(first.rect.min, first.rect.size() * 0.75));
let resized_keys = crate::viewport::display_scene_keys(&h.app, &resized);
assert_eq!(keys.0, resized_keys.0);
assert_ne!(zoom_keys.1, resized_keys.1);
let c = curve_sample(&h.app, sid, index, &plane, &resized);
assert!(!Arc::ptr_eq(&b, &c));
h.app.viewport.set_pixels_per_point(2.0);
let hidpi_keys = crate::viewport::display_scene_keys(&h.app, &resized);
assert_eq!(keys.0, hidpi_keys.0);
assert_ne!(resized_keys.1, hidpi_keys.1);
let d = curve_sample(&h.app, sid, index, &plane, &resized);
assert!(!Arc::ptr_eq(&c, &d));
assert!(d.vertex_count > c.vertex_count, "HiDPI must refine physical error, not just invalidate a key");
assert!(dense_circle_error(&d, &plane, &resized, 1.0, 2.0) <= 1.0);
assert_eq!(h.app.session.doc.to_json(), baseline);
assert_eq!((h.app.session.undo.len(), h.app.session.redo.len()), history);
}
#[test]
fn pixel_adapter_uses_exact_ellipse_periodic_fit_and_control_spline_spans() {
for (command, params) in [
("sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":0.3})),
("sketch.spline.fit_point", json!({"points":[[0,0],[10,2],[5,10],[-1,4]],"periodic":true,"connect":false})),
("sketch.spline.control_point_5", json!({"points":[[0,0],[2,12],[4,-12],[6,12],[8,-12],[10,0]],"connect":false})),
] {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run(command, params).unwrap();
h.settle();
h.step(&json!({"call":"ui.view","params":{"view":"top"}})).unwrap();
h.step(&json!({"call":"ui.view","params":{"view":"fit"}})).unwrap();
h.settle();
let baseline = h.app.session.doc.to_json();
let sid = h.app.session.active_sketch.unwrap();
let state = h.app.session.world_state();
let ss = state.sketch(sid).unwrap();
let exact = ss.sketch.exact_segs(0).unwrap();
assert!(
exact.iter().all(|s| !matches!(s, solvecraft_engine::geom::Seg2::Line { .. })),
"freeform display must reach the exact span extractor"
);
let proj = projection(&h.app, h.app.viewport.rect.unwrap());
let path = curve_sample(&h.app, sid, 0, &ss.plane, &proj);
assert_eq!(path.quality, SamplingQuality::Complete, "{command}");
assert!(path.vertex_count <= 4096);
let lines: Vec<_> =
path.parts.iter().flat_map(|p| p.windows(2)).map(|p| (proj.to_screen(p[0]).unwrap(), proj.to_screen(p[1]).unwrap())).collect();
let mut worst = 0.0f64;
for segment in exact {
for i in 0..=2048 {
let point = segment.point_at(i as f64 / 2048.0);
let screen = proj.to_screen(ss.plane.to_world(point)).unwrap();
let distance = lines.iter().map(|&(a, b)| segment_distance(screen, a, b)).fold(f64::INFINITY, f64::min);
worst = worst.max(distance * f64::from(h.app.viewport.pixels_per_point()));
}
}
assert!(worst <= 1.0, "{command} projected error: {worst}px");
assert_eq!(h.app.session.doc.to_json(), baseline);
}
}
#[test]
fn pixel_curve_second_nested_rotated_occurrence_uses_its_world_plane() {
let mut h = Harness::new();
let parent = h.app.run("component.create", json!({"name":"Assembly"})).unwrap();
let parent_component = parent["component"].as_u64().unwrap();
let first = parent["occurrence"].as_u64().unwrap();
let child = h.app.run("component.create", json!({"name":"Part"})).unwrap();
let component = child["component"].as_u64().unwrap();
let leaf = child["occurrence"].as_u64().unwrap();
h.app.run("component.activate", json!({"component":0})).unwrap();
let second = h.app.run("occurrence.copy", json!({"component":parent_component})).unwrap()["occurrence"].as_u64().unwrap();
h.app.run("occurrence.move", json!({"occurrence":second,"translate":[20,30,40],"axis":[1,0,0],"angle":"35 deg","capture":true})).unwrap();
h.app.run("component.activate", json!({"component":component,"occurrence":leaf,"path":[second,leaf]})).unwrap();
h.app.run("browser.visibility", json!({"paths":[[first]],"visible":false})).unwrap();
let sid = circle(&mut h);
let baseline = h.app.session.doc.to_json();
let state = h.app.session.world_state();
let ss = state.sketch(sid).unwrap();
assert!(ss.plane.origin.dist(Vec3::new(20.0, 30.0, 40.0)) < 1e-8);
let tilt = 35.0f64.to_radians();
assert!(ss.plane.x.dist(Vec3::X) < 1e-8);
assert!(
ss.plane.y.dist(Vec3::new(0.0, tilt.cos(), tilt.sin())) < 1e-8,
"independent expected occurrence rotation must tilt the actual sketch plane"
);
let proj = projection(&h.app, h.app.viewport.rect.unwrap());
let path = curve_sample(&h.app, sid, 0, &ss.plane, &proj);
assert!(dense_circle_error(&path, &ss.plane, &proj, 1.0, h.app.viewport.pixels_per_point()) <= 1.0);
let world = ss.plane.to_world(Vec2::new(0.8090169943749475, 0.5877852522924731));
h.step(&json!({"click":{"world":[world.x,world.y,world.z]}})).unwrap();
assert!(h.app.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::SketchCurve{id} if id == "c1")));
assert_eq!(h.app.session.doc.to_json(), baseline);
}
#[test]
fn pixel_annulus_fill_keeps_hole_and_cpu_bridge_matches_cached_boundaries() {
let mut h = Harness::new();
let sid = circle(&mut h);
h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":0.5})).unwrap();
h.settle();
let baseline = h.app.session.doc.to_json();
let state = h.app.session.world_state();
let ss = state.sketch(sid).unwrap();
let proj = projection(&h.app, h.app.viewport.rect.unwrap());
let ppp = h.app.viewport.pixels_per_point();
let profiles = solvecraft_engine::sketch::find_profiles(&ss.sketch);
let ring = profiles.iter().find(|p| p.region.holes.len() == 1).unwrap();
let display = crate::sketch_display::display_region(&ring.region, &ss.plane, &proj, ppp).unwrap();
assert_eq!(display.holes.len(), 1);
let triangles = crate::sketch_display::region_triangles(&ring.region, &ss.plane, &proj, ppp);
assert!(!triangles.is_empty());
let mut area = 0.0;
let center = proj.to_screen(ss.plane.origin).unwrap();
let inner_px = proj.to_screen(ss.plane.to_world(Vec2::new(0.5, 0.0))).unwrap().distance(center);
for t in &triangles {
let cross = |a: Vec2, b: Vec2| a.x * b.y - a.y * b.x;
area += cross(t[1] - t[0], t[2] - t[0]).abs() * 0.5;
// No triangle can contain the hole centre; every edge remains outside the
// exact inner circle up to the advertised physical display error.
let signs = [cross(t[1] - t[0], -t[0]), cross(t[2] - t[1], -t[1]), cross(t[0] - t[2], -t[2])];
assert!(!(signs.iter().all(|s| *s >= 0.0) || signs.iter().all(|s| *s <= 0.0)));
for i in 0..3 {
let a = proj.to_screen(ss.plane.to_world(t[i])).unwrap();
let b = proj.to_screen(ss.plane.to_world(t[(i + 1) % 3])).unwrap();
assert!((f64::from(inner_px) - segment_distance(center, a, b)) * f64::from(ppp) <= 1.0);
}
}
assert!((area - std::f64::consts::PI * 0.75).abs() < 0.02, "adaptive fill area: {area}");
let cpu = solvecraft_engine::view::sketch_lines_with_sampler(&ss.sketch, &ss.plane, true, &[], |sk, index, plane| {
crate::sketch_display::curve_parts(sk, index, plane, &proj, ppp)
});
let cached: Vec<_> = (0..ss.sketch.curves.len()).flat_map(|index| curve_sample(&h.app, sid, index, &ss.plane, &proj).parts.clone()).collect();
assert_eq!(
cpu.iter().map(|(points, _, _)| points.clone()).collect::<Vec<_>>(),
cached,
"CPU styling bridge and GPU/picking cache must retain identical boundary parts"
);
assert_eq!(h.app.session.doc.to_json(), baseline);
}
#[test]
fn pixel_frame_budget_does_not_cache_unbounded_empty_curve_results() {
let mut h = Harness::new();
let sid = circle(&mut h);
let baseline = h.app.session.doc.to_json();
let state = h.app.session.world_state();
let mut plane = state.sketch(sid).unwrap().plane;
let proj = projection(&h.app, h.app.viewport.rect.unwrap());
let mut limited = 0;
for i in 0..5000 {
// Distinct legal placement keys of one source avoid manufacturing a huge
// document. Even exhausted samples must not add entries forever.
plane.origin.x = i as f64 * 1e-7;
let path = curve_sample(&h.app, sid, 0, &plane, &proj);
assert!(path.vertex_count <= 4096);
if path.quality == SamplingQuality::BudgetLimited {
limited += 1;
}
}
assert!(limited > 0, "fixture must actually exhaust the shared frame budget");
assert!(h.app.viewport.display.lock().unwrap().len() <= 4096);
assert_eq!(h.app.session.doc.to_json(), baseline);
}
+88 -39
View File
@@ -178,7 +178,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
};
let t = Tokens::get();
let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect());
let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::AXIS_VIEW_CLEARANCE);
let bottom = vp.bottom().min(ctx.content_rect().bottom()) - 8.0;
// Prefer the space below Axis View, but reclaim it on short/HiDPI views.
let top = (vp.top() + crate::viewport::AXIS_VIEW_CLEARANCE).min((bottom - 360.0).max(vp.top()));
let anchor = egui::pos2(vp.right(), top);
let frame = egui::Frame::window(&ctx.global_style())
.fill(t.dialog_bg)
.stroke(Stroke::new(1.0, t.dialog_border))
@@ -205,12 +208,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
app.tr("ui.heading_sketch_palette"),
egui::Id::new("sc_sketch_palette"),
anchor,
ctx.content_rect().bottom() - anchor.y - 8.0,
bottom - anchor.y,
crate::frame::Width::Normal,
)
.frame(frame)
.show(ctx, |ui| {
ui.set_width(WIDTH);
let width = WIDTH.min((vp.width() - 24.0).max(120.0));
ui.set_width(width);
ui.horizontal(|ui| {
let folded = FOLDED.with(Cell::get);
if ui.add(egui::Button::new(RichText::new(if folded { "+" } else { "−" }).size(14.0)).frame(false)).clicked() {
@@ -229,47 +233,53 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
OPTIONS_FOLDED.with(|f| f.set(!ofold));
}
if !ofold {
egui::Grid::new("sc_palette_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(app.tr("ui.linetype"));
ui.horizontal(|ui| {
// With a selection: convert it; without: the mode new curves are drawn in.
let current = mode(app);
for (i, (label, tip)) in [("—", "Normal"), ("- -", "Construction"), ("-·-", "Centerline")].iter().enumerate() {
let on = !has_curves && current == i as u8;
let tr_tip = app.tr_label(tip);
let tip_str = if has_curves {
crate::i18n::format("sketch.make_selection", &[("linetype", tr_tip)])
} else {
crate::i18n::format("sketch.draw_curves", &[("linetype", tr_tip)])
};
if ui.add(egui::Button::new(*label).selected(on).min_size(vec2(30.0, 20.0))).on_hover_text(tip_str).clicked() {
linetype = Some(i as u8);
let options_height = (bottom - ui.cursor().top() - 68.0).max(24.0);
let options =
egui::ScrollArea::vertical().id_salt("sc_palette_options").max_height(options_height).auto_shrink([false, true]).show(ui, |ui| {
egui::Grid::new("sc_palette_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(app.tr("ui.linetype"));
ui.horizontal(|ui| {
// With a selection: convert it; without: the mode new curves are drawn in.
let current = mode(app);
for (i, (label, tip)) in [("—", "Normal"), ("- -", "Construction"), ("-·-", "Centerline")].iter().enumerate() {
let on = !has_curves && current == i as u8;
let tr_tip = app.tr_label(tip);
let tip = if has_curves {
crate::i18n::format("sketch.make_selection", &[("linetype", tr_tip)])
} else {
crate::i18n::format("sketch.draw_curves", &[("linetype", tr_tip)])
};
if ui.add(egui::Button::new(*label).selected(on).min_size(vec2(30.0, 20.0))).on_hover_text(tip).clicked() {
linetype = Some(i as u8);
}
}
});
ui.end_row();
ui.label(app.tr("ui.look_at"));
if ui.button(app.tr("ui.look_at")).clicked() {
look = true;
}
}
ui.end_row();
for (k, label) in TOGGLES {
// The label is part of the checkbox: clicking it toggles too.
let mut on = shown(k);
if ui.checkbox(&mut on, app.tr_label(label)).changed() {
set = Some((k.to_string(), on));
}
ui.end_row();
}
});
});
ui.end_row();
ui.label(app.tr("ui.look_at"));
if ui.button(app.tr("ui.look_at")).clicked() {
look = true;
}
ui.end_row();
for (k, label) in TOGGLES {
// The label is part of the checkbox: clicking it toggles too.
let mut on = shown(k);
if ui.checkbox(&mut on, app.tr_label(label)).changed() {
set = Some((k.to_string(), on));
}
ui.end_row();
}
});
crate::scenario::publish_rect("sketch_palette:options", options.inner_rect);
}
ui.add_space(4.0);
let tr_dof = crate::i18n::tr_dyn(&dof_line);
ui.label(RichText::new(tr_dof).color(if ok { t.sketch_accent } else { t.text_dim }));
ui.label(RichText::new(crate::i18n::tr_dyn(&dof_line)).color(if ok { t.sketch_accent } else { t.text_dim }));
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_space(WIDTH - 100.0);
if ui.add(egui::Button::new(RichText::new(app.tr("ui.finish_sketch")).color(Color32::WHITE)).fill(t.accent)).clicked() {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let button = ui.add(egui::Button::new(RichText::new(app.tr("ui.finish_sketch")).color(Color32::WHITE)).fill(t.accent));
crate::scenario::publish_rect("sketch_palette:finish", button.rect);
crate::scenario::publish_handle("sketch_palette:finish", button.rect.center());
if button.clicked() {
finish = true;
}
});
@@ -300,6 +310,45 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
mod tests {
use super::*;
#[test]
fn palette_footer_and_scrolled_lower_option_remain_reachable_at_scale_and_resize() {
for (scale, height) in [(1.0, 900.0), (2.0, 900.0), (2.0, 720.0)] {
FOLDED.with(|v| v.set(false));
OPTIONS_FOLDED.with(|v| v.set(false));
let mut h = crate::scenario::Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":1})).unwrap();
h.call("ui.resize", json!({"width":1400,"height":height}));
h.call("ui.prefs", json!({"ui_scale":scale}));
h.settle();
let baseline = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let vp = h.app.viewport.rect.unwrap();
let footer = crate::scenario::rect_of("sketch_palette:finish").unwrap();
assert!(vp.contains_rect(footer), "scale{scale} height{height}: {footer:?} outside {vp:?}");
let options = crate::scenario::rect_of("sketch_palette:options").unwrap();
assert!(vp.contains_rect(options));
h.call("ui.scroll", json!({"x":options.center().x,"y":options.center().y,"delta":-1000}));
h.settle();
assert_eq!(h.app.session.doc.to_json(), baseline);
assert_eq!(h.app.session.undo.len(), undo);
let [x, y] = h.widget("3D Sketch").unwrap();
assert!(options.contains(egui::pos2(x as f32, y as f32)), "lower option must be visible after scrolling");
h.call("ui.click", json!({"x":x,"y":y}));
h.settle();
assert!(h.app.session.world_state().sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch.view.three_d);
assert_eq!(h.app.session.undo.len(), undo + 1);
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), baseline);
h.settle();
let footer = crate::scenario::rect_of("sketch_palette:finish").unwrap();
h.call("ui.click", json!({"x":footer.center().x,"y":footer.center().y}));
h.settle();
assert!(h.app.session.active_sketch.is_none(), "Finish Sketch must work at scale{scale} height{height}");
assert_eq!(h.app.session.doc.to_json(), baseline);
}
}
#[test]
fn dof_readout() {
assert_eq!(dof_text(0, true), "Fully constrained");
+9 -4
View File
@@ -970,11 +970,16 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) {
{
painter.line_segment([*last, hs], stroke);
}
if let Some((h, _)) = t.hover.as_ref()
&& let Some(curve) = crate::tools::preview_curve(t, *h, None)
{
crate::tools::paint_preview_curve(&curve, &ss.plane, painter, proj, stroke);
}
// Picked curves highlighted.
let mut display_budget = solvecraft_engine::render::SamplingBudget::new(32_768, 65_536);
for id in t.picks.iter().filter(|x| *x != "|") {
if let Some(ci) = ss.sketch.curve_index(id) {
let v: Vec<Pos2> = ss.sketch.polyline(ci).iter().filter_map(|q| to(*q)).collect();
painter.add(egui::Shape::line(v, Stroke::new(3.0, tk.accent.gamma_multiply(0.7))));
crate::tools::paint_curve(&ss.sketch, ci, &ss.plane, painter, proj, Stroke::new(3.0, tk.accent.gamma_multiply(0.7)), &mut display_budget);
}
}
}
@@ -1028,9 +1033,9 @@ fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj, hover: Option<Po
&& let Some(c) = ss.sketch.constraints.iter().find(|c| &c.id == id)
{
let (cs, ps) = constraint_entities(&c.kind);
let mut display_budget = solvecraft_engine::render::SamplingBudget::new(32_768, 65_536);
for ci in cs {
let pts: Vec<Pos2> = ss.sketch.polyline(ci).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect();
painter.add(egui::Shape::line(pts, Stroke::new(3.0, tk.accent.gamma_multiply(0.7))));
crate::tools::paint_curve(&ss.sketch, ci, &ss.plane, painter, proj, Stroke::new(3.0, tk.accent.gamma_multiply(0.7)), &mut display_budget);
}
for pi in ps {
if let Some(q) = ss.sketch.point(pi).and_then(|q| proj.to_screen(ss.plane.to_world(q))) {
+120 -7
View File
@@ -1,13 +1,17 @@
//! Interactive sketch tools: click points on the sketch plane (or pick sketch entities) and the
//! tool runs the matching command. Tools are UI state only; the work is done by commands.
use egui::{Pos2, Shape, Stroke};
use egui::{Pos2, Stroke};
use serde_json::{Value, json};
use solvecraft_engine::geom::Vec2;
use crate::SolveApp;
use crate::viewport::{Hit, Proj, pick, sketch_point_at};
#[cfg(test)]
#[path = "sketch_display_preview_tests.rs"]
mod display_preview_tests;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
/// Click points; `n` points complete the shape (0 = chain until finished).
@@ -350,6 +354,117 @@ pub fn finish(app: &mut SolveApp) {
}
/// Rubber-band preview of the shape being drawn.
pub(crate) fn paint_preview_curve(
sketch: &solvecraft_engine::sketch::Sketch,
plane: &solvecraft_engine::geom::Plane,
painter: &egui::Painter,
proj: &Proj,
stroke: Stroke,
) {
paint_curve(sketch, 0, plane, painter, proj, stroke, &mut solvecraft_engine::render::SamplingBudget::new(4096, 65_536));
}
pub(crate) fn paint_curve(
sketch: &solvecraft_engine::sketch::Sketch,
index: usize,
plane: &solvecraft_engine::geom::Plane,
painter: &egui::Painter,
proj: &Proj,
stroke: Stroke,
budget: &mut solvecraft_engine::render::SamplingBudget,
) {
for part in crate::sketch_display::curve_with_budget(sketch, index, plane, proj, painter.ctx().pixels_per_point(), budget).parts {
let screen: Vec<_> = part.into_iter().filter_map(|point| proj.to_screen(point)).collect();
if screen.len() >= 2 {
painter.add(egui::Shape::line(screen, stroke));
}
}
}
/// Temporary analytic geometry uses the same exact span extraction as committed curves.
/// It never solves constraints or modifies the document.
pub(crate) fn preview_curve(t: &Tool, hover: Vec2, sweep: Option<f64>) -> Option<solvecraft_engine::sketch::Sketch> {
use solvecraft_engine::sketch::{CurveKind, Sketch};
if !hover.is_finite() || t.pts.len() >= solvecraft_engine::sketch::MAX_SPLINE_POINTS || t.pts.iter().any(|p| !p.0.is_finite()) {
return None;
}
let mut sk = Sketch::new();
let pts: Vec<_> = t.pts.iter().map(|p| p.0).collect();
match (t.cmd, pts.as_slice()) {
("sketch.circle.center" | "sketch.polygon.inscribed" | "sketch.polygon.circumscribed", [c]) => {
sk.add_circle(*c, c.dist(hover), None, None).ok()?;
}
("sketch.circle.two_point", [a]) => {
sk.add_circle((*a + hover) * 0.5, a.dist(hover) * 0.5, None, None).ok()?;
}
("sketch.circle.three_point" | "sketch.arc.three_point", [a, b]) => {
let ab = *b - *a;
let ah = hover - *a;
let d = 2.0 * ab.cross(ah);
if !d.is_finite() || d.abs() < 1e-12 {
return None;
}
let centre = *a + Vec2::new(ah.y * ab.len2() - ab.y * ah.len2(), ab.x * ah.len2() - ah.x * ab.len2()) / d;
if t.cmd == "sketch.circle.three_point" {
sk.add_circle(centre, centre.dist(*a), None, None).ok()?;
} else {
let angle = |p: Vec2| (p - centre).angle();
let span = |p: Vec2| (angle(p) - angle(*a)).rem_euclid(std::f64::consts::TAU);
let ccw = span(hover) < span(*b);
sk.add_arc(centre, if ccw { *a } else { *b }, if ccw { *b } else { *a }, [None; 3], None).ok()?;
if let Some(curve) = sk.curves.first_mut() {
curve.reversed = !ccw;
}
}
}
("sketch.arc.center_point", [c, a]) => {
let start = (*a - *c).angle();
let sweep = sweep.unwrap_or_else(|| ((hover - *c).angle() - start).rem_euclid(std::f64::consts::TAU));
if !sweep.is_finite() || sweep.abs() <= 1e-9 || sweep.abs() >= std::f64::consts::TAU {
return None;
}
let b = *c + Vec2::from_angle(start + sweep) * c.dist(*a);
sk.add_arc(*c, if sweep > 0.0 { *a } else { b }, if sweep > 0.0 { b } else { *a }, [None; 3], None).ok()?;
if let Some(curve) = sk.curves.first_mut() {
curve.reversed = sweep < 0.0;
}
}
("sketch.ellipse", [c, m]) => {
let u = (*m - *c).normalized()?;
let d = hover - *c;
let k = 1.0 - (d.dot(u) / c.dist(*m)).powi(2);
if !k.is_finite() || k <= 1e-12 {
return None;
}
let r = d.cross(u).abs() / k.sqrt();
let c = sk.add_point(*c, None).ok()?;
let m = sk.add_point(*m, None).ok()?;
sk.add_curve(CurveKind::Ellipse { c, m, r }, None).ok()?;
}
("sketch.spline.fit_point" | "sketch.spline.control_point" | "sketch.spline.control_point_5", points) if !points.is_empty() => {
let periodic = t.cmd == "sketch.spline.fit_point" && points.len() >= 3 && points.first() == Some(&hover);
let mut indices = points.iter().map(|p| sk.add_point(*p, None).ok()).collect::<Option<Vec<_>>>()?;
if periodic {
indices.push(*indices.first()?);
} else if points.last() != Some(&hover) {
indices.push(sk.add_point(hover, None).ok()?);
}
sk.add_curve(
CurveKind::Spline {
pts: indices,
control: t.cmd != "sketch.spline.fit_point",
degree: if t.cmd == "sketch.spline.control_point_5" { 5 } else { 3 },
periodic,
},
None,
)
.ok()?;
}
_ => return None,
}
Some(sk)
}
pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) {
if crate::sketch3d::preview(app, t, painter, proj) {
return;
@@ -377,16 +492,16 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) {
}
let Some((h, _)) = t.hover.clone() else { return };
let h = crate::sketch_dims::effective(app, t, h);
if let Some(curve) = preview_curve(t, h, crate::sketch_dims::locked_value(app, t, "Sweep")) {
paint_preview_curve(&curve, &ss.plane, painter, proj, stroke);
return;
}
let pts: Vec<Vec2> = t.pts.iter().map(|p| p.0).collect();
let line = |a: Vec2, b: Vec2| {
if let (Some(x), Some(y)) = (to(a), to(b)) {
painter.line_segment([x, y], stroke);
}
};
let circle = |c: Vec2, r: f64| {
let v: Vec<Pos2> = (0..=64).filter_map(|i| to(c + Vec2::from_angle(i as f64 / 64.0 * std::f64::consts::TAU) * r)).collect();
painter.add(Shape::line(v, stroke));
};
match (t.cmd, pts.as_slice()) {
("sketch.line", [.., last]) => {
line(*last, h);
@@ -419,8 +534,6 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) {
line(k[i], k[(i + 1) % 4]);
}
}
("sketch.circle.center" | "sketch.polygon.inscribed" | "sketch.polygon.circumscribed", [c]) => circle(*c, c.dist(h)),
("sketch.circle.two_point", [a]) => circle((*a + h) * 0.5, a.dist(h) * 0.5),
(_, [a]) => line(*a, h),
(_, [a, b]) => {
line(*a, *b);
+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"] {