feat: preserve adaptive sketch profiles and curved modeling workflows

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

No files matched your search

+286
View File
@@ -0,0 +1,286 @@
//! Pixel-bounded display paths. CAD segments remain untouched; positive homogeneous
//! control hulls bound perspective projection, including looping and rational curves.
use solvecraft_geom::{Plane, Seg2, Vec2, Vec3};
use crate::Mat4;
const MAX_VERTICES: usize = 4096;
const MAX_DEPTH: u8 = 24;
const PIXEL_ERROR: f64 = 0.5;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PixelCurveView {
pub vp: Mat4,
pub width_px: f64,
pub height_px: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SamplingQuality {
Complete,
BudgetLimited,
Invalid,
}
/// Shared per-frame allocation and traversal limits. Each segment also has a local cap.
#[derive(Clone, Debug)]
pub struct SamplingBudget {
pub remaining_vertices: usize,
pub remaining_nodes: usize,
}
impl SamplingBudget {
pub fn new(max_vertices: usize, max_nodes: usize) -> Self {
Self { remaining_vertices: max_vertices.min(1_000_000), remaining_nodes: max_nodes.min(2_000_000) }
}
}
impl Default for SamplingBudget {
fn default() -> Self {
Self::new(200_000, 400_000)
}
}
#[derive(Clone, Debug)]
pub struct SampledPath {
pub parts: Vec<Vec<Vec3>>,
pub quality: SamplingQuality,
pub vertex_count: usize,
}
type H = [f64; 4];
fn mix(a: H, b: H, t: f64) -> H {
std::array::from_fn(|i| a[i] * (1.0 - t) + b[i] * t)
}
fn halves(c: &[H]) -> (Vec<H>, Vec<H>) {
let mut work = c.to_vec();
let mut l = Vec::with_capacity(c.len());
let mut r = Vec::with_capacity(c.len());
while let (Some(a), Some(b)) = (work.first(), work.last()) {
l.push(*a);
r.push(*b);
if work.len() == 1 {
break;
}
work = work.windows(2).map(|p| mix(p[0], p[1], 0.5)).collect();
}
r.reverse();
(l, r)
}
fn world(h: H) -> Option<Vec3> {
let p = Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3]);
(h[3] > 0.0 && [p.x, p.y, p.z].iter().all(|x| x.is_finite())).then_some(p)
}
fn project_h(m: Mat4, h: H) -> H {
std::array::from_fn(|r| (0..4).map(|c| m.0[c][r] * h[c]).sum())
}
// Wgpu clip volume (depth 0..w), plus positive w to avoid bridging the eye plane.
fn distances(p: H, view: &PixelCurveView, clip_xy: bool) -> [f64; 7] {
// A one-pixel guard band retains the approximating chord of every visible
// curve point, even when that chord lies just outside the viewport.
let x = if clip_xy { p[3] * (1.0 + 2.0 / view.width_px) } else { f64::INFINITY };
let y = if clip_xy { p[3] * (1.0 + 2.0 / view.height_px) } else { f64::INFINITY };
[x + p[0], x - p[0], y + p[1], y - p[1], p[2], p[3] - p[2], p[3] - 1e-12]
}
fn screen(p: H, view: &PixelCurveView) -> Vec2 {
Vec2::new(p[0] / p[3] * view.width_px * 0.5, p[1] / p[3] * view.height_px * 0.5)
}
fn distance_segment(p: Vec2, a: Vec2, b: Vec2) -> f64 {
let d = b - a;
let t = ((p - a).dot(d) / d.dot(d).max(1e-300)).clamp(0.0, 1.0);
p.dist(a + d * t)
}
fn controls(segment: &Seg2, plane: &Plane) -> Option<(Vec<Vec<H>>, H)> {
let mut terms: H = [0.0; 4];
let mut h = |p: Vec2, w: f64| {
for (r, (o, x, y)) in [(plane.origin.x, plane.x.x, plane.y.x), (plane.origin.y, plane.x.y, plane.y.y), (plane.origin.z, plane.x.z, plane.y.z)]
.into_iter()
.enumerate()
{
terms[r] = terms[r].max((o.abs() + x.abs() * p.x.abs() + y.abs() * p.y.abs()) * w.abs());
}
terms[3] = terms[3].max(w.abs());
let p = plane.to_world(p);
[p.x * w, p.y * w, p.z * w, w]
};
let spans = match *segment {
Seg2::Line { a, b } => vec![vec![h(a, 1.0), h(b, 1.0)]],
Seg2::Cubic { p0, p1, p2, p3 } => vec![vec![h(p0, 1.0), h(p1, 1.0), h(p2, 1.0), h(p3, 1.0)]],
Seg2::Conic { a, apex, b, w } if w > 0.0 => vec![vec![h(a, 1.0), h(apex, w), h(b, 1.0)]],
Seg2::Bezier { n, p } if (1..=7).contains(&n) => vec![p[..=n as usize].iter().map(|p| h(*p, 1.0)).collect()],
Seg2::Arc { center, radius, start, sweep }
if radius > 0.0 && start.is_finite() && sweep.is_finite() && sweep.abs() <= std::f64::consts::TAU + 1e-9 =>
{
let count = (sweep.abs() / std::f64::consts::FRAC_PI_2).ceil().max(1.0) as usize;
(0..count)
.map(|i| {
let a = start + sweep * i as f64 / count as f64;
let b = start + sweep * (i + 1) as f64 / count as f64;
let mid = (a + b) * 0.5;
let w = ((b - a) * 0.5).cos();
let p = |t: f64, r: f64| center + Vec2::new(t.cos(), t.sin()) * r;
vec![h(p(a, radius), 1.0), h(p(mid, radius / w), w), h(p(b, radius), 1.0)]
})
.collect()
}
_ => return None,
};
spans.iter().flatten().all(|p| p.iter().all(|x| x.is_finite()) && p[3] > 0.0).then_some((spans, terms))
}
/// Samples one exact segment to a conservative 0.5 physical-pixel control-hull
/// bound. Clipped gaps remain separate parts. Budget-limited paths may omit unresolved
/// portions; callers must not treat their quality as an accuracy certificate.
pub fn sample_segment(segment: &Seg2, plane: &Plane, view: &PixelCurveView, budget: &mut SamplingBudget) -> SampledPath {
sample(segment, plane, view, budget, true)
}
/// Retains offscreen XY boundaries for display-only fill triangulation. Eye/near/far
/// clipping and budgets still apply; only a complete closed path can form a fill.
pub fn sample_segment_unclipped(segment: &Seg2, plane: &Plane, view: &PixelCurveView, budget: &mut SamplingBudget) -> SampledPath {
sample(segment, plane, view, budget, false)
}
fn sample(segment: &Seg2, plane: &Plane, view: &PixelCurveView, budget: &mut SamplingBudget, clip_xy: bool) -> SampledPath {
let mut out = SampledPath { parts: Vec::new(), quality: SamplingQuality::Complete, vertex_count: 0 };
if !view.width_px.is_finite()
|| !view.height_px.is_finite()
|| view.width_px <= 0.0
|| view.height_px <= 0.0
|| view.width_px > 1e7
|| view.height_px > 1e7
|| view.vp.0.iter().flatten().any(|x| !x.is_finite())
{
out.quality = SamplingQuality::Invalid;
return out;
}
let Some((spans, source_terms)) = controls(segment, plane) else {
out.quality = SamplingQuality::Invalid;
return out;
};
let mut stack: Vec<_> = spans
.into_iter()
.rev()
.map(|c| {
let magnitude: H = std::array::from_fn(|i| c.iter().map(|p| p[i].abs()).fold(source_terms[i], f64::max));
let terms: H = std::array::from_fn(|r| (0..4).map(|i| view.vp.0[i][r].abs() * magnitude[i]).sum());
(c, 0u8, terms)
})
.collect();
let mut connected = false;
let mut nodes = 0usize;
while let Some((c, depth, terms)) = stack.pop() {
if budget.remaining_nodes == 0 || nodes == 32_768 {
out.quality = SamplingQuality::BudgetLimited;
break;
}
budget.remaining_nodes -= 1;
nodes += 1;
let q: Vec<H> = c.iter().map(|h| project_h(view.vp, *h)).collect();
if q.iter().flatten().any(|x| !x.is_finite()) {
out.quality = SamplingQuality::Invalid;
break;
}
let e: H = terms.map(|t| t * f64::EPSILON * (f64::from(depth) + 4.0) * 64.0);
let clip_error = [
e[0] + e[3] * (1.0 + 2.0 / view.width_px),
e[0] + e[3] * (1.0 + 2.0 / view.width_px),
e[1] + e[3] * (1.0 + 2.0 / view.height_px),
e[1] + e[3] * (1.0 + 2.0 / view.height_px),
e[2],
e[2] + e[3],
e[3],
];
if (0..7).any(|i| q.iter().all(|p| distances(*p, view, clip_xy)[i] < -clip_error[i])) {
connected = false;
continue;
}
let positive = q.iter().all(|p| p[3] > 1e-12);
let depth_safe = c.len() == 2 || (4..7).all(|i| q.iter().all(|p| distances(*p, view, clip_xy)[i] >= 0.0));
let flat = positive
&& q.first().zip(q.last()).is_some_and(|(a, b)| {
let (a, b) = (screen(*a, view), screen(*b, view));
let magnitude = q.iter().map(|p| screen(*p, view).len()).fold(1.0, f64::max);
// Keep floating-point roundoff inside the advertised bound.
// Absolute summands also cover cancellation in VP multiplication.
// Subdivision propagates roundoff from the original homogeneous hull.
let projection_error = q
.iter()
.map(|p| {
let safe_w = p[3] - e[3];
if safe_w <= 0.0 {
return f64::INFINITY;
}
let x = (e[0] + (p[0] / p[3]).abs() * e[3]) / safe_w * view.width_px * 0.5;
let y = (e[1] + (p[1] / p[3]).abs() * e[3]) / safe_w * view.height_px * 0.5;
x.hypot(y)
})
.fold(0.0, f64::max);
let allowance = projection_error + magnitude * f64::EPSILON * 128.0;
allowance < PIXEL_ERROR && q.iter().all(|p| distance_segment(screen(*p, view), a, b) + allowance <= PIXEL_ERROR)
});
if !flat || !depth_safe {
if depth == MAX_DEPTH || out.vertex_count >= MAX_VERTICES {
out.quality = SamplingQuality::BudgetLimited;
connected = false;
continue;
}
let (l, r) = halves(&c);
stack.push((r, depth + 1, terms));
stack.push((l, depth + 1, terms));
continue;
}
let (Some(a), Some(b), Some(qa), Some(qb)) = (c.first(), c.last(), q.first(), q.last()) else { continue };
let mut lo: f64 = 0.0;
let mut hi: f64 = 1.0;
for (a, b) in distances(*qa, view, clip_xy).into_iter().zip(distances(*qb, view, clip_xy)) {
if a < 0.0 && b < 0.0 {
hi = -1.0;
break;
}
if a < 0.0 {
lo = lo.max(a / (a - b));
} else if b < 0.0 {
hi = hi.min(a / (a - b));
}
}
if lo > hi {
connected = false;
continue;
}
let (Some(a), Some(b)) = (world(mix(*a, *b, lo)), world(mix(*a, *b, hi))) else {
out.quality = SamplingQuality::Invalid;
break;
};
// Exact consecutive subdivision endpoints share their homogeneous value.
let join = connected && lo == 0.0 && out.parts.last().and_then(|p| p.last()).is_some_and(|p| *p == a);
let need = if join { 1 } else { 2 };
if need > budget.remaining_vertices || out.vertex_count + need > MAX_VERTICES {
out.quality = SamplingQuality::BudgetLimited;
break;
}
budget.remaining_vertices -= need;
out.vertex_count += need;
if join {
if let Some(part) = out.parts.last_mut() {
part.push(b);
}
} else {
out.parts.push(vec![a, b]);
}
connected = hi == 1.0;
}
out
}
#[cfg(test)]
#[path = "curve_display_tests.rs"]
mod tests;
+121
View File
@@ -0,0 +1,121 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
fn plane() -> Plane {
Plane::new(Vec3::ZERO, Vec3::X, Vec3::Y).unwrap()
}
fn view(scale: f64, pixel_scale: f64) -> PixelCurveView {
PixelCurveView {
vp: Mat4([[scale, 0.0, 0.0, 0.0], [0.0, scale, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]),
width_px: 1400.0 * pixel_scale,
height_px: 900.0 * pixel_scale,
}
}
fn dense_error(s: &Seg2, plane: &Plane, v: &PixelCurveView, path: &SampledPath) -> f64 {
let segments: Vec<_> =
path.parts.iter().flat_map(|part| part.windows(2)).map(|w| (screen(v.vp.apply(w[0]), v), screen(v.vp.apply(w[1]), v))).collect();
(0..=12_000)
.filter_map(|i| {
let q = v.vp.apply(plane.to_world(s.point_at(i as f64 / 12_000.0)));
(distances(q, v, true).iter().all(|x| *x >= 0.0))
.then(|| segments.iter().map(|(a, b)| distance_segment(screen(q, v), *a, *b)).fold(f64::INFINITY, f64::min))
})
.fold(0.0, f64::max)
}
#[test]
fn exact_arcs_ellipses_rational_and_looping_control_hulls_bound_pixels() {
let mut degree7 = [Vec2::ZERO; 8];
for (i, p) in degree7.iter_mut().enumerate() {
*p = Vec2::new(i as f64 / 4.0 - 0.875, if i % 2 == 0 { -0.8 } else { 0.8 });
}
let curves = [
Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start: 0.27, sweep: -5.8 },
Seg2::Conic { a: Vec2::new(0.8, 0.0), apex: Vec2::new(0.8, 0.025), b: Vec2::new(0.0, 0.025), w: 0.5_f64.sqrt() },
Seg2::Conic { a: Vec2::new(-0.8, -0.4), apex: Vec2::new(0.0, 0.8), b: Vec2::new(0.8, -0.4), w: 0.06 },
Seg2::Cubic { p0: Vec2::new(-0.8, 0.0), p1: Vec2::new(0.9, 0.9), p2: Vec2::new(-0.9, -0.9), p3: Vec2::new(0.8, 0.0) },
Seg2::Cubic { p0: Vec2::new(0.0, -0.4), p1: Vec2::new(1.0, 0.9), p2: Vec2::new(-1.0, 0.9), p3: Vec2::new(0.0, -0.4) },
Seg2::Bezier { n: 7, p: degree7 },
];
for scale in [0.003, 0.03, 0.3, 1.3] {
for pixels in [1.0, 1.5, 2.0] {
for s in curves {
let v = view(scale, pixels);
let path = sample_segment(&s, &plane(), &v, &mut SamplingBudget::default());
assert_eq!(path.quality, SamplingQuality::Complete, "{s:?}");
assert!(dense_error(&s, &plane(), &v, &path) <= 0.501, "{s:?}");
assert!(path.vertex_count < 1024);
}
}
}
}
#[test]
fn fit_circle_zoom_budget_and_closed_unclipped_fill() {
let s = Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start: 0.0, sweep: std::f64::consts::TAU };
let small = sample_segment(&s, &plane(), &view(0.003, 1.0), &mut SamplingBudget::default());
let fitted = sample_segment(&s, &plane(), &view(219.0 / 700.0, 1.0), &mut SamplingBudget::default());
assert!(fitted.vertex_count > small.vertex_count);
assert!(dense_error(&s, &plane(), &view(219.0 / 700.0, 1.0), &fitted) < 0.5);
let fill = sample_segment_unclipped(&s, &plane(), &view(3.0, 2.0), &mut SamplingBudget::default());
assert_eq!(fill.quality, SamplingQuality::Complete);
assert_eq!(fill.parts.len(), 1);
let part = &fill.parts[0];
assert!(part.first().unwrap().dist(*part.last().unwrap()) < 1e-12);
let limited = sample_segment_unclipped(&s, &plane(), &view(1e20, 1.0), &mut SamplingBudget::new(64, 1000));
assert_eq!(limited.quality, SamplingQuality::BudgetLimited);
assert!(limited.vertex_count <= 64);
assert!(limited.parts.iter().flatten().all(|p| [p.x, p.y, p.z].iter().all(|v| v.is_finite())));
}
#[test]
fn perspective_tilt_exact_s_curve_and_clipping_do_not_bridge_gaps() {
let p = Plane::new(Vec3::new(0.0, 0.0, -2.0), Vec3::new(0.8, 0.0, 0.6), Vec3::Y).unwrap();
let v = PixelCurveView {
vp: Mat4([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, -1.01, -1.0], [0.0, 0.0, -0.101, 0.0]]),
width_px: 900.0,
height_px: 720.0,
};
let s = Seg2::Cubic { p0: Vec2::new(-0.9, 0.0), p1: Vec2::new(0.9, 1.0), p2: Vec2::new(-0.9, -1.0), p3: Vec2::new(0.9, 0.0) };
let path = sample_segment(&s, &p, &v, &mut SamplingBudget::default());
assert_eq!(path.quality, SamplingQuality::Complete);
assert!(dense_error(&s, &p, &v, &path) < 0.501);
let crossing = Plane::new(Vec3::new(0.0, 0.0, -0.15), Vec3::new(0.6, 0.0, 0.8), Vec3::Y).unwrap();
let s = Seg2::Arc { center: Vec2::ZERO, radius: 0.2, start: std::f64::consts::PI, sweep: std::f64::consts::TAU };
let path = sample_segment_unclipped(&s, &crossing, &v, &mut SamplingBudget::default());
assert!(!path.parts.is_empty());
assert_eq!(path.quality, SamplingQuality::BudgetLimited);
for point in path.parts.iter().flatten() {
let q = v.vp.apply(*point);
assert!(q[3] > 0.0 && q[2] >= -1e-10);
}
assert!(path.parts.len() >= 2);
}
#[test]
fn invalid_weights_matrices_and_shared_frame_exhaustion_are_explicit() {
let bad = Seg2::Conic { a: Vec2::ZERO, apex: Vec2::X, b: Vec2::Y, w: -1.0 };
assert_eq!(sample_segment(&bad, &plane(), &view(1.0, 1.0), &mut SamplingBudget::default()).quality, SamplingQuality::Invalid);
let line = Seg2::Line { a: Vec2::ZERO, b: Vec2::X };
let mut budget = SamplingBudget::new(2, 2);
assert_eq!(sample_segment(&line, &plane(), &view(0.2, 1.0), &mut budget).quality, SamplingQuality::Complete);
assert_eq!(sample_segment(&line, &plane(), &view(0.2, 1.0), &mut budget).quality, SamplingQuality::BudgetLimited);
let mut bad = view(1.0, 1.0);
bad.vp.0[0][0] = f64::NAN;
assert_eq!(sample_segment(&line, &plane(), &bad, &mut SamplingBudget::default()).quality, SamplingQuality::Invalid);
// The projected coordinates themselves are small, but huge VP summands cancel.
// Do not certify accuracy from the small result alone.
let far = Plane::new(Vec3::new(1e15, 0.0, 0.0), Vec3::X, Vec3::Y).unwrap();
let mut cancellation = view(0.2, 1.0);
cancellation.vp.0[3][0] = -2e14;
let path = sample_segment_unclipped(&line, &far, &cancellation, &mut SamplingBudget::new(16, 100));
assert_eq!(path.quality, SamplingQuality::BudgetLimited);
assert!(path.vertex_count <= 16);
let plane_cancellation = Plane::new(Vec3::new(1e9, 0.0, 0.0), Vec3::X, Vec3::Y).unwrap();
let local_circle = Seg2::Arc { center: Vec2::new(-1e9 + 1.0, 0.0), radius: 1.0, start: 0.0, sweep: std::f64::consts::TAU };
let path = sample_segment_unclipped(&local_circle, &plane_cancellation, &view(1e9, 1.0), &mut SamplingBudget::new(16, 100));
assert_eq!(path.quality, SamplingQuality::BudgetLimited);
assert!(path.vertex_count <= 16);
}
+2
View File
@@ -5,7 +5,9 @@
#![forbid(unsafe_code)]
mod camera;
mod curve_display;
mod raster;
pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out};
pub use curve_display::{PixelCurveView, SampledPath, SamplingBudget, SamplingQuality, sample_segment, sample_segment_unclipped};
pub use raster::{Canvas, Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent};