Prepare 0.2.0 usability release and refresh desktop demo

This commit is contained in:
bherbruck committed 2026-10-11 10:55:55 -04:00
1 parent a094fa46b5
commit ab05b24dee
54 files changed
+4888 -234

No files matched your search

+9 -1
View File
@@ -664,7 +664,15 @@ pub(super) fn paste(
}
}
}
if (ma.volume - mb.volume).abs() > 1e-5 * (1.0 + ma.volume.abs())
// Meshes of the same current circular section can have different
// chord-volume biases after STEP. Only independently certified current
// sections may replace that volume comparison; shape mismatches still
// refuse even when their volumes happen to agree.
let volume_matches = match solvecraft_kernel::certified_prism_equivalent(&a, &b)? {
Some(same) => same,
None => (ma.volume - mb.volume).abs() <= 1e-5 * (1.0 + ma.volume.abs()),
};
if !volume_matches
|| (ma.area - mb.area).abs() > 1e-5 * (1.0 + ma.area.abs())
|| ma.centroid.dist(mb.centroid) > tolerance
|| ma.bbox.min.dist(mb.bbox.min) > tolerance
@@ -519,3 +519,36 @@ fn frozen_bore_boundary_copy_preserves_stock_void_and_reopen() {
check(&s);
check(&Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()));
}
#[test]
fn frozen_r6_bore_copy_uses_current_geometry_despite_mesh_measure_bias() {
use solvecraft_geom::Vec3;
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"body_name":"Drilled"}));
run(&mut s, "solid.cylinder", json!({"base":[10,10,0],"radius":6,"height":10,"operation":"cut"}));
let source = s.model.state().body("Drilled").unwrap().body.clone();
let original = solvecraft_kernel::step_export(&[&source], "source").unwrap();
let created = run(&mut s, "component.from_bodies", json!({"bodies":["Drilled"]}));
let part = created["components"][0]["component"].as_u64().unwrap();
let before = s.doc.to_json();
let copied = copy(&mut s, part)["component"].as_u64().unwrap();
let check = |session: &Session| {
let state = session.model.state();
let body = state.bodies.iter().find(|b| session.doc.model_body_component(b) == copied).unwrap();
assert_eq!(solvecraft_kernel::certified_prism_equivalent(&source, &body.body).unwrap(), Some(true));
let mesh = body.body.tessellate(1e-3).unwrap();
for (point, material) in
[(Vec3::new(3., 10., 5.), true), (Vec3::new(5., 10., 5.), false), (Vec3::new(10., 10., 5.), false), (Vec3::new(18., 10., 5.), true)]
{
assert_eq!(mesh.contains(point), material, "{point:?}");
}
let original_body = state.body("Drilled").unwrap();
assert_eq!(solvecraft_kernel::step_export(&[&original_body.body], "source").unwrap(), original);
};
check(&s);
check(&Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before);
run(&mut s, "edit.redo", json!({}));
check(&s);
}
+1 -1
View File
@@ -235,7 +235,7 @@ pub fn scene_with_section_and_sketch_sampler(
};
sc.meshes.push(SceneMesh { mesh: Arc::clone(&mesh), color, face_colors });
for (i, e) in mesh.edges.iter().enumerate() {
if mesh.seams.get(i).copied().unwrap_or(false) {
if !mesh.shaded_edge_visible(i) {
continue;
}
sc.lines.push(SceneLine { points: e.clone(), color: colors::EDGE, width: 1.2, on_top: false });
+21 -2
View File
@@ -64,6 +64,13 @@ pub struct Mesh {
/// Per edge: a seam between two pieces of the same smooth surface (not drawn).
#[serde(default)]
pub seams: Vec<bool>,
/// Display-only tangent joins. Edge identities, picking and face groups stay intact.
#[serde(skip)]
pub smooth_edges: Vec<bool>,
/// Display-protected boundaries: certified current creases or authored splits.
/// Overrides coarse seam fits; not a geometric/topological authority.
#[serde(skip)]
pub sharp_edges: Vec<bool>,
/// Per edge: the B-rep faces it bounds (one or two).
#[serde(default)]
pub edge_faces: Vec<Vec<u32>>,
@@ -78,6 +85,15 @@ pub struct MeshMeasure {
}
impl Mesh {
/// Visibility in shaded edge presentation, independent of selection/topology.
pub fn shaded_edge_visible(&self, index: usize) -> bool {
self.edge_pickable(index) && !self.smooth_edges.get(index).copied().unwrap_or(false)
}
pub fn edge_pickable(&self, index: usize) -> bool {
!self.seams.get(index).copied().unwrap_or(false) || self.sharp_edges.get(index).copied().unwrap_or(false)
}
/// Edges bounding face `f`.
pub fn face_edges(&self, f: u32) -> Vec<usize> {
self.edge_faces.iter().enumerate().filter(|(_, fs)| fs.contains(&f)).map(|(i, _)| i).collect()
@@ -88,7 +104,10 @@ impl Mesh {
let mut todo = vec![face];
while let Some(f) = todo.pop() {
for (ei, fs) in self.edge_faces.iter().enumerate() {
if !self.seams.get(ei).copied().unwrap_or(false) || !fs.iter().any(|x| *x as usize == f) {
if !self.seams.get(ei).copied().unwrap_or(false)
|| self.sharp_edges.get(ei).copied().unwrap_or(false)
|| !fs.iter().any(|x| *x as usize == f)
{
continue;
}
for &next in fs {
@@ -176,7 +195,7 @@ impl Mesh {
while let Some(cur) = todo.pop() {
let Some(ce) = ends(cur) else { continue };
for (i, _) in self.edges.iter().enumerate() {
if chain.contains(&i) || self.seams.get(i).copied().unwrap_or(false) {
if chain.contains(&i) || !self.edge_pickable(i) {
continue;
}
let Some(ne) = ends(i) else { continue };
+35 -1
View File
@@ -6,7 +6,41 @@ fn cube_mesh(s: f64) -> Mesh {
let triangles =
vec![[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]];
let n = triangles.len();
Mesh { normals: vec![Vec3::Z; 8], positions, triangles, tri_face: vec![0; n], edges: vec![], seams: vec![], edge_faces: vec![] }
Mesh {
normals: vec![Vec3::Z; 8],
positions,
triangles,
tri_face: vec![0; n],
edges: vec![],
seams: vec![],
smooth_edges: vec![],
sharp_edges: vec![],
edge_faces: vec![],
}
}
#[test]
fn display_edge_masks_keep_identity_and_break_only_certified_sharp_patches() {
let mut mesh =
Mesh { edges: vec![vec![Vec3::ZERO, Vec3::Z]; 2], edge_faces: vec![vec![0, 1], vec![1, 2]], seams: vec![true, true], ..Mesh::default() };
let stored = serde_json::to_value(&mesh).unwrap();
assert_eq!(mesh.surface_patch(0), vec![0, 1, 2]);
mesh.smooth_edges = vec![true, false];
assert_eq!(mesh.surface_patch(0), vec![0, 1, 2]);
assert!(!mesh.shaded_edge_visible(0));
mesh.smooth_edges[0] = false;
mesh.sharp_edges = vec![true, false];
assert!(mesh.shaded_edge_visible(0));
assert!(mesh.edge_pickable(0));
assert!(!mesh.edge_pickable(1));
assert_eq!(mesh.surface_patch(0), vec![0]);
assert_eq!(mesh.surface_patch(1), vec![1, 2]);
assert_eq!(mesh.edges.len(), 2);
assert_eq!(mesh.face_edges(1), vec![0, 1]);
assert_eq!(serde_json::to_value(&mesh).unwrap(), stored);
let reopened: Mesh = serde_json::from_value(stored).unwrap();
assert!(reopened.smooth_edges.is_empty() && reopened.sharp_edges.is_empty());
assert!(Mesh::default().shaded_edge_visible(0));
}
#[test]
+1
View File
@@ -750,6 +750,7 @@ impl Body {
return Ok(m);
}
m.seams = crate::topo::seam_flags(self, &m);
(m.smooth_edges, m.sharp_edges) = crate::smooth_edges::flags(self, &m);
Ok(m)
}
+186
View File
@@ -0,0 +1,186 @@
//! Necessary boundary safety check for the narrowed-bore fallback.
//! This is not a certificate of general Boolean equivalence. It prevents a
//! cylinder recut from silently removing an additional material boundary that
//! the fallback's coarse material samples do not see.
use crate::body::{Body, from_p3};
use solvecraft_geom::{Plane, Seg2, Vec3};
use std::collections::HashSet;
use truck_modeling as mt;
pub(super) fn boundaries_clear(body: &Body, bore_faces: &[usize], origin: Vec3, axis: Vec3, radius: f64, lo: f64, hi: f64, tol: f64) -> Option<()> {
let plane = Plane::from_normal(origin, axis)?;
if !origin.is_finite()
|| !axis.is_finite()
|| ![radius, lo, hi, tol].iter().all(|v| v.is_finite())
|| radius <= tol
|| hi <= lo
|| tol <= 0.
|| bore_faces.len() > 16_384
{
return None;
}
let mut remaining_faces = 16_384usize;
let mut remaining_uses = 65_536usize;
for face in body.solid.face_iter() {
remaining_faces = remaining_faces.checked_sub(1)?;
for wire in face.absolute_boundaries() {
for _ in wire.edge_iter() {
remaining_uses = remaining_uses.checked_sub(1)?;
}
}
}
// Exempt only existing coincident bore boundaries, never newly inferred
// edges or every edge of a planar face adjoining a cylinder.
let bore_faces: HashSet<usize> = bore_faces.iter().copied().collect();
let mut bore_edges = HashSet::new();
for (i, face) in body.solid.face_iter().enumerate() {
if bore_faces.contains(&i) {
for edge in face.edge_iter() {
bore_edges.insert(edge.id());
}
}
}
let mut seen = HashSet::new();
let mut work = 2_000_000usize;
for edge in body.solid.edge_iter() {
if !seen.insert(edge.id()) {
continue;
}
let common_bore = bore_edges.contains(&edge.id());
work = work.checked_sub(1)?;
let curve = edge.curve();
// Original matched-cylinder trims retain the legacy fallback policy.
// Boolean-produced rational trims need not be exact analytic circles.
// This exemption follows existing face/edge identity only; additional
// trims must pass the whole-boundary proof below. It is deliberately
// not a general proof that the cylinder contains no material.
if common_bore && matches!(curve, mt::Curve::IntersectionCurve(_) | mt::Curve::BSplineCurve(_) | mt::Curve::NurbsCurve(_)) {
continue;
}
if let mt::Curve::Line(line) = curve {
let (a, b) = (from_p3(line.0), from_p3(line.1));
if !a.is_finite() || !b.is_finite() {
return None;
}
let ha = plane.height(a);
let hb = plane.height(b);
let error = tol + 512. * f64::EPSILON * (1. + a.len() + b.len() + origin.len());
if common_bore {
let pa = plane.to_local(a);
let pb = plane.to_local(b);
if pa.dist(pb) <= error && (pa.len() - radius).abs() + (pb.len() - radius).abs() <= error * 2. {
continue;
}
return None;
}
let (mut t0, mut t1): (f64, f64) = (0., 1.);
let dh = hb - ha;
if dh.abs() <= error {
if ha.min(hb) > hi + error || ha.max(hb) < lo - error {
continue;
}
} else {
let x = (lo - error - ha) / dh;
let y = (hi + error - ha) / dh;
t0 = t0.max(x.min(y));
t1 = t1.min(x.max(y));
if t0 > t1 {
continue;
}
}
let pa = plane.to_local(a + (b - a) * t0);
let pb = plane.to_local(a + (b - a) * t1);
let d = pb - pa;
let t = if d.len2() > 0. { (-pa.dot(d) / d.len2()).clamp(0., 1.) } else { 0. };
if (pa + d * t).len() <= radius + error {
return None;
}
continue;
}
// Positive rational controls enclose the entire axial extent. Project
// the current curve onto the axis plane; the existing whole-span
// certificate proves its Line/Arc image, without point sampling.
let (knots, controls): (mt::KnotVec, Vec<mt::Vector4>) = match curve {
mt::Curve::BSplineCurve(c) => {
if c.control_points().len() > 4096 || c.knot_vec().len() > 8192 {
return None;
}
work = work.checked_sub(c.control_points().len().checked_add(c.knot_vec().len())?)?;
(c.knot_vec().clone(), c.control_points().iter().map(|p| mt::Vector4::new(p.x, p.y, p.z, 1.)).collect())
}
mt::Curve::NurbsCurve(c) => {
if c.control_points().len() > 4096 || c.knot_vec().len() > 8192 {
return None;
}
work = work.checked_sub(c.control_points().len().checked_add(c.knot_vec().len())?)?;
(c.knot_vec().clone(), c.control_points().clone())
}
_ => return None,
};
let mut heights = (f64::INFINITY, f64::NEG_INFINITY);
let mut projection_error: f64 = 0.;
let mut projected_origin = None;
let mut projected_spread: f64 = 0.;
let mut projected = Vec::with_capacity(controls.len());
for p in controls {
if ![p.x, p.y, p.z, p.w].iter().all(|v| v.is_finite()) || p.w <= 0. {
return None;
}
let point = Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w);
if !point.is_finite() {
return None;
}
let error = tol + 512. * f64::EPSILON * (1. + point.len() + origin.len());
projection_error = projection_error.max(error);
let h = plane.height(point);
heights.0 = heights.0.min(h - error);
heights.1 = heights.1.max(h + error);
let q = plane.to_local(point);
let first = *projected_origin.get_or_insert(q);
projected_spread = projected_spread.max(q.dist(first));
projected.push(mt::Vector4::new(q.x * p.w, q.y * p.w, 0., p.w));
}
if heights.0 > hi || heights.1 < lo {
continue;
}
// A swept seam may be represented as a rational straight edge. Its
// positive control hull proves a point-sized radial projection even
// though a nondegenerate planar Line/Arc recognizer cannot accept it.
if let Some(q) = projected_origin {
if common_bore && projected_spread + projection_error + (q.len() - radius).abs() <= tol * 8. {
continue;
}
if !common_bore && q.len() - projected_spread - projection_error > radius + tol * 4. {
continue;
}
}
use mt::{BoundedCurve, ParametricCurve};
let projected = mt::Curve::NurbsCurve(mt::NurbsCurve::new(mt::BSplineCurve::try_new(knots, projected).ok()?));
let (a, b) = projected.range_tuple();
let projected_edge = mt::Edge::new(&mt::Vertex::new(projected.subs(a)), &mt::Vertex::new(projected.subs(b)), projected);
let segments = crate::prism_curve::segment(&projected_edge, &Plane::XY, tol, &mut work)?;
for segment in segments {
if common_bore {
if let Seg2::Arc { center, radius: r, .. } = segment
&& center.len() + (r - radius).abs() + projection_error <= tol * 8.
{
continue;
}
return None;
}
let clearance = match segment {
Seg2::Line { a, b } => {
let d = b - a;
let t = if d.len2() > 0. { (-a.dot(d) / d.len2()).clamp(0., 1.) } else { 0. };
(a + d * t).len()
}
Seg2::Arc { center, radius, .. } => radius - center.len(),
_ => return None,
};
if clearance <= radius + tol * 4. + projection_error {
return None;
}
}
}
Some(())
}
+118
View File
@@ -0,0 +1,118 @@
#![allow(clippy::unwrap_used, clippy::panic)]
use crate::*;
use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
fn fixture() -> (Body, Body, Vec3, f64) {
let slope = 1f64.to_radians().tan();
let hs = |normal: Vec3, point: Vec3| {
let n = normal.normalized().unwrap();
HalfSpace { n, d: n.dot(point) }
};
let stock = convex_polyhedron(&[
hs(Vec3::X, Vec3::new(40., 0., 0.)),
hs(-Vec3::X, Vec3::ZERO),
hs(Vec3::Y, Vec3::new(0., 30., 0.)),
hs(-Vec3::Y, Vec3::ZERO),
hs(-Vec3::Z, Vec3::ZERO),
hs(Vec3::new(-slope, 0., 1.), Vec3::new(0., 0., 20.)),
])
.unwrap();
let cutter = cylinder(Vec3::new(20., 15., -5.), Vec3::Z, 5., 40.).unwrap();
let bored = boolean(&stock, &cutter, BoolOp::Cut).unwrap().unwrap();
let web = box_solid(Vec3::new(14., 14., 16.05), Vec3::new(26., 16., 16.07)).unwrap();
let target = boolean(&bored, &web, BoolOp::Union).unwrap().unwrap();
let region = Region2 { outer: Loop2::circle(Vec2::new(20., 15.), 8.), holes: vec![Loop2::circle(Vec2::new(20., 15.), 5.).reversed()] };
let ring = extrude(&Plane::XY, &[region], 14., 24.).unwrap().remove(0);
let web_volume = 0.02 * 2. * (24f64.sqrt() + 25. * 0.2f64.asin());
let fine_target = target.tessellate_with(1e-4, true).unwrap();
let added = fine_target.measure().volume - bored.tessellate_with(1e-4, true).unwrap().measure().volume;
assert!((added - web_volume).abs() < 0.005, "actual connected web adds {added}, expected {web_volume}");
for x in [16., 20., 24.] {
for y in [14.2, 15., 15.8] {
assert!(fine_target.contains(Vec3::new(x, y, 16.06)), "source web must exist at {x},{y}");
}
}
assert!(!fine_target.contains(Vec3::new(20., 12., 16.06)));
let floor_volume = (20. + slope * 20.) * (1200. - 25. * std::f64::consts::PI);
let ring_added = std::f64::consts::PI * (64. - 25.) * (24. - (20. + slope * 20.));
(target, ring, Vec3::new(20., 15., 16.06), floor_volume + web_volume + ring_added)
}
#[test]
fn thin_connected_web_between_bore_stations_survives_union() {
let (target, ring, probe, expected) = fixture();
let target_before = step_export(&[&target], "source").unwrap();
let ring_before = step_export(&[&ring], "ring").unwrap();
let size = target.size().max(ring.size());
let tol = (size * 5e-4).max(size * 1e-5);
let ma = target.tessellate_with(tol, true).unwrap();
let mb = ring.tessellate_with(tol, true).unwrap();
assert!(ma.contains(probe), "fixture must contain the real connected web");
assert!(!mb.contains(probe));
let va = ma.measure().volume;
let vb = mb.measure().volume;
let slack = 2e-3 * (va + vb) + 1e-9;
let accept = |body: &Body| {
let mr = body.tessellate_with(tol, true).unwrap();
let v = mr.measure().volume;
let mismatch = super::mismatch(&ma, &mb, &mr, BoolOp::Union);
eprintln!("fallback candidate V={v}, expected={expected}, web={}, mismatch={mismatch:?}", mr.contains(probe));
v > 0. && v >= va.max(vb) - slack && v <= va + vb + slack && mismatch.is_none_or(|f| f < 0.004)
};
let direct = crate::coplanar::bore_union(&target, &ring, &accept);
eprintln!("direct fallback result={}", direct.is_some());
let public = boolean(&target, &ring, BoolOp::Union);
if let Ok(dir) = std::env::var("SOLVECRAFT_BORE_AUDIT_DIR") {
let dir = std::path::Path::new(&dir);
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("target-with-web.step"), step_export(&[&target], "target with web").unwrap()).unwrap();
if let Some(result) = &direct {
std::fs::write(dir.join("direct-union.step"), step_export(&[result], "direct union").unwrap()).unwrap();
}
if let Ok(Some(result)) = &public {
std::fs::write(dir.join("public-union.step"), step_export(&[result], "public union").unwrap()).unwrap();
}
}
let direct_keeps_web = direct.as_ref().is_none_or(|result| result.tessellate_with(1e-3, true).unwrap().contains(probe));
let public_keeps_web = match &public {
Ok(Some(result)) => {
let keep = result.tessellate_with(1e-3, true).unwrap().contains(probe);
eprintln!("public Union result web={keep}");
keep
}
result => {
eprintln!("public Union honestly refused: {result:?}");
true
}
};
assert!(direct_keeps_web, "accepted fallback removed real web material");
assert!(public_keeps_web, "public Union removed real web material");
assert_eq!(step_export(&[&target], "source").unwrap(), target_before);
assert_eq!(step_export(&[&ring], "ring").unwrap(), ring_before);
}
#[test]
fn unproved_curved_boundary_between_clear_endpoints_refuses_recut() {
let a = Vec2::new(6., -1.);
let b = Vec2::new(6., 1.);
let c = Vec2::new(8., 1.);
let d = Vec2::new(8., -1.);
let region = Region2 {
outer: Loop2 {
segs: vec![
Seg2::Cubic { p0: a, p1: Vec2::new(3., -0.5), p2: Vec2::new(3., 0.5), p3: b },
Seg2::Line { a: b, b: c },
Seg2::Line { a: c, b: d },
Seg2::Line { a: d, b: a },
],
}
.reversed(),
holes: vec![],
};
let body = extrude(&Plane::XY, &[region], 16.05, 16.07).unwrap().remove(0);
assert!(a.len() > 5. && b.len() > 5., "endpoint samples miss the interior excursion");
assert!(body.tessellate_with(1e-4, true).unwrap().contains(Vec3::new(4.5, 0., 16.06)));
let before = step_export(&[&body], "curved source").unwrap();
assert!(crate::bore_guard::boundaries_clear(&body, &[], Vec3::ZERO, Vec3::Z, 5., 14., 24., 1e-6).is_none());
assert_eq!(step_export(&[&body], "curved source").unwrap(), before);
}
+14 -3
View File
@@ -461,9 +461,11 @@ pub(crate) fn has_coincident_cylinders(a: &Body, b: &Body) -> bool {
/// it is joined over). The mesh intersection can't take the coincident walls, so the tool's
/// bore is narrowed (its walls pushed toward the axis, now clear of the target's), the bodies
/// are joined, and the cylinder of the original bore is cut out again over the tool's length.
/// That is exact when the cylinder holds no material of either body, which is checked on
/// sample points. Each way (the tool's bore narrowed, else the target's) gives a result only
/// when `accept` takes it. `None` when there are no coincident bores or no way works.
/// Recutting is valid only where that cylinder holds no material of either body. The
/// samples are plausibility checks; a whole-boundary guard rejects additional boundaries
/// crossing the recut. Original common-bore trims retain the legacy eligibility policy.
/// This is not a general emptiness or Boolean-equivalence certificate. Each direction
/// returns a result only when `accept` takes it; otherwise this returns `None`.
pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) -> Option<Body> {
let size = a.size().max(b.size());
let tol = (size * 1e-7).max(1e-9) * 10.0;
@@ -536,6 +538,15 @@ pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) ->
};
let lo = if past(t0 - ext * 0.5) && past(t0 - ext) { t0 - ext } else { t0 };
let hi = if past(t1 + ext * 0.5) && past(t1 + ext) { t1 + ext } else { t1 };
// The samples above are plausibility checks, not proof that an
// arbitrarily thin web is absent. Every additional current boundary
// must be wholly clear of the recut; unsupported trims refuse.
let other_bore: Vec<usize> = co.iter().filter(|x| same_cylinder(c, x, tol)).map(|x| x.index).collect();
if crate::bore_guard::boundaries_clear(tool, &bore, c.origin, c.axis, c.radius, lo, hi, tol).is_none()
|| crate::bore_guard::boundaries_clear(target, &other_bore, c.origin, c.axis, c.radius, lo, hi, tol).is_none()
{
continue;
}
let d = (c.radius * 0.2).min(size * 0.01);
let run = || -> Result<Option<Body>> {
let narrowed = crate::offset::offset_body(tool, |i, _| if bore.contains(&i) { d } else { 0.0 })?;
+4
View File
@@ -16,6 +16,7 @@ mod blend;
mod blend_corner;
mod blend_variable;
mod body;
mod bore_guard;
mod build;
mod coplanar;
mod curveblend;
@@ -39,8 +40,10 @@ mod polybool;
mod polyhedron;
mod prism;
mod prism_curve;
mod prism_measure;
pub mod provenance;
mod sew;
mod smooth_edges;
mod splitface;
mod step;
mod step_in;
@@ -70,6 +73,7 @@ pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transfor
pub use polybool::{faceted_boolean, planar_boolean};
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
pub use prism::{extrude_planar_regions, planar_regions_boolean};
pub use prism_measure::certified_prism_equivalent;
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew};
pub use splitface::{SplitTool, split_body, split_faces, split_faces_with_map};
pub use step::step_export;
+4
View File
@@ -7,6 +7,10 @@ use truck_modeling as mt;
use crate::body::{Body, Solid, from_p3};
use crate::{KernelError, Result, guard};
#[cfg(test)]
#[path = "bore_union_tests.rs"]
mod bore_union_tests;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BoolOp {
+903
View File
@@ -0,0 +1,903 @@
//! Independent current-geometry intervals for uniform prisms.
//! These are not mesh measurements or certificates of construction history.
use crate::body::from_p3;
use solvecraft_geom::{Plane, Seg2, Vec2, Vec3};
use truck_modeling as mt;
#[derive(Clone, Copy, Debug)]
struct Interval {
lo: f64,
hi: f64,
}
impl Interval {
fn enclosing(value: f64, error: f64) -> Option<Self> {
if !value.is_finite() || !error.is_finite() || error < 0. {
return None;
}
// The explicit extra arithmetic allowance also covers subtraction and
// accumulation; do not round an uncertainty inward at its endpoints.
let padding = 16. * f64::EPSILON * (value.abs() + error + 1.);
Some(Self { lo: value - error - padding, hi: value + error + padding })
}
fn add(self, other: Self) -> Option<Self> {
Self::enclosing((self.lo + self.hi + other.lo + other.hi) * 0.5, (self.hi - self.lo + other.hi - other.lo) * 0.5)
}
}
/// Signed Green integral of a fitted segment with the *whole-curve* spatial
/// uncertainty. Angular/longitudinal monotonicity is mandatory: a curve in a
/// thin corridor could otherwise retrace it arbitrarily often.
fn green_interval(segment: &Seg2, tolerance: f64) -> Option<Interval> {
let (length, a, b) = (segment.length(), segment.start(), segment.end());
let extra = match *segment {
Seg2::Line { .. } => 0.,
Seg2::Arc { center, radius, .. } => center.len() + radius,
_ => return None,
};
let scale = length + a.len() + b.len() + extra;
// A monotone line differs from its chord by a strip; a radial-monotone
// arc contributes 1/2 integral r(theta)^2 dtheta. The endpoint wedges,
// coordinate uncertainty, and angular endpoint uncertainty are included
// by this conservative bound (rather than treating the fitted arc exact).
let error = 8. * tolerance * scale + 64. * tolerance * tolerance + 1024. * f64::EPSILON * (1. + scale * scale);
Interval::enclosing(segment.area_term(), error)
}
struct CapLoop {
segments: Vec<Seg2>,
area: Interval,
}
/// Cross-product cancellation belongs to the surface representation, not
/// just the later incidence dot product. Reject ill-conditioned plane bases.
fn plane_normal(p: &mt::Plane) -> Option<(Vec3, Vec3)> {
let u = p.u_axis();
let v = p.v_axis();
let u = Vec3::new(u.x, u.y, u.z);
let v = Vec3::new(v.x, v.y, v.z);
let cross = u.cross(v);
let length = cross.len();
if !length.is_finite() || length <= 1e-6 * u.len() * v.len() {
return None;
}
let n = cross.normalized()?;
let ce = Vec3::new((u.y * v.z).abs() + (u.z * v.y).abs(), (u.z * v.x).abs() + (u.x * v.z).abs(), (u.x * v.y).abs() + (u.y * v.x).abs())
* (256. * f64::EPSILON);
let ne = ce * (1. / length) + Vec3::new(n.x.abs(), n.y.abs(), n.z.abs()) * (ce.len() / length);
(ne.x.is_finite() && ne.y.is_finite() && ne.z.is_finite()).then_some((n, ne))
}
/// A whole positive control-net bound for the wall's projected variation.
/// A sampled normal or a vertex-only test is deliberately insufficient.
fn wall_error(surface: &mt::Surface, d: Vec3, height: f64, work: &mut usize) -> Option<f64> {
if let mt::Surface::Plane(p) = surface {
let (n, ne) = plane_normal(p)?;
let terms = (n.x * d.x).abs() + (n.y * d.y).abs() + (n.z * d.z).abs();
let error = ne.x * d.x.abs() + ne.y * d.y.abs() + ne.z * d.z.abs() + 1024. * f64::EPSILON * terms;
return Some((n.dot(d).abs() + error) * height * 2.);
}
if let mt::Surface::RevolutedCurve(p) = surface {
let r = p.entity();
let mt::Curve::Line(line) = r.entity_curve() else { return None };
let axis = r.axis();
let meridian = line.1 - line.0;
let a = Vec3::new(axis.x, axis.y, axis.z).normalized()?;
let m = Vec3::new(meridian.x, meridian.y, meridian.z).normalized()?;
let placed = *p.transform() * mt::Vector4::new(axis.x, axis.y, axis.z, 0.);
if placed.w != 0. {
return None;
}
let pa = Vec3::new(placed.x, placed.y, placed.z).normalized()?;
return Some((a.cross(m).len() + pa.cross(d).len()) * height * 4.);
}
let (rows, cols) = match surface {
mt::Surface::BSplineSurface(s) => (s.control_points().len(), s.control_points().first()?.len()),
mt::Surface::NurbsSurface(s) => (s.control_points().len(), s.control_points().first()?.len()),
_ => return None,
};
let count = rows.checked_mul(cols)?;
let rectangular = match surface {
mt::Surface::BSplineSurface(s) => s.control_points().iter().all(|r| r.len() == cols),
mt::Surface::NurbsSurface(s) => s.control_points().iter().all(|r| r.len() == cols),
_ => false,
};
if rows < 2 || cols < 2 || count > 16_384 || !rectangular {
return None;
}
*work = work.checked_sub(count.checked_mul(4)?)?;
let points: Vec<Vec<(Vec3, f64)>> = match surface {
mt::Surface::BSplineSurface(s) => s.control_points().iter().map(|r| r.iter().map(|p| (from_p3(*p), 1.)).collect()).collect(),
mt::Surface::NurbsSurface(s) => {
s.control_points().iter().map(|r| r.iter().map(|p| (Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w), p.w)).collect()).collect()
}
_ => return None,
};
if points.iter().any(|r| r.len() != cols) || points.iter().flatten().any(|(p, w)| !p.is_finite() || !w.is_finite() || *w <= 0.) {
return None;
}
let max_w = points.iter().flatten().map(|p| p.1).fold(0., f64::max);
let min_w = points.iter().flatten().map(|p| p.1 / max_w).fold(1., f64::min);
if min_w < 1e-6 {
return None;
}
let magnitude = points.iter().flatten().map(|p| p.0.len()).fold(1., f64::max);
let mut best = f64::INFINITY;
for transpose in [false, true] {
let at = |i: usize, j: usize| if transpose { points.get(j)?.get(i).copied() } else { points.get(i)?.get(j).copied() };
let (groups, length) = if transpose { (cols, rows) } else { (rows, cols) };
let mut variation: f64 = 0.;
let mut rank_error: f64 = 0.;
let mut monotone = true;
let mut axial_sign = 0.;
for i in 0..groups {
let (base, bw) = at(i, 0)?;
let mut previous = base.dot(d);
for j in 0..length {
let (point, w) = at(i, j)?;
let step = point.dot(d) - previous;
previous = point.dot(d);
if step.abs() > 2048. * f64::EPSILON * magnitude {
if axial_sign * step < 0. {
monotone = false;
}
axial_sign = step.signum();
}
variation = variation.max((point - base).cross(d).len());
let lhs = w / max_w * (at(0, 0)?.1 / max_w);
let rhs = bw / max_w * (at(0, j)?.1 / max_w);
rank_error = rank_error.max((lhs - rhs).abs());
}
if (at(i, length - 1)?.0 - base).dot(d).abs() < height * 0.5 {
monotone = false;
}
}
if !monotone || axial_sign == 0. {
continue;
}
// Positive weights turn the control hull into a positional bound.
// Rank-one weight error perturbs the normalized convex coefficients;
// the positive minimum weight prevents an unbounded denominator.
let error = 2. * variation + 8. * magnitude * rank_error / min_w.powi(2) + 2048. * f64::EPSILON * magnitude / min_w.powi(2);
best = best.min(error);
}
best.is_finite().then_some(best)
}
fn segment_same(a: &Seg2, b: &Seg2, tolerance: f64) -> bool {
match (*a, *b) {
(Seg2::Line { a, b }, Seg2::Line { a: c, b: d }) => {
(a.dist(c) <= tolerance && b.dist(d) <= tolerance) || (a.dist(d) <= tolerance && b.dist(c) <= tolerance)
}
(Seg2::Arc { center, radius, start, sweep }, Seg2::Arc { center: c, radius: r, start: t, sweep: s }) => {
center.dist(c) <= tolerance
&& (radius - r).abs() <= tolerance
&& (sweep.abs() - s.abs()).abs() * radius <= tolerance
&& ((Vec2::new(start.cos(), start.sin()) * radius).dist(Vec2::new(t.cos(), t.sin()) * r) <= tolerance
|| (Vec2::new(start.cos(), start.sin()) * radius).dist(Vec2::new((t + s).cos(), (t + s).sin()) * r) <= tolerance)
}
_ => false,
}
}
fn wall_supports_cap(surface: &mt::Surface, edge: &mt::Edge, plane: &Plane, levels: (f64, f64), tol: f64, work: &mut usize) -> Option<bool> {
use mt::{BoundedCurve, ParametricCurve};
let cap = crate::prism_curve::segment(edge, plane, tol, work)?;
if let mt::Surface::Plane(p) = surface {
let (n, ne) = plane_normal(p)?;
let origin = from_p3(p.origin());
for s in cap {
let Seg2::Line { a, b } = s else { return Some(false) };
for q in [a, b] {
let point = plane.to_world(q) + plane.normal() * plane.height(from_p3(edge.front().point()));
let delta = point - origin;
let error =
1024. * f64::EPSILON * (1. + point.len() + origin.len()) + delta.x.abs() * ne.x + delta.y.abs() * ne.y + delta.z.abs() * ne.z;
if delta.dot(n).abs() + error > tol * 8. {
return Some(false);
}
}
}
return Some(true);
}
if let mt::Surface::RevolutedCurve(processor) = surface {
let r = processor.entity();
let mt::Curve::Line(line) = r.entity_curve() else { return None };
let o = from_p3(r.origin());
let axis = r.axis();
let axis = Vec3::new(axis.x, axis.y, axis.z).normalized()?;
let delta = from_p3(line.0) - o;
let radial = (delta - axis * delta.dot(axis)).len();
let u = axis.any_perp();
let v = axis.cross(u);
let matrix = *processor.transform();
let place = |p: Vec3, w: f64| {
let q = matrix * mt::Vector4::new(p.x, p.y, p.z, w);
(q.w == w).then_some(Vec3::new(q.x, q.y, q.z))
};
let center = plane.to_local(place(o, 1.)?);
let ha = plane.height(place(from_p3(line.0), 1.)?);
let hb = plane.height(place(from_p3(line.1), 1.)?);
if ha.min(hb) > levels.0 + tol * 8. || ha.max(hb) < levels.1 - tol * 8. {
return Some(false);
}
let (pu, pv) = (place(u, 0.)?, place(v, 0.)?);
let radius = pu.len() * radial;
if (pu.len() - pv.len()).abs() * radial > tol
|| pu.dot(pv).abs() * radial > tol
|| pu.dot(plane.normal()).abs() * radial > tol
|| pv.dot(plane.normal()).abs() * radial > tol
{
return None;
}
return Some(cap.iter().all(|s| matches!(*s,Seg2::Arc {center:c,radius:r,..} if c.dist(center)+(r-radius).abs()<=tol*8.)));
}
let (rows, cols) = match surface {
mt::Surface::BSplineSurface(s) => (s.control_points().len(), s.control_points().first()?.len()),
mt::Surface::NurbsSurface(s) => (s.control_points().len(), s.control_points().first()?.len()),
_ => return None,
};
*work = work.checked_sub(rows.checked_mul(cols)?.checked_mul(4)?)?;
let at = |i: usize, j: usize| -> Option<(Vec3, f64)> {
match surface {
mt::Surface::BSplineSurface(s) => s.control_points().get(i)?.get(j).map(|p| (from_p3(*p), 1.)),
mt::Surface::NurbsSurface(s) => s.control_points().get(i)?.get(j).map(|p| (Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w), p.w)),
_ => None,
}
};
for transpose in [false, true] {
let point = |i, j| if transpose { at(j, i) } else { at(i, j) };
let (groups, length) = if transpose { (cols, rows) } else { (rows, cols) };
// The first measurement subset uses a degree-one axial ruling. Its
// positive rational interpolation is monotone and reaches both caps;
// projected incidence alone would miss an axially displaced support.
if !(2..=4096).contains(&groups) || length != 2 {
continue;
}
let axial_knots = match surface {
mt::Surface::BSplineSurface(s) => {
if transpose {
s.uknot_vec()
} else {
s.vknot_vec()
}
}
mt::Surface::NurbsSurface(s) => {
if transpose {
s.uknot_vec()
} else {
s.vknot_vec()
}
}
_ => return None,
};
if axial_knots.len() != 4
|| axial_knots.first() != axial_knots.get(1)
|| axial_knots.get(2) != axial_knots.last()
|| axial_knots.get(1)? >= axial_knots.get(2)?
{
continue;
}
let mut projected = Vec::new();
let mut valid = true;
let mut increasing = None;
for i in 0..groups {
let (base, w) = point(i, 0)?;
let h0 = plane.height(base);
let h1 = plane.height(point(i, 1)?.0);
if h0.min(h1) > levels.0 + tol * 8. || h0.max(h1) < levels.1 - tol * 8. || increasing.is_some_and(|s| s != (h1 > h0)) {
valid = false;
break;
}
increasing = Some(h1 > h0);
let p = plane.to_local(base);
for j in 0..length {
if plane.to_local(point(i, j)?.0).dist(p) > tol * 8. {
valid = false;
break;
}
}
let q = plane.to_world(p);
projected.push(mt::Vector4::new(q.x * w, q.y * w, q.z * w, w));
}
if !valid {
continue;
}
let knots = match surface {
mt::Surface::BSplineSurface(s) => {
if transpose {
s.vknot_vec()
} else {
s.uknot_vec()
}
}
mt::Surface::NurbsSurface(s) => {
if transpose {
s.vknot_vec()
} else {
s.uknot_vec()
}
}
_ => return None,
};
if knots.len() > 8192 {
return None;
}
*work = work.checked_sub(knots.len())?;
let c = mt::Curve::NurbsCurve(mt::NurbsCurve::new(mt::BSplineCurve::try_new(knots.clone(), projected).ok()?));
let (a, b) = c.range_tuple();
let support = mt::Edge::new(&mt::Vertex::new(c.subs(a)), &mt::Vertex::new(c.subs(b)), c);
let Some(fit) = crate::prism_curve::segment(&support, plane, tol, work) else {
continue;
};
let fit_loop = CapLoop { segments: fit.clone(), area: Interval::enclosing(0., 0.)? };
if let Some((center, radius)) = circle(&fit_loop, tol * 8.)
&& cap.iter().all(|s| matches!(*s,Seg2::Arc {center:c,radius:r,..} if c.dist(center)+(r-radius).abs()<=tol*8.))
{
return Some(true);
}
let same = loops_same(
&CapLoop { segments: cap.clone(), area: Interval::enclosing(0., 0.)? },
&CapLoop { segments: fit, area: Interval::enclosing(0., 0.)? },
tol * 8.,
work,
)?;
if same {
return Some(true);
}
}
Some(false)
}
fn circle(lp: &CapLoop, tolerance: f64) -> Option<(Vec2, f64)> {
let Seg2::Arc { center, radius, sweep: first, .. } = *lp.segments.first()? else { return None };
let mut sweep = 0.;
for s in &lp.segments {
let Seg2::Arc { center: c, radius: r, sweep: a, .. } = *s else { return None };
if c.dist(center) > tolerance || (r - radius).abs() > tolerance || a * first <= 0. {
return None;
}
sweep += a;
}
((sweep.abs() - std::f64::consts::TAU).abs() * radius <= tolerance).then_some((center, radius))
}
fn loops_same(a: &CapLoop, b: &CapLoop, tolerance: f64, work: &mut usize) -> Option<bool> {
*work = work.checked_sub(a.segments.len().checked_add(b.segments.len())?)?;
if (a.area.lo > 0.) != (b.area.lo > 0.) {
return Some(false);
}
if let (Some((ca, ra)), Some((cb, rb))) = (circle(a, tolerance), circle(b, tolerance)) {
return Some(ca.dist(cb) + (ra - rb).abs() <= tolerance);
}
if a.segments.len() != b.segments.len() {
return Some(false);
}
*work = work.checked_sub(a.segments.len().checked_mul(b.segments.len())?)?;
let mut used = vec![false; b.segments.len()];
for s in &a.segments {
let mut match_index = None;
for (i, t) in b.segments.iter().enumerate() {
if !used.get(i).copied()? && segment_same(s, t, tolerance) {
if match_index.is_some() {
return None;
}
match_index = Some(i);
}
}
let Some(i) = match_index else { return Some(false) };
*used.get_mut(i)? = true;
}
Some(true)
}
struct Certificate {
loops: Vec<CapLoop>,
volume: Interval,
lo: f64,
hi: f64,
}
fn preflight_body(body: &crate::Body) -> Option<()> {
if body.solid.boundaries().len() != 1 || body.mesh.is_some() {
return None;
}
let mut faces = 16_384usize;
let mut edges = 65_536usize;
for face in body.solid.face_iter() {
faces = faces.checked_sub(1)?;
// Borrow absolute wires: the oriented iterator would clone a hostile
// oversized wire before its edge count has been checked.
for wire in face.absolute_boundaries() {
for _ in wire.edge_iter() {
edges = edges.checked_sub(1)?;
}
}
}
Some(())
}
/// Initial material scope: a convex straight outer or a circle, with separated
/// circular holes. Wider profiles must acquire their own separation proof.
fn separated_material(loops: &[CapLoop], clearance: f64, work: &mut usize) -> Option<()> {
let outer = loops.iter().find(|l| l.area.lo > 0.)?;
if loops.iter().filter(|l| l.area.lo > 0.).count() != 1 {
return None;
}
let outer_circle = circle(outer, clearance);
if outer_circle.is_none() {
*work = work.checked_sub(outer.segments.len().checked_mul(outer.segments.len())?)?;
for s in &outer.segments {
let Seg2::Line { a, b } = *s else { return None };
let delta = b - a;
if delta.len() <= clearance {
return None;
}
for t in &outer.segments {
if delta.cross(t.start() - a) < -clearance * delta.len() {
return None;
}
}
}
let mut turn = 0.;
let mut angular_error = 0.;
for (i, s) in outer.segments.iter().enumerate() {
let Seg2::Line { a, b } = *s else {
return None;
};
let next = outer.segments.get((i + 1) % outer.segments.len())?;
let x = b - a;
let y = next.end() - next.start();
turn += x.cross(y).atan2(x.dot(y));
angular_error += clearance * 8. / x.len() + 256. * f64::EPSILON;
for (j, t) in outer.segments.iter().enumerate() {
if i != j && a.dist(t.start()) <= clearance {
return None;
}
}
}
if angular_error > 1e-3 || (turn - std::f64::consts::TAU).abs() > angular_error {
return None;
}
}
let mut holes = Vec::new();
for lp in loops {
if std::ptr::eq(lp, outer) {
continue;
}
if lp.area.hi >= 0. {
return None;
}
let (center, radius) = circle(lp, clearance)?;
if let Some((c, r)) = outer_circle {
if center.dist(c) + radius + clearance >= r {
return None;
}
} else {
*work = work.checked_sub(outer.segments.len())?;
for s in &outer.segments {
let Seg2::Line { a, b } = *s else { return None };
if (b - a).cross(center - a) / (b - a).len() <= radius + clearance {
return None;
}
}
}
*work = work.checked_sub(holes.len())?;
for (c, r) in &holes {
if center.dist(*c) <= radius + r + clearance {
return None;
}
}
holes.push((center, radius));
}
Some(())
}
fn certificate(body: &crate::Body, plane: &Plane, tol: f64) -> Option<Certificate> {
preflight_body(body)?;
let d = plane.normal();
let body_size = body.size();
let mut remaining = 16_384usize;
let mut caps = Vec::new();
let mut walls = Vec::new();
let mut edge_uses = 65_536usize;
for face in body.solid.face_iter() {
remaining = remaining.checked_sub(1)?;
for _ in face.edge_iter() {
edge_uses = edge_uses.checked_sub(1)?;
}
let surface = face.oriented_surface();
if let mt::Surface::Plane(p) = &surface {
let n = p.normal();
let n = Vec3::new(n.x, n.y, n.z).normalized()?;
if n.dot(d).abs() > 1. - 1e-12 {
if n.cross(d).len() * body_size > tol {
return None;
}
// A planar support's parameter origin may be arbitrarily far
// from its trimmed face. Use actual boundary geometry only.
let level = plane.height(from_p3(face.vertex_iter().next()?.point()));
caps.push((face, level, n.dot(d) > 0.));
if caps.len() > 2 {
return None;
}
continue;
}
}
walls.push((face, surface));
}
if caps.len() != 2 {
return None;
}
let top = caps.iter().find(|c| c.2)?;
let bottom = caps.iter().find(|c| !c.2)?;
let height = top.1 - bottom.1;
if height <= tol || !height.is_finite() {
return None;
}
for cap in [top, bottom] {
let mt::Surface::Plane(support) = cap.0.oriented_surface() else {
return None;
};
let origin = from_p3(support.origin());
let (n, ne) = plane_normal(&support)?;
for vertex in cap.0.vertex_iter() {
let point = from_p3(vertex.point());
let delta = point - origin;
let terms = (delta.x * n.x).abs() + (delta.y * n.y).abs() + (delta.z * n.z).abs();
let error = 1024. * f64::EPSILON * terms + delta.x.abs() * ne.x + delta.y.abs() * ne.y + delta.z.abs() * ne.z;
if delta.dot(n).abs() + error > tol * 2. || (plane.height(point) - cap.1).abs() > tol * 2. {
return None;
}
}
}
let mut work = 2_000_000;
let loops = top.0.boundaries().iter().map(|w| cap_loop(w, plane, tol, &mut work)).collect::<Option<Vec<_>>>()?;
let lower = bottom.0.boundaries().iter().map(|w| cap_loop(w, plane, tol, &mut work)).collect::<Option<Vec<_>>>()?;
if loops.len() != lower.len() || loops.is_empty() || loops.len() > 8192 {
return None;
}
let mut pair_work = 16_000_000;
for a in &loops {
// Bottom cap has the opposite material orientation.
let reverse = CapLoop { segments: a.segments.clone(), area: Interval { lo: -a.area.hi, hi: -a.area.lo } };
let mut matches = 0usize;
for b in &lower {
if loops_same(&reverse, b, tol * 8., &mut pair_work)? {
matches += 1;
}
}
if matches != 1 {
return None;
}
}
let mut controls = 262_144;
// Include cap fitting, support fitting and the <=8tol support-to-cap
// comparison, not just the control-net's projected ruling variation.
let mut corridor = tol * 16.;
let cap_edges = top.0.edge_iter().count().checked_add(bottom.0.edge_iter().count())?;
pair_work = pair_work.checked_sub((65_536 - edge_uses).checked_mul(cap_edges)?)?;
for (face, surface) in &walls {
let variation = wall_error(surface, d, height, &mut controls)?;
if variation > tol * 8. {
return None;
}
corridor = corridor.max(tol * 16. + variation);
if corridor > tol * 24. {
return None;
}
// Every wall must end on these actual cap wires. This is topology
// correspondence, not nearest geometry or stale face provenance.
let mut upper = 0usize;
let mut lower = 0usize;
for edge in face.edge_iter() {
if top.0.edge_iter().any(|e| e.id() == edge.id()) {
if !wall_supports_cap(surface, &edge, plane, (bottom.1, top.1), tol, &mut work)? {
return None;
}
upper += 1;
continue;
}
if bottom.0.edge_iter().any(|e| e.id() == edge.id()) {
if !wall_supports_cap(surface, &edge, plane, (bottom.1, top.1), tol, &mut work)? {
return None;
}
lower += 1;
continue;
}
let mt::Curve::Line(line) = edge.curve() else { return None };
if from_p3(line.0).dist(from_p3(edge.absolute_front().point())) > tol || from_p3(line.1).dist(from_p3(edge.absolute_back().point())) > tol
{
return None;
}
let delta = from_p3(line.1) - from_p3(line.0);
if delta.cross(d).len() > corridor || delta.dot(d).abs() < height - tol * 8. {
return None;
}
}
if upper == 0 || upper != lower {
return None;
}
}
let mut area = Interval::enclosing(0., 0.)?;
let mut length = 0.;
let mut count = 0usize;
for lp in &loops {
area = area.add(lp.area)?;
for edge in &lp.segments {
length += edge.length();
count = count.checked_add(1)?;
}
}
if count > 8192 || area.lo <= 0. {
return None;
}
separated_material(&loops, corridor * 16., &mut pair_work)?;
// Each boundary's monotone radial/linear corridor contributes at most a
// two-sided strip plus four endpoint disks. Unknown intersecting or
// touching corridors will be refused by the separation check below.
let strip = 2. * length * corridor + 4. * std::f64::consts::PI * count as f64 * corridor * corridor;
// Each cap's vertices are within 2tol and its whole rational edge
// control heights within another tol; include both cap corridors.
let h_error = tol * 8.;
if area.lo <= strip || height <= h_error {
return None;
}
let lo = (area.lo - strip) * (height - h_error);
let hi = (area.hi + strip) * (height + h_error);
let volume = Interval::enclosing((lo + hi) * 0.5, (hi - lo) * 0.5)?;
if volume.lo <= 0. || volume.hi - volume.lo > 0.001 {
return None;
}
Some(Certificate { loops, volume, lo: bottom.1, hi: top.1 })
}
/// Independently prove equality of the supported current uniform prisms.
/// `None` means unproved, including budget exhaustion; it never grants a copy.
pub fn certified_prism_equivalent(a: &crate::Body, b: &crate::Body) -> crate::Result<Option<bool>> {
crate::guard("certified prism comparison", || {
let result = (|| {
preflight_body(a)?;
preflight_body(b)?;
if !a.size().is_finite() || !b.size().is_finite() {
return None;
}
let size = a.size().max(b.size()).max(1.);
let tol = size * 1e-10;
let identity_tol = size * 1e-7;
let origin = a.solid.vertex_iter().next().map(|v| from_p3(v.point()))?;
let mut candidates = Vec::new();
let mut remaining = 16_384usize;
for f in a.solid.face_iter() {
remaining = remaining.checked_sub(1)?;
if let mt::Surface::Plane(p) = f.oriented_surface() {
let n = p.normal();
let n = Vec3::new(n.x, n.y, n.z).normalized()?;
if !candidates.iter().any(|d: &Vec3| d.dot(n).abs() > 1. - 1e-12) {
candidates.push(n);
if candidates.len() == 8 {
break;
}
}
}
}
for d in candidates {
let plane = Plane::from_normal(origin, d)?;
let Some(ca) = certificate(a, &plane, tol) else { continue };
let Some(cb) = certificate(b, &plane, tol) else { continue };
if (ca.lo - cb.lo).abs() > identity_tol || (ca.hi - cb.hi).abs() > identity_tol || ca.loops.len() != cb.loops.len() {
return Some(false);
}
let mut work = 16_000_000;
let mut used = vec![false; cb.loops.len()];
for lp in &ca.loops {
let mut matched = None;
for (i, other) in cb.loops.iter().enumerate() {
if !used.get(i).copied()? && loops_same(lp, other, identity_tol, &mut work)? {
if matched.is_some() {
return None;
}
matched = Some(i);
}
}
let Some(i) = matched else { return Some(false) };
*used.get_mut(i)? = true;
}
let delta = (ca.volume.hi - cb.volume.lo).abs().max((cb.volume.hi - ca.volume.lo).abs());
return Some(delta <= 1e-5 * (1. + ca.volume.lo.abs()));
}
None
})();
Ok(result)
})
}
fn cap_loop(wire: &mt::Wire, plane: &Plane, tolerance: f64, work: &mut usize) -> Option<CapLoop> {
let mut segments = Vec::new();
let mut area = Interval::enclosing(0., 0.)?;
let mut ids = std::collections::HashSet::new();
for edge in wire.edge_iter() {
if !ids.insert(edge.id()) {
return None;
}
let certified = crate::prism_curve::segment(edge, plane, tolerance, work)?;
if segments.len().checked_add(certified.len())? > 8192 {
return None;
}
for segment in &certified {
area = area.add(green_interval(segment, tolerance)?)?;
}
segments.extend(certified);
}
if segments.is_empty() || !wire.is_closed() {
return None;
}
// Curve endpoints already agree with actual shared vertices in the
// whole-span proof. Check the fitted representation's continuity too.
for i in 0..segments.len() {
if segments.get(i)?.end().dist(segments.get((i + 1) % segments.len())?.start()) > 2. * tolerance {
return None;
}
}
Some(CapLoop { segments, area })
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use mt::builder;
fn drilled(radius: f64, shift: f64) -> crate::Body {
use solvecraft_geom::{Loop2, Region2};
let region = Region2 {
outer: Loop2::polygon(&[Vec2::ZERO, Vec2::new(20., 0.), Vec2::new(20., 20.), Vec2::new(0., 20.)]),
holes: vec![Loop2::circle(Vec2::new(10. + shift, 10.), radius)],
};
crate::extrude(&Plane::XY, &[region], 0., 10.).unwrap().remove(0)
}
#[test]
fn actual_drilled_prism_and_step_reopen_are_certified_without_history() {
let source = drilled(6., 0.);
let before = crate::step_export(&[&source], "source").unwrap();
let imported = crate::step_import(&before).unwrap();
let copy = &imported.bodies[0].body;
let exact = 4000. - 360. * std::f64::consts::PI;
for body in [&source, copy] {
let cap = certificate(body, &Plane::XY, 3e-9).expect("actual complete current cap and walls");
assert!(cap.volume.lo <= exact && cap.volume.hi >= exact, "{:?}, expected {exact}", cap.volume);
assert!(cap.volume.hi - cap.volume.lo <= 0.001);
}
assert_eq!(certified_prism_equivalent(&source, copy).unwrap(), Some(true));
let wider = drilled(6.0001, 0.);
let a = certificate(&source, &Plane::XY, 3e-9).unwrap();
let b = certificate(&wider, &Plane::XY, 3e-9).unwrap();
assert!(a.volume.lo - b.volume.hi > 1e-5 * (1. + a.volume.hi));
assert_eq!(certified_prism_equivalent(&source, &wider).unwrap(), Some(false));
let shifted = drilled(6., 0.0001);
let same_volume = certificate(&shifted, &Plane::XY, 3e-9).unwrap();
assert!(same_volume.volume.lo <= exact && same_volume.volume.hi >= exact);
assert_eq!(certified_prism_equivalent(&source, &shifted).unwrap(), Some(false));
assert_eq!(crate::step_export(&[&source], "source").unwrap(), before);
}
#[test]
fn poorly_conditioned_plane_walls_do_not_get_certificates() {
let origin = mt::Point3::new(0., 0., 0.);
let u = mt::Point3::new(1., 0., 0.);
let v = mt::Point3::new(1., 0., 1e-9);
let surface = mt::Surface::Plane(mt::Plane::new(origin, u, v));
let edge = builder::line(&mt::Vertex::new(origin), &mt::Vertex::new(u));
let mut work = 2_000_000;
assert!(wall_error(&surface, Vec3::Z, 10., &mut work).is_none());
assert!(wall_supports_cap(&surface, &edge, &Plane::XY, (0., 10.), 1e-9, &mut work).is_none());
let normal = plane_normal(&mt::Plane::new(origin, u, mt::Point3::new(0., 0., 1.))).unwrap();
assert!(normal.0.dist(-Vec3::Y) < 1e-12);
assert!(normal.1.len() > 0.);
}
#[test]
fn rotated_current_boolean_prism_step_and_wrong_surface_incidence() {
let stock = crate::box_solid(Vec3::ZERO, Vec3::new(20., 20., 10.)).unwrap();
let cutter = crate::cylinder(Vec3::new(10., 10., 0.), Vec3::Z, 6., 10.).unwrap();
let source = crate::boolean(&stock, &cutter, crate::BoolOp::Cut).unwrap().unwrap();
let moved = crate::transform(&source, Vec3::new(30., 20., 10.), Vec3::ZERO, Vec3::new(1., 2., 3.), 0.7).unwrap();
let saved = crate::step_export(&[&moved], "actual boolean").unwrap();
let imported = crate::step_import(&saved).unwrap();
assert_eq!(certified_prism_equivalent(&moved, &imported.bodies[0].body).unwrap(), Some(true));
// Preserve every current cap/edge ID while replacing only the curved
// wall support with radius five. Topology incidence alone must fail.
let wrong = drilled(5., 0.);
let wrong_surface = wrong.solid.face_iter().find(|f| !matches!(f.surface(), mt::Surface::Plane(_))).unwrap().surface();
let faces = source
.solid
.face_iter()
.map(|f| {
if matches!(f.surface(), mt::Surface::Plane(_)) {
return f.clone();
}
let mut face = mt::Face::new(f.absolute_boundaries().clone(), wrong_surface.clone());
if !f.orientation() {
face.invert();
}
face
})
.collect::<Vec<_>>();
let invalid = crate::Body::new(crate::body::Solid::try_new(vec![faces.into()]).unwrap()).unwrap();
assert!(certificate(&invalid, &Plane::XY, 3e-9).is_none());
let actual = source.solid.face_iter().find(|f| !matches!(f.surface(), mt::Surface::Plane(_))).unwrap().surface();
let shifted = mt::Transformed::transformed(&actual, mt::Matrix4::from_translation(mt::Vector3::new(0., 0., 1000.)));
let top = source.solid.face_iter().find(|f| matches!(f.oriented_surface(),mt::Surface::Plane(p) if p.normal().z>0.9)).unwrap();
let arc = top.edge_iter().find(|e| !matches!(e.curve(), mt::Curve::Line(_))).unwrap();
assert_ne!(wall_supports_cap(&shifted, &arc, &Plane::XY, (0., 10.), 3e-9, &mut 2_000_000), Some(true));
}
#[test]
fn cap_support_origin_is_not_a_trimmed_face_height() {
let source = drilled(6., 0.);
let faces = source
.solid
.face_iter()
.map(|f| {
let mt::Surface::Plane(p) = f.surface() else {
return f.clone();
};
if p.normal().z.abs() < 0.9 {
return f.clone();
}
let origin = p.origin() + p.u_axis() * 1e8;
let plane = mt::Surface::Plane(mt::Plane::new(origin, origin + p.u_axis(), origin + p.v_axis()));
let mut face = mt::Face::new(f.absolute_boundaries().clone(), plane);
if !f.orientation() {
face.invert();
}
face
})
.collect::<Vec<_>>();
let same = crate::Body::new(crate::body::Solid::try_new(vec![faces.into()]).unwrap()).unwrap();
assert_eq!(certified_prism_equivalent(&source, &same).unwrap(), Some(true));
assert!(
loops_same(
&cap_loop(&source.solid.face_iter().last().unwrap().boundaries()[0], &Plane::XY, 3e-9, &mut 2_000_000).unwrap(),
&cap_loop(&source.solid.face_iter().last().unwrap().boundaries()[0], &Plane::XY, 3e-9, &mut 2_000_000).unwrap(),
1e-6,
&mut 0
)
.is_none()
);
}
#[test]
fn winding_retracing_and_uncertain_material_do_not_get_certificates() {
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 6., start: 0., sweep: std::f64::consts::TAU };
let retraced = CapLoop { segments: vec![arc, arc.reversed(), arc], area: Interval::enclosing(36. * std::f64::consts::PI, 1e-8).unwrap() };
assert!(circle(&retraced, 1e-6).is_none());
let region = solvecraft_geom::Loop2::polygon(&[Vec2::ZERO, Vec2::new(20., 0.), Vec2::new(20., 20.), Vec2::new(0., 20.)]);
let doubled = CapLoop { segments: region.segs.iter().chain(&region.segs).copied().collect(), area: Interval::enclosing(800., 1e-8).unwrap() };
assert!(separated_material(&[doubled], 1e-6, &mut 16_000_000).is_none());
let thin = crate::box_solid(Vec3::ZERO, Vec3::new(20., 20., 1e-5)).unwrap();
assert!(certificate(&thin, &Plane::XY, 3e-6).is_none());
}
#[test]
fn current_rational_circle_green_interval_encloses_independent_area() {
let r = 6.;
let vertices =
[mt::Point3::new(r, 0., 0.), mt::Point3::new(0., r, 0.), mt::Point3::new(-r, 0., 0.), mt::Point3::new(0., -r, 0.)].map(builder::vertex);
let mut edges = Vec::new();
for i in 0..4 {
let angle = (i as f64 + 0.5) * std::f64::consts::FRAC_PI_2;
edges.push(builder::circle_arc(&vertices[i], &vertices[(i + 1) % 4], mt::Point3::new(r * angle.cos(), r * angle.sin(), 0.)));
}
let wire: mt::Wire = edges.into();
let cap = cap_loop(&wire, &Plane::XY, 3e-9, &mut 2_000_000).unwrap();
let exact = std::f64::consts::PI * r * r;
assert!(cap.area.lo <= exact && cap.area.hi >= exact, "{:?}", cap.area);
assert!((cap.area.hi - cap.area.lo) * 10. < 0.001);
assert_eq!(cap.segments.len(), 4);
let reversed = cap_loop(&wire.inverse(), &Plane::XY, 3e-9, &mut 2_000_000).unwrap();
assert!(reversed.area.lo <= -exact && reversed.area.hi >= -exact);
}
}
+354
View File
@@ -0,0 +1,354 @@
//! Display-only tangent joins. Unknown geometry retains its existing presentation.
use solvecraft_geom::{Mesh, Vec3};
use truck_modeling as mt;
use crate::body::{Body, from_p3};
const MAX_FACES: usize = 50_000;
const MAX_EDGES: usize = 200_000;
const WORK: usize = 2_000_000;
const ANGLE: f64 = 1e-8;
#[derive(Clone, Copy)]
struct Boundary {
a: Vec3,
b: Vec3,
normal: Vec3,
error: f64,
}
fn charge(work: &mut usize, amount: usize) -> Option<()> {
*work = work.checked_sub(amount)?;
Some(())
}
fn roundoff(points: &[Vec3]) -> f64 {
points.iter().flat_map(|p| [p.x.abs(), p.y.abs(), p.z.abs()]).fold(1., f64::max) * f64::EPSILON * 64.
}
/// Only a clamped Bezier profile swept by one common linear translation.
/// Its whole ruling normal is bounded from the linear cross derivative, not sampled.
fn ruled(surface: &mt::Surface, orientation: bool, work: &mut usize) -> Option<[Boundary; 2]> {
let mt::Surface::BSplineSurface(surface) = surface else { return None };
let controls = surface.control_points();
let rows = controls.len();
let cols = controls.first()?.len();
if !(2..=33).contains(&rows) || !(2..=33).contains(&cols) || controls.iter().any(|row| row.len() != cols) {
return None;
}
charge(work, rows.checked_mul(cols)?.checked_mul(4)?)?;
let (ku, kv) = surface.knot_vecs();
let clamped = |knots: &mt::KnotVec, count: usize| {
let (Some(first), Some(last)) = (knots.first(), knots.last()) else { return false };
knots.len() == count * 2
&& first.is_finite()
&& last.is_finite()
&& first < last
&& knots.iter().take(count).all(|k| k == first)
&& knots.iter().skip(count).all(|k| k == last)
};
if !clamped(ku, rows) || !clamped(kv, cols) {
return None;
}
for transpose in [false, true] {
let (count, axial) = if transpose { (cols, rows) } else { (rows, cols) };
if axial != 2 {
continue;
}
let at = |i: usize, j: usize| {
let p = if transpose { controls.get(j)?.get(i)? } else { controls.get(i)?.get(j)? };
let p = from_p3(*p);
p.is_finite().then_some(p)
};
let a = at(0, 0)?;
let b = at(0, 1)?;
let ruling = b - a;
if ruling.len() < 1e-6 {
continue;
}
let mut valid = true;
for i in 0..count {
let p = at(i, 0)?;
let q = at(i, 1)?;
if (q - p - ruling).len() > roundoff(&[a, b, p, q]) {
valid = false;
break;
}
}
if !valid {
continue;
}
let boundary = |index: usize, next: usize, end: bool| {
let a = at(index, 0)?;
let b = at(index, 1)?;
let tangent = if end { a - at(next, 0)? } else { at(next, 0)? - a };
let error = roundoff(&[a, b, at(next, 0)?]);
// Ill-conditioned derivatives cannot certify a tiny angular join.
if tangent.len() <= error / ANGLE || ruling.len() <= error / ANGLE {
return None;
}
let cross = if transpose { ruling.cross(tangent) } else { tangent.cross(ruling) };
let normal = cross.normalized()? * if orientation { 1. } else { -1. };
let top_tangent = if end { b - at(next, 1)? } else { at(next, 1)? - b };
let top_cross = if transpose { ruling.cross(top_tangent) } else { top_tangent.cross(ruling) };
let top_normal = top_cross.normalized()? * if orientation { 1. } else { -1. };
// Along this linear ruling the cross derivative is a linear
// interpolation of these endpoint vectors. Positive alignment
// excludes a singularity; all intermediate normal directions lie
// between their directions. Include that whole-span variation.
if normal.dot(top_normal) <= 0. {
return None;
}
let conditioning = cross.len().min(top_cross.len());
let normal_error = 8. * error * (tangent.len().max(top_tangent.len()) + ruling.len()) / conditioning + normal.dist(top_normal);
if !normal_error.is_finite() || normal_error > ANGLE {
return None;
}
Some(Boundary { a, b, normal, error: normal_error })
};
if let (Some(first), Some(last)) = (boundary(0, 1, false), boundary(count - 1, count - 2, true)) {
return Some([first, last]);
}
}
None
}
fn on_boundary(boundaries: &[Boundary; 2], a: Vec3, b: Vec3) -> Option<Boundary> {
boundaries.iter().find_map(|boundary| {
let tolerance = roundoff(&[a, b, boundary.a, boundary.b]);
((a.dist(boundary.a) <= tolerance && b.dist(boundary.b) <= tolerance) || (a.dist(boundary.b) <= tolerance && b.dist(boundary.a) <= tolerance))
.then_some(*boundary)
})
}
pub(crate) fn flags(body: &Body, mesh: &Mesh) -> (Vec<bool>, Vec<bool>) {
crate::guard("smooth display edges", || Ok(classify(body, mesh, WORK))).unwrap_or_default()
}
fn classify(body: &Body, mesh: &Mesh, mut work: usize) -> (Vec<bool>, Vec<bool>) {
if body.solid.face_iter().take(MAX_FACES + 1).count() > MAX_FACES || mesh.edges.len() > MAX_EDGES {
return (Vec::new(), Vec::new());
}
let mut split_points = 0usize;
for line in body.split_keep() {
if charge(&mut work, line.len().saturating_add(1)).is_none() {
return (Vec::new(), Vec::new());
}
let Some(total) = split_points.checked_add(line.len()) else { return (Vec::new(), Vec::new()) };
split_points = total;
}
let mut boundaries = Vec::new();
for face in body.solid.face_iter() {
// Borrow the geometry through mapping, avoiding a deep clone of an
// unknown/hostile surface before inspecting its control-net bounds.
let wires = face.absolute_boundaries();
if wires.len() > 16 {
boundaries.push(None);
continue;
}
let edge_count = wires.iter().fold(0usize, |sum, wire| sum.saturating_add(wire.len()));
if edge_count > 16 || charge(&mut work, edge_count + 1).is_none() {
boundaries.push(None);
continue;
}
let mapped = face.try_mapped(|_| Some(()), |_| Some(()), |surface| ruled(surface, face.orientation(), &mut work));
boundaries.push(mapped.map(|face| face.surface()));
}
let mut out = vec![false; mesh.edges.len()];
let mut sharp = vec![false; mesh.edges.len()];
// Same first-occurrence order as tessellate().edges; never remove/reindex it.
let mut seen = std::collections::HashSet::new();
let mut index = 0usize;
for edge in body.solid.edge_iter() {
if index >= mesh.edges.len() || seen.len() >= MAX_EDGES {
break;
}
if charge(&mut work, 1).is_none() {
break;
}
if !seen.insert(edge.id()) {
continue;
}
let current = index;
index += 1;
let Some([a, b]) = mesh.edge_faces.get(current).map(Vec::as_slice) else { continue };
if a == b {
continue;
}
let Some(line) = mesh.edges.get(current) else { continue };
let Some(split_work) = line.len().checked_add(1).and_then(|n| n.checked_mul(split_points)) else { break };
if charge(&mut work, split_work).is_none() {
break;
}
if crate::heal::on_split_line(line, body.split_keep()) {
if let Some(flag) = sharp.get_mut(current) {
*flag = true;
}
continue;
}
let (Some(Some(ba)), Some(Some(bb))) = (boundaries.get(*a as usize), boundaries.get(*b as usize)) else { continue };
// Curve is inspected by borrowing. Only exact straight rulings qualify;
// a bowed/twisted edge with matching endpoints must remain visible.
let mapped = edge.try_mapped(
|_| Some(()),
|curve| match curve {
mt::Curve::Line(line) => Some((from_p3(line.0), from_p3(line.1))),
_ => None,
},
);
let Some(mapped) = mapped else { continue };
let (p, q) = mapped.curve();
let (Some(na), Some(nb)) = (on_boundary(ba, p, q), on_boundary(bb, p, q)) else { continue };
let uncertainty = na.error + nb.error;
let cross = na.normal.cross(nb.normal).len();
if na.normal.dot(nb.normal) > 0.
&& cross + uncertainty <= ANGLE
&& let Some(flag) = out.get_mut(current)
{
*flag = true;
} else if (cross > ANGLE + uncertainty || na.normal.dot(nb.normal) < -uncertainty)
&& let Some(flag) = sharp.get_mut(current)
{
*flag = true;
}
}
(out, sharp)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2};
fn circle(radius: f64, count: usize) -> Loop2 {
let step = std::f64::consts::TAU / count as f64;
let k = 4. / 3. * (step / 4.).tan() * radius;
let point = |angle: f64| Vec2::new(angle.cos(), angle.sin()) * radius;
let tangent = |angle: f64| Vec2::new(-angle.sin(), angle.cos()) * k;
Loop2 {
segs: (0..count)
.map(|i| {
let a = i as f64 * step;
let b = (i + 1) as f64 * step;
let p0 = point(a);
let p3 = if i + 1 == count { point(0.) } else { point(b) };
Seg2::Cubic { p0, p1: p0 + tangent(a), p2: p3 - tangent(b), p3 }
})
.collect(),
}
}
fn ring(count: usize) -> Body {
crate::extrude(&Plane::XY, &[Region2 { outer: circle(10., count), holes: vec![circle(9., count).reversed()] }], 0., 3.).unwrap().remove(0)
}
#[test]
fn periodic_ring_hides_only_whole_smooth_wall_joins() {
let body = ring(256);
let mesh = body.display_mesh(0.02).unwrap();
assert_eq!(mesh.edges.len(), 1536);
assert_eq!(mesh.smooth_edges.iter().filter(|f| **f).count(), 512);
for (index, (line, smooth)) in mesh.edges.iter().zip(&mesh.smooth_edges).enumerate() {
let vertical = (line.first().unwrap().z - line.last().unwrap().z).abs() > 2.9;
assert_eq!(*smooth, vertical);
if !vertical {
assert!(mesh.shaded_edge_visible(index));
}
}
let raw = body.tessellate(0.02).unwrap();
assert_eq!(mesh.edges, raw.edges);
assert_eq!(mesh.edge_faces, raw.edge_faces);
assert_eq!(mesh.tri_face, raw.tri_face);
assert_eq!(mesh.triangles, raw.triangles);
assert_eq!(mesh.positions, raw.positions);
assert_eq!(mesh.normals, raw.normals);
let moved = crate::transform_matrix(&body, [[1., 0., 0., 0.], [0., 0., 1., 0.], [0., -1., 0., 0.], [30., 20., 10., 1.]]).unwrap();
assert_eq!(moved.display_mesh(0.02).unwrap().smooth_edges.iter().filter(|f| **f).count(), 512);
}
#[test]
fn authored_split_unknown_adjacency_and_exhausted_work_stay_visible() {
let body = ring(8);
let mesh = body.display_mesh(0.02).unwrap();
let index = mesh.smooth_edges.iter().position(|f| *f).unwrap();
let split = body.clone().with_split_lines(Some(std::sync::Arc::new(vec![mesh.edges[index].clone()])));
assert!(!flags(&split, &mesh).0[index]);
let mut unknown = mesh.clone();
unknown.edge_faces[index].push(999);
assert!(!flags(&body, &unknown).0[index]);
assert!(classify(&body, &mesh, 0).0.iter().all(|f| !*f));
// Matching endpoints alone cannot authorize a bowed current edge.
let changed = Body::new(body.deep_copy()).unwrap();
let edges = Body::unique_edges(&changed.solid);
let edge = &edges[index];
let (a, b) = (edge.front().point(), edge.back().point());
let mid = mt::Point3::new((a.x + b.x) / 2. + 0.1, (a.y + b.y) / 2., (a.z + b.z) / 2.);
edge.set_curve(mt::Curve::BSplineCurve(mt::BSplineCurve::new(mt::KnotVec::bezier_knot(2), vec![a, mid, b])));
let (smooth, sharp) = flags(&changed, &mesh);
assert!(!smooth[index] && !sharp[index]);
}
#[test]
fn a_small_real_crease_is_visible() {
let mut outer = circle(10., 8);
if let Seg2::Cubic { p0, p1, .. } = &mut outer.segs[0] {
p1.x += (p1.y - p0.y) * 1e-6;
}
let body = crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0., 3.).unwrap().remove(0);
let mesh = body.display_mesh(0.02).unwrap();
assert_eq!(mesh.smooth_edges.iter().filter(|f| **f).count(), 7);
let crease = mesh
.edges
.iter()
.position(|line| {
line.first().is_some_and(|p| (p.x - 10.).abs() < 1e-8 && p.y.abs() < 1e-8)
&& line.last().is_some_and(|p| (p.x - 10.).abs() < 1e-8 && p.y.abs() < 1e-8)
&& line.first().zip(line.last()).is_some_and(|(a, b)| (a.z - b.z).abs() > 2.9)
})
.unwrap();
assert!(!mesh.smooth_edges[crease]);
assert!(mesh.sharp_edges[crease]);
assert!(mesh.shaded_edge_visible(crease));
assert!(mesh.edge_pickable(crease));
}
#[test]
fn actual_authored_split_of_a_curved_ruled_wall_stays_visible() {
let body = ring(4);
let face = body
.solid
.face_iter()
.position(|face| face.try_mapped(|_| Some(()), |_| Some(()), |surface| ruled(surface, face.orientation(), &mut { WORK })).is_some())
.unwrap();
let tool = crate::SplitTool::Plane(Plane::new(Vec3::new(7., 0., 0.), Vec3::Y, Vec3::Z).unwrap());
let split = crate::split_faces(&body, &[face], &tool).unwrap();
assert!(split.face_count() > body.face_count());
assert!(!split.split_keep().is_empty());
let mesh = split.display_mesh(0.02).unwrap();
let authored: Vec<_> =
mesh.edges.iter().enumerate().filter(|(_, line)| crate::heal::on_split_line(line, split.split_keep())).map(|(i, _)| i).collect();
assert!(!authored.is_empty());
for index in authored {
assert!(!mesh.smooth_edges[index]);
assert!(mesh.shaded_edge_visible(index));
}
}
#[test]
fn singular_twisted_and_reversed_normal_certificates_are_conservative() {
let surface = |points: Vec<Vec<mt::Point3>>| {
mt::Surface::BSplineSurface(mt::BSplineSurface::new((mt::KnotVec::bezier_knot(points.len() - 1), mt::KnotVec::bezier_knot(1)), points))
};
let p = |x, y, z| mt::Point3::new(x, y, z);
let straight = surface(vec![vec![p(0., 0., 0.), p(0., 0., 3.)], vec![p(1., 0., 0.), p(1., 0., 3.)]]);
let forward = ruled(&straight, true, &mut { WORK }).unwrap();
let backward = ruled(&straight, false, &mut { WORK }).unwrap();
assert!(forward[0].normal.dot(backward[0].normal) < -0.99);
let twisted = surface(vec![vec![p(0., 0., 0.), p(0., 0., 3.)], vec![p(1., 0., 0.), p(1., 1., 3.)]]);
assert!(ruled(&twisted, true, &mut { WORK }).is_none());
let singular = surface(vec![vec![p(0., 0., 0.), p(0., 0., 3.)], vec![p(0., 0., 0.), p(0., 0., 3.)], vec![p(1., 0., 0.), p(1., 0., 3.)]]);
assert!(ruled(&singular, true, &mut { WORK }).is_none());
assert!(ruled(&straight, true, &mut 0).is_none());
}
}
+2
View File
@@ -359,6 +359,8 @@ mod tests {
tri_face: vec![0],
edges: vec![],
seams: vec![],
smooth_edges: vec![],
sharp_edges: vec![],
edge_faces: vec![],
};
let scene = Scene {
+2 -2
View File
@@ -312,7 +312,7 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
}
}
let id = egui::Id::new("sc_canvas_value");
egui::Area::new(egui::Id::new("sc_canvas_area")).fixed_pos(box_at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
crate::frame::area(egui::Id::new("sc_canvas_area")).fixed_pos(box_at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(label).color(t.text_dim).size(11.5));
@@ -579,7 +579,7 @@ fn draw_revolve(
}
}
}
egui::Area::new(egui::Id::new("sc_canvas_area")).fixed_pos(box_at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
crate::frame::area(egui::Id::new("sc_canvas_area")).fixed_pos(box_at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
if to_mode {
let text = if extent.curved_to {
+11 -9
View File
@@ -1369,7 +1369,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let area = Rect::from_center_size(c, vec2(2.0 * (RX + 165.0), 2.0 * (RY + 34.0)));
inside |= pointer.is_some_and(|p| area.contains(p));
let hot = pointer.and_then(|p| sector(p - c));
let resp = egui::Area::new(egui::Id::new("sc_marking_menu")).fixed_pos(area.min).order(egui::Order::Foreground).show(ctx, |ui| {
let resp = crate::frame::area(egui::Id::new("sc_marking_menu")).fixed_pos(area.min).order(egui::Order::Foreground).show(ctx, |ui| {
let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover());
let painter = ui.painter_at(r.expand(4.0));
let font = FontId::proportional(13.0);
@@ -1435,15 +1435,16 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
picked = Some(item.clone());
}
let key_row = app.menu.key_row;
let resp = egui::Area::new(egui::Id::new("sc_context_menu")).fixed_pos(list_at).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| {
menu_frame(ui, |ui| {
for (k, item) in list.iter().enumerate() {
if let Some(p) = list_row_lit(ui, item, key_row == Some(k)) {
picked = Some(p);
let resp =
crate::frame::area(egui::Id::new("sc_context_menu")).fixed_pos(list_at).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| {
menu_frame(ui, |ui| {
for (k, item) in list.iter().enumerate() {
if let Some(p) = list_row_lit(ui, item, key_row == Some(k)) {
picked = Some(p);
}
}
}
});
});
});
inside |= resp.response.contains_pointer() || resp.response.rect.contains(pointer.unwrap_or(Pos2::ZERO));
let (primary, secondary, esc) = ctx.input(|i| {
@@ -1508,6 +1509,7 @@ pub fn list_row_lit(ui: &mut egui::Ui, item: &Item, lit: bool) -> Option<Item> {
return None;
}
let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), if item.enabled { Sense::click() } else { Sense::hover() });
crate::scenario::publish_handle(&format!("menu:{}", item.id), r.center());
if item.enabled && (resp.hovered() || lit) {
ui.painter().rect_filled(r.shrink2(vec2(2.0, 1.0)), 4.0, t.hover);
}
@@ -1536,7 +1538,7 @@ fn show_rename(app: &mut SolveApp, ctx: &egui::Context) {
let Some(mut r) = app.menu.rename.take() else { return };
let t = Tokens::get();
let mut done: Option<bool> = None;
let resp = egui::Area::new(egui::Id::new("sc_rename")).fixed_pos(r.at).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| {
let resp = crate::frame::area(egui::Id::new("sc_rename")).fixed_pos(r.at).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| {
menu_frame(ui, |ui| {
ui.add_space(2.0);
ui.label(egui::RichText::new("Rename").size(11.0).color(t.text_dim));
+1 -1
View File
@@ -199,7 +199,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
app.animate_view(v);
return ok(json!({ "animating": true, "to": app.cam_anim.map(|a| a.to) }));
}
app.cam_anim = None;
app.cancel_view_animation();
match v {
"fit" => app.fit_view(),
"home" => {
+20 -5
View File
@@ -1265,6 +1265,7 @@ fn auto_operation(app: &SolveApp, d: &mut Dialog) {
/// Show the active dialog (if any), docked flush to the right edge of the viewport just below
/// the axis view, sized to its content.
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
app.viewport.dialog_rect = None;
let Some(mut d) = app.dialog.take() else { return };
let _language = crate::i18n::scope(&app.preferences.language);
auto_operation(app, &mut d);
@@ -1294,7 +1295,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let valid = !has_ok || !d.previews() || (apply_commands(app, &d).is_ok() && app.preview.error.is_none());
let width = if wide { crate::frame::Width::Wide } else { crate::frame::Width::Normal };
let wkey = egui::Id::new(("sc_dialog_w", wide));
crate::frame::docked(
let response = crate::frame::docked(
RichText::new(heading.clone()).strong().size(13.0),
egui::Id::new(("sc_dialog", title(&d.kind))),
anchor,
@@ -1909,9 +1910,16 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
}
}
if let Some(e) = d.error.as_ref().or(app.preview.error.as_ref()) {
if let Some(e) = d.error.as_ref().or(app.preview.error.as_ref()).cloned() {
row_label(ui, "");
ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap());
ui.vertical(|ui| {
ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap());
let response = ui.button("Create Issue…");
crate::scenario::publish_handle("dialog:create_issue", response.rect.center());
if response.clicked() {
crate::issue_report::begin(app, ctx, Some(&d), Some(&e));
}
});
ui.end_row();
}
});
@@ -1959,6 +1967,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
});
crate::frame::remember_width(ui, wkey);
});
app.viewport.dialog_rect = response.map(|r| r.response.rect);
// Keyboard: Enter applies (from a value field, or with nothing focused), Esc cancels.
let (enter_free, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
let nothing_focused = ctx.memory(|m| m.focused().is_none());
@@ -1966,10 +1975,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
// Tables (configurations, a study's keys) take Enter for their cells.
let enter_applies = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::ConfirmDelete { .. })
&& !matches!(&d.kind, Kind::Motion(k) if k.wide());
if enter_applies && (enter || canvas_enter || (enter_free && nothing_focused)) {
let report_active = app.issue_report.draft.is_some();
if !report_active && enter_applies && (enter || canvas_enter || (enter_free && nothing_focused)) {
ok = true;
}
if esc && !app.esc_handled {
if !report_active && esc && !app.esc_handled {
app.esc_handled = true;
cancel = true;
}
@@ -2264,6 +2274,11 @@ pub(crate) fn feature_frame(s: &Session, component: u64) -> solvecraft_engine::d
if component == s.active_component { s.active_component_frame() } else { s.placed_document().component_transform(component) }
}
/// The feature dialog's current operation name for a reviewed issue draft.
pub(crate) fn report_title(d: &Dialog) -> String {
title(&d.kind).to_string()
}
/// The commands OK runs (an edit rebuilds its feature in place); the live preview runs the same.
pub fn apply_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, String> {
let cmds = dialog_commands(app, d)?;
+168 -11
View File
@@ -10,6 +10,7 @@
//! `parameters.change`.
use std::cell::RefCell;
use std::sync::Arc;
use egui::{FontId, Pos2, Rect, Shape, Stroke, vec2};
use serde_json::json;
@@ -44,6 +45,89 @@ struct Edit {
fresh: u8,
/// Frames left to wait for the dimension to be drawn.
wait: u8,
draft: Option<DimensionDraft>,
}
/// Creation stays in a scratch session until the user accepts the value. Retaining the
/// document also makes the editor's identity check safe across document replacements.
#[derive(Clone, Debug)]
struct DimensionDraft {
baseline: Arc<solvecraft_engine::doc::Document>,
sketch: u64,
component: u64,
path: Option<Vec<u64>>,
pending: std::collections::BTreeMap<u64, solvecraft_engine::doc::Mat>,
entities: Vec<String>,
at: Vec2,
checked_expr: String,
preview_doc: Arc<solvecraft_engine::doc::Document>,
preview: Arc<solvecraft_engine::doc::ModelState>,
error: Option<String>,
}
impl DimensionDraft {
fn valid(&self, app: &SolveApp) -> bool {
Arc::ptr_eq(&self.baseline, &app.session.doc)
&& app.session.active_sketch == Some(self.sketch)
&& app.session.active_component == self.component
&& app.session.active_occurrence_path == self.path
&& app.session.pending_moves == self.pending
}
fn params(&self, expr: &str) -> serde_json::Value {
json!({"entities": self.entities, "text_at": [self.at.x, self.at.y], "value": expr})
}
fn evaluate(&self, app: &SolveApp, expr: &str) -> Result<solvecraft_engine::Session, String> {
let mut scratch = app.session.scratch();
let result = scratch.execute("sketch.dimension", &self.params(expr)).map_err(|e| e.to_string())?;
let param = result.get("param").and_then(serde_json::Value::as_str).ok_or("The dimension value is unavailable")?;
let state = scratch.world_state();
let solved = state.sketch(self.sketch).ok_or("The dimension sketch is unavailable")?;
if !solved.report.ok() {
return Err("That value conflicts with the sketch constraints".into());
}
if let Some(kind) = solved.sketch.constraints.iter().find(|c| c.param.as_deref() == Some(param)).map(|c| &c.kind)
&& !kind.is_angle()
&& kind.value().is_some_and(|value| !value.is_finite() || value <= 0.0)
{
return Err("Enter a dimension greater than zero".into());
}
Ok(scratch)
}
}
pub fn cancel() {
EDIT.with(|edit| *edit.borrow_mut() = None);
}
/// Infer the measured value through the same command as scripts, without creating a real
/// parameter or an undo entry. Only accepting the editor executes the command on the design.
pub fn begin_dimension(app: &SolveApp, entities: Vec<String>, at: Vec2) -> Result<(), String> {
let sketch = app.session.active_sketch.ok_or("Edit a sketch to add a dimension")?;
if entities.is_empty() || entities.len() > 2 || !at.x.is_finite() || !at.y.is_finite() {
return Err("Pick one edge or two endpoints, then place the dimension".into());
}
let mut scratch = app.session.scratch();
let result = scratch.execute("sketch.dimension", &json!({"entities": entities, "text_at": [at.x, at.y]})).map_err(|e| e.to_string())?;
let param = result.get("param").and_then(serde_json::Value::as_str).ok_or("This dimension has no editable value")?.to_string();
let expr = scratch.doc.param(&param).ok_or("The dimension value is unavailable")?.expr.clone();
let draft = DimensionDraft {
baseline: app.session.doc.clone(),
sketch,
component: app.session.active_component,
path: app.session.active_occurrence_path.clone(),
pending: app.session.pending_moves.clone(),
entities,
at,
checked_expr: expr.clone(),
preview: scratch.world_state(),
preview_doc: scratch.doc.clone(),
error: None,
};
if !draft.preview.sketch(sketch).is_some_and(|solved| solved.report.ok()) {
return Err("That dimension conflicts with the sketch constraints".into());
}
EDIT.with(|edit| *edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft) }));
Ok(())
}
thread_local! {
@@ -117,7 +201,7 @@ pub fn double_click(app: &mut SolveApp, pos: Pos2) -> bool {
return true;
};
let expr = app.session.doc.param(&param).map(|p| p.expr.clone()).unwrap_or_default();
EDIT.with(|e| *e.borrow_mut() = Some(Edit { param, expr, at: Some(d.text.center()), fresh: FRESH, wait: 0 }));
EDIT.with(|e| *e.borrow_mut() = Some(Edit { param, expr, at: Some(d.text.center()), fresh: FRESH, wait: 0, draft: None }));
true
}
@@ -174,6 +258,30 @@ pub fn label(k: &ConstraintKind, expr: Option<&str>, driven: bool) -> String {
/// Draw the active sketch's dimensions and remember where they are for picking.
pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) {
let mut drawn = Vec::new();
let draft = EDIT.with(|edit| {
let mut edit = edit.borrow_mut();
if edit.as_ref().and_then(|e| e.draft.as_ref()).is_some_and(|d| !d.valid(app)) {
*edit = None;
}
if let Some(e) = edit.as_mut()
&& let Some(draft) = e.draft.as_mut()
{
if e.expr != draft.checked_expr {
draft.checked_expr = e.expr.clone();
match draft.evaluate(app, &e.expr) {
Ok(scratch) => {
draft.preview = scratch.world_state();
draft.preview_doc = scratch.doc.clone();
draft.error = None;
}
Err(error) => draft.error = Some(error),
}
}
Some((e.param.clone(), draft.clone()))
} else {
None
}
});
let st = app.session.world_state();
let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else {
DRAWN.with(|d| d.borrow_mut().clear());
@@ -183,15 +291,28 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
let sk = &ss.sketch;
if sk.view.hide_dimensions {
DRAWN.with(|d| d.borrow_mut().clear());
return;
if draft.is_none() {
return;
}
}
let tk = Tokens::get();
let is_sel = |id: &str| app.session.selection.iter().any(|s| matches!(s, Sel::SketchConstraint { id: x } if x == id));
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(proj.rect)).and_then(hit);
let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p));
let font = FontId::proportional(12.0);
let centres = chain_centres(sk);
for c in &sk.constraints {
let draft_sketch = draft.as_ref().and_then(|(_, d)| d.preview.sketch(d.sketch));
let draft_centres = draft_sketch.map(|solved| chain_centres(&solved.sketch));
let mut constraints = sk.constraints.iter().map(|c| (ss, c, false)).collect::<Vec<_>>();
if let (Some(source), Some((param, _))) = (draft_sketch, draft.as_ref()) {
constraints.extend(source.sketch.constraints.iter().filter(|c| c.param.as_deref() == Some(param.as_str())).map(|c| (source, c, true)));
}
for (source, c, is_draft) in constraints {
if ss.sketch.view.hide_dimensions && !is_draft {
continue;
}
let sk = &source.sketch;
let centres = if is_draft { draft_centres.as_ref().unwrap_or(&centres) } else { &centres };
let to = |p: Vec2| proj.to_screen(source.plane.to_world(p));
let Some(frame) = dim_frame(sk, &c.kind) else { continue };
let anchor = match frame {
DimFrame::Linear { p0, p1, .. } => (p0 + p1) * 0.5,
@@ -204,8 +325,10 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
if !px.is_finite() {
continue;
}
let expr = c.param.as_ref().and_then(|p| app.session.doc.param(p)).map(|p| p.expr.as_str());
let failing = ss.report.failing.contains(&c.id);
let display_doc =
if is_draft { draft.as_ref().map(|(_, d)| d.preview_doc.as_ref()).unwrap_or(app.session.doc.as_ref()) } else { app.session.doc.as_ref() };
let expr = c.param.as_ref().and_then(|p| display_doc.param(p)).map(|p| p.expr.as_str());
let failing = source.report.failing.contains(&c.id);
let col = if failing {
tk.error
} else if is_sel(&c.id) || hover.as_deref() == Some(c.id.as_str()) {
@@ -249,7 +372,15 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
.collect();
painter.add(Shape::line(pts, stroke));
}
drawn.push(Drawn { id: c.id.clone(), param: c.param.clone(), text: r, segs, at: lay.text });
if is_draft {
EDIT.with(|edit| {
if let Some(e) = edit.borrow_mut().as_mut() {
e.at = Some(tc);
}
});
} else {
drawn.push(Drawn { id: c.id.clone(), param: c.param.clone(), text: r, segs, at: lay.text });
}
}
DRAWN.with(|d| *d.borrow_mut() = drawn);
edit_box(app, ui.ctx());
@@ -274,7 +405,7 @@ fn pos2_mid_top(r: Rect) -> Pos2 {
/// its value is selected, so typing replaces it).
pub fn edit_param(app: &SolveApp, param: &str) {
let expr = app.session.doc.param(param).map(|p| p.expr.clone()).unwrap_or_default();
EDIT.with(|e| *e.borrow_mut() = Some(Edit { param: param.to_string(), expr, at: None, fresh: FRESH, wait: 10 }));
EDIT.with(|e| *e.borrow_mut() = Some(Edit { param: param.to_string(), expr, at: None, fresh: FRESH, wait: 10, draft: None }));
}
fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
@@ -292,10 +423,25 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
};
let id = egui::Id::new("sc_dim_edit");
let mut done: Option<bool> = None;
egui::Area::new(egui::Id::new("sc_dim_edit_area")).fixed_pos(at - vec2(45.0, 11.0)).order(egui::Order::Foreground).show(ctx, |ui| {
let mut anchor = at - vec2(45.0, 11.0);
let mut popup_width = 220.0;
if let Some(layout) = &app.viewport.layout
&& layout.drawable.is_finite()
&& layout.drawable.is_positive()
{
let rect = layout.drawable;
popup_width = (rect.width() - 8.0).clamp(24.0, 220.0);
anchor.x = anchor.x.clamp(rect.left(), (rect.right() - popup_width - 6.0).max(rect.left()));
anchor.y = anchor.y.clamp(rect.top(), (rect.bottom() - 80.0).max(rect.top()));
}
crate::frame::area(egui::Id::new("sc_dim_edit_area")).fixed_pos(anchor).order(egui::Order::Foreground).show(ctx, |ui| {
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| {
let r = ui.add(egui::TextEdit::singleline(&mut e.expr).id(id).desired_width(90.0));
ui.set_max_width(popup_width);
let r = ui.add(egui::TextEdit::singleline(&mut e.expr).id(id).desired_width(popup_width.min(90.0)));
crate::params_dialog::complete(ui, &r, &mut e.expr);
if let Some(error) = e.draft.as_ref().and_then(|d| d.error.as_ref()) {
ui.add(egui::Label::new(egui::RichText::new(error).color(Tokens::get().error)).wrap());
}
if e.fresh == FRESH {
// The whole value is selected, so typing replaces it.
let n = e.expr.chars().count();
@@ -321,9 +467,20 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
e.fresh = e.fresh.saturating_sub(1);
match done {
Some(true) => {
if let Err(err) = app.run("parameters.change", json!({"name": e.param, "expression": e.expr})) {
let result = if let Some(draft) = &e.draft {
if draft.valid(app) {
draft.evaluate(app, &e.expr).and_then(|_| app.run("sketch.dimension", draft.params(&e.expr)))
} else {
Err("The sketch changed; place the dimension again".into())
}
} else {
app.run("parameters.change", json!({"name": e.param, "expression": e.expr}))
};
if let Err(err) = result {
app.set_status(err, true);
EDIT.with(|x| *x.borrow_mut() = Some(Edit { fresh: FRESH, ..e }));
} else {
app.set_status("Dimension applied", false);
}
}
Some(false) => {}
@@ -0,0 +1,292 @@
//! Dimension creation is an editable draft, accepted by the real focused value field.
use crate::scenario::Harness;
use serde_json::json;
fn line(end: [f64; 2]) -> Harness {
crate::dim_view::cancel();
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,0],end]})).unwrap();
h.call("ui.view", json!({"view":"top"}));
// Leave room around the measured shape while testing the draft itself. Separate Fit
// tests exercise measured dock occlusion without changing the picking projection.
h.app.cam.distance = 80.0;
h.app.cam.target = solvecraft_engine::geom::Vec3::new(end[0] * 0.5, end[1] * 0.5, 0.0);
h.settle();
h
}
fn place(h: &mut Harness, picks: &[[f64; 2]], at: [f64; 2]) {
h.step(&json!({"start":"sketch.dimension"})).unwrap();
for p in picks {
h.step(&json!({"click":{"world":[p[0],p[1],0]}})).unwrap();
}
h.step(&json!({"click":{"world":[at[0],at[1],0]}})).unwrap();
assert!(crate::dim_view::editing(), "placement opens an editor");
assert_eq!(h.ctx.memory(|m| m.focused()), Some(egui::Id::new("sc_dim_edit")));
}
fn type_value(h: &mut Harness, text: &str) {
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"text":text})).unwrap();
}
#[test]
fn edge_and_endpoint_creation_commit_once_and_undo_restores_exact_design() {
for (end, picks, at) in [
([20., 0.], vec![[10., 0.]], [10., 5.]),
([20., 0.], vec![[0., 0.], [20., 0.]], [10., 5.]),
([0., 20.], vec![[0., 0.], [0., 20.]], [-5., 10.]),
([12., 16.], vec![[0., 0.], [12., 16.]], [-5., 12.]),
] {
let mut h = line(end);
let original = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
place(&mut h, &picks, at);
assert_eq!(h.app.session.doc.to_json(), original);
assert_eq!(h.app.session.undo.len(), undo);
type_value(&mut h, "30 mm");
h.step(&json!({"key":"Enter"})).unwrap();
assert!(!crate::dim_view::editing());
assert_eq!(h.app.session.undo.len(), undo + 1);
let state = h.app.session.world_state();
let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
assert!(
(sk.point(sk.resolve_point("l1.start").unwrap()).unwrap().dist(sk.point(sk.resolve_point("l1.end").unwrap()).unwrap()) - 30.).abs()
< 1e-6
);
assert!(sk.constraints.iter().any(|c| c.param.is_some()));
let accepted = h.app.session.doc.to_json();
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), original);
h.app.run("edit.redo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), accepted);
}
}
#[test]
fn escape_invalid_correction_and_seed_enter_do_not_create_partial_dimensions() {
let mut h = line([20., 0.]);
let original = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
place(&mut h, &[[10., 0.]], [10., 5.]);
h.step(&json!({"key":"Escape"})).unwrap();
assert!(!crate::dim_view::editing());
assert_eq!(h.app.session.doc.to_json(), original);
assert_eq!(h.app.session.undo.len(), undo);
place(&mut h, &[[0., 0.], [20., 0.]], [10., 5.]);
type_value(&mut h, "unknown_length");
h.step(&json!({"key":"Enter"})).unwrap();
assert!(crate::dim_view::editing());
assert_eq!(h.app.session.doc.to_json(), original);
assert_eq!(h.app.session.undo.len(), undo);
type_value(&mut h, "25 mm");
h.step(&json!({"key":"Enter"})).unwrap();
assert!(!crate::dim_view::editing());
assert_eq!(h.app.session.undo.len(), undo + 1);
h.app.run("edit.undo", json!({})).unwrap();
h.settle();
place(&mut h, &[[10., 0.]], [10., 5.]);
h.step(&json!({"key":"Enter"})).unwrap();
assert_eq!(h.app.session.undo.len(), undo + 1);
assert!(!crate::dim_view::editing());
}
#[test]
fn switching_documents_cancels_creation_and_existing_parameter_edit_is_separate() {
let mut h = line([20., 0.]);
let original = h.app.session.doc.to_json();
place(&mut h, &[[10., 0.]], [10., 5.]);
h.call("ui.documents", json!({"action":"new"}));
assert!(!crate::dim_view::editing());
h.call("ui.documents", json!({"action":"switch","index":0}));
assert_eq!(h.app.session.doc.to_json(), original);
place(&mut h, &[[10., 0.]], [10., 5.]);
h.step(&json!({"key":"Enter"})).unwrap();
let undo = h.app.session.undo.len();
crate::dim_view::edit_param(&h.app, "d1");
h.settle();
type_value(&mut h, "24 mm");
h.step(&json!({"key":"Enter"})).unwrap();
assert_eq!(h.app.session.undo.len(), undo + 1);
h.app.run("edit.undo", json!({})).unwrap();
assert!(h.app.session.doc.param("d1").unwrap().expr.starts_with("20"));
}
#[test]
fn rectangle_side_and_fixed_origin_line_dimensions_preserve_other_constraints() {
for edge in [false, true] {
let mut h = line([20., 0.]);
h.app.run("sketch.constraint.fix", json!({"entity":"l1.start"})).unwrap();
h.settle();
let original = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let picks = if edge { vec![[10., 0.]] } else { vec![[0., 0.], [20., 0.]] };
place(&mut h, &picks, [10., 5.]);
type_value(&mut h, "30 mm");
h.step(&json!({"key":"Enter"})).unwrap();
let state = h.app.session.world_state();
let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
assert!(sk.point(sk.resolve_point("l1.start").unwrap()).unwrap().len() < 1e-8, "fixed origin remains fixed");
assert!((sk.point(sk.resolve_point("l1.end").unwrap()).unwrap().x - 30.).abs() < 1e-6);
assert_eq!(h.app.session.undo.len(), undo + 1);
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), original);
}
for (picks, at) in
[(vec![[0., 0.], [0., 10.]], [-5., 5.]), (vec![[0., 5.]], [-5., 5.]), (vec![[20., 0.], [20., 10.]], [25., 5.]), (vec![[20., 5.]], [25., 5.])]
{
let mut h = Harness::new();
crate::dim_view::cancel();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[20,10]})).unwrap();
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 80.;
h.app.cam.target = solvecraft_engine::geom::Vec3::new(10., 5., 0.);
h.settle();
let baseline = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
place(&mut h, &picks, at);
type_value(&mut h, "12 mm");
h.step(&json!({"key":"Enter"})).unwrap();
let state = h.app.session.world_state();
let solved = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
assert!((solved.profiles[0].area - 240.).abs() < 1e-5, "side dimensions keep rectangle width: {}", solved.profiles[0].area);
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);
}
}
#[test]
fn pending_second_nested_plane_uses_world_picks_but_one_local_dimension() {
use solvecraft_engine::geom::Vec2;
crate::dim_view::cancel();
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 = h.app.run("sketch.create", json!({"plane":"XY"})).unwrap()["sketch"].as_u64().unwrap();
// The 2D producer uses sketch-local coordinates; pointer picks below use placed world coordinates.
h.app.run("sketch.line", json!({"points":[[0,0],[20,0]]})).unwrap();
h.app.run("occurrence.move", json!({"occurrence":second,"translate":[25,32,40],"axis":[1,0,0],"angle":"35 deg","capture":false})).unwrap();
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 90.;
let plane = h.app.session.world_state().sketch(sid).unwrap().plane;
h.app.cam.target = plane.to_world(Vec2::new(10., 0.));
h.settle();
let baseline = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let pending = h.app.session.pending_moves.clone();
h.step(&json!({"start":"sketch.dimension"})).unwrap();
for p in [Vec2::ZERO, Vec2::new(20., 0.), Vec2::new(10., 5.)] {
let w = plane.to_world(p);
h.step(&json!({"click":{"world":[w.x,w.y,w.z]}})).unwrap();
}
assert!(crate::dim_view::editing());
assert_eq!(h.app.session.doc.to_json(), baseline);
type_value(&mut h, "30 mm");
h.step(&json!({"key":"Enter"})).unwrap();
assert_eq!(h.app.session.undo.len(), undo + 1);
assert_eq!(h.app.session.active_occurrence_path, Some(vec![second, leaf]));
assert_eq!(h.app.session.pending_moves, pending);
let state = h.app.session.model.state();
let sk = &state.sketch(sid).unwrap().sketch;
assert!(
(sk.point(sk.resolve_point("l1.start").unwrap()).unwrap().dist(sk.point(sk.resolve_point("l1.end").unwrap()).unwrap()) - 30.).abs() < 1e-6
);
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), baseline);
assert_eq!(h.app.session.pending_moves, pending);
h.app.run("edit.redo", json!({})).unwrap();
assert_eq!(h.app.session.pending_moves, pending);
assert_eq!(h.app.session.active_occurrence_path, Some(vec![second, leaf]));
}
#[test]
fn invalid_zero_negative_failed_open_and_click_away_leave_no_dimension() {
let mut h = line([20., 0.]);
let baseline = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
for invalid in ["0 mm", "-2 mm"] {
place(&mut h, &[[10., 0.]], [10., 5.]);
type_value(&mut h, invalid);
h.step(&json!({"key":"Enter"})).unwrap();
assert!(crate::dim_view::editing());
assert_eq!(h.app.session.doc.to_json(), baseline);
assert_eq!(h.app.session.undo.len(), undo);
h.step(&json!({"key":"Escape"})).unwrap();
}
place(&mut h, &[[10., 0.]], [10., 5.]);
assert!(h.app.run("doc.open", json!({"path":"missing-dimension-draft-design.solvecraft"})).is_err());
assert!(crate::dim_view::editing(), "failed replacement must preserve the draft");
assert_eq!(h.app.session.doc.to_json(), baseline);
h.frames(8);
h.step(&json!({"click":{"world":[12,-10,0]}})).unwrap();
assert!(!crate::dim_view::editing());
assert_eq!(h.app.session.doc.to_json(), baseline);
assert_eq!(h.app.session.undo.len(), undo);
}
#[test]
fn dimension_to_fillet_virtual_sharp_keeps_the_arc_and_fixed_origin() {
crate::dim_view::cancel();
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,0],[0,20],[20,20]],"ids":["riser","top"]})).unwrap();
h.app.run("sketch.constraint.fix", json!({"entity":"riser.start"})).unwrap();
h.app.run("sketch.constraint.horizontal_vertical", json!({"line":"riser","mode":"vertical"})).unwrap();
h.app.run("sketch.constraint.horizontal_vertical", json!({"line":"top","mode":"horizontal"})).unwrap();
// An existing width requires the fillet's retained virtual sharp. Add an independent
// height through the visible corner while preserving that width and the fillet arc.
h.app.run("sketch.dimension", json!({"entities":["top"],"value":20})).unwrap();
let fillet = h.app.run("sketch.fillet", json!({"point":"riser.end","radius":5})).unwrap();
let corner = fillet["virtual_corners"][0].as_str().unwrap().to_string();
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 90.;
h.app.cam.target = solvecraft_engine::geom::Vec3::new(5., 10., 0.);
h.settle();
let baseline = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
place(&mut h, &[[0., 0.], [0., 20.]], [-5., 10.]);
type_value(&mut h, "30 mm");
h.step(&json!({"key":"Enter"})).unwrap();
assert!(!crate::dim_view::editing());
assert_eq!(h.app.session.undo.len(), undo + 1);
let state = h.app.session.world_state();
let solved = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
assert!(solved.report.ok());
let sk = &solved.sketch;
assert!((sk.point(sk.point_index(&corner).unwrap()).unwrap().y - 30.).abs() < 1e-6);
assert!(sk.point(sk.resolve_point("riser.start").unwrap()).unwrap().len() < 1e-8);
assert!(sk.curves.iter().any(|c| matches!(c.kind, solvecraft_engine::sketch::CurveKind::Arc { .. })), "the dimension retains the fillet arc");
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), baseline);
}
#[test]
fn draft_annotation_does_not_move_committed_dimension_hitboxes() {
crate::dim_view::cancel();
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[20,10]})).unwrap();
let width = h.app.run("sketch.dimension", json!({"entities":["l3"],"value":20})).unwrap()["param"].as_str().unwrap().to_string();
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 80.;
h.app.cam.target = solvecraft_engine::geom::Vec3::new(10., 5., 0.);
h.settle();
let at = crate::dim_view::text_at(&width).unwrap();
let drawn = crate::dim_view::drawn();
let baseline = h.app.session.doc.to_json();
place(&mut h, &[[0., 0.], [0., 10.]], [-5., 5.]);
type_value(&mut h, "12 mm");
h.settle();
assert_eq!(crate::dim_view::text_at(&width), Some(at));
assert_eq!(crate::dim_view::drawn(), drawn, "draft annotations must not become committed hit targets");
assert_eq!(h.app.session.doc.to_json(), baseline);
h.step(&json!({"key":"Escape"})).unwrap();
assert_eq!(crate::dim_view::drawn(), drawn);
}
+16 -14
View File
@@ -76,9 +76,10 @@ fn put_view(app: &mut SolveApp, v: DocView) {
fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
// Whatever was going on in the design leaves with it.
crate::dialogs::cancel(app);
crate::dim_view::cancel();
app.tool = None;
app.params = None;
app.cam_anim = None;
app.cancel_view_animation();
app.viewport.orbit_snap = None;
app.viewport.orbit_free = None;
app.preview = crate::preview::PreviewState::default();
@@ -391,20 +392,21 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
ui.ctx().data_mut(|d| d.insert_temp(id, !d.get_temp::<bool>(id).unwrap_or(false)));
}
if ui.ctx().data(|d| d.get_temp::<bool>(id).unwrap_or(false)) {
let resp = egui::Area::new(id.with("area")).fixed_pos(pos2(mr.left(), r.bottom())).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
crate::context_menu::menu_frame(ui, |ui| {
for (i, (name, dirty)) in info.iter().enumerate() {
let item = crate::context_menu::Item::action(
"doc",
&format!("{name}{}", if *dirty { " •" } else { "" }),
if i == app.docs.active { "box" } else { "" },
);
if crate::context_menu::list_row(ui, &item).is_some() {
pick = Some(i);
let resp =
crate::frame::area(id.with("area")).fixed_pos(pos2(mr.left(), r.bottom())).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
crate::context_menu::menu_frame(ui, |ui| {
for (i, (name, dirty)) in info.iter().enumerate() {
let item = crate::context_menu::Item::action(
"doc",
&format!("{name}{}", if *dirty { " •" } else { "" }),
if i == app.docs.active { "box" } else { "" },
);
if crate::context_menu::list_row(ui, &item).is_some() {
pick = Some(i);
}
}
}
})
});
})
});
let pressed_outside = ui.ctx().input(|i| i.pointer.any_pressed()) && !resp.response.contains_pointer() && !mresp.contains_pointer();
if pick.is_some() || pressed_outside {
ui.ctx().data_mut(|d| d.insert_temp(id, false));
+12 -1
View File
@@ -33,6 +33,7 @@ pub fn docked<'a>(title: impl Into<WidgetText>, id: egui::Id, anchor: Pos2, max_
Width::Wide => DIALOG_WIDE_MAX_W,
};
egui::Window::new(title)
.enabled(!crate::issue_report::cad_input_blocked())
.id(id)
.title_bar(false)
.pivot(Align2::RIGHT_TOP)
@@ -52,7 +53,17 @@ pub fn window<'a>(ctx: &egui::Context, title: impl Into<WidgetText>, width: Widt
Width::Wide => WINDOW_WIDE_MAX_W,
};
let c = ctx.content_rect().center();
egui::Window::new(title).collapsible(false).pivot(Align2::CENTER_CENTER).default_pos(c - vec2(0.0, 60.0)).max_width(max)
egui::Window::new(title)
.enabled(!crate::issue_report::cad_input_blocked())
.collapsible(false)
.pivot(Align2::CENTER_CENTER)
.default_pos(c - vec2(0.0, 60.0))
.max_width(max)
}
/// Floating model controls share the report's read-only drawing boundary with windows.
pub fn area(id: egui::Id) -> egui::Area {
egui::Area::new(id).enabled(!crate::issue_report::cad_input_blocked())
}
/// The width a window's content had last frame (at least `min`), for laying out rows that end at
+5
View File
@@ -241,6 +241,11 @@ fn snap(v: f64, step: f64, free: bool) -> f64 {
if free || step <= 0.0 { v } else { (v / step).round() * step }
}
/// Discard a suspended pointer gesture without reporting a final model change.
pub(crate) fn cancel(ctx: &egui::Context, id: egui::Id) {
ctx.data_mut(|d| d.remove::<State>(id));
}
/// Draw the triad and run a drag on it. `step` is the distance snap step.
pub fn show(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, id: egui::Id, t: &Triad, step: f64) -> Option<Drag> {
let tk = Tokens::get();
+18 -13
View File
@@ -1,6 +1,6 @@
//! The Help menu (the ? button on the application bar): Documentation (the online book), About
//! SolveCraft (version, commit, build date, licences, attributions, fonts), the keyboard shortcuts
//! (read only), and Report an Issue, which copies diagnostic information to the clipboard.
//! (read only), and Report an Issue, which opens a local editable report draft.
use egui::{RichText, vec2};
@@ -54,8 +54,9 @@ pub fn run(app: &mut SolveApp, ctx: &egui::Context, item: &str) {
app.keymap.read_only = true;
}
"report" => {
ctx.copy_text(diagnostics(app));
app.set_status("Diagnostic information copied: paste it into your issue report", false);
let dialog = app.dialog.clone();
let error = dialog.as_ref().and_then(|d| d.error.clone()).or_else(|| app.preview.error.clone());
crate::issue_report::begin(app, ctx, dialog.as_ref(), error.as_deref());
}
_ => {}
}
@@ -65,17 +66,21 @@ pub fn run(app: &mut SolveApp, ctx: &egui::Context, item: &str) {
pub fn show(app: &mut SolveApp, ctx: &egui::Context, anchor: egui::Pos2) {
if app.help.menu {
let mut pick: Option<&str> = None;
let resp = egui::Area::new(egui::Id::new("sc_help_menu")).fixed_pos(anchor).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| {
crate::context_menu::menu_frame(ui, |ui| {
for (id, label) in
[("docs", "Documentation"), ("about", "About SolveCraft"), ("shortcuts", "Keyboard Shortcuts"), ("report", "Report an Issue…")]
{
if crate::context_menu::list_row(ui, &crate::context_menu::Item::action(id, label, "")).is_some() {
pick = Some(id);
let resp =
crate::frame::area(egui::Id::new("sc_help_menu")).fixed_pos(anchor).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| {
crate::context_menu::menu_frame(ui, |ui| {
for (id, label) in [
("docs", "Documentation"),
("about", "About SolveCraft"),
("shortcuts", "Keyboard Shortcuts"),
("report", "Report an Issue…"),
] {
if crate::context_menu::list_row(ui, &crate::context_menu::Item::action(id, label, "")).is_some() {
pick = Some(id);
}
}
}
})
});
})
});
if let Some(p) = pick {
run(app, ctx, p);
} else if ctx.input(|i| i.pointer.any_pressed() || i.key_pressed(egui::Key::Escape)) && !resp.response.contains_pointer() {
+992
View File
@@ -0,0 +1,992 @@
//! Reviewed issue drafts with local evidence; never publishes or uploads a report.
use std::fmt::Write as _;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use egui::{ColorImage, RichText, TextureHandle, vec2};
use crate::{SolveApp, dialogs, help};
const MAX_BODY: usize = 24_000;
const MAX_IMAGE_PIXELS: usize = 16_000_000;
const ISSUE_URL: &str = "https://github.com/bherbruck/solvecraft/issues/new";
thread_local! {
static CAD_INPUT_BLOCKED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
pub(crate) fn cad_input_blocked() -> bool {
CAD_INPUT_BLOCKED.with(std::cell::Cell::get)
}
/// Draw CAD behind a report without delivering its input or focus to any model editor.
/// Fresh focus history also prevents inline editors from committing on report focus loss.
pub(crate) struct CadInput {
ctx: egui::Context,
input: Option<egui::InputState>,
memory: Option<egui::Memory>,
previous_blocked: bool,
}
impl CadInput {
pub(crate) fn block(app: &SolveApp, ui: &mut egui::Ui, guard: &mut Option<Self>) {
if guard.is_none() {
*guard = Self::begin(app, ui.ctx());
}
if guard.is_some() {
ui.visuals_mut().disabled_alpha = 1.0;
ui.disable();
}
}
pub(crate) fn begin(app: &SolveApp, ctx: &egui::Context) -> Option<Self> {
app.issue_report.draft.as_ref()?;
let mut raw = ctx.input(|i| i.raw.clone());
raw.events.clear();
raw.dropped_files.clear();
raw.hovered_files.clear();
let neutral = egui::InputState::default().begin_pass(raw.clone(), true, ctx.pixels_per_point(), egui::InputOptions::default());
let input = ctx.input_mut(|i| std::mem::replace(i, neutral));
let memory = ctx.memory_mut(|m| {
let mut saved = std::mem::take(m);
let mut neutral = egui::Memory::default();
neutral.options = saved.options.clone();
Arc::make_mut(&mut neutral.options.dark_style).visuals.disabled_alpha = 1.0;
Arc::make_mut(&mut neutral.options.light_style).visuals.disabled_alpha = 1.0;
neutral.data = saved.data.clone();
neutral.to_global = saved.to_global.clone();
*neutral.areas_mut() = saved.areas().clone();
neutral.caches = std::mem::take(&mut saved.caches);
*m = neutral;
saved
});
let previous_blocked = CAD_INPUT_BLOCKED.with(|blocked| blocked.replace(true));
Some(Self { ctx: ctx.clone(), input: Some(input), memory: Some(memory), previous_blocked })
}
}
impl Drop for CadInput {
fn drop(&mut self) {
CAD_INPUT_BLOCKED.with(|blocked| blocked.set(self.previous_blocked));
if let Some(input) = self.input.take() {
self.ctx.input_mut(|i| *i = input);
}
if let Some(mut memory) = self.memory.take() {
self.ctx.memory_mut(|m| {
// Keep current layout/widget state and caches; isolate only input/focus history.
memory.data = std::mem::take(&mut m.data);
memory.caches = std::mem::take(&mut m.caches);
memory.to_global = std::mem::take(&mut m.to_global);
*memory.areas_mut() = m.areas().clone();
*m = memory;
});
}
}
}
#[derive(Default)]
pub struct ReportState {
pub draft: Option<Draft>,
pending: Option<Pending>,
cad_focus: Option<egui::Id>,
}
pub struct Draft {
pub title: String,
pub body: String,
image: Option<Arc<ColorImage>>,
texture: Option<TextureHandle>,
pub note: String,
source_signature: u64,
}
struct Pending {
token: u64,
signature: u64,
deadline: f64,
window: egui::Rect,
pixels_per_point: f32,
}
/// A bounded formatting sink also avoids allocating a full hostile Debug/JSON value first.
struct Text {
value: String,
limit: usize,
}
impl std::fmt::Write for Text {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
let room = self.limit.saturating_sub(self.value.len());
if s.len() <= room {
self.value.push_str(s);
return Ok(());
}
let mut end = room.min(s.len());
while !s.is_char_boundary(end) {
end = end.saturating_sub(1);
}
self.value.push_str(s.get(..end).unwrap_or_default());
Err(std::fmt::Error)
}
}
fn debug(value: &impl std::fmt::Debug, limit: usize) -> String {
let mut text = Text { value: String::new(), limit };
if write!(&mut text, "{value:#?}").is_err() {
text.value.push_str("\n[truncated]");
}
text.value
}
struct JsonBytes {
value: Vec<u8>,
limit: usize,
}
impl std::io::Write for JsonBytes {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
let n = bytes.len().min(self.limit.saturating_sub(self.value.len()));
self.value.extend_from_slice(bytes.get(..n).unwrap_or_default());
if n != bytes.len() {
return Err(std::io::Error::other("report context limit"));
}
Ok(n)
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn json_context(value: &impl serde::Serialize, limit: usize) -> String {
let mut bytes = JsonBytes { value: Vec::new(), limit };
let truncated = serde_json::to_writer_pretty(&mut bytes, value).is_err();
let mut value = String::from_utf8_lossy(&bytes.value).into_owned();
if truncated {
value.push_str("\n[truncated]");
}
value
}
fn truncate(value: &mut String, limit: usize) {
if value.len() > limit {
let mut end = limit;
while !value.is_char_boundary(end) {
end = end.saturating_sub(1);
}
value.truncate(end);
}
}
fn limited(value: &str, limit: usize) -> String {
let mut end = limit.min(value.len());
while !value.is_char_boundary(end) {
end = end.saturating_sub(1);
}
value.get(..end).unwrap_or_default().to_string()
}
fn block(value: &str) -> String {
value.lines().map(|line| format!(" {line}\n")).collect()
}
/// Hash directly through formatting, without constructing an unbounded Debug string.
struct Fingerprint(std::collections::hash_map::DefaultHasher);
impl std::fmt::Write for Fingerprint {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
self.0.write(s.as_bytes());
Ok(())
}
}
fn source_signature(app: &SolveApp, dialog: Option<&dialogs::Dialog>) -> u64 {
let mut h = Fingerprint(std::collections::hash_map::DefaultHasher::new());
app.session.revision.hash(&mut h.0);
app.docs.active.hash(&mut h.0);
app.docs.count().hash(&mut h.0);
app.session.doc.name.hash(&mut h.0);
app.preview.key.hash(&mut h.0);
app.preview.error.hash(&mut h.0);
app.preview.busy.hash(&mut h.0);
let _ = write!(&mut h, "{:?}{:?}", dialog, app.session.selection);
h.0.finish()
}
fn signature(app: &SolveApp, dialog: Option<&dialogs::Dialog>) -> u64 {
let mut h = Fingerprint(std::collections::hash_map::DefaultHasher::new());
source_signature(app, dialog).hash(&mut h.0);
let _ = write!(&mut h, "{:?}{:?}{:?}", app.cam, app.viewport.rect, app.ui.perspective);
h.0.finish()
}
fn reportable(feature: &solvecraft_engine::doc::Feature) -> bool {
!matches!(
feature.kind,
solvecraft_engine::doc::FeatureKind::Sketch { .. }
| solvecraft_engine::doc::FeatureKind::Import { .. }
| solvecraft_engine::doc::FeatureKind::MeshImport { .. }
)
}
fn feature_context(feature: &solvecraft_engine::doc::Feature) -> String {
format!("Feature {}: {}\n{}", feature.id, debug(&feature.name, 160), block(&json_context(&feature.kind, 2000)))
}
/// Read-only context excludes whole documents, sketch geometry and imported file contents.
pub fn draft_for(app: &SolveApp, dialog: Option<&dialogs::Dialog>, error: Option<&str>) -> Draft {
let mut context = String::new();
let label = dialog.map(dialogs::report_title).unwrap_or_else(|| "Modeling".into());
if let Some(d) = dialog {
match dialogs::apply_commands(app, d) {
Ok(commands) => context.push_str(&block(&json_context(&commands, 10_000))),
Err(e) => {
context.push_str(&format!("Could not build command parameters: {}\n", debug(&e, 1000)));
context.push_str(&block(&debug(&d.kind, 6000)));
context.push_str(&block(&debug(&d.inputs, 3000)));
context.push_str(&block(&debug(&d.extra, 1000)));
}
}
}
context.push_str("\nSelected items (including occurrence paths):\n");
context.push_str(&block(&debug(&app.session.selection.iter().take(32).collect::<Vec<_>>(), 3000)));
for item in app.session.selection.iter().take(8) {
if let solvecraft_engine::Sel::Feature { id } = item
&& let Some(feature) = app.session.doc.feature(*id)
&& reportable(feature)
{
context.push_str(&feature_context(feature));
if let Some(e) = app.session.model.result(*id).and_then(|r| r.error.as_deref()) {
context.push_str(&format!("Feature error: {}\n", debug(&e, 1000)));
}
}
}
if dialog.is_none()
&& let Some(feature) = app.session.doc.features.iter().rev().find(|f| reportable(f))
{
context.push_str("\nLatest modeling operation:\n");
context.push_str(&feature_context(feature));
if let Some(e) = app.session.model.result(feature.id).and_then(|r| r.error.as_deref()) {
context.push_str(&format!("Feature error: {}\n", debug(&e, 1000)));
}
}
let actual = error.map(|e| debug(&e, 4000)).unwrap_or_else(|| "Describe the unexpected result shown in the view.".into());
let image_state = if error.is_some() {
"A failed preview may display the last valid geometry; that geometry is not evidence that the failed operation succeeded."
} else {
"Describe what looks wrong in the current result."
};
let mut body = format!(
"## Expected behavior\nDescribe the intended result.\n\n## Actual behavior\n{actual}\n\n{image_state}\n\n## Reproduction\nDescribe the steps or a small public-safe example.\n\n## Settings and selection\n{context}\n## Environment\n{}\nRevision: {}\nCamera: {}\n\n## Visual context\nAttach the reviewed screenshot or a short example showing the problem.\n",
block(&debug(&help::diagnostics(app), 2000)),
app.session.revision,
block(&debug(&app.cam, 1500))
);
if body.len() > MAX_BODY {
truncate(&mut body, MAX_BODY - 32);
body.push_str("\n[report context truncated]\n");
}
let mut title = format!("{label}: {}", if error.is_some() { "operation failed" } else { "unexpected result" });
truncate(&mut title, 160);
Draft { title, body, image: None, texture: None, note: String::new(), source_signature: source_signature(app, dialog) }
}
fn encode(value: &str) -> String {
let mut out = String::new();
for b in value.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
out.push(char::from(b));
} else {
let _ = write!(&mut out, "%{b:02X}");
}
}
out
}
/// Long drafts stay local and can be copied into the browser editor; never discard them.
pub fn draft_url(title: &str, body: &str) -> (String, bool) {
let title = limited(title, 160);
let base = format!("{ISSUE_URL}?title={}", encode(&title));
if body.len() <= MAX_BODY {
let url = format!("{base}&body={}", encode(body));
if url.len() <= 2000 {
return (url, false);
}
}
(base, true)
}
pub fn begin(app: &mut SolveApp, ctx: &egui::Context, dialog: Option<&dialogs::Dialog>, error: Option<&str>) {
if app.issue_report.draft.is_none() {
app.issue_report.cad_focus = ctx.memory(|m| m.focused());
}
cancel_capture(app);
app.issue_report.draft = Some(draft_for(app, dialog, error));
if let Some(draft) = app.issue_report.draft.as_mut() {
let rect = ctx.content_rect();
let _ = write!(
&mut draft.body,
"\nWindow: {:.0}×{:.0} logical points; {:.2} physical pixels/point\n",
rect.width(),
rect.height(),
ctx.pixels_per_point()
);
truncate(&mut draft.body, MAX_BODY);
}
capture(app, ctx, dialog);
}
fn cancel_capture(app: &mut SolveApp) {
if let Some(pending) = app.issue_report.pending.take() {
app.queued_shots.retain(|(token, ..)| *token != pending.token);
}
}
fn capture(app: &mut SolveApp, ctx: &egui::Context, dialog: Option<&dialogs::Dialog>) {
cancel_capture(app);
let current_source = source_signature(app, dialog);
let Some(draft) = app.issue_report.draft.as_mut() else { return };
draft.image = None;
draft.texture = None;
if draft.source_signature != current_source {
draft.note =
"Settings or model changed. Close this draft and create a new report to capture matching context; your text is still available to copy."
.into();
return;
}
if app.preview.busy || app.queued_shots.len() >= 16 {
draft.note = "Screenshot unavailable while the preview is updating. Reopen the report after it settles, or continue with text.".into();
return;
}
let Some(token) = app.shot_token.checked_add(1) else {
draft.note = "Screenshot request limit reached; restart the app to capture another frame.".into();
return;
};
app.shot_token = token;
draft.note = "Capturing the current app view locally…".into();
let now = crate::now_ms();
app.issue_report.pending = Some(Pending {
token,
signature: signature(app, dialog),
deadline: now + 8000.0,
window: ctx.content_rect(),
pixels_per_point: ctx.pixels_per_point(),
});
app.queued_shots.push((token, now + 150.0, 0));
ctx.request_repaint();
}
pub(crate) fn pending(app: &SolveApp) -> bool {
app.issue_report.pending.is_some()
}
pub(crate) fn tick(app: &mut SolveApp, ctx: &egui::Context) {
let problem = app.issue_report.pending.as_ref().and_then(|p| {
if signature(app, app.dialog.as_ref()) != p.signature || p.window != ctx.content_rect() || p.pixels_per_point != ctx.pixels_per_point() {
Some("The model, inputs or view changed during capture. Retake the current view.")
} else if crate::now_ms() >= p.deadline {
Some("No app frame was presented. Restore the window and retake, or continue with text.")
} else {
None
}
});
if let Some(problem) = problem {
cancel_capture(app);
if let Some(draft) = app.issue_report.draft.as_mut() {
draft.note = problem.into();
}
}
if pending(app) {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
pub(crate) fn receive(app: &mut SolveApp, token: u64, image: Arc<ColorImage>) {
if !app.issue_report.pending.as_ref().is_some_and(|p| p.token == token) {
return;
}
let stale = app.issue_report.pending.as_ref().is_some_and(|p| p.signature != signature(app, app.dialog.as_ref()));
app.issue_report.pending = None;
if let Some(draft) = app.issue_report.draft.as_mut() {
if stale {
draft.note = "The model, inputs or view changed during capture. Retake the current view.".into();
return;
}
let pixels = image.width().checked_mul(image.height());
if !pixels.is_some_and(|n| n > 0 && n <= MAX_IMAGE_PIXELS && n == image.pixels.len()) {
draft.note = "Screenshot dimensions are unsupported. The text draft is still available.".into();
} else {
draft.image = Some(image);
draft.note = "Screenshot captured locally. Review it before attaching it on GitHub.".into();
}
}
}
/// The report window is hidden only while a frame is captured, so it cannot cover the evidence.
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
if pending(app) {
return;
}
let Some(mut draft) = app.issue_report.draft.take() else { return };
let mut open = true;
let mut retake = false;
let mut close = false;
let screen = ctx.content_rect();
crate::frame::window(ctx, "Create Issue — review draft", crate::frame::Width::Wide)
.id(egui::Id::new("sc_issue_report"))
.open(&mut open)
.resizable(true)
.default_width((screen.width() - 32.0).clamp(100.0, 640.0))
.max_width((screen.width() - 24.0).max(100.0))
.max_height((screen.height() - 32.0).max(100.0))
.vscroll(true)
.show(ctx, |ui| {
ui.label("Review the report and screenshot before opening GitHub.");
ui.add(
egui::TextEdit::singleline(&mut draft.title)
.id(egui::Id::new("issue_report_title"))
.hint_text("Issue title")
.char_limit(160)
.desired_width(f32::INFINITY),
);
egui::ScrollArea::vertical().id_salt("issue-report-body").max_height((screen.height() * 0.35).clamp(72.0, 240.0)).show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut draft.body)
.id(egui::Id::new("issue_report_body"))
.char_limit(MAX_BODY)
.desired_rows(5)
.desired_width(f32::INFINITY),
);
});
truncate(&mut draft.title, 160);
truncate(&mut draft.body, MAX_BODY);
ui.label(RichText::new("Remove private design details or paths before sharing.").small());
ui.label(&draft.note);
if let Some(image) = draft.image.as_ref() {
let texture =
draft.texture.get_or_insert_with(|| ctx.load_texture("issue-report-frame", image.clone(), egui::TextureOptions::LINEAR));
ui.add(
egui::Image::new(&*texture)
.max_size(vec2(ui.available_width(), (screen.height() * 0.25).clamp(60.0, 220.0)))
.maintain_aspect_ratio(true),
);
}
ui.horizontal_wrapped(|ui| {
retake = ui.button("Retake screenshot").clicked();
if ui.add_enabled(draft.image.is_some(), egui::Button::new("Copy screenshot")).clicked()
&& let Some(image) = draft.image.as_ref()
{
ctx.copy_image((**image).clone());
draft.note = "Paste the image into the GitHub draft, or save the PNG if image copying is unavailable.".into();
}
if ui.add_enabled(draft.image.is_some() && app.services.pick_save.is_some(), egui::Button::new("Save screenshot…")).clicked()
&& let Some(image) = draft.image.as_ref()
&& let Some(path) =
app.services.pick_save.as_ref().and_then(|pick| pick(&format!("solvecraft-issue-{}.png", app.shot_token), &["png"]))
{
let result = crate::control::save_screenshot(image, Some(&path));
draft.note = if result.get("ok").and_then(serde_json::Value::as_bool) == Some(true) {
"Screenshot saved. Attach the PNG in the GitHub draft.".into()
} else {
format!("Could not save screenshot: {}", debug(&result.get("error"), 1000))
};
}
if ui.add_enabled(draft.image.is_some(), egui::Button::new("Remove screenshot")).clicked() {
draft.image = None;
draft.texture = None;
draft.note = "Screenshot removed from this draft.".into();
}
});
ui.horizontal_wrapped(|ui| {
if ui.button("Copy report").clicked() {
ctx.copy_text(draft.body.clone());
draft.note = "Report copied. Paste it into the GitHub draft if needed.".into();
}
let (url, copy_needed) = draft_url(&draft.title, &draft.body);
if ui.button("Open GitHub draft").clicked() {
if copy_needed {
ctx.copy_text(draft.body.clone());
}
ctx.open_url(egui::OpenUrl::new_tab(url));
draft.note = if copy_needed {
"Report copied. Paste into the GitHub draft, then attach the screenshot."
} else {
"Review the GitHub draft and attach the screenshot. GitHub's Submit new issue button publishes it."
}
.into();
}
close = ui.button("Close draft").clicked();
});
});
if open && !close {
app.issue_report.draft = Some(draft);
if retake {
let dialog = app.dialog.clone();
capture(app, ctx, dialog.as_ref());
}
} else {
cancel_capture(app);
// Pointer release may have happened during review; never finalize it against
// the report's last cursor position when CAD interaction resumes.
app.viewport.point_drag = None;
app.viewport.boxsel = None;
app.viewport.axis_drag = None;
app.viewport.orbit_free = None;
app.viewport.orbit_snap = None;
crate::dim_view::drag_end();
crate::sketch3d::drag_end();
crate::drag_snap::clear();
crate::gizmo::cancel(ctx, egui::Id::new("sc_move_triad"));
crate::gizmo::cancel(ctx, egui::Id::new("sc_occ_triad"));
if let Some(focus) = app.issue_report.cad_focus.take() {
ctx.memory_mut(|m| m.request_focus(focus));
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic)]
use super::*;
use serde_json::json;
use solvecraft_engine::{Sel, Session};
fn app() -> SolveApp {
SolveApp::new(Session::default(), Default::default())
}
fn combine(app: &mut SolveApp) {
app.start("solid.combine");
let dialog = app.dialog.as_mut().unwrap();
dialog.kind = dialogs::Kind::Combine { operation: 2, keep_tools: true };
dialog.inputs.get_mut(0).unwrap().items = vec![Sel::Body { name: "Stock".into() }];
dialog.inputs.get_mut(1).unwrap().items = vec![Sel::Body { name: "Cutter".into() }];
}
#[test]
fn issue_report_actual_pending_boolean_settings_and_failure_are_read_only() {
let mut app = app();
combine(&mut app);
let before = (app.session.doc.to_json(), app.session.revision, app.session.undo.len(), app.session.redo.len(), app.cam);
let draft = draft_for(&app, app.dialog.as_ref(), Some("Boolean tool is outside target"));
for expected in
["solid.combine", "Stock", "Cutter", "cut", "keep_tools", "true", "Boolean tool is outside target", help::VERSION, "last valid geometry"]
{
assert!(draft.body.contains(expected), "missing {expected}: {}", draft.body);
}
assert_eq!(before, (app.session.doc.to_json(), app.session.revision, app.session.undo.len(), app.session.redo.len(), app.cam));
}
#[test]
fn issue_report_hostile_context_and_url_are_bounded_without_new_query_parameters() {
let mut app = app();
combine(&mut app);
let hostile = "錯誤 ```\n## fake &labels=secret%20tag 🚀".repeat(40_000);
let draft = draft_for(&app, app.dialog.as_ref(), Some(&hostile));
assert!(draft.body.len() <= MAX_BODY);
assert!(draft.body.contains("[truncated]"));
let (url, copy) = draft_url("Cut &labels=secret #? 🚀", "actual\n```\n&assignees=someone");
assert!(!copy);
assert!(url.starts_with(ISSUE_URL));
assert_eq!(url.matches('&').count(), 1);
assert!(url.contains("%26labels%3Dsecret") && url.contains("%F0%9F%9A%80"));
let (short, copy) = draft_url(&hostile, &draft.body);
assert!(copy && short.len() <= 2000 && !short.contains("&body="));
assert!(draft.body.contains("Stock"), "fallback must retain the editable full report");
}
#[test]
fn issue_report_help_supports_successful_geometry_without_mutating_it() {
let mut app = app();
app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"})).unwrap();
app.run("solid.box", json!({"length":5,"width":5,"height":5,"body_name":"Cutter"})).unwrap();
app.run("solid.combine", json!({"target":"Stock","tools":["Cutter"],"operation":"cut","keep_tools":true})).unwrap();
assert!(app.session.model.results.iter().all(|r| r.error.is_none()));
let before = (app.session.doc.to_json(), app.session.undo.len(), app.session.redo.len(), app.cam);
let ctx = egui::Context::default();
help::run(&mut app, &ctx, "report");
let draft = app.issue_report.draft.as_ref().unwrap();
assert!(draft.body.contains("Describe the unexpected result"));
assert!(draft.body.contains(help::COMMIT));
assert!(draft.body.contains("combine") && draft.body.contains("keep_tools") && draft.body.contains("Cutter"));
assert!(pending(&app), "Help must request real evidence, not just copy generic text");
assert_eq!(before, (app.session.doc.to_json(), app.session.undo.len(), app.session.redo.len(), app.cam));
}
#[test]
fn issue_report_selected_and_recent_sketch_geometry_is_not_exported() {
let mut app = app();
app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"PublicExample"})).unwrap();
let created = app.run("sketch.create", json!({"plane":"XY"})).unwrap();
let sketch = created["sketch"].as_u64().unwrap();
app.run("sketch.point", json!({"point":[123456.789123,987654.321987]})).unwrap();
app.run("sketch.finish", json!({})).unwrap();
app.session.selection = vec![Sel::Feature { id: sketch }];
let stored = app.session.doc.to_json();
assert!(stored.contains("123456.789123") && stored.contains("987654.321987"));
let draft = draft_for(&app, None, None);
assert!(
draft.body.contains("\"type\": \"box\"") && draft.body.contains("\"length\""),
"keep the relevant last modeling settings: {}",
draft.body
);
assert!(!draft.body.contains("123456.789123") && !draft.body.contains("987654.321987"));
assert!(!draft.body.contains("\"points\""), "never serialize the selected or recent sketch point list");
assert_eq!(stored, app.session.doc.to_json());
}
#[test]
fn issue_report_error_row_click_opens_the_actual_boolean_draft() {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"})).unwrap();
h.app.home.open = false;
combine(&mut h.app);
let error = h.app.run("solid.combine", json!({"target":"Stock","tools":["Cutter"],"operation":"cut","keep_tools":true})).unwrap_err();
h.app.dialog.as_mut().unwrap().error = Some(error.clone());
h.frames(3);
let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len());
h.step(&json!({"widget":"Create Issue…"})).unwrap();
let draft = h.app.issue_report.draft.as_ref().unwrap();
assert!(draft.body.contains("solid.combine") && draft.body.contains("Cutter") && draft.body.contains(&format!("{error:?}")));
assert!(h.app.dialog.is_some(), "reporting must not cancel the live feature dialog");
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len()));
}
#[test]
fn issue_report_long_draft_open_click_copies_the_full_reviewed_text() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
let mut draft = draft_for(&h.app, None, None);
draft.body = "Reviewed context 🚀 &labels=private\n".repeat(120);
let reviewed = draft.body.clone();
h.app.issue_report.draft = Some(draft);
h.frames(4);
let [x, y] = h.widget("Open GitHub draft").unwrap();
assert_eq!(h.call("ui.move", json!({"x":x,"y":y}))["ok"], true);
let (request, _reply) = crate::control::ControlRequest::new("ui.click", json!({"x":x,"y":y}));
assert!(matches!(crate::control::handle(&mut h.app, &h.ctx, &request), crate::control::Outcome::Done(value) if value["ok"] == true));
let mut commands = Vec::new();
for _ in 0..12 {
h.frame();
commands.extend(h.output.commands.clone());
}
assert!(commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(text) if text == &reviewed)), "commands: {commands:?}");
assert!(
commands.iter().any(|c| matches!(c, egui::OutputCommand::OpenUrl(url) if url.url.starts_with(ISSUE_URL) && !url.url.contains("&body=")))
);
assert_eq!(h.app.issue_report.draft.as_ref().unwrap().body, reviewed);
assert!(h.app.issue_report.draft.as_ref().unwrap().note.starts_with("Report copied. Paste into the GitHub draft"));
}
#[test]
fn issue_report_editing_enter_and_cad_shortcuts_leave_the_live_boolean_unchanged() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"})).unwrap();
h.app.run("solid.box", json!({"length":5,"width":5,"height":5,"body_name":"Cutter"})).unwrap();
combine(&mut h.app);
h.app.issue_report.draft = Some(draft_for(&h.app, h.app.dialog.as_ref(), None));
h.frames(4);
let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision);
for field in ["issue_report_title", "issue_report_body"] {
let id = egui::Id::new(field);
h.ctx.memory_mut(|m| m.request_focus(id));
h.frames(1);
h.step(&json!({"text":"edited-report-marker"})).unwrap();
h.step(&json!({"key":"Enter"})).unwrap();
assert!(h.app.dialog.is_some(), "Enter in {field} must not apply the valid Boolean");
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision));
h.ctx.memory_mut(|m| m.surrender_focus(id));
}
let draft = h.app.issue_report.draft.as_ref().unwrap();
assert!(draft.title.contains("edited-report-marker") && draft.body.contains("edited-report-marker"));
for key in [json!({"key":"Enter"}), json!({"key":"Escape"}), json!({"key":"Z","cmd":true})] {
h.step(&key).unwrap();
assert!(h.app.dialog.is_some() && h.app.issue_report.draft.is_some());
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision));
}
}
#[test]
fn issue_report_placed_sketch_text_cannot_steal_focus_apply_or_cancel_behind_draft() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.start("sketch.text");
h.frames(3);
h.step(&json!({"click":{"sketch":[0,0]}})).unwrap();
h.step(&json!({"text":"Pending CAD text"})).unwrap();
assert!(h.widget("Pending CAD text").is_some(), "the real placed Text editor must be open");
let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision);
h.app.help.menu = true;
h.frames(2);
h.step(&json!({"click":{"handle":"menu:report"}})).unwrap();
h.frames(3);
assert!(h.widget("Pending CAD text").is_some(), "capture must keep the underlying Text editor drawn");
let token = h.app.issue_report.pending.as_ref().unwrap().token;
receive(&mut h.app, token, Arc::new(ColorImage::new([2, 2], vec![egui::Color32::GREEN; 4])));
h.frames(3);
for field in ["issue_report_title", "issue_report_body"] {
let id = egui::Id::new(field);
h.ctx.memory_mut(|m| m.request_focus(id));
h.frames(2);
assert_eq!(h.ctx.memory(|m| m.focused()), Some(id), "placed Text must not steal report focus");
h.step(&json!({"text":"report-only-marker"})).unwrap();
h.step(&json!({"key":"Enter"})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision));
assert!(h.widget("Pending CAD text").is_some(), "report keys must not apply, edit or cancel the Text editor");
}
let draft = h.app.issue_report.draft.as_ref().unwrap();
assert!(draft.title.contains("report-only-marker") && draft.body.contains("report-only-marker"));
h.step(&json!({"widget":"Close draft"})).unwrap();
assert!(h.app.issue_report.draft.is_none());
h.frames(2);
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision));
assert!(h.widget("Pending CAD text").is_some());
h.step(&json!({"key":"Enter"})).unwrap();
assert_ne!(before.0, h.app.session.doc.to_json(), "closing the report must return control to the preserved CAD text");
assert_eq!(h.app.session.undo.len(), before.1 + 1);
}
#[test]
fn issue_report_boundary_preserves_opacity_widget_state_and_real_focus() {
let mut app = app();
let ctx = egui::Context::default();
let focus = egui::Id::new("report-review-focus");
let state = egui::Id::new("cad-layout-state");
begin(&mut app, &ctx, None, None);
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
focused: true,
..Default::default()
};
let mut output = ctx.run_ui(raw, |ui| {
ctx.memory_mut(|m| m.request_focus(focus));
let barrier = CadInput::begin(&app, &ctx).unwrap();
ui.visuals_mut().disabled_alpha = 1.0;
ui.disable();
assert_eq!(ui.opacity(), 1.0, "capture must retain normal drawing opacity");
assert!(!ui.is_enabled());
crate::frame::area(egui::Id::new("cad-boundary-proof")).fade_in(false).show(&ctx, |ui| {
assert!(!ui.is_enabled());
assert_eq!(ui.opacity(), 1.0);
ui.ctx().data_mut(|d| d.insert_temp(state, 42_u32));
ui.ctx().memory_mut(|m| m.request_focus(egui::Id::new("underlying-editor")));
});
drop(barrier);
assert_eq!(ctx.memory(|m| m.focused()), Some(focus));
assert_eq!(ctx.data(|d| d.get_temp::<u32>(state)), Some(42));
assert!(crate::frame::area(egui::Id::new("report-boundary-proof")).is_enabled());
});
output.textures_delta.clear();
app.issue_report.draft = None;
assert!(!cad_input_blocked());
}
#[test]
fn issue_report_cached_point_drag_release_does_not_finish_cad_drag() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,0],[20,0]],"id":"line"})).unwrap();
h.frames(3);
let at = h.call("ui.at", json!({"sketch":[0,0]}));
let p = egui::pos2(at["result"][0].as_f64().unwrap() as f32, at["result"][1].as_f64().unwrap() as f32);
h.app.synthetic.push(egui::Event::PointerMoved(p));
h.frame();
h.app.synthetic.push(egui::Event::PointerButton {
pos: p,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::NONE,
});
h.frame();
let q = p + egui::vec2(40.0, -40.0);
h.app.synthetic.push(egui::Event::PointerMoved(q));
h.frames(2);
let drag = h.app.viewport.point_drag.clone();
assert!(drag.is_some(), "real viewport point drag must have started");
let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision);
help::run(&mut h.app, &h.ctx, "report");
// egui calculates this release before the CAD input barrier begins.
h.app.synthetic.push(egui::Event::PointerButton {
pos: q,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::NONE,
});
h.frame();
assert_eq!(h.app.viewport.point_drag, drag, "cached release must not clear or constrain the CAD drag");
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision));
let token = h.app.issue_report.pending.as_ref().unwrap().token;
receive(&mut h.app, token, Arc::new(ColorImage::new([2, 2], vec![egui::Color32::GREEN; 4])));
h.frames(3);
h.step(&json!({"widget":"Close draft"})).unwrap();
assert!(h.app.viewport.point_drag.is_none());
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision));
}
#[test]
fn issue_report_new_dimension_draft_resumes_and_commits_once_after_close() {
crate::dim_view::cancel();
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,0],[20,0]]})).unwrap();
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 80.0;
h.app.cam.target = solvecraft_engine::geom::Vec3::new(10.0, 0.0, 0.0);
h.settle();
h.step(&json!({"start":"sketch.dimension"})).unwrap();
for point in [[0, 0, 0], [20, 0, 0], [10, 5, 0]] {
h.step(&json!({"click":{"world":point}})).unwrap();
}
h.step(&json!({"text":"30 mm"})).unwrap();
assert!(crate::dim_view::editing());
let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision);
help::run(&mut h.app, &h.ctx, "report");
h.frames(3);
let token = h.app.issue_report.pending.as_ref().unwrap().token;
receive(&mut h.app, token, Arc::new(ColorImage::new([2, 2], vec![egui::Color32::GREEN; 4])));
h.frames(3);
h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("issue_report_body")));
h.step(&json!({"text":"Typing here describes the bug, not a CAD dimension."})).unwrap();
h.step(&json!({"key":"Enter"})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision));
assert!(crate::dim_view::editing() && h.widget("30 mm").is_some());
assert!(h.app.issue_report.draft.as_ref().unwrap().body.contains("Typing here describes"));
h.step(&json!({"widget":"Close draft"})).unwrap();
h.frames(2);
assert_eq!(h.ctx.memory(|m| m.focused()), Some(egui::Id::new("sc_dim_edit")));
h.step(&json!({"key":"Enter"})).unwrap();
assert!(!crate::dim_view::editing());
assert_eq!(h.app.session.undo.len(), before.1 + 1);
assert_eq!(h.app.session.doc.param("d1").unwrap().expr, "30 mm");
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), before.0);
}
#[test]
fn issue_report_inline_dimension_retains_expression_and_resumes_after_close() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"circle"})).unwrap();
let out = h.app.run("sketch.dimension", json!({"entities":["circle"],"type":"radius","value":10})).unwrap();
let param = out["param"].as_str().unwrap().to_owned();
h.frames(3);
crate::dim_view::edit_param(&h.app, &param);
h.frames(2);
h.step(&json!({"text":"12"})).unwrap();
assert!(crate::dim_view::editing() && h.widget("12").is_some());
let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision);
help::run(&mut h.app, &h.ctx, "report");
h.frames(3);
let token = h.app.issue_report.pending.as_ref().unwrap().token;
receive(&mut h.app, token, Arc::new(ColorImage::new([2, 2], vec![egui::Color32::GREEN; 4])));
h.frames(3);
h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("issue_report_body")));
h.step(&json!({"text":"report only"})).unwrap();
h.step(&json!({"key":"Enter"})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision));
assert!(crate::dim_view::editing() && h.widget("12").is_some());
h.step(&json!({"widget":"Close draft"})).unwrap();
h.frames(2);
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision));
assert!(crate::dim_view::editing() && h.widget("12").is_some());
h.step(&json!({"key":"Enter"})).unwrap();
assert!(!crate::dim_view::editing());
assert_eq!(h.app.session.doc.param(&param).unwrap().expr, "12");
assert_eq!(h.app.session.undo.len(), before.1 + 1);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn issue_report_native_file_drop_cannot_import_behind_review() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
h.frames(3);
let tabs_before = h.app.docs.count();
assert!(!h.app.file_load.busy());
let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision);
help::run(&mut h.app, &h.ctx, "report");
let path = h.dir.join("private-design.json");
std::fs::write(&path, h.app.session.doc.to_json()).unwrap();
#[derive(Debug)]
struct File(std::path::PathBuf);
impl egui::DroppedFile for File {
fn path(&self) -> &std::path::Path {
&self.0
}
fn bytes(&self) -> Result<Vec<u8>, String> {
std::fs::read(&self.0).map_err(|e| e.to_string())
}
}
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))),
focused: true,
dropped_files: vec![Arc::new(File(path))],
..Default::default()
};
let app = &mut h.app;
let mut output = h.ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
output.textures_delta.clear();
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision));
assert!(h.app.issue_report.draft.is_some());
assert!(!h.app.file_load.busy(), "native drop must not queue an asynchronous open behind the draft");
assert_eq!(h.app.docs.count(), tabs_before);
}
#[test]
fn issue_report_capture_accepts_only_its_matching_unchanged_frame() {
let mut app = app();
let ctx = egui::Context::default();
begin(&mut app, &ctx, None, None);
let token = app.issue_report.pending.as_ref().unwrap().token;
let (reply, _receiver) = std::sync::mpsc::channel();
app.pending_shots.push((token + 1, None, reply, crate::now_ms() + 8000.0));
app.shot_token = token + 1;
let image = Arc::new(ColorImage::new([4, 3], vec![egui::Color32::GREEN; 12]));
receive(&mut app, token + 1, image.clone());
assert!(pending(&app) && app.issue_report.draft.as_ref().unwrap().image.is_none());
receive(&mut app, token, image.clone());
assert!(!pending(&app));
assert_eq!(app.issue_report.draft.as_ref().unwrap().image.as_ref().unwrap().size, [4, 3]);
assert_eq!(app.pending_shots.len(), 1, "report cannot consume the control request");
begin(&mut app, &ctx, None, None);
let token = app.issue_report.pending.as_ref().unwrap().token;
app.cam.distance *= 2.0;
receive(&mut app, token, image);
let draft = app.issue_report.draft.as_ref().unwrap();
assert!(draft.image.is_none() && draft.note.contains("changed"));
}
#[test]
fn issue_report_capture_rejects_changed_inputs_timeout_and_malformed_images() {
let mut app = app();
combine(&mut app);
let ctx = egui::Context::default();
let dialog = app.dialog.clone();
begin(&mut app, &ctx, dialog.as_ref(), Some("bad Boolean"));
app.dialog.as_mut().unwrap().extra.insert("direction".into(), json!("negative"));
tick(&mut app, &ctx);
assert!(!pending(&app));
assert!(app.issue_report.draft.as_ref().unwrap().note.contains("changed"));
let dialog = app.dialog.clone();
begin(&mut app, &ctx, dialog.as_ref(), None);
app.issue_report.pending.as_mut().unwrap().deadline = -1.0;
tick(&mut app, &ctx);
assert!(app.issue_report.draft.as_ref().unwrap().note.contains("No app frame"));
let dialog = app.dialog.clone();
begin(&mut app, &ctx, dialog.as_ref(), None);
let token = app.issue_report.pending.as_ref().unwrap().token;
receive(&mut app, token, Arc::new(ColorImage::new([0, 0], vec![])));
assert!(app.issue_report.draft.as_ref().unwrap().image.is_none());
assert!(app.issue_report.draft.as_ref().unwrap().note.contains("dimensions"));
}
}
+82 -13
View File
@@ -25,6 +25,8 @@ pub mod dialogs_plastic;
pub mod dialogs_sheet;
pub mod dialogs_surface;
pub mod dim_view;
#[cfg(test)]
mod dimension_create_ui_tests;
pub mod documents;
pub mod drag_snap;
pub mod fonts;
@@ -38,6 +40,7 @@ pub mod i18n;
mod i18n_ui_tests;
pub mod icons;
pub mod inference;
pub mod issue_report;
pub mod keymap;
#[cfg(test)]
mod menu_tests;
@@ -98,6 +101,7 @@ pub mod toolbar;
pub mod toolbar_custom;
pub mod tools;
pub mod viewport;
mod viewport_layout;
pub mod workspace;
use std::sync::mpsc::Receiver;
@@ -209,6 +213,8 @@ pub struct SolveApp {
pub keymap: keymap::Keymap,
/// The Help menu and the About window.
pub help: help::HelpState,
/// An editable local issue draft and optional captured app frame.
pub issue_report: issue_report::ReportState,
/// Toolbar buttons the user pinned, removed or reordered.
pub toolbar_custom: toolbar_custom::ToolbarCustom,
/// Preferences kept between runs, and their window.
@@ -247,6 +253,7 @@ pub struct SolveApp {
shot_token: u64,
styled: bool,
fitted: bool,
pending_fit: Option<(Camera, solvecraft_engine::geom::Aabb3, bool)>,
/// The start page.
pub home: home::HomeState,
pub file_load: file_load::FileLoad,
@@ -275,6 +282,7 @@ impl SolveApp {
sbox: shortcut_box::ShortcutBox::default(),
keymap: keymap::Keymap::default(),
help: help::HelpState::default(),
issue_report: issue_report::ReportState::default(),
toolbar_custom: toolbar_custom::ToolbarCustom::default(),
preferences: prefs::Prefs::default(),
prefs_window: prefs::PrefsWindow::default(),
@@ -300,6 +308,7 @@ impl SolveApp {
shot_token: 0,
styled: false,
fitted: false,
pending_fit: None,
home: home::HomeState::default(),
file_load: file_load::FileLoad::default(),
docs: documents::Documents::default(),
@@ -433,6 +442,7 @@ impl SolveApp {
Ok(_) => {
self.session.echo(format!("{} done", command_label(id)));
if matches!(id, "file.new" | "doc.open" | "doc.recover") {
dim_view::cancel();
self.menu.props = None;
sketch_attachment_ui::clear(self);
// The old document is already replaced: discard its editor without
@@ -471,10 +481,11 @@ impl SolveApp {
/// that have them, otherwise run it with defaults.
pub fn start(&mut self, id: &str) {
// A component moved without Capture Position: ask first (Capture, Revert or Cancel).
if browser::needs_capture(self, id) {
if browser::needs_capture(self, id) && !(id == "sketch.dimension" && self.session.active_sketch.is_some()) {
self.tree.capture_prompt = Some(id.to_string());
return;
}
dim_view::cancel();
self.tool = None;
self.home.open = false;
if matches!(id, "sketch.insert_dxf" | "sketch.insert_svg") {
@@ -649,6 +660,7 @@ impl SolveApp {
/// Finish the active sketch: stop editing, restore the history marker Edit Sketch moved and
/// return to the view from before the sketch.
pub fn finish_sketch(&mut self) {
dim_view::cancel();
self.tool = None;
let _ = self.run("sketch.finish", json!({}));
if let Some(marker) = self.pre_sketch_marker.take() {
@@ -741,6 +753,8 @@ impl SolveApp {
self.cam_anim = None;
self.viewport.orbit_snap = None;
self.viewport.orbit_free = None;
let bounds = solvecraft_engine::view::bounds(&self.session);
self.pending_fit = Some((self.cam, bounds, false));
self.cam = self.fitted(self.cam);
}
@@ -751,13 +765,39 @@ impl SolveApp {
cam.target = solvecraft_engine::geom::Vec3::ZERO;
cam.distance = 150.0;
} else {
cam.fit(&b);
cam = self.fitted_bounds(cam, &b);
}
cam
}
fn fitted_bounds(&self, mut cam: Camera, bounds: &solvecraft_engine::geom::Aabb3) -> Camera {
if let Some(layout) = &self.viewport.layout {
return viewport_layout::fit_bounds(cam, bounds, layout, self.ui.perspective).unwrap_or(cam);
}
let previous = cam;
cam.fit(bounds);
if cam.is_valid() { cam } else { previous }
}
fn resolve_fit(&mut self, ctx: &egui::Context) {
viewport::refresh_layout(self, ctx.pixels_per_point());
if self.viewport.layout.is_some()
&& let Some((cam, bounds, animate)) = self.pending_fit.take()
{
let to = if bounds.is_empty() { self.fitted(cam) } else { self.fitted_bounds(cam, &bounds) };
if animate {
self.animate_to(to);
} else {
self.cam = to;
self.cam_anim = None;
}
ctx.request_repaint();
}
}
/// Move the camera to `to` over half a second (ease in-out, orientation slerped).
pub fn animate_to(&mut self, to: Camera) {
self.pending_fit = None;
self.viewport.orbit_snap = None;
self.viewport.orbit_free = None;
if !to.is_valid() {
@@ -779,15 +819,23 @@ impl SolveApp {
/// Animated view changes for the axis view and navigation bar.
pub fn animate_view(&mut self, v: &str) {
self.pending_fit = None;
let mut to = self.cam;
match v {
// With something selected, Fit frames the selection.
"fit" => match viewport::selection_bounds(self) {
Some(b) => to.fit(&b),
None => to = self.fitted(to),
Some(b) => {
self.pending_fit = Some((to, b, true));
to = self.fitted_bounds(to, &b);
}
None => {
self.pending_fit = Some((to, solvecraft_engine::view::bounds(&self.session), true));
to = self.fitted(to);
}
},
"home" => {
to.set_view(StandardView::Iso);
self.pending_fit = Some((to, solvecraft_engine::view::bounds(&self.session), true));
to = self.fitted(to);
}
v => match StandardView::parse(v) {
@@ -795,11 +843,14 @@ impl SolveApp {
None => return,
},
}
let request = self.pending_fit.take();
self.animate_to(to);
self.pending_fit = request;
}
/// Stop any view animation where it is (the user took over the camera).
pub fn cancel_view_animation(&mut self) {
self.pending_fit = None;
self.cam_anim = None;
}
@@ -838,14 +889,19 @@ impl SolveApp {
if !self.synthetic.is_empty() {
ctx.request_repaint();
}
issue_report::tick(self, ctx);
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
toolbar::shortcuts(self, ctx);
if self.issue_report.draft.is_none() {
toolbar::shortcuts(self, ctx);
}
recovery_ui::tick(self);
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
let p = f.path().to_string_lossy().to_string();
if !p.is_empty() {
self.drop_path(&p);
if self.issue_report.draft.is_none() {
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
let p = f.path().to_string_lossy().to_string();
if !p.is_empty() {
self.drop_path(&p);
}
}
}
}
@@ -881,14 +937,26 @@ impl SolveApp {
pub fn ui(&mut self, ui: &mut egui::Ui) {
let t0 = now_ms();
let _language = crate::i18n::scope(&self.preferences.language);
let mut report_input = None;
issue_report::CadInput::block(self, ui, &mut report_input);
self.cad_ui(ui, &mut report_input);
drop(report_input);
issue_report::show(self, ui.ctx());
if !self.home.open {
agent_cursor::paint(self, ui.ctx());
}
self.frame_ms = now_ms() - t0;
}
fn cad_ui(&mut self, ui: &mut egui::Ui, report_input: &mut Option<issue_report::CadInput>) {
scenario::clear_handles();
toolbar::app_bar(self, ui);
issue_report::CadInput::block(self, ui, report_input);
file_load::show(self, ui.ctx());
documents::prompt(self, ui.ctx());
if self.home.open {
home::show(self, ui);
if self.home.open {
self.frame_ms = now_ms() - t0;
return;
}
}
@@ -905,7 +973,9 @@ impl SolveApp {
viewport::show(self, ui);
});
dialogs::show(self, ui.ctx());
issue_report::CadInput::block(self, ui, report_input);
sketch_palette::show(self, ui.ctx());
self.resolve_fit(ui.ctx());
params_dialog::show(self, ui.ctx());
recovery_ui::show(self, ui.ctx());
stand_up::show(self, ui.ctx());
@@ -916,8 +986,6 @@ impl SolveApp {
}
shortcut_box::show(self, ui.ctx());
prefs::show(self, ui.ctx());
agent_cursor::paint(self, ui.ctx());
self.frame_ms = now_ms() - t0;
}
fn drain_control(&mut self, ctx: &egui::Context) {
@@ -963,7 +1031,7 @@ impl SolveApp {
}
fn collect_screenshots(&mut self, ctx: &egui::Context) {
if self.pending_shots.is_empty() {
if self.pending_shots.is_empty() && !issue_report::pending(self) {
return;
}
let events: Vec<_> = ctx.input(|i| {
@@ -980,6 +1048,7 @@ impl SolveApp {
.collect()
});
for (token, image) in events {
issue_report::receive(self, token, image.clone());
if let Some(i) = self.pending_shots.iter().position(|(t, ..)| *t == token) {
let (_, path, reply, _) = self.pending_shots.remove(i);
let _ = reply.send(control::save_screenshot(&image, path.as_deref()));
+1 -1
View File
@@ -463,7 +463,7 @@ pub fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut String) ->
}
let mut pick: Option<String> = if tab { list.first().map(|s| s.text.clone()) } else { None };
let t = Tokens::get();
egui::Area::new(resp.id.with("completions")).order(egui::Order::Tooltip).fixed_pos(resp.rect.left_bottom() + egui::vec2(0.0, 2.0)).show(
crate::frame::area(resp.id.with("completions")).order(egui::Order::Tooltip).fixed_pos(resp.rect.left_bottom() + egui::vec2(0.0, 2.0)).show(
&ctx,
|ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
+2 -2
View File
@@ -245,7 +245,7 @@ fn build_tool(before: &ModelState, after: &ModelState, c: Colors, op: &str) -> B
}
let edge = if op == "cut" { [c.removed[0], c.removed[1], c.removed[2], 255] } else { c.edge };
for (ei, e) in m.edges.iter().enumerate() {
if !m.seams.get(ei).copied().unwrap_or(false) {
if m.shaded_edge_visible(ei) {
for w in e.windows(2) {
sc.line(w[0].to_f32(), w[1].to_f32(), edge, 1.3, op != "join");
}
@@ -306,7 +306,7 @@ pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built {
}
}
for (ei, e) in am.edges.iter().enumerate() {
if am.seams.get(ei).copied().unwrap_or(false) {
if !am.shaded_edge_visible(ei) {
continue;
}
for w in e.windows(2) {
+1 -1
View File
@@ -158,7 +158,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
at.x.clamp(screen.left() + 4.0, (screen.right() - W - 4.0).max(screen.left())),
at.y.clamp(screen.top() + 4.0, (screen.bottom() - h).max(screen.top())),
);
let resp = egui::Area::new(egui::Id::new("sc_sbox")).fixed_pos(pos).order(egui::Order::Foreground).show(ctx, |ui| {
let resp = crate::frame::area(egui::Id::new("sc_sbox")).fixed_pos(pos).order(egui::Order::Foreground).show(ctx, |ui| {
crate::context_menu::menu_frame(ui, |ui| {
ui.set_width(W - 10.0);
// Pinned commands: a mini toolbar.
+1 -1
View File
@@ -323,7 +323,7 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
s.store(ui.ctx(), id);
}
let pos: Pos2 = sp + vec2(-34.0, -11.0);
egui::Area::new(egui::Id::new(("sc_dim_area", i))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
crate::frame::area(egui::Id::new(("sc_dim_area", i))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
let fill = if b.locked { tk.accent_soft } else { tk.panel };
egui::Frame::popup(ui.style()).fill(fill).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| {
let r = ui
@@ -90,6 +90,11 @@ 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);
// This cache test needs a controlled projected radius, independent of floating controls
// used by Fit. At this radius, doubling physical scale crosses a subdivision threshold.
h.app.cancel_view_animation();
h.app.cam.distance = 5.0;
h.app.cam.target = Vec3::ZERO;
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();
+16 -13
View File
@@ -167,6 +167,7 @@ pub fn set_linetype(app: &mut SolveApp, kind: u8) {
/// Draw the palette (while sketching and no command dialog is open).
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
app.viewport.palette_rect = None;
let _language = crate::i18n::scope(&app.preferences.language);
if app.dialog.is_some() {
return;
@@ -204,7 +205,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let mut look = false;
let mut finish = false;
let has_curves = !selected_curves(app).is_empty();
crate::frame::docked(
let response = crate::frame::docked(
app.tr("ui.heading_sketch_palette"),
egui::Id::new("sc_sketch_palette"),
anchor,
@@ -213,8 +214,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
)
.frame(frame)
.show(ctx, |ui| {
let width = WIDTH.min((vp.width() - 24.0).max(120.0));
let width = WIDTH.min((vp.width() * 0.46 - 16.0).max(120.0));
ui.set_width(width);
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
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() {
@@ -236,7 +238,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
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.vertical(|ui| {
ui.label(app.tr("ui.linetype"));
ui.horizontal(|ui| {
// With a selection: convert it; without: the mode new curves are drawn in.
@@ -254,21 +256,18 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
}
});
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();
}
});
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));
}
}
});
crate::scenario::publish_rect("sketch_palette:options", options.inner_rect);
}
@@ -284,6 +283,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
});
});
app.viewport.palette_rect = response.map(|r| r.response.rect);
if let Some(r) = app.viewport.palette_rect {
crate::scenario::publish_rect("sketch_palette:outer", r);
}
if let Some((k, on)) = set {
let mut p = serde_json::Map::new();
p.insert(k, Value::Bool(on));
+2 -2
View File
@@ -148,7 +148,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
return;
}
let mut close = false;
let resp = egui::Area::new(egui::Id::new("sc_file_area"))
let resp = crate::frame::area(egui::Id::new("sc_file_area"))
.fixed_pos(pos2(100.0 + if app.integrated_titlebar { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 }, 34.0))
.order(egui::Order::Foreground)
.show(ctx, |ui| {
@@ -432,7 +432,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
}
let open = ui.ctx().data(|d| d.get_temp::<egui::Id>(egui::Id::new("sc_panel_open"))) == Some(popup_id);
if open {
let area = egui::Area::new(popup_id).fixed_pos(lr.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
let area = crate::frame::area(popup_id).fixed_pos(lr.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(200.0);
if *panel == "SELECT" {
+9 -4
View File
@@ -132,6 +132,11 @@ pub fn on_hover(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
}
pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
// A click away dismisses the inline editor; it must not also pick the first entity of
// the next dimension before that cancellation has been processed.
if crate::dim_view::editing() {
return;
}
let Some(mut tool) = app.tool.take() else { return };
// In a 3D sketch the drawing tools take points off the plane.
if crate::sketch3d::on_click(app, &mut tool, proj, pos) {
@@ -199,12 +204,11 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
}
} else if !tool.picks.is_empty()
&& let Some((at, _)) = sketch_point_at(app, proj, pos)
&& let Ok(v) = app.run("sketch.dimension", json!({"entities": tool.picks, "text_at": [at.x, at.y]}))
{
if let Some(p) = v["param"].as_str() {
crate::dim_view::edit_param(app, p);
match crate::dim_view::begin_dimension(app, tool.picks.clone(), at) {
Ok(()) => tool.picks.clear(),
Err(error) => app.set_status(error, true),
}
tool.picks.clear();
}
}
}
@@ -333,6 +337,7 @@ fn run_pick(app: &mut SolveApp, t: &Tool) {
/// Right click / Esc: end the current chain (line) or cancel the tool.
pub fn finish(app: &mut SolveApp) {
crate::dim_view::cancel();
if crate::sketch3d::finish(app) {
return;
}
+196 -28
View File
@@ -33,6 +33,9 @@ pub struct ViewportState {
/// The last pick (its key and hits): reused while nothing it depends on changed.
last_pick: Option<(u64, Vec<Hit>)>,
pub rect: Option<Rect>,
pub(crate) layout: Option<crate::viewport_layout::Layout>,
pub(crate) palette_rect: Option<Rect>,
pub(crate) dialog_rect: Option<Rect>,
pub gpu: Option<GpuTarget>,
slot: SceneSlot,
key: u64,
@@ -339,7 +342,7 @@ fn build_scene(app: &SolveApp) -> GpuScene {
}
}
for (ei, e) in m.edges.iter().enumerate().filter(|_| edges) {
if m.seams.get(ei).copied().unwrap_or(false) {
if !m.edge_pickable(ei) || (shaded && !m.shaded_edge_visible(ei)) {
continue;
}
for w in e.windows(2) {
@@ -790,7 +793,7 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
}
let m = b.mesh();
for (ei, e) in m.edges.iter().enumerate() {
if m.seams.get(ei).copied().unwrap_or(false) {
if !m.edge_pickable(ei) {
continue;
}
for v in [e.first(), e.last()].into_iter().flatten() {
@@ -1116,7 +1119,7 @@ fn edge_lines(sc: &mut GpuScene, e: &[Vec3], core: Color32, halo: Color32, w: f3
fn hidden_face_outline(sc: &mut GpuScene, m: &solvecraft_engine::geom::Mesh, face: usize, col: Color32) {
for (ei, e) in m.edges.iter().enumerate() {
let on = m.edge_faces.get(ei).is_some_and(|f| f.iter().any(|x| *x as usize == face));
if on && !m.seams.get(ei).copied().unwrap_or(false) {
if on && m.edge_pickable(ei) {
for p in e.windows(2) {
sc.hidden_line(p[0].to_f32(), p[1].to_f32(), c4(col.gamma_multiply(0.45)), 1.4);
}
@@ -1341,9 +1344,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
}
}
for (ei, e) in m.edges.iter().enumerate() {
if !m.seams.get(ei).copied().unwrap_or(false)
&& (whole || m.edge_faces.get(ei).is_some_and(|fs| fs.iter().any(|f| faces.contains(&(*f as usize)))))
{
if m.edge_pickable(ei) && (whole || m.edge_faces.get(ei).is_some_and(|fs| fs.iter().any(|f| faces.contains(&(*f as usize))))) {
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 1.5);
}
}
@@ -1375,7 +1376,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
}
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)
&& !m.seams.get(e).copied().unwrap_or(false)
&& m.edge_pickable(e)
{
edge_lines(&mut sc, p, t.hover_edge, t.hover_edge_halo, 1.5);
}
@@ -1474,6 +1475,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
let rect = ui.max_rect();
app.viewport.rect = Some(rect);
refresh_layout(app, ui.ctx().pixels_per_point());
let resp = ui.interact(rect, ui.id().with("viewport"), Sense::click_and_drag());
let (hover, scroll, mods, middle, secondary_down, primary_down, delta) = ui.input(|i| {
(
@@ -1489,7 +1491,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
// The right button belongs to the marking menu while it is open.
let secondary_down = secondary_down && app.viewport.context_menu.is_none();
// The axis view handles its own clicks; the model underneath must not see them.
let axes = axis_rect(rect);
let axes = axis_rect(app, rect);
// Floating windows and menus own input over the model, including wheel navigation.
let inside = resp.contains_pointer() && hover.is_some_and(|p| !axes.contains(p));
app.viewport.mouse = hover.filter(|_| inside);
@@ -1556,7 +1558,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
if app.viewport.spin {
// A click on the navigation bar (its own button) is not a stop.
let bar = Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(320.0, 30.0));
let bar = app
.viewport
.layout
.as_ref()
.map(|l| l.navigation)
.unwrap_or_else(|| Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(320.0, 30.0)));
let (pressed, key, dt) = ui.input(|i| {
(
i.pointer.any_pressed() && inside && !i.pointer.press_origin().is_some_and(|p| bar.contains(p)),
@@ -2057,7 +2064,7 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
for (ei, e) in m.edges.iter().enumerate() {
let rim = m.circular_rim(ei);
let takes = if bx.crossing { bx.takes(&to2(e)) } else { rim.iter().all(|&i| m.edges.get(i).is_some_and(|p| bx.takes(&to2(p)))) };
if !m.seams.get(ei).copied().unwrap_or(false) && takes {
if m.edge_pickable(ei) && takes {
out.push(Sel::Edge { body: b.name.clone(), index: ei, point: polyline_mid(e) });
}
}
@@ -2257,22 +2264,39 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter,
}
}
pub(crate) fn refresh_layout(app: &mut SolveApp, scale: f32) {
if let Some(rect) = app.viewport.rect {
let docks: Vec<_> = [
app.viewport.palette_rect.filter(|_| app.session.active_sketch.is_some() && app.dialog.is_none()),
app.viewport.dialog_rect.filter(|_| app.dialog.is_some()),
]
.into_iter()
.flatten()
.collect();
app.viewport.layout = Some(crate::viewport_layout::Layout::new(rect, &docks, scale));
}
}
/// Compact orientation gizmo, drawn and hit-tested in world coordinates.
const AXIS_LENGTH: f64 = 38.0;
const AXIS_RADIUS: f32 = 11.0;
fn axis_center(rect: Rect) -> Pos2 {
pos2(rect.right() - 68.0, rect.top() + 68.0)
fn axis_center(app: &SolveApp, rect: Rect) -> Pos2 {
axis_rect(app, rect).center()
}
fn axis_rect(rect: Rect) -> Rect {
Rect::from_center_size(axis_center(rect), vec2(116.0, 116.0))
fn axis_rect(app: &SolveApp, rect: Rect) -> Rect {
app.viewport
.layout
.as_ref()
.filter(|l| l.viewport == rect)
.map(|l| l.axis_view)
.unwrap_or_else(|| Rect::from_center_size(pos2(rect.right() - 68.0, rect.top() + 68.0), vec2(116.0, 116.0)))
}
/// Six signed world axes, sorted back to front so overlapping tips have one owner.
fn axis_tips(cam: &Camera, rect: Rect) -> Vec<(&'static str, Vec3, Pos2, f64)> {
fn axis_tips(cam: &Camera, c: Pos2) -> Vec<(&'static str, Vec3, Pos2, f64)> {
let (r, u, b) = cam.basis();
let c = axis_center(rect);
let mut tips: Vec<_> = [("x", Vec3::X), ("-x", -Vec3::X), ("y", Vec3::Y), ("-y", -Vec3::Y), ("z", Vec3::Z), ("-z", -Vec3::Z)]
.into_iter()
.map(|(name, d)| (name, d, c + vec2((d.dot(r) * AXIS_LENGTH) as f32, -(d.dot(u) * AXIS_LENGTH) as f32), d.dot(b)))
@@ -2386,7 +2410,7 @@ fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool {
(p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos(), i.modifiers.shift)
});
if pressed && ui.rect_contains_pointer(rect) {
app.viewport.axis_drag = origin.filter(|p| axis_rect(rect).contains(*p));
app.viewport.axis_drag = origin.filter(|p| axis_rect(app, rect).contains(*p));
if app.viewport.axis_drag.is_some() {
app.viewport.orbit_free = None;
}
@@ -2406,14 +2430,20 @@ fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool {
}
fn axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
if !axis_rect(app, rect).is_positive() {
app.viewport.axis_drag = None;
crate::scenario::publish_count("axis_view_highlights", 0.0);
return;
}
crate::scenario::publish_rect("axis_view:outer", axis_rect(app, rect));
let t = Tokens::get();
let c = axis_center(rect);
let c = axis_center(app, rect);
let orbiting = axis_view_orbit(app, ui, rect);
let tips = axis_tips(&app.cam, rect);
let tips = axis_tips(&app.cam, c);
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect));
let hovered = if orbiting { None } else { hover.and_then(|p| axis_hit(&tips, p)) };
let painter = ui.painter_at(rect);
if orbiting || hover.is_some_and(|p| axis_rect(rect).contains(p)) {
if orbiting || hover.is_some_and(|p| axis_rect(app, rect).contains(p)) {
painter.circle_filled(c, 55.0, t.overlay);
}
for (i, (name, d, at, depth)) in tips.iter().enumerate() {
@@ -2462,7 +2492,9 @@ fn axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
let home = Rect::from_center_size(pos2(c.x - 46.0, c.y - 54.0), vec2(18.0, 18.0));
let home = Rect::from_center_size(pos2(c.x - 44.0, c.y - 44.0), vec2(18.0, 18.0));
crate::scenario::publish_rect("axis_view:home", home);
crate::scenario::publish_handle("axis_view:home", home.center());
let hr = ui.interact(home, ui.id().with("axis_view_home"), Sense::click());
icons::paint(&painter, home, "home", t.icon, if hr.hovered() { t.accent_soft } else { t.overlay }, t.accent);
if hr.on_hover_text("Home view").clicked() {
@@ -2508,6 +2540,9 @@ pub fn look_at_selection(app: &mut SolveApp) {
}
fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
if app.viewport.layout.as_ref().is_some_and(|l| !l.navigation.is_positive()) {
return;
}
let t = Tokens::get();
let items: [(&str, &str); 10] = [
("orbit", "Orbit (right drag; hold Shift to snap)"),
@@ -2524,11 +2559,21 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
let menu_id = ui.id().with("nav_view_menu");
let mut menu_open = ui.data(|d| d.get_temp::<bool>(menu_id)).unwrap_or(false);
let w = items.len() as f32 * 30.0 + 10.0;
let bar = Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(w, 30.0));
let bar = app
.viewport
.layout
.as_ref()
.map(|l| l.navigation)
.unwrap_or_else(|| Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(w, 30.0)));
crate::scenario::publish_rect("nav:outer", bar);
let painter = ui.painter_at(rect);
painter.rect(bar, 6.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
let columns = ((bar.width() - 10.0) / 30.0).round().clamp(1.0, 10.0) as usize;
for (i, (icon, tip)) in items.iter().enumerate() {
let br = Rect::from_min_size(pos2(bar.left() + 5.0 + i as f32 * 30.0, bar.top() + 2.0), vec2(26.0, 26.0));
let br = Rect::from_min_size(
pos2(bar.left() + 5.0 + (i % columns) as f32 * 30.0, bar.top() + 2.0 + (i / columns) as f32 * 30.0),
vec2(26.0, 26.0),
);
let resp = ui.interact(br, ui.id().with(("nav", *icon)), Sense::click());
let active = match *icon {
"orbit" => app.viewport.nav == Some(NavMode::Orbit),
@@ -2574,7 +2619,7 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
/// The View settings above the navigation bar's last button: grid, ground shadow and the agent
/// cursor (#35). False once it should close (a click outside it).
fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool {
let area = egui::Area::new(egui::Id::new("sc_view_menu"))
let area = crate::frame::area(egui::Id::new("sc_view_menu"))
.order(egui::Order::Foreground)
.pivot(Align2::RIGHT_BOTTOM)
.fixed_pos(button.right_top() - vec2(0.0, 6.0));
@@ -2731,12 +2776,14 @@ mod tests {
fn hover_frame(app: &mut SolveApp, ctx: &egui::Context, events: Vec<egui::Event>) {
let selected = app.highlighted();
let _ = ctx.run_ui(
let mut output = ctx.run_ui(
egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() },
|ui| {
show(app, ui);
},
);
// This headless frame checks interactions without applying textures to a renderer.
output.textures_delta.clear();
// Click selection is applied after rendering; hover itself must already be current.
if app.viewport.gpu.is_some() && selected == app.highlighted() {
assert_eq!(app.viewport.hl_key, highlight_key(app), "GPU highlight must use this frame's hover");
@@ -2819,6 +2866,126 @@ mod tests {
app
}
#[test]
fn smooth_wall_display_preserves_wireframe_and_edge_selection() {
let mut s = solvecraft_engine::Session::default();
let sketch = s.execute("sketch.create", &json!({"plane":"XY"})).unwrap()["sketch"].as_u64().unwrap();
s.execute("sketch.ellipse", &json!({"center":[0,0],"major":[25,0],"minor_radius":5})).unwrap();
let inner =
s.execute("sketch.offset", &json!({"curves":["e1"],"distance":-1,"chain":false})).unwrap()["curves"][0].as_str().unwrap().to_string();
let outer =
s.execute("sketch.offset", &json!({"curves":[inner],"distance":-0.5,"chain":false})).unwrap()["curves"][0].as_str().unwrap().to_string();
s.execute(
"solid.extrude",
&json!({"sketch":sketch,"distance":3,"profiles":{"type":"whole_boundaries","profiles":[{"outer":[outer],"holes":[[inner]]}]}}),
)
.unwrap();
let mut app = SolveApp::new(s, crate::Services::default());
app.ui.ground_shadow = false;
let st = app.session.world_state();
let b = st.bodies.first().unwrap();
let mesh = b.mesh();
assert_eq!(mesh.edges.len(), 1536);
assert_eq!(mesh.smooth_edges.iter().filter(|f| **f).count(), 512);
let smooth = mesh.smooth_edges.iter().position(|f| *f).unwrap();
let expected = mesh.edges.iter().enumerate().filter(|(i, _)| mesh.shaded_edge_visible(*i)).map(|(_, e)| e.len() - 1).sum::<usize>();
let shaded = build_scene(&app);
assert_eq!(shaded.lines.len() / crate::gpu::LINE_SIZE, expected);
assert!(!shaded.tris.is_empty());
app.ui.visual_style = 2;
let wire = build_scene(&app);
let expected_wire =
mesh.edges.iter().enumerate().filter(|(i, _)| !mesh.seams.get(*i).copied().unwrap_or(false)).map(|(_, e)| e.len() - 1).sum::<usize>();
assert_eq!(wire.lines.len() / crate::gpu::LINE_SIZE, expected_wire);
assert!(expected_wire > expected);
assert!(wire.tris.is_empty());
app.ui.visual_style = 0;
app.session.selection = vec![Sel::Edge { body: b.name.clone(), index: smooth, point: polyline_mid(&mesh.edges[smooth]) }];
assert!(!build_highlight(&app).lines.is_empty());
let mut without_display_mask = (*mesh).clone();
without_display_mask.smooth_edges.clear();
assert_eq!(mesh.surface_patch(mesh.edge_faces[smooth][0] as usize), without_display_mask.surface_patch(mesh.edge_faces[smooth][0] as usize));
let cam = solvecraft_engine::view::home_camera(&app.session);
let cpu = solvecraft_engine::view::scene(&app.session, &cam);
let actual_edges = cpu.lines.iter().filter(|line| line.color == solvecraft_engine::view::colors::EDGE).count();
assert_eq!(actual_edges, mesh.edges.iter().enumerate().filter(|(i, _)| mesh.shaded_edge_visible(*i)).count());
if let Ok(directory) = std::env::var("SOLVECRAFT_SMOOTH_SCREENSHOTS") {
std::fs::create_dir_all(&directory).unwrap();
for (name, old_edges, wireframe) in [("after", false, false), ("before", true, false), ("wireframe", true, true)] {
let mut scene = solvecraft_engine::view::scene(&app.session, &cam);
scene.lines.retain(|line| line.color == solvecraft_engine::view::colors::EDGE);
if old_edges {
scene.lines.clear();
for (i, edge) in mesh.edges.iter().enumerate() {
if mesh.edge_pickable(i) {
scene.lines.push(solvecraft_engine::render::SceneLine {
points: edge.clone(),
color: solvecraft_engine::view::colors::EDGE,
width: 1.2,
on_top: false,
});
}
}
}
if wireframe {
scene.meshes.clear();
}
let png = solvecraft_engine::render::render_png(&scene, &cam, 1200, 800).unwrap();
std::fs::write(std::path::Path::new(&directory).join(format!("smooth-ring-{name}.png")), png).unwrap();
}
}
}
#[test]
fn certified_small_crease_overrides_old_seam_fit_for_picking_and_patches() {
let mut s = solvecraft_engine::Session::default();
let sketch = s.execute("sketch.create", &json!({"plane":"XY"})).unwrap()["sketch"].as_u64().unwrap();
let step = std::f64::consts::TAU / 8.;
let k = 4. / 3. * (step / 4.).tan() * 10.;
for i in 0..8 {
let a = i as f64 * step;
let b = (i + 1) as f64 * step;
let p0 = [10. * a.cos(), 10. * a.sin()];
let p3 = if i == 7 { [10., 0.] } else { [10. * b.cos(), 10. * b.sin()] };
let mut p1 = [p0[0] - k * a.sin(), p0[1] + k * a.cos()];
let mut p2 = [p3[0] + k * b.sin(), p3[1] - k * b.cos()];
if i == 0 {
p1[0] += k * 1e-6;
p2[0] += k * 1e-6 * b.cos();
p2[1] += k * 1e-6 * b.sin();
}
s.execute("sketch.spline.control_point", &json!({"points":[p0,p1,p2,p3]})).unwrap();
}
s.execute("solid.extrude", &json!({"sketch":sketch,"distance":3})).unwrap();
let mut app = SolveApp::new(s, crate::Services::default());
let st = app.session.world_state();
let body = st.bodies.first().unwrap();
let mesh = body.mesh();
let crease = mesh
.edges
.iter()
.position(|line| {
line.first().zip(line.last()).is_some_and(|(a, b)| {
(a.x - 10.).abs() < 1e-8 && a.y.abs() < 1e-8 && (b.x - 10.).abs() < 1e-8 && b.y.abs() < 1e-8 && (a.z - b.z).abs() > 2.9
})
})
.unwrap();
assert!(mesh.seams[crease], "the fixture must exercise the coarse seam false positive");
assert!(mesh.sharp_edges[crease]);
assert!(mesh.shaded_edge_visible(crease));
assert!(mesh.edge_pickable(crease));
assert!(mesh.edge_faces[crease].iter().any(|f| mesh.surface_patch(*f as usize).len() == 1));
let mid = polyline_mid(&mesh.edges[crease]);
app.cam = app.cam.looking_from(Vec3::new(30., 0., 1.5));
app.cam.target = Vec3::new(0., 0., 1.5);
for perspective in [false, true] {
app.ui.perspective = perspective;
let proj = projection(&app, Rect::from_min_size(pos2(0., 0.), vec2(800., 600.)));
let hits = pick(&app, &proj, proj.to_screen(mid).unwrap());
assert!(hits.iter().any(|hit| matches!(hit,Hit::Edge{index,..} if *index==crease)), "{hits:?}");
}
}
#[test]
fn circular_items_measure_as_whole_rims_and_surfaces() {
let mut app = cylinder_app();
@@ -2963,7 +3130,7 @@ mod tests {
phase: egui::TouchPhase::Move,
});
}
let _ = ctx.run_ui(
let mut output = ctx.run_ui(
egui::RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 800.0))),
time: Some(time),
@@ -2975,6 +3142,7 @@ mod tests {
crate::prefs::show(app, ui.ctx());
},
);
output.textures_delta.clear();
};
// Let egui establish the floating window's size and hit-test widgets.
for _ in 0..4 {
@@ -3240,7 +3408,7 @@ mod tests {
h.call("ui.view", json!({"view":"front"}));
let before = h.app.cam;
h.call("ui.drag", json!({"x0":800,"y0":500,"x1":1020,"y1":500,"button":"middle","shift":true,"steps":40}));
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6);
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6, "before={before:?}, after={:?}", h.app.cam);
h.call("ui.view", json!({"view":"front"}));
h.call("ui.drag", json!({"x0":800,"y0":500,"x1":850,"y1":520,"button":"middle"}));
assert_eq!(h.app.cam.yaw, before.yaw);
@@ -3260,7 +3428,7 @@ mod tests {
{"call":"ui.view","params":{"view":"front"}}
]))
.unwrap();
let c = axis_center(h.app.viewport.rect.unwrap());
let c = axis_center(&h.app, h.app.viewport.rect.unwrap());
h.call("ui.drag", json!({"x0":c.x,"y0":c.y,"x1":c.x+220.0,"y1":c.y,"shift":true,"steps":40}));
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6);
assert!(h.app.viewport.axis_drag.is_none());
+130
View File
@@ -0,0 +1,130 @@
//! Floating controls reserve drawing space without changing rendering or picking projection.
use egui::{Rect, pos2, vec2};
use solvecraft_engine::geom::{Aabb3, Vec3};
use solvecraft_engine::render::Camera;
#[derive(Clone, Debug)]
pub(crate) struct Layout {
pub viewport: Rect,
pub drawable: Rect,
pub axis_view: Rect,
pub navigation: Rect,
}
/// Enumerate rectangles whose edges meet the viewport or a bounded number of overlays.
fn free_rect(view: Rect, obstacles: &[Rect]) -> Rect {
let mut xs = vec![view.left(), view.right()];
let mut ys = vec![view.top(), view.bottom()];
let obstacles: Vec<_> = obstacles.iter().take(8).map(|r| r.intersect(view)).filter(|r| r.is_positive() && r.is_finite()).collect();
for r in &obstacles {
xs.extend([r.left(), r.right()]);
ys.extend([r.top(), r.bottom()]);
}
xs.sort_by(f32::total_cmp);
ys.sort_by(f32::total_cmp);
let mut best = Rect::from_min_size(view.min, vec2(0.0, 0.0));
for (i, &left) in xs.iter().enumerate() {
for &right in xs.iter().skip(i + 1) {
for (j, &top) in ys.iter().enumerate() {
for &bottom in ys.iter().skip(j + 1) {
let r = Rect::from_min_max(pos2(left, top), pos2(right, bottom));
if r.area() > best.area().max(0.0) && !obstacles.iter().any(|o| r.intersect(*o).is_positive()) {
best = r;
}
}
}
}
}
best
}
impl Layout {
pub fn new(viewport: Rect, docks: &[Rect], scale: f32) -> Self {
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
let mut obstacles: Vec<_> = docks.iter().take(6).copied().filter(|r| r.is_finite()).map(|r| r.expand(4.0)).collect();
let available = free_rect(viewport, &obstacles);
let axis_view = if available.width() >= 116.0 && available.height() >= 116.0 {
Rect::from_min_size(pos2(available.right() - 116.0, available.top()), vec2(116.0, 116.0))
} else {
Rect::NOTHING
};
if axis_view.is_positive() {
obstacles.push(axis_view);
}
let nav_space = free_rect(viewport, &obstacles);
let columns = ((nav_space.width() - 10.0) / 30.0).floor().clamp(1.0, 10.0);
let nav_height = (10.0 / columns).ceil() * 30.0;
let navigation = if nav_space.width() >= columns * 30.0 + 10.0 && nav_space.height() >= nav_height + 7.0 {
Rect::from_center_size(pos2(nav_space.center().x, nav_space.bottom() - nav_height * 0.5 - 7.0), vec2(columns * 30.0 + 10.0, nav_height))
} else {
Rect::NOTHING
};
if navigation.is_positive() {
obstacles.push(navigation);
}
let drawable = free_rect(viewport, &obstacles).shrink(16.0 / scale);
Self { viewport, drawable, axis_view, navigation }
}
}
pub(crate) fn fit_bounds(mut cam: Camera, bounds: &Aabb3, layout: &Layout, perspective: bool) -> Option<Camera> {
if bounds.is_empty()
|| !bounds.min.is_finite()
|| !bounds.max.is_finite()
|| !cam.is_valid()
|| !layout.viewport.is_positive()
|| !layout.drawable.is_positive()
|| !layout.drawable.is_finite()
{
return None;
}
let original_fov = cam.fov;
cam.fov = if perspective { 0.6 } else { 0.0 };
let (r, u, b) = cam.basis();
let centre = bounds.center();
let mut hx: f64 = 0.0;
let mut hy: f64 = 0.0;
let mut depth: f64 = 0.0;
let corners: Vec<_> = [bounds.min.x, bounds.max.x]
.into_iter()
.flat_map(|x| {
[bounds.min.y, bounds.max.y].into_iter().flat_map(move |y| [bounds.min.z, bounds.max.z].into_iter().map(move |z| Vec3::new(x, y, z)))
})
.collect();
for p in &corners {
let d = *p - centre;
hx = hx.max(d.dot(r).abs());
hy = hy.max(d.dot(u).abs());
depth = depth.max(d.dot(b).abs());
}
let h = f64::from(layout.viewport.height());
let hh = (hx * h / f64::from(layout.drawable.width())).max(hy * h / f64::from(layout.drawable.height())).max(0.5);
let offset = layout.drawable.center() - layout.viewport.center();
let tangent = if perspective { (0.3_f64).tan() } else { 0.4 };
cam.distance = hh / tangent + if perspective { depth } else { 0.0 };
// Offset the target so the model projects into the free rectangle in the original viewport.
// Perspective needs a bounded correction because corners have different depths.
for _ in 0..16 {
let k = 2.0 * cam.half_height() / h;
cam.target = centre - r * (f64::from(offset.x) * k) + u * (f64::from(offset.y) * k);
let radius = (bounds.diagonal() * 0.5 + centre.dist(cam.target)).max(cam.half_height() * 2.0).max(10.0);
let fits = corners.iter().all(|p| {
cam.to_screen(*p, f64::from(layout.viewport.width()), h, radius).is_some_and(|(x, y, z)| {
x.is_finite()
&& y.is_finite()
&& (0.0..=1.0).contains(&z)
&& layout.drawable.contains(pos2(layout.viewport.left() + x as f32, layout.viewport.top() + y as f32))
})
});
if fits {
cam.fov = original_fov;
return cam.is_valid().then_some(cam);
}
cam.distance *= 1.15;
}
None
}
#[cfg(test)]
#[path = "viewport_layout_tests.rs"]
mod tests;
+196
View File
@@ -0,0 +1,196 @@
use super::*;
use crate::scenario::Harness;
use serde_json::json;
use solvecraft_engine::render::StandardView;
#[test]
fn thin_rotated_bounds_fit_using_the_original_full_projection() {
for (width, height, scale) in [(1400.0, 900.0, 1.0), (700.0, 450.0, 2.0), (700.0, 360.0, 2.0), (900.0, 720.0, 1.0)] {
let vp = Rect::from_min_max(pos2(250.0, 126.0), pos2(width, height - 30.0));
let dock = Rect::from_min_max(pos2(width - 205.0, vp.top()), vp.max);
let layout = Layout::new(vp, &[dock], scale);
assert!(layout.drawable.width() >= 80.0 && layout.drawable.height() >= 30.0, "{layout:?}");
assert!(layout.axis_view.is_positive() && layout.navigation.is_positive(), "{layout:?}");
assert!(!layout.drawable.intersect(dock).is_positive());
assert!(!layout.axis_view.intersect(layout.navigation).is_positive());
for perspective in [false, true] {
for view in [StandardView::Top, StandardView::Iso, StandardView::Right] {
let mut cam = Camera::default();
cam.set_view(view);
let bounds = Aabb3 { min: Vec3::new(-30.0, -1.0, -2.0), max: Vec3::new(70.0, 2.0, 3.0) };
let fit = fit_bounds(cam, &bounds, &layout, perspective).unwrap();
assert_eq!((cam.yaw, cam.pitch, cam.up), (fit.yaw, fit.pitch, fit.up));
assert_eq!(fit_bounds(fit, &bounds, &layout, perspective).unwrap(), fit);
let mut effective = fit;
effective.fov = if perspective { 0.6 } else { 0.0 };
let radius = (bounds.diagonal() * 0.5 + bounds.center().dist(fit.target)).max(effective.half_height() * 2.0).max(10.0);
for x in [bounds.min.x, bounds.max.x] {
for y in [bounds.min.y, bounds.max.y] {
for z in [bounds.min.z, bounds.max.z] {
let (sx, sy, depth) =
effective.to_screen(Vec3::new(x, y, z), f64::from(vp.width()), f64::from(vp.height()), radius).unwrap();
assert!((0.0..=1.0).contains(&depth));
assert!(layout.drawable.contains(pos2(vp.left() + sx as f32, vp.top() + sy as f32)));
}
}
}
}
}
}
}
#[test]
fn measured_palette_fit_exposes_endpoints_and_layout_changes_preserve_manual_view() {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,0],[20,0]]})).unwrap();
h.call("ui.view", json!({"view":"top"}));
h.settle();
for (scale, width, height) in [(1.0, 1400.0, 900.0), (2.0, 700.0, 450.0), (2.0, 700.0, 360.0), (1.0, 900.0, 720.0)] {
h.call("ui.prefs", json!({"ui_scale":scale}));
h.call("ui.resize", json!({"width":width,"height":height}));
h.settle();
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
h.app.fit_view();
h.settle();
let layout = h.app.viewport.layout.as_ref().unwrap();
let palette = h.app.viewport.palette_rect.unwrap();
assert!(layout.drawable.width() >= 80.0 && layout.drawable.height() >= 30.0, "{layout:?}");
assert!(layout.axis_view.is_positive() && layout.navigation.is_positive(), "{layout:?}");
assert!(palette.width() <= 260.0, "{palette:?}");
let projection = crate::viewport::projection(&h.app, layout.viewport);
for point in [Vec3::ZERO, Vec3::new(20.0, 0.0, 0.0)] {
let p = projection.to_screen(point).unwrap();
assert!(layout.drawable.contains(p), "endpoint {p:?} outside {layout:?}");
assert!(!palette.contains(p));
assert!(!layout.axis_view.contains(p));
assert!(!layout.navigation.contains(p));
}
let fitted = h.app.cam;
h.app.fit_view();
h.settle();
assert_eq!(h.app.cam, fitted);
h.app.cancel_view_animation();
h.app.cam.pan(13.0, -19.0, 500.0);
let manual = h.app.cam;
h.call("ui.resize", json!({"width":width+20.0,"height":height}));
h.settle();
assert_eq!(h.app.cam, manual);
assert_eq!(h.app.session.doc.to_json(), before);
assert_eq!(h.app.session.undo.len(), undo);
}
}
#[test]
fn hostile_or_empty_fit_keeps_the_existing_camera_valid() {
let cam = Camera::default();
let layout = Layout::new(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), &[], 1.0);
assert!(fit_bounds(cam, &Aabb3::EMPTY, &layout, false).is_none());
let bad = Aabb3 { min: Vec3::new(f64::NAN, 0.0, 0.0), max: Vec3::new(1.0, 1.0, 1.0) };
assert!(fit_bounds(cam, &bad, &layout, true).is_none());
let mut app = crate::SolveApp::new(Default::default(), Default::default());
let tiny = Layout::new(Rect::from_min_size(pos2(0.0, 0.0), vec2(5.0, 5.0)), &[], 1.0);
app.viewport.layout = Some(tiny);
let bounds = Aabb3 { min: Vec3::ZERO, max: Vec3::new(20.0, 1.0, 1.0) };
assert_eq!(app.fitted_bounds(cam, &bounds), cam);
}
#[test]
fn fit_home_animation_and_manual_takeover_use_one_layout_solution() {
let mut h = 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.settle();
for view in ["fit", "home"] {
h.app.preferences.look_at_anim = false;
h.app.animate_view(view);
h.settle();
let immediate = h.app.cam;
h.app.cam.pan(50.0, 10.0, 400.0);
h.app.preferences.look_at_anim = true;
h.app.animate_view(view);
h.settle();
assert_eq!(h.app.cam, immediate);
}
h.app.fit_view();
h.app.cancel_view_animation();
h.app.cam.pan(7.0, 13.0, 400.0);
let takeover = h.app.cam;
h.settle();
assert_eq!(h.app.cam, takeover);
}
#[test]
fn measured_axis_view_targets_home_and_drag_work_beside_the_palette_at_hidpi() {
for (scale, width, height) in [(1.0, 900.0, 720.0), (2.0, 700.0, 450.0), (2.0, 700.0, 360.0)] {
let mut h = Harness::new();
h.app.preferences.look_at_anim = false;
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.prefs", json!({"ui_scale":scale}));
h.call("ui.resize", json!({"width":width,"height":height}));
h.app.fit_view();
h.settle();
let baseline = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
h.call("ui.view", json!({"view":"front"}));
let layout = h.app.viewport.layout.as_ref().unwrap().clone();
let palette = h.app.viewport.palette_rect.unwrap();
let outer = crate::scenario::rect_of("axis_view:outer").unwrap();
let home = crate::scenario::rect_of("axis_view:home").unwrap();
assert_eq!(outer, layout.axis_view);
assert!(outer.contains_rect(home));
assert!(layout.viewport.contains_rect(outer));
assert!(!outer.intersect(palette).is_positive());
assert!(!layout.navigation.intersect(outer).is_positive());
for name in ["x", "-x", "y", "-y", "z", "-z"] {
let p = crate::scenario::handle_at(&format!("axis_view:{name}")).unwrap();
assert!(outer.contains_rect(Rect::from_center_size(p, vec2(22.0, 22.0))));
}
let z = crate::scenario::handle_at("axis_view:z").unwrap();
h.call("ui.click", json!({"x":z.x,"y":z.y}));
assert!(h.app.cam.back().dist(Vec3::Z) < 1e-5);
let z = crate::scenario::handle_at("axis_view:z").unwrap();
h.call("ui.click", json!({"x":z.x,"y":z.y}));
assert!(h.app.cam.back().dist(-Vec3::Z) < 1e-5);
h.call("ui.view", json!({"view":"front"}));
h.app.viewport.nav = Some(crate::viewport::NavMode::Pan);
let before = h.app.cam;
let c = h.app.viewport.layout.as_ref().unwrap().axis_view.center();
h.call("ui.drag", json!({"x0":c.x,"y0":c.y,"x1":c.x+24.0,"y1":c.y+8.0,"steps":8}));
assert!((h.app.cam.yaw - before.yaw - 24.0 * 0.008).abs() < 1e-6);
assert!((h.app.cam.pitch - before.pitch - 8.0 * 0.008).abs() < 1e-6);
assert_eq!(h.app.cam.target, before.target);
assert_eq!(h.app.cam.distance, before.distance);
assert!(h.app.viewport.axis_drag.is_none());
assert!(h.app.viewport.boxsel.is_none());
let home = crate::scenario::handle_at("axis_view:home").unwrap();
h.call("ui.click", json!({"x":home.x,"y":home.y}));
let mut iso = h.app.cam;
iso.set_view(StandardView::Iso);
assert_eq!((h.app.cam.yaw, h.app.cam.pitch), (iso.yaw, iso.pitch));
assert_eq!(h.app.session.doc.to_json(), baseline);
assert_eq!(h.app.session.undo.len(), undo);
assert!(h.app.session.selection.is_empty());
}
}
#[test]
fn immediate_standard_view_cancels_fit_before_the_measured_frame() {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap();
let baseline = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
h.app.cam.set_view(StandardView::Iso);
h.app.fit_view();
assert!(h.app.pending_fit.is_some(), "Fit awaits the measured overlay frame");
let mut front = h.app.cam;
front.set_view(StandardView::Front);
h.call("ui.view", json!({"view":"front"}));
h.settle();
assert!(h.app.pending_fit.is_none());
assert_eq!(h.app.cam, front, "the later frame must keep the explicitly requested view");
assert_eq!(h.app.session.doc.to_json(), baseline);
assert_eq!(h.app.session.undo.len(), undo);
}