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

+5
View File
@@ -15,6 +15,7 @@ mod linalg;
mod link;
mod model;
mod offset;
mod offset_topology;
mod periodic;
mod profiles;
mod shape;
@@ -31,6 +32,7 @@ pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE};
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError, SketchView};
pub use offset::OffsetChain;
pub use offset_topology::{OffsetCreated, OffsetRole, OffsetRoleFlags, OffsetRoleKind, OffsetTopology};
pub use periodic::periodic_beziers;
pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions};
pub use shape::{Shape, intersections};
@@ -40,3 +42,6 @@ pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireSpace, fit_polyline};
#[cfg(test)]
mod tests;
#[cfg(test)]
mod virtual_corner_tests;
+2 -2
View File
@@ -429,7 +429,7 @@ impl Sketch {
if self.points.len() + lay.pts.len() > crate::model::MAX_POINTS {
return Err(SketchError::TooLarge);
}
let id = self.fresh("j");
let id = self.fresh("j")?;
self.links.push(Link { id: id.clone(), kind, source, face_name: None, auto_face: false, lost: false });
self.build_link(&id, &lay)?;
Ok(id)
@@ -468,7 +468,7 @@ impl Sketch {
(CurveKind::Spline { pts, control, degree, periodic }, "s")
}
};
let cid = self.fresh(prefix);
let cid = self.fresh(prefix)?;
self.curves.push(Curve {
id: cid,
kind,
+92 -25
View File
@@ -385,7 +385,7 @@ impl ConstraintKind {
LinearDiameter { p, l, .. } => (vec![p], vec![l]),
}
}
fn remap(&mut self, pmap: &dyn Fn(usize) -> Option<usize>, cmap: &dyn Fn(usize) -> Option<usize>) -> bool {
pub(crate) fn remap(&mut self, pmap: &dyn Fn(usize) -> Option<usize>, cmap: &dyn Fn(usize) -> Option<usize>) -> bool {
use ConstraintKind::*;
let mut ok = true;
let mut fp = |i: &mut usize| match pmap(*i) {
@@ -540,16 +540,50 @@ impl Sketch {
}
/// A new unused id with the given prefix (`l` → `l3`).
pub fn fresh(&mut self, prefix: &str) -> String {
pub fn fresh(&mut self, prefix: &str) -> Result<String> {
let mut n = self.counters.get(prefix).copied().unwrap_or(0);
loop {
n += 1;
n = n.checked_add(1).ok_or_else(|| SketchError::Invalid("sketch identity counter exhausted".into()))?;
let first = format!("{prefix}{n}");
if !self.id_taken(&first) {
self.counters.insert(prefix.to_string(), n);
return Ok(first);
}
let budget = self
.points
.len()
.saturating_add(self.curves.len())
.saturating_add(self.constraints.len())
.saturating_add(self.links.len())
.saturating_add(self.wires.len())
.saturating_add(
self.offsets.iter().filter_map(|r| r.topology.as_ref()).fold(0usize, |n, r| n.saturating_add(r.roles.len()).saturating_add(1)),
)
.saturating_add(1);
// A damaged counter may start before a long occupied prefix. Index
// reservations once so probing that prefix cannot rescan all geometry
// for each candidate ID.
let mut taken: std::collections::BTreeSet<&str> = self
.points
.iter()
.map(|p| p.id.as_str())
.chain(self.curves.iter().map(|c| c.id.as_str()))
.chain(self.constraints.iter().map(|c| c.id.as_str()))
.chain(self.links.iter().map(|l| l.id.as_str()))
.chain(self.wires.iter().map(|w| w.id.as_str()))
.collect();
for topology in self.offsets.iter().filter_map(|r| r.topology.as_ref()) {
taken.insert(topology.id.as_str());
taken.extend(topology.roles.iter().map(|r| r.output.as_str()));
}
for _ in 0..budget.saturating_sub(1) {
n = n.checked_add(1).ok_or_else(|| SketchError::Invalid("sketch identity counter exhausted".into()))?;
let id = format!("{prefix}{n}");
if !self.id_taken(&id) {
if !taken.contains(id.as_str()) {
self.counters.insert(prefix.to_string(), n);
return id;
return Ok(id);
}
}
Err(SketchError::TooLarge)
}
pub(crate) fn id_taken(&self, id: &str) -> bool {
self.points.iter().any(|p| p.id == id)
@@ -557,6 +591,7 @@ impl Sketch {
|| self.constraints.iter().any(|c| c.id == id)
|| self.links.iter().any(|l| l.id == id)
|| self.wires.iter().any(|w| w.id == id)
|| self.offsets.iter().filter_map(|r| r.topology.as_ref()).any(|r| r.id == id || r.roles.iter().any(|role| role.output == id))
}
pub fn point_index(&self, id: &str) -> Option<usize> {
@@ -583,7 +618,7 @@ impl Sketch {
Some(i) if self.id_taken(i) => return Err(SketchError::Duplicate(i.into())),
Some("") => return Err(SketchError::Invalid("empty id".into())),
Some(i) => i.to_string(),
None => self.fresh("p"),
None => self.fresh("p")?,
};
self.points.push(SPoint { id, pos: p, fixed: false, link: None });
Ok(self.points.len() - 1)
@@ -594,7 +629,7 @@ impl Sketch {
Some(i) if self.id_taken(i) => Err(SketchError::Duplicate(i.into())),
Some("") => Err(SketchError::Invalid("empty id".into())),
Some(i) => Ok(i.into()),
None => Ok(self.fresh(prefix)),
None => self.fresh(prefix),
}
}
@@ -701,7 +736,7 @@ impl Sketch {
return Err(SketchError::TooLarge);
}
self.validate(&kind)?;
let id = self.fresh("k");
let id = self.fresh("k")?;
self.constraints.push(Constraint { id: id.clone(), kind, param, driven: false, text: None });
Ok(id)
}
@@ -901,36 +936,68 @@ impl Sketch {
/// Each with the extension segments from the corner to the nearest end of those curves, for
/// drawing.
pub fn virtual_corners(&self) -> Vec<(usize, Vec<(Vec2, Vec2)>)> {
let mut out = Vec::new();
for (i, p) in self.points.iter().enumerate().skip(1) {
if self.point_used_by_curve(i) {
const MAX_POINT_REFERENCES: usize = 1_000_000;
if self.points.len() > MAX_POINTS || self.constraints.len() > MAX_CONSTRAINTS || self.curves.len() > MAX_POINT_REFERENCES {
return Vec::new();
}
let mut remaining = MAX_POINT_REFERENCES;
for curve in &self.curves {
let count = match &curve.kind {
CurveKind::Line { .. } | CurveKind::Ellipse { .. } => 2,
CurveKind::Circle { .. } => 1,
CurveKind::Arc { .. } | CurveKind::Conic { .. } => 3,
CurveKind::Spline { pts, .. } => pts.len(),
};
let Some(left) = remaining.checked_sub(count) else { return Vec::new() };
remaining = left;
}
// Index curve usage once, including centres and spline interior points.
let mut used = vec![false; self.points.len()];
for curve in &self.curves {
for point in curve.kind.point_ids() {
if let Some(slot) = used.get_mut(point) {
*slot = true;
}
}
}
let mut incidence = vec![Vec::new(); self.points.len()];
let mut seen = std::collections::HashSet::new();
for constraint in &self.constraints {
let ConstraintKind::PointOnCurve { p, c } = constraint.kind else { continue };
if p == 0 || used.get(p).copied() != Some(false) || !self.point(p).is_some_and(|p| p.is_finite()) {
continue;
}
let on: Vec<usize> = self
.constraints
.iter()
.filter_map(|c| match c.kind {
ConstraintKind::PointOnCurve { p, c } if p == i => Some(c),
_ => None,
})
.filter(|c| matches!(self.curves.get(*c).map(|c| &c.kind), Some(CurveKind::Line { .. } | CurveKind::Arc { .. })))
.collect();
let Some(curve) = self.curves.get(c) else { continue };
if !matches!(curve.kind, CurveKind::Line { .. } | CurveKind::Arc { .. })
|| curve.kind.point_ids().iter().any(|i| !self.point(*i).is_some_and(|p| p.is_finite()))
{
continue;
}
if seen.insert((p, c))
&& let Some(on) = incidence.get_mut(p)
{
on.push(c);
}
}
let mut out = Vec::new();
for (i, on) in incidence.into_iter().enumerate().skip(1) {
if on.len() < 2 {
continue;
}
let Some(p) = self.point(i) else { continue };
let mut ext = Vec::new();
for c in on {
let Some((a, b)) = self.curves.get(c).and_then(|c| c.kind.ends()) else { continue };
let (Some(pa), Some(pb)) = (self.point(a), self.point(b)) else { continue };
let near = if p.pos.dist(pa) <= p.pos.dist(pb) { pa } else { pb };
let near = if p.dist(pa) <= p.dist(pb) { pa } else { pb };
// A line only needs one when the corner is beyond its ends.
let inside = matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. })) && {
let d = pb - pa;
let t = (p.pos - pa).dot(d) / d.dot(d).max(1e-300);
let t = (p - pa).dot(d) / d.dot(d).max(1e-300);
(0.0..=1.0).contains(&t)
};
if !inside && p.pos.dist(near) > 1e-9 {
ext.push((p.pos, near));
if !inside && p.dist(near) > 1e-9 {
ext.push((p, near));
}
}
out.push((i, ext));
+7 -1
View File
@@ -9,6 +9,8 @@ pub struct OffsetChain {
pub outputs: Vec<String>,
pub distance: f64,
pub closed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub topology: Option<crate::offset_topology::OffsetTopology>,
}
impl Sketch {
@@ -57,7 +59,11 @@ impl Sketch {
}
fn refresh_analytic_offsets(&mut self) -> Result<(), SketchError> {
for offset in self.offsets.clone() {
for index in 0..self.offsets.len() {
if self.refresh_convex_offset(index)? {
continue;
}
let offset = self.offsets.get(index).cloned().ok_or_else(|| SketchError::Invalid("missing offset chain".into()))?;
if offset.sources.len() != offset.outputs.len() || offset.sources.len() > 10_000 || !offset.distance.is_finite() {
return Err(SketchError::Invalid("damaged offset chain".into()));
}
+847
View File
@@ -0,0 +1,847 @@
//! Exact convex parallel boundaries with persistent extinct arc identities.
use std::collections::{BTreeMap, BTreeSet};
use std::f64::consts::TAU;
use serde::{Deserialize, Serialize};
use solvecraft_geom::{Seg2, Vec2};
use crate::{Curve, CurveKind, OffsetChain, Sketch, SketchError};
type Result<T> = std::result::Result<T, SketchError>;
const MAX_GROUPS: usize = 128;
const MAX_ROLES: usize = 512;
const MAX_TOTAL_ROLES: usize = 20_000;
const MAX_WORK: usize = 262_144;
fn invalid(message: &str) -> SketchError {
SketchError::Invalid(message.into())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OffsetRoleKind {
Line,
Arc,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OffsetRoleFlags {
pub construction: bool,
pub centerline: bool,
pub reversed: bool,
pub fixed: bool,
}
impl OffsetRoleFlags {
fn is_default(&self) -> bool {
*self == Self::default()
}
fn of(curve: &Curve) -> Self {
Self { construction: curve.construction, centerline: curve.centerline, reversed: curve.reversed, fixed: curve.fixed }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OffsetRole {
pub output: String,
pub kind: OffsetRoleKind,
pub start: String,
pub end: String,
pub retired: bool,
#[serde(default, skip_serializing_if = "OffsetRoleFlags::is_default")]
pub flags: OffsetRoleFlags,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OffsetTopology {
pub version: u8,
pub id: String,
pub roles: Vec<OffsetRole>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct OffsetCreated {
pub association: String,
pub curves: Vec<String>,
}
#[derive(Clone, Copy)]
struct Arc {
centre: Vec2,
radius: f64,
start: f64,
sweep: f64,
centre_index: usize,
}
#[derive(Clone, Copy)]
struct Source {
a: Vec2,
b: Vec2,
arc: Option<Arc>,
}
impl Source {
fn tangent(&self, end: bool, orientation: f64) -> Option<Vec2> {
match self.arc {
Some(arc) => (if end { self.b } else { self.a } - arc.centre).normalized().map(|r| r.perp() * orientation),
None => (self.b - self.a).normalized(),
}
}
}
struct Boundary {
source: Vec<Source>,
orientation: f64,
pad: f64,
}
#[derive(Clone, Copy)]
struct Span {
a: Vec2,
b: Vec2,
arc: Option<Arc>,
retired: bool,
}
fn angle_in(angle: f64, arc: Arc) -> bool {
(angle - arc.start).rem_euclid(TAU) <= arc.sweep + 1e-12
}
fn max_dot(v: Vec2, arc: Arc) -> f64 {
let mut value = v.dot(Vec2::from_angle(arc.start)).max(v.dot(Vec2::from_angle(arc.start + arc.sweep)));
if angle_in(v.angle(), arc) {
value = value.max(v.len());
}
value
}
fn arc_projection(arc: Arc, n: Vec2) -> f64 {
arc.centre.dot(n) + arc.radius * max_dot(n, arc)
}
fn arc_against_arc(output: Arc, source: Arc) -> f64 {
let delta = output.centre - source.centre;
let mut candidates = vec![source.start, source.start + source.sweep];
for theta in [output.start, output.start + output.sweep, delta.angle()] {
if angle_in(theta, source) {
candidates.push(theta);
}
}
for phi in [output.start, output.start + output.sweep] {
let theta = (delta + Vec2::from_angle(phi) * output.radius).angle();
if angle_in(theta, source) {
candidates.push(theta);
}
}
candidates
.into_iter()
.map(|theta| {
let n = Vec2::from_angle(theta);
delta.dot(n) + output.radius * max_dot(n, output)
})
.fold(f64::NEG_INFINITY, f64::max)
}
impl Sketch {
fn convex_boundary(&self, sources: &[(String, bool)]) -> Result<Option<Boundary>> {
if !(3..=MAX_ROLES).contains(&sources.len()) {
return Ok(None);
}
let mut seen = BTreeSet::new();
let mut source = Vec::with_capacity(sources.len());
for (id, forward) in sources {
if !seen.insert(id) {
return Err(invalid("duplicate convex offset source"));
}
let curve = self
.curves
.get(self.curve_index(id).ok_or_else(|| invalid("offset source vanished"))?)
.ok_or_else(|| invalid("offset source vanished"))?;
let get = |i| self.point(i).ok_or_else(|| invalid("offset source point vanished"));
let piece = match curve.kind {
CurveKind::Line { a, b } => {
let (a, b) = (get(a)?, get(b)?);
if *forward { Source { a, b, arc: None } } else { Source { a: b, b: a, arc: None } }
}
CurveKind::Arc { c, a, b } => {
let (centre, a, b) = (get(c)?, get(a)?, get(b)?);
let radius = centre.dist(a);
let start = (a - centre).angle();
let sweep = ((b - centre).angle() - start).rem_euclid(TAU);
if !radius.is_finite() || radius <= 1e-9 || sweep <= 1e-9 || (centre.dist(b) - radius).abs() > 1e-6 {
return Ok(None);
}
let arc = Some(Arc { centre, radius, start, sweep, centre_index: c });
if *forward { Source { a, b, arc } } else { Source { a: b, b: a, arc } }
}
_ => return Ok(None),
};
if !piece.a.is_finite() || !piece.b.is_finite() || piece.a.dist(piece.b) <= 1e-9 {
return Ok(None);
}
source.push(piece);
}
let origin = source.first().ok_or_else(|| invalid("empty offset boundary"))?.a;
let scale = source.iter().flat_map(|p| [p.a, p.b]).map(|p| p.dist(origin)).fold(1.0, f64::max);
let pad = 1e-7 + 256.0 * f64::EPSILON * scale;
if !pad.is_finite() || pad >= 0.002 {
return Err(invalid("convex offset coordinate precision exceeds certificate"));
}
let mut area = 0.0;
for (i, piece) in source.iter().enumerate() {
if piece.b.dist(source.get((i + 1) % source.len()).ok_or_else(|| invalid("missing offset neighbour"))?.a) > pad {
return Ok(None);
}
area += match piece.arc {
Some(arc) => {
let forward = sources.get(i).is_some_and(|x| x.1);
Seg2::Arc {
center: arc.centre - origin,
radius: arc.radius,
start: if forward { arc.start } else { arc.start + arc.sweep },
sweep: if forward { arc.sweep } else { -arc.sweep },
}
.area_term()
}
None => (piece.a - origin).cross(piece.b - origin) * 0.5,
};
}
if !area.is_finite() || area.abs() <= pad * scale {
return Ok(None);
}
let orientation = area.signum();
let mut turn = 0.0;
let mut lines = 0;
for (i, piece) in source.iter().enumerate() {
if let Some(arc) = piece.arc {
if sources.get(i).is_none_or(|x| if orientation > 0.0 { !x.1 } else { x.1 }) {
return Ok(None);
}
turn += arc.sweep;
} else {
lines += 1;
}
let end = piece.tangent(true, orientation).ok_or_else(|| invalid("singular offset tangent"))?;
let next =
source.get((i + 1) % source.len()).and_then(|p| p.tangent(false, orientation)).ok_or_else(|| invalid("singular offset tangent"))?;
if end.dot(next) < 1.0 - 1e-8 || end.cross(next).abs() > 1e-6 {
return Ok(None);
}
}
// A regular closed G1 boundary with monotone curvature and total turning
// exactly 2pi is a simple convex boundary. Reject loops and sharp sources.
if lines < 3 || (turn - TAU).abs() > 1e-6 {
return Ok(None);
}
Ok(Some(Boundary { source, orientation, pad }))
}
pub fn convex_offset_supported(&self, sources: &[(String, bool)]) -> Result<bool> {
Ok(self.convex_boundary(sources)?.is_some())
}
}
impl Boundary {
fn build(&self, distance: f64) -> Result<Vec<Span>> {
if !distance.is_finite() || distance.abs() <= 1e-9 || distance.abs() >= 1e9 {
return Err(invalid("invalid convex offset distance"));
}
let inset = distance * self.orientation;
let mut spans = Vec::with_capacity(self.source.len());
for piece in &self.source {
let span = match piece.arc {
Some(mut arc) => {
arc.radius -= inset;
let retired = arc.radius <= 1e-9;
if retired {
Span { a: piece.a, b: piece.b, arc: Some(arc), retired: true }
} else {
let a = arc.centre + (piece.a - arc.centre).normalized().ok_or_else(|| invalid("singular offset arc"))? * arc.radius;
let b = arc.centre + (piece.b - arc.centre).normalized().ok_or_else(|| invalid("singular offset arc"))? * arc.radius;
Span { a, b, arc: Some(arc), retired: false }
}
}
None => {
let shift = (piece.b - piece.a).normalized().ok_or_else(|| invalid("singular offset line"))?.perp() * distance;
Span { a: piece.a + shift, b: piece.b + shift, arc: None, retired: false }
}
};
spans.push(span);
}
let active: Vec<_> = spans.iter().enumerate().filter_map(|(i, s)| (!s.retired).then_some(i)).collect();
if active.len() < 3 {
return Err(invalid("the convex offset region disappears"));
}
for (position, i) in active.iter().copied().enumerate() {
let j = *active.get((position + 1) % active.len()).ok_or_else(|| invalid("missing active offset neighbour"))?;
let a = *spans.get(i).ok_or_else(|| invalid("missing offset span"))?;
let b = *spans.get(j).ok_or_else(|| invalid("missing offset span"))?;
let next = (i + 1) % spans.len();
let joint = if next == j {
if a.b.dist(b.a) > self.pad {
return Err(invalid("offset tangent junction no longer meets"));
}
a.b
} else {
let retired = *spans.get(next).ok_or_else(|| invalid("missing retired offset span"))?;
if (next + 1) % spans.len() != j
|| !retired.retired
|| a.arc.is_some()
|| b.arc.is_some()
|| retired.arc.is_none_or(|x| x.sweep >= std::f64::consts::PI - 1e-8)
{
return Err(invalid("offset arc extinction requires two adjacent straight supports"));
}
let (u, v) = (a.b - a.a, b.b - b.a);
let cross = u.cross(v);
if cross.abs() <= 1e-10 * u.len() * v.len() {
return Err(invalid("ambiguous offset extinction junction"));
}
let q = a.a + u * ((b.a - a.a).cross(v) / cross);
if !q.is_finite() || q.x.abs() >= 1e9 || q.y.abs() >= 1e9 {
return Err(invalid("unbounded offset extinction junction"));
}
if let Some(s) = spans.get_mut(next) {
s.a = q;
s.b = q;
}
q
};
if let Some(s) = spans.get_mut(i) {
s.b = joint;
}
if let Some(s) = spans.get_mut(j) {
s.a = joint;
}
}
let mut work = 0usize;
for (index, span) in spans.iter().enumerate().filter(|(_, s)| !s.retired) {
if span.a.dist(span.b) <= self.pad {
return Err(invalid("the convex offset region disappears"));
}
if span.arc.is_none() {
let source = self.source.get(index).ok_or_else(|| invalid("missing source span"))?;
if (span.b - span.a).dot(source.b - source.a) <= self.pad * source.a.dist(source.b) {
return Err(invalid("offset line is overrun by its neighbours"));
}
}
for source in &self.source {
work += 1;
if work > MAX_WORK {
return Err(invalid("convex offset certificate work limit"));
}
let (maximum, limit) = match source.arc {
Some(arc) => {
let maximum = match span.arc {
Some(output) => arc_against_arc(output, arc),
None => max_dot(span.a - arc.centre, arc).max(max_dot(span.b - arc.centre, arc)),
};
(maximum, arc.radius - inset)
}
None => {
let normal = (source.b - source.a).normalized().ok_or_else(|| invalid("singular source support"))?.perp() * -self.orientation;
let maximum = match span.arc {
Some(mut arc) => {
arc.centre -= source.a;
arc_projection(arc, normal)
}
None => (span.a - source.a).dot(normal).max((span.b - source.a).dot(normal)),
};
(maximum, -inset)
}
};
if !maximum.is_finite() || maximum > limit + self.pad {
return Err(invalid("convex offset fails whole-boundary normal support certificate"));
}
}
}
Ok(spans)
}
}
impl Sketch {
/// Validate saved role ownership without evaluating source geometry/parameters.
pub fn validate_offset_topologies(&self) -> Result<()> {
if self.offsets.iter().all(|r| r.topology.is_none()) {
return Ok(());
}
let mut groups = BTreeSet::new();
let mut outputs = BTreeSet::new();
let mut total = 0usize;
let point_index: BTreeMap<_, _> = self.points.iter().enumerate().map(|(i, p)| (p.id.as_str(), i)).collect();
let curve_index: BTreeMap<_, _> = self.curves.iter().enumerate().map(|(i, p)| (p.id.as_str(), i)).collect();
let mut owners: BTreeMap<usize, BTreeSet<&str>> = BTreeMap::new();
for curve in &self.curves {
for p in curve.kind.point_ids() {
owners.entry(p).or_default().insert(curve.id.as_str());
}
}
let other_ids: BTreeSet<&str> = self
.points
.iter()
.map(|x| x.id.as_str())
.chain(self.constraints.iter().map(|x| x.id.as_str()))
.chain(self.links.iter().map(|x| x.id.as_str()))
.chain(self.wires.iter().map(|x| x.id.as_str()))
.collect();
let id_ok = |id: &str| !id.is_empty() && id.len() <= 512;
for record in &self.offsets {
let Some(topology) = &record.topology else { continue };
total = total.checked_add(topology.roles.len()).ok_or(SketchError::TooLarge)?;
if topology.version != 1
|| !record.closed
|| !id_ok(&topology.id)
|| !(3..=MAX_ROLES).contains(&topology.roles.len())
|| total > MAX_TOTAL_ROLES
|| topology.roles.len() != record.sources.len()
|| !record.distance.is_finite()
|| record.distance.abs() <= 1e-9
|| record.distance.abs() >= 1e9
|| !groups.insert(topology.id.as_str())
|| groups.len() > MAX_GROUPS
|| curve_index.contains_key(topology.id.as_str())
|| other_ids.contains(topology.id.as_str())
{
return Err(invalid("damaged convex offset topology"));
}
let active: Vec<_> = topology.roles.iter().filter(|r| !r.retired).map(|r| r.output.as_str()).collect();
if active.len() < 3 || !record.outputs.iter().map(String::as_str).eq(active) {
return Err(invalid("damaged offset active role order"));
}
let record_outputs: BTreeSet<_> = topology.roles.iter().map(|r| r.output.as_str()).collect();
let mut sources = BTreeSet::new();
for ((source, forward), role) in record.sources.iter().zip(&topology.roles) {
if !id_ok(source)
|| !sources.insert(source.as_str())
|| !id_ok(&role.output)
|| !id_ok(&role.start)
|| !id_ok(&role.end)
|| role.start == role.end
|| role.output == *source
|| !outputs.insert(role.output.as_str())
|| other_ids.contains(role.output.as_str())
{
return Err(invalid("damaged offset reserved identity"));
}
let source_curve = self
.curves
.get(*curve_index.get(source.as_str()).ok_or_else(|| invalid("missing offset source role"))?)
.ok_or_else(|| invalid("missing offset source role"))?;
let centre = match (&source_curve.kind, role.kind) {
(CurveKind::Line { .. }, OffsetRoleKind::Line) => None,
(CurveKind::Arc { c, .. }, OffsetRoleKind::Arc) => Some(*c),
_ => return Err(invalid("offset source changed role kind")),
};
if role.retired && role.kind != OffsetRoleKind::Arc {
return Err(invalid("only offset arcs may retire"));
}
let a = *point_index.get(role.start.as_str()).ok_or_else(|| invalid("missing offset endpoint role"))?;
let b = *point_index.get(role.end.as_str()).ok_or_else(|| invalid("missing offset endpoint role"))?;
for p in [a, b] {
if p == 0
|| self.points.get(p).is_none_or(|p| !p.fixed || p.link.is_some())
|| owners.get(&p).is_none_or(|users| users.is_empty() || users.iter().any(|id| !record_outputs.contains(id)))
{
return Err(invalid("offset endpoints must be fixed private group roles"));
}
}
if role.retired {
if curve_index.contains_key(role.output.as_str()) {
return Err(invalid("retired offset curve is still active"));
}
} else {
let output = self
.curves
.get(*curve_index.get(role.output.as_str()).ok_or_else(|| invalid("missing active offset curve"))?)
.ok_or_else(|| invalid("missing active offset curve"))?;
let valid = match output.kind {
CurveKind::Line { a: pa, b: pb } => role.kind == OffsetRoleKind::Line && pa == a && pb == b,
CurveKind::Arc { c, a: pa, b: pb } => {
role.kind == OffsetRoleKind::Arc && centre == Some(c) && if *forward { pa == a && pb == b } else { pa == b && pb == a }
}
_ => false,
};
if !valid || output.link.is_some() {
return Err(invalid("active offset curve has incompatible point roles"));
}
}
}
if sources.iter().any(|id| record_outputs.contains(id)) {
return Err(invalid("offset source/output alias"));
}
}
if groups.iter().any(|id| outputs.contains(id)) {
return Err(invalid("offset group/output identity alias"));
}
// Index associations once, rather than copying every source for every
// output. Charge all legacy records too, before allocating the graph.
let mut budget = self.offsets.len().saturating_add(self.freeform_offsets.len());
for r in &self.offsets {
budget = budget.saturating_add(r.sources.len()).saturating_add(r.topology.as_ref().map_or(r.outputs.len(), |t| t.roles.len()));
}
budget = budget.saturating_add(self.freeform_offsets.len().saturating_mul(2));
if budget > MAX_WORK {
return Err(invalid("offset dependency work limit"));
}
let count = self.offsets.len() + self.freeform_offsets.len();
let mut producer = BTreeMap::new();
for (index, record) in self.offsets.iter().enumerate() {
let ids: Box<dyn Iterator<Item = &str> + '_> = match &record.topology {
Some(t) => Box::new(t.roles.iter().map(|r| r.output.as_str())),
None => Box::new(record.outputs.iter().map(String::as_str)),
};
for id in ids {
if producer.insert(id, index).is_some() {
return Err(invalid("offset output has multiple producers"));
}
}
}
for (index, record) in self.freeform_offsets.iter().enumerate() {
if producer.insert(record.output.as_str(), self.offsets.len() + index).is_some() {
return Err(invalid("offset output has multiple producers"));
}
}
let mut edges = vec![BTreeSet::new(); count];
for (index, record) in self.offsets.iter().enumerate() {
for (source, _) in &record.sources {
if let Some(parent) = producer.get(source.as_str()) {
edges.get_mut(index).ok_or_else(|| invalid("invalid dependency node"))?.insert(*parent);
}
}
}
for (index, record) in self.freeform_offsets.iter().enumerate() {
if let Some(parent) = producer.get(record.source.as_str()) {
edges.get_mut(self.offsets.len() + index).ok_or_else(|| invalid("invalid dependency node"))?.insert(*parent);
}
}
let mut colour = vec![0u8; count];
for start in 0..count {
if colour.get(start).is_none_or(|c| *c != 0) {
continue;
}
let mut stack = vec![(start, false)];
while let Some((node, finish)) = stack.pop() {
if finish {
*colour.get_mut(node).ok_or_else(|| invalid("invalid dependency node"))? = 2;
continue;
}
let state = *colour.get(node).ok_or_else(|| invalid("invalid dependency node"))?;
if state == 2 {
continue;
}
if state == 1 {
return Err(invalid("cyclic offset association"));
}
*colour.get_mut(node).ok_or_else(|| invalid("invalid dependency node"))? = 1;
stack.push((node, true));
for &parent in edges.get(node).ok_or_else(|| invalid("invalid dependency node"))? {
stack.push((parent, false));
}
}
}
Ok(())
}
pub fn offset_boundary(&self, id: &str) -> Result<Vec<String>> {
let mut matching = self.offsets.iter().filter(|r| r.topology.as_ref().is_some_and(|t| t.id == id));
let record = matching.next().ok_or_else(|| invalid("unknown offset boundary association"))?;
if matching.next().is_some() {
return Err(invalid("ambiguous offset boundary association"));
}
Ok(record.outputs.clone())
}
fn match_offset_boundary(&self, ids: &[String], reserved: bool) -> Result<Option<String>> {
let requested: BTreeSet<_> = ids.iter().map(String::as_str).collect();
if ids.is_empty() || requested.len() != ids.len() {
return Err(invalid("duplicate or empty offset boundary selection"));
}
let mut found = None;
for record in &self.offsets {
let Some(topology) = &record.topology else { continue };
let expected: BTreeSet<_> = if reserved {
topology.roles.iter().map(|r| r.output.as_str()).collect()
} else {
record.outputs.iter().map(String::as_str).collect()
};
if expected == requested {
if found.is_some() {
return Err(invalid("ambiguous offset boundary selection"));
}
found = Some(topology.id.clone());
}
}
Ok(found)
}
pub fn offset_boundary_for_curves(&self, ids: &[String]) -> Result<Option<String>> {
self.match_offset_boundary(ids, false)
}
pub fn offset_boundary_for_reserved_curves(&self, ids: &[String]) -> Result<Option<String>> {
self.match_offset_boundary(ids, true)
}
pub fn add_convex_offset(&mut self, sources: Vec<(String, bool)>, distance: f64) -> Result<OffsetCreated> {
let mut candidate = self.clone();
let boundary = candidate.convex_boundary(&sources)?.ok_or_else(|| invalid("offset source is not a closed convex G1 analytic boundary"))?;
let spans = boundary.build(distance)?;
if candidate.offsets.iter().filter(|r| r.topology.is_some()).count() >= MAX_GROUPS {
return Err(SketchError::TooLarge);
}
let association = candidate.fresh("o")?;
let mut nodes = Vec::new();
// One node per original directed joint. Retired arcs keep both endpoint
// roles, each still used by an adjacent surviving straight output.
for span in &spans {
let point = candidate.add_point(span.a, None)?;
if let Some(p) = candidate.points.get_mut(point) {
p.fixed = true;
}
nodes.push(point);
}
let mut roles = Vec::new();
let mut outputs = Vec::new();
for (i, span) in spans.iter().enumerate() {
let a = *nodes.get(i).ok_or_else(|| invalid("missing output node"))?;
let b = *nodes.get((i + 1) % nodes.len()).ok_or_else(|| invalid("missing output node"))?;
let kind = if span.arc.is_some() { OffsetRoleKind::Arc } else { OffsetRoleKind::Line };
let output = candidate.fresh(if kind == OffsetRoleKind::Arc { "a" } else { "l" })?;
let role = OffsetRole {
output: output.clone(),
kind,
start: candidate.points.get(a).ok_or_else(|| invalid("missing output node"))?.id.clone(),
end: candidate.points.get(b).ok_or_else(|| invalid("missing output node"))?.id.clone(),
retired: span.retired,
flags: OffsetRoleFlags::default(),
};
if !span.retired {
let curve_kind = match span.arc {
Some(arc) => {
if sources.get(i).is_some_and(|x| x.1) {
CurveKind::Arc { c: arc.centre_index, a, b }
} else {
CurveKind::Arc { c: arc.centre_index, a: b, b: a }
}
}
None => CurveKind::Line { a, b },
};
candidate.curves.push(Curve {
id: output.clone(),
kind: curve_kind,
construction: false,
reversed: false,
link: None,
centerline: false,
fixed: false,
});
outputs.push(output);
}
roles.push(role);
}
candidate.offsets.push(OffsetChain {
sources,
outputs: outputs.clone(),
distance,
closed: true,
topology: Some(OffsetTopology { version: 1, id: association.clone(), roles }),
});
candidate.validate_offset_topologies()?;
*self = candidate;
Ok(OffsetCreated { association, curves: outputs })
}
pub(crate) fn refresh_convex_offset(&mut self, index: usize) -> Result<bool> {
let record = self.offsets.get(index).cloned().ok_or_else(|| invalid("missing offset association"))?;
if !record.closed {
return Ok(false);
}
let boundary = self.convex_boundary(&record.sources)?;
let Some(boundary) = boundary else {
if record.topology.is_some() {
return Err(invalid("topology-aware offset source is no longer convex G1"));
}
return Ok(false);
};
let spans = boundary.build(record.distance)?;
let mut topology = match record.topology.clone() {
Some(t) => t,
None => {
if record.outputs.len() != record.sources.len() {
return Err(invalid("damaged legacy offset correspondence"));
}
let mut roles = Vec::new();
for ((output, (_, forward)), source) in record.outputs.iter().zip(&record.sources).zip(&boundary.source) {
let curve = self
.curves
.get(self.curve_index(output).ok_or_else(|| invalid("missing legacy offset output"))?)
.ok_or_else(|| invalid("missing legacy offset output"))?;
let (a, b, kind) = match curve.kind {
CurveKind::Line { a, b } if source.arc.is_none() => (a, b, OffsetRoleKind::Line),
CurveKind::Arc { c, a, b } if source.arc.is_some_and(|arc| arc.centre_index == c) => {
if *forward {
(a, b, OffsetRoleKind::Arc)
} else {
(b, a, OffsetRoleKind::Arc)
}
}
_ => return Err(invalid("legacy offset output changed role")),
};
roles.push(OffsetRole {
output: output.clone(),
kind,
start: self.points.get(a).ok_or_else(|| invalid("missing legacy offset node"))?.id.clone(),
end: self.points.get(b).ok_or_else(|| invalid("missing legacy offset node"))?.id.clone(),
retired: false,
flags: OffsetRoleFlags::of(curve),
});
}
OffsetTopology { version: 1, id: self.fresh("o")?, roles }
}
};
if topology.roles.len() != spans.len() {
return Err(invalid("damaged convex offset role count"));
}
let retiring: BTreeSet<_> =
topology.roles.iter().zip(&spans).filter_map(|(r, s)| (s.retired && !r.retired).then_some(r.output.clone())).collect();
let dead: BTreeSet<_> = self.curves.iter().enumerate().filter_map(|(i, c)| retiring.contains(c.id.as_str()).then_some(i)).collect();
for role in &mut topology.roles {
if retiring.contains(role.output.as_str()) {
let curve = self
.curves
.get(self.curve_index(&role.output).ok_or_else(|| invalid("missing retiring curve"))?)
.ok_or_else(|| invalid("missing retiring curve"))?;
role.flags = OffsetRoleFlags::of(curve);
}
}
for constraint in &self.constraints {
let mut kind = constraint.kind.clone();
if !kind.remap(&Some, &|c| (!dead.contains(&c)).then_some(c)) {
return Err(invalid("an extinguished offset arc is referenced by a constraint"));
}
}
if self.offsets.iter().any(|r| r.sources.iter().any(|s| retiring.contains(s.0.as_str())))
|| self.freeform_offsets.iter().any(|r| retiring.contains(r.source.as_str()))
{
return Err(invalid("an extinguished offset arc is retained by a dependent association"));
}
let mut map = vec![None; self.curves.len()];
let mut next = 0usize;
for (i, slot) in map.iter_mut().enumerate() {
if !dead.contains(&i) {
*slot = Some(next);
next += 1;
}
}
let mut i = 0;
self.curves.retain(|_| {
let keep = !dead.contains(&i);
i += 1;
keep
});
for constraint in &mut self.constraints {
if !constraint.kind.remap(&Some, &|i| map.get(i).copied().flatten()) {
return Err(invalid("offset constraint remapping failed"));
}
}
let mut outputs = Vec::new();
for (i, (role, span)) in topology.roles.iter_mut().zip(&spans).enumerate() {
let a = self.point_index(&role.start).ok_or_else(|| invalid("missing reserved offset endpoint"))?;
let b = self.point_index(&role.end).ok_or_else(|| invalid("missing reserved offset endpoint"))?;
for (point, pos) in [(a, span.a), (b, span.b)] {
let p = self.points.get_mut(point).ok_or_else(|| invalid("missing reserved offset endpoint"))?;
p.pos = pos;
p.fixed = true;
}
if !span.retired {
let kind = match span.arc {
Some(arc) => {
if record.sources.get(i).is_some_and(|x| x.1) {
CurveKind::Arc { c: arc.centre_index, a, b }
} else {
CurveKind::Arc { c: arc.centre_index, a: b, b: a }
}
}
None => CurveKind::Line { a, b },
};
if let Some(ci) = self.curve_index(&role.output) {
if let Some(curve) = self.curves.get_mut(ci) {
curve.kind = kind;
}
} else {
if !role.retired {
return Err(invalid("active offset output vanished"));
}
if self.points.iter().any(|p| p.id == role.output)
|| self.constraints.iter().any(|c| c.id == role.output)
|| self.links.iter().any(|l| l.id == role.output)
|| self.wires.iter().any(|w| w.id == role.output)
{
return Err(invalid("retired offset identity was stolen"));
}
self.curves.push(Curve {
id: role.output.clone(),
kind,
construction: role.flags.construction,
reversed: role.flags.reversed,
link: None,
centerline: role.flags.centerline,
fixed: role.flags.fixed,
});
}
outputs.push(role.output.clone());
}
role.retired = span.retired;
}
let record = self.offsets.get_mut(index).ok_or_else(|| invalid("missing offset association"))?;
record.outputs = outputs;
record.topology = Some(topology);
self.validate_offset_topologies()?;
Ok(true)
}
/// Caller materializes current driving values before freezing the association.
pub fn break_offset_topology(&mut self, output: &str) -> Result<Option<Vec<String>>> {
let Some(index) =
self.offsets.iter().position(|r| r.topology.as_ref().is_some_and(|t| t.id == output || t.roles.iter().any(|role| role.output == output)))
else {
return Ok(None);
};
let mut candidate = self.clone();
candidate.refresh_offsets()?;
let record = candidate.offsets.get(index).cloned().ok_or_else(|| invalid("missing offset association"))?;
let outputs: BTreeSet<_> = record.outputs.iter().map(String::as_str).collect();
let owned: BTreeSet<_> = candidate
.curves
.iter()
.filter(|c| outputs.contains(c.id.as_str()))
.flat_map(|c| match c.kind {
CurveKind::Line { a, b } | CurveKind::Arc { a, b, .. } => vec![a, b],
_ => Vec::new(),
})
.collect();
// Arc centres were deliberately shared with their analytic sources.
// Detach each distinct centre once, so released geometry stays frozen
// when the source is subsequently moved or dimensioned.
let centres: BTreeSet<_> = candidate
.curves
.iter()
.filter_map(|c| {
if outputs.contains(c.id.as_str())
&& let CurveKind::Arc { c, .. } = c.kind
{
return Some(c);
}
None
})
.collect();
let mut detached = BTreeMap::new();
for centre in centres {
let position = candidate.points.get(centre).ok_or_else(|| invalid("missing offset arc centre"))?.pos;
detached.insert(centre, candidate.add_point(position, None)?);
}
for curve in &mut candidate.curves {
if outputs.contains(curve.id.as_str())
&& let CurveKind::Arc { c, .. } = &mut curve.kind
&& let Some(&point) = detached.get(c)
{
*c = point;
}
}
candidate.offsets.remove(index);
for point in owned {
if point != 0
&& candidate.curves.iter().all(|c| !c.kind.uses(point) || outputs.contains(c.id.as_str()))
&& let Some(p) = candidate.points.get_mut(point)
{
p.fixed = false;
}
}
*self = candidate;
Ok(Some(record.outputs))
}
}
#[cfg(test)]
#[path = "offset_topology_tests.rs"]
mod tests;
+224
View File
@@ -0,0 +1,224 @@
#![allow(clippy::unwrap_used, clippy::panic)]
use super::*;
use crate::find_profiles;
fn rounded(radius: f64) -> (Sketch, Vec<(String, bool)>) {
let mut sk = Sketch::new();
let p = positions(radius);
let nodes: Vec<_> = p.iter().enumerate().map(|(i, p)| sk.add_point(*p, Some(&format!("source-p{i}"))).unwrap()).collect();
let centres = [Vec2::new(37., -32.), Vec2::new(37., 32.), Vec2::new(-37., 32.), Vec2::new(-37., -32.)];
let mut sources = Vec::new();
for i in 0..8 {
let id = format!("source-edge{i}");
if i % 2 == 0 {
sk.add_line_pts(nodes[i], nodes[(i + 1) % 8], Some(&id)).unwrap();
} else {
sk.add_arc(centres[i / 2], p[i], p[(i + 1) % 8], [None, Some(nodes[i]), Some(nodes[(i + 1) % 8])], Some(&id)).unwrap();
}
sources.push((id, true));
}
for point in &mut sk.points {
point.fixed = true;
}
(sk, sources)
}
fn positions(r: f64) -> [Vec2; 8] {
[
Vec2::new(-37., -32. - r),
Vec2::new(37., -32. - r),
Vec2::new(37. + r, -32.),
Vec2::new(37. + r, 32.),
Vec2::new(37., 32. + r),
Vec2::new(-37., 32. + r),
Vec2::new(-37. - r, 32.),
Vec2::new(-37. - r, -32.),
]
}
fn resize(sk: &mut Sketch, r: f64) {
for (i, value) in positions(r).into_iter().enumerate() {
sk.points.iter_mut().find(|p| p.id == format!("source-p{i}")).unwrap().pos = value;
}
}
fn json(sk: &Sketch) -> String {
serde_json::to_string(sk).unwrap()
}
fn area(sk: &Sketch, ids: &[String]) -> f64 {
let mut only = sk.clone();
only.curves.retain(|c| ids.contains(&c.id));
let profiles = find_profiles(&only);
assert_eq!(profiles.len(), 1);
profiles[0].area
}
#[test]
fn convex_extinction_material_ring_and_identity_resurrection() {
let (mut sk, sources) = rounded(1.);
let source_points = sk.points.clone();
let outer = sk.add_convex_offset(sources.clone(), 0.2).unwrap();
let inner = sk.add_convex_offset(sources, 1.4).unwrap();
assert_eq!(inner.curves.len(), 4);
assert!((area(&sk, &inner.curves) - 4626.24).abs() < 1e-8);
let ring_area = area(&sk, &outer.curves) - area(&sk, &inner.curves);
assert!((ring_area - 332.570619298295).abs() < 1e-8);
assert_eq!(&sk.points[..source_points.len()], source_points.as_slice());
let topology = sk.offsets[1].topology.clone().unwrap();
let nodes: Vec<_> = topology.roles.iter().map(|r| (r.start.clone(), r.end.clone())).collect();
let reserved: Vec<_> = topology.roles.iter().map(|r| r.output.clone()).collect();
assert_eq!(sk.offset_boundary_for_reserved_curves(&reserved).unwrap(), Some(inner.association.clone()));
let saved = json(&sk);
sk = serde_json::from_str(&saved).unwrap();
sk.validate_offset_topologies().unwrap();
resize(&mut sk, 2.);
sk.refresh_offsets().unwrap();
assert_eq!(sk.offset_boundary(&inner.association).unwrap(), reserved);
assert_eq!(sk.offsets[1].topology.as_ref().unwrap().roles.iter().map(|r| (r.start.clone(), r.end.clone())).collect::<Vec<_>>(), nodes);
assert!(sk.offset_boundary_for_reserved_curves(&inner.curves).unwrap().is_none());
assert!((area(&sk, &reserved) - (4. * 37.6 * 32.6 - (4. - std::f64::consts::PI) * 0.6 * 0.6)).abs() < 1e-8);
resize(&mut sk, 1.);
sk.refresh_offsets().unwrap();
assert_eq!(sk.offset_boundary(&inner.association).unwrap(), inner.curves);
}
#[test]
fn convex_legacy_upgrade_preserves_all_reserved_curve_ids() {
let (mut sk, sources) = rounded(2.);
let made = sk.add_convex_offset(sources, 1.4).unwrap();
let nodes = sk.points.clone();
sk.offsets[0].topology = None;
resize(&mut sk, 1.);
sk.refresh_offsets().unwrap();
let topology = sk.offsets[0].topology.as_ref().unwrap();
assert_eq!(topology.roles.iter().map(|r| r.output.clone()).collect::<Vec<_>>(), made.curves);
assert_eq!(sk.points.len(), nodes.len());
assert_eq!(sk.offsets[0].outputs.len(), 4);
resize(&mut sk, 2.);
sk.refresh_offsets().unwrap();
assert_eq!(sk.offsets[0].outputs, made.curves);
}
#[test]
fn convex_break_detaches_arc_centres_and_preserves_released_geometry() {
let (mut sk, sources) = rounded(2.);
let made = sk.add_convex_offset(sources, 1.4).unwrap();
sk.break_offset_topology(&made.association).unwrap();
let output_points: BTreeSet<_> = sk.curves.iter().filter(|c| made.curves.contains(&c.id)).flat_map(|c| c.kind.point_ids()).collect();
let frozen: Vec<_> = output_points.iter().map(|&p| (p, sk.points[p].pos)).collect();
let source_points: BTreeSet<_> = sk.curves.iter().filter(|c| c.id.starts_with("source-edge")).flat_map(|c| c.kind.point_ids()).collect();
assert!(output_points.is_disjoint(&source_points));
for p in source_points {
sk.points[p].pos += Vec2::new(7., -3.);
}
sk.refresh_offsets().unwrap();
for (p, pos) in frozen {
assert_eq!(sk.points[p].pos, pos);
}
assert!((area(&sk, &made.curves) - (4. * 37.6 * 32.6 - (4. - std::f64::consts::PI) * 0.36)).abs() < 1e-8);
}
#[test]
fn convex_clockwise_and_exact_radius_threshold_are_real_closed_regions() {
let (mut sk, mut sources) = rounded(1.4);
sources.reverse();
for (_, forward) in &mut sources {
*forward = false;
}
let made = sk.add_convex_offset(sources, -1.4).unwrap();
assert_eq!(made.curves.len(), 4);
assert!((area(&sk, &made.curves) - 4. * 37. * 32.).abs() < 1e-8);
}
#[test]
fn convex_retained_arc_dependencies_refuse_atomically() {
let (mut sk, sources) = rounded(2.);
let made = sk.add_convex_offset(sources, 1.4).unwrap();
let arc = made.curves.iter().find(|id| matches!(sk.curves[sk.curve_index(id).unwrap()].kind, CurveKind::Arc { .. })).unwrap();
let index = sk.curve_index(arc).unwrap();
sk.add_constraint(crate::ConstraintKind::Radius { c: index, value: 0.6 }, None).unwrap();
resize(&mut sk, 1.);
let before = json(&sk);
assert!(sk.refresh_offsets().unwrap_err().to_string().contains("constraint"));
assert_eq!(json(&sk), before);
}
#[test]
fn convex_reserved_identity_and_counter_bounds_are_enforced() {
let (mut sk, sources) = rounded(1.);
sk.add_convex_offset(sources, 1.4).unwrap();
let retired = sk.offsets[0].topology.as_ref().unwrap().roles.iter().find(|r| r.retired).unwrap().output.clone();
assert!(sk.add_line(Vec2::new(1., 2.), Vec2::new(2., 3.), None, None, Some(&retired)).is_err());
let mut stored = serde_json::to_value(&sk).unwrap();
stored["counters"]["o"] = serde_json::json!(u64::MAX);
sk = serde_json::from_value(stored).unwrap();
let before = json(&sk);
assert!(sk.fresh("o").is_err());
assert_eq!(json(&sk), before);
let mut invalid = sk.clone();
invalid.offsets[0].topology.as_mut().unwrap().roles[0].start = "source-p0".into();
assert!(invalid.validate_offset_topologies().is_err());
let mut wide = Sketch::new();
for i in 1..=2048 {
wide.add_point(Vec2::new(i as f64, 1.), Some(&format!("o{i}"))).unwrap();
}
assert_eq!(wide.fresh("o").unwrap(), "o2049");
}
#[test]
fn convex_retired_arc_flags_survive_reopen_and_resurrection() {
let (mut sk, sources) = rounded(2.);
let made = sk.add_convex_offset(sources, 1.4).unwrap();
let arc = made.curves.iter().find(|id| matches!(sk.curves[sk.curve_index(id).unwrap()].kind, CurveKind::Arc { .. })).unwrap().clone();
let index = sk.curve_index(&arc).unwrap();
let curve = &mut sk.curves[index];
curve.construction = true;
curve.centerline = true;
curve.reversed = true;
curve.fixed = true;
resize(&mut sk, 1.);
sk.refresh_offsets().unwrap();
sk = serde_json::from_str(&json(&sk)).unwrap();
sk.validate_offset_topologies().unwrap();
resize(&mut sk, 2.);
sk.refresh_offsets().unwrap();
let curve = &sk.curves[sk.curve_index(&arc).unwrap()];
assert!(curve.construction && curve.centerline && curve.reversed && curve.fixed);
}
#[test]
fn convex_dependency_preflight_and_disappearing_region_are_atomic() {
let (mut sk, sources) = rounded(1.);
let before = json(&sk);
assert!(sk.add_convex_offset(sources.clone(), 40.).is_err());
assert_eq!(json(&sk), before);
sk.add_convex_offset(sources, 1.4).unwrap();
sk.offsets.push(OffsetChain {
sources: vec![("unrelated".into(), true); MAX_WORK],
outputs: Vec::new(),
distance: 1.,
closed: false,
topology: None,
});
assert!(sk.validate_offset_topologies().unwrap_err().to_string().contains("work limit"));
}
#[test]
fn convex_rotated_translated_support_is_independent_of_world_origin() {
let (mut sk, sources) = rounded(1.);
let angle: f64 = 0.317;
let offset = Vec2::new(1e6, -2e6);
let map = |p: Vec2| Vec2::new(p.x * angle.cos() - p.y * angle.sin(), p.x * angle.sin() + p.y * angle.cos()) + offset;
for p in &mut sk.points {
p.pos = map(p.pos);
}
let made = sk.add_convex_offset(sources, 1.4).unwrap();
for curve in sk.curves.iter().filter(|c| made.curves.contains(&c.id)) {
let CurveKind::Line { a, b } = curve.kind else {
panic!("collapsed boundary should be straight");
};
for i in 0..=10 {
let world = sk.points[a].pos * (1. - i as f64 / 10.) + sk.points[b].pos * (i as f64 / 10.);
let q = world - offset;
let local = Vec2::new(q.x * angle.cos() + q.y * angle.sin(), -q.x * angle.sin() + q.y * angle.cos());
assert!(local.x.abs() <= 36.6 + 1e-8 && local.y.abs() <= 31.6 + 1e-8);
}
}
}
+176 -14
View File
@@ -28,9 +28,20 @@ pub struct Profile {
pub hole_curves: Vec<Vec<String>>,
pub area: f64,
pub centroid: Vec2,
// Computed before exact-segment restoration; never trusted from serialized input.
#[serde(skip)]
complete_outer: Option<Vec<String>>,
#[serde(skip)]
complete_holes: Option<Vec<Vec<String>>>,
}
impl Profile {
/// Whole source boundaries, certified by complete graph-piece coverage. Intersections,
/// missing spans and unknown sampled geometry cannot acquire this persistent identity.
pub fn whole_curve_boundaries(&self) -> Option<(Vec<String>, Vec<Vec<String>>)> {
Some((self.complete_outer.clone()?, self.complete_holes.clone()?))
}
/// All curve ids bounding the profile.
pub fn curve_ids(&self) -> Vec<String> {
let mut v = self.outer_curves.clone();
@@ -48,10 +59,65 @@ struct Edge {
id: String,
/// A piece of an exact free-form segment: (curve, segment, piece, pieces).
tag: Option<Piece>,
coverage: Option<Piece>,
}
type Piece = (usize, usize, usize, usize);
/// Prove each represented source uses all its original spans exactly once and consistently
/// in one direction. Work and storage are bounded by the existing graph-edge budget.
fn complete_loop(tags: &[Option<(Piece, bool)>], expected: &std::collections::HashMap<usize, Vec<usize>>, sk: &Sketch) -> Option<Vec<String>> {
use std::collections::{BTreeMap, BTreeSet};
if tags.is_empty() || tags.len() > MAX_EDGES {
return None;
}
let mut seen = BTreeSet::new();
let mut directions = BTreeMap::new();
let mut counts = BTreeMap::<usize, Vec<(usize, usize)>>::new();
let mut previous_curve = tags.last().copied().flatten()?.0.0;
let mut runs = BTreeSet::new();
for tag in tags {
let ((ci, segment, piece, pieces), forward) = (*tag)?;
let expected_pieces = *expected.get(&ci)?.get(segment)?;
if pieces == 0 || pieces != expected_pieces || piece >= pieces || !seen.insert((ci, segment, piece)) {
return None;
}
if directions.insert(ci, forward).is_some_and(|old| old != forward) {
return None;
}
if previous_curve != ci && !runs.insert(ci) {
return None;
}
previous_curve = ci;
counts.entry(ci).or_default().push((segment, piece));
}
let mut ids = Vec::with_capacity(counts.len());
for (ci, order) in counts {
let spans = expected.get(&ci)?;
let total = spans.iter().try_fold(0usize, |sum, n| sum.checked_add(*n))?;
if spans.is_empty() || spans.contains(&0) || total > MAX_EDGES || total != order.len() {
return None;
}
let mut offsets = Vec::with_capacity(spans.len());
let mut offset = 0;
for count in spans {
offsets.push(offset);
offset += count;
}
let forward = *directions.get(&ci)?;
let indices = order.iter().map(|(segment, piece)| offsets[*segment] + piece).collect::<Vec<_>>();
if indices.windows(2).any(|w| w[1] != if forward { (w[0] + 1) % total } else { (w[0] + total - 1) % total }) {
return None;
}
ids.push(sk.curves.get(ci)?.id.clone());
}
ids.sort();
if ids.windows(2).any(|w| w[0] == w[1]) {
return None;
}
Some(ids)
}
/// Parameter of `p` at an end of the shape (lines: 0 or 1), for intersections at end points.
fn end_param(sh: &Shape, p: Vec2, tol: f64) -> Option<f64> {
match *sh {
@@ -373,15 +439,24 @@ pub fn find_drawn_profiles(sk: &Sketch) -> Vec<Profile> {
}
pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
find_profiles_bounded(sk, MAX_EDGES)
}
fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec<Profile> {
let tol = MERGE_TOL * 10.0;
// Analytic shapes of the profile curves.
let mut shapes: Vec<(usize, Shape)> = Vec::new();
let mut tags: Vec<Option<Piece>> = Vec::new();
let mut exact: std::collections::HashMap<usize, Vec<Seg2>> = std::collections::HashMap::new();
let mut whole_graph_complete = true;
let mut expected = std::collections::HashMap::<usize, Vec<usize>>::new();
for (ci, c) in sk.curves.iter().enumerate() {
if c.construction || c.centerline {
continue;
}
if matches!(c.kind, CurveKind::Line { .. } | CurveKind::Arc { .. } | CurveKind::Circle { .. }) {
expected.insert(ci, vec![1]);
}
match c.kind {
CurveKind::Line { a, b } => {
if let (Some(a), Some(b)) = (sk.point(a), sk.point(b))
@@ -404,9 +479,11 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
// a whole segment get the exact one back.
_ => match sk.exact_segs(ci) {
Some(ex) => {
let mut spans = Vec::with_capacity(ex.len());
for (k, seg) in ex.iter().enumerate() {
let poly = seg.polyline(1e-3);
let n = poly.len().saturating_sub(1);
spans.push(n);
for (j, w) in poly.windows(2).enumerate() {
if w[0].dist(w[1]) > MERGE_TOL {
shapes.push((ci, Shape::Line { a: w[0], b: w[1] }));
@@ -415,6 +492,7 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
}
}
}
expected.insert(ci, spans);
exact.insert(ci, ex);
}
None => {
@@ -427,7 +505,8 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
},
}
tags.resize(shapes.len(), None);
if shapes.len() > MAX_EDGES {
if shapes.len() > max_edges {
whole_graph_complete = false;
break;
}
}
@@ -580,7 +659,7 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
grid.entry((cx, cy)).or_default().push(i);
i
};
let mut loops: Vec<(Loop2, Vec<String>, usize)> = Vec::new(); // (ccw loop, curve ids, component)
let mut loops: Vec<(Loop2, Vec<String>, usize, Option<Vec<String>>)> = Vec::new(); // (ccw loop, curve ids, component)
let mut edges: Vec<Edge> = Vec::new();
for (k, (ci, sh)) in shapes.iter().enumerate() {
let mut ts = cuts.get(k).cloned().unwrap_or_default();
@@ -600,6 +679,13 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
ts.pop();
}
let id = sk.curves.get(*ci).map(|c| c.id.clone()).unwrap_or_default();
let coverage = tags.get(k).copied().flatten().or_else(|| {
sk.curves
.get(*ci)
.filter(|c| matches!(c.kind, CurveKind::Line { .. } | CurveKind::Arc { .. } | CurveKind::Circle { .. }))
.map(|_| (*ci, 0, 0, 1))
});
let coverage = if ts.is_empty() { coverage } else { None };
match *sh {
Shape::Line { a, b } => {
let mut ps = vec![a];
@@ -611,14 +697,14 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
let (p, q) = (w[0], w[1]);
if p.dist(q) > MERGE_TOL {
let (u, v) = (node(p), node(q));
edges.push(Edge { seg: Seg2::Line { a: p, b: q }, u, v, id: id.clone(), tag });
edges.push(Edge { seg: Seg2::Line { a: p, b: q }, u, v, id: id.clone(), tag, coverage });
}
}
}
Shape::Round { c, r, start, sweep } => {
let full = sweep >= TAU - 1e-12;
if full && ts.is_empty() {
loops.push((Loop2::circle(c, r), vec![id.clone()], usize::MAX - *ci));
loops.push((Loop2::circle(c, r), vec![id.clone()], usize::MAX - *ci, whole_graph_complete.then(|| vec![id.clone()])));
continue;
}
let mut bounds: Vec<f64> =
@@ -634,7 +720,7 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
}
let seg = Seg2::Arc { center: c, radius: r, start: start + t0, sweep: t1 - t0 };
let (u, v) = (node(seg.start()), node(seg.end()));
edges.push(Edge { seg, u, v, id: id.clone(), tag: None });
edges.push(Edge { seg, u, v, id: id.clone(), tag: None, coverage });
}
}
}
@@ -705,6 +791,7 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
let mut segs = Vec::new();
let mut ids = Vec::new();
let mut ptags: Vec<Option<(Piece, bool)>> = Vec::new();
let mut coverage_tags = Vec::new();
let mut h = start;
let mut ok = true;
for _ in 0..=edges.len() * 2 {
@@ -719,8 +806,10 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
if let Some(e) = edges.get(h / 2) {
ids.push(e.id.clone());
ptags.push(e.tag.map(|t| (t, h.is_multiple_of(2))));
coverage_tags.push(e.coverage.map(|t| (t, h.is_multiple_of(2))));
} else {
ptags.push(None);
coverage_tags.push(None);
}
let back = (-seg.end_tangent()).angle();
let twin = h ^ 1;
@@ -766,16 +855,16 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
let lp = Loop2 { segs: restore_exact(segs, &ptags, &exact) };
if lp.signed_area() > 1e-9 {
let c = edges.get(start / 2).map(|e| find(&mut comp, e.u)).unwrap_or(0);
loops.push((lp, ids, c));
loops.push((lp, ids, c, whole_graph_complete.then(|| complete_loop(&coverage_tags, &expected, sk)).flatten()));
}
}
// Nesting: parent = smallest containing loop from another component. Bounding boxes rule
// out most pairs before the point-in-loop test.
let areas: Vec<f64> = loops.iter().map(|(l, _, _)| l.signed_area().abs()).collect();
let areas: Vec<f64> = loops.iter().map(|(l, _, _, _)| l.signed_area().abs()).collect();
let boxes: Vec<(Vec2, Vec2)> = loops
.iter()
.map(|(l, _, _)| {
.map(|(l, _, _, _)| {
l.polyline(1e-3).iter().fold((Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)), |(lo, hi), p| {
(Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y)))
})
@@ -783,11 +872,11 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
.collect();
let inside_box = |j: usize, p: Vec2| boxes.get(j).is_some_and(|(lo, hi)| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y);
let mut parent_of: Vec<Option<usize>> = vec![None; loops.len()];
for (i, (li, _, ci)) in loops.iter().enumerate() {
for (i, (li, _, ci, _)) in loops.iter().enumerate() {
let probe = li.segs.first().map(Seg2::mid).unwrap_or_default();
let ai = areas.get(i).copied().unwrap_or(0.0);
let mut best: Option<(f64, usize)> = None;
for (j, (lj, _, cj)) in loops.iter().enumerate() {
for (j, (lj, _, cj, _)) in loops.iter().enumerate() {
let aj = areas.get(j).copied().unwrap_or(0.0);
if i == j || ci == cj || aj <= ai || !inside_box(j, probe) || best.is_some_and(|(ba, _)| aj >= ba) {
continue;
@@ -811,14 +900,18 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
let out: Vec<Profile> = loops
.iter()
.enumerate()
.map(|(i, (l, ids, _))| {
.map(|(i, (l, ids, _, complete_outer))| {
let kids: Vec<usize> = kids_of.get(i).cloned().unwrap_or_default();
let holes: Vec<Loop2> = kids.iter().filter_map(|k| loops.get(*k).map(|(h, _, _)| h.ccw().reversed())).collect();
let hole_curves: Vec<Vec<String>> = kids.iter().filter_map(|k| loops.get(*k).map(|(_, ids, _)| ids.clone())).collect();
let holes: Vec<Loop2> = kids.iter().filter_map(|k| loops.get(*k).map(|(h, _, _, _)| h.ccw().reversed())).collect();
let hole_curves: Vec<Vec<String>> = kids.iter().filter_map(|k| loops.get(*k).map(|(_, ids, _, _)| ids.clone())).collect();
let region = Region2 { outer: l.ccw(), holes };
let area = region.area();
let centroid = region.centroid();
Profile { region, outer_curves: ids.clone(), hole_curves, area, centroid }
let mut complete_holes = kids.iter().map(|k| loops.get(*k)?.3.clone()).collect::<Option<Vec<_>>>();
if let Some(holes) = &mut complete_holes {
holes.sort();
}
Profile { region, outer_curves: ids.clone(), hole_curves, area, centroid, complete_outer: complete_outer.clone(), complete_holes }
})
.collect();
let index: std::collections::HashMap<&str, usize> = sk.curves.iter().enumerate().map(|(i, c)| (c.id.as_str(), i)).collect();
@@ -932,3 +1025,72 @@ pub fn merge_regions(regions: &[Region2]) -> Vec<Region2> {
}
if out.is_empty() { regions.to_vec() } else { out }
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod whole_boundary_tests {
use super::*;
#[test]
fn whole_boundary_coverage_requires_every_piece_once_in_one_direction() {
let mut sk = Sketch::new();
sk.add_circle(Vec2::ZERO, 5.0, None, Some("c1")).unwrap();
let expected = std::collections::HashMap::from([(0, vec![2, 3])]);
let full = vec![
Some(((0, 0, 0, 2), true)),
Some(((0, 0, 1, 2), true)),
Some(((0, 1, 0, 3), true)),
Some(((0, 1, 1, 3), true)),
Some(((0, 1, 2, 3), true)),
];
assert_eq!(complete_loop(&full, &expected, &sk), Some(vec!["c1".into()]));
assert!(complete_loop(&full[..4], &expected, &sk).is_none());
let mut duplicate = full.clone();
duplicate[4] = duplicate[3];
assert!(complete_loop(&duplicate, &expected, &sk).is_none());
let mut cut = full.clone();
cut[2] = None;
assert!(complete_loop(&cut, &expected, &sk).is_none());
let mut permuted = full.clone();
permuted.swap(2, 3);
assert!(complete_loop(&permuted, &expected, &sk).is_none());
assert!(complete_loop(&full, &std::collections::HashMap::new(), &sk).is_none());
// A skipped tiny graph span still belongs to the original curve and cannot be ignored.
let omitted = std::collections::HashMap::from([(0, vec![2, 4])]);
assert!(complete_loop(&full, &omitted, &sk).is_none());
let mut reverse = full;
reverse[2] = Some(((0, 1, 0, 3), false));
assert!(complete_loop(&reverse, &expected, &sk).is_none());
}
#[test]
fn whole_boundary_refuses_globally_truncated_graph_context() {
let mut sk = Sketch::new();
sk.add_circle(Vec2::ZERO, 10.0, None, Some("outline")).unwrap();
sk.add_circle(Vec2::ZERO, 5.0, None, Some("hole")).unwrap();
sk.add_line(Vec2::new(-12.0, 0.0), Vec2::new(12.0, 0.0), None, None, Some("later-crossing")).unwrap();
// Exercise the production truncation branch with a small deterministic budget;
// the old display still has outlines, but the omitted crossing forbids persistence.
let displayed = find_profiles_bounded(&sk, 1);
assert_eq!(displayed.len(), 2);
assert!(displayed.iter().all(|p| p.whole_curve_boundaries().is_none()));
}
#[test]
fn whole_boundary_records_holes_but_refuses_intersection_fragments() {
let mut sk = Sketch::new();
sk.add_circle(Vec2::ZERO, 10.0, None, Some("outer")).unwrap();
sk.add_circle(Vec2::ZERO, 5.0, None, Some("hole")).unwrap();
let profiles = find_profiles(&sk);
let annulus = profiles.iter().find(|p| !p.region.holes.is_empty()).unwrap();
assert_eq!(annulus.whole_curve_boundaries(), Some((vec!["outer".into()], vec![vec!["hole".into()]])));
// A line through the ring creates bounded fragments of both original circles.
sk.add_line(Vec2::new(-12.0, 0.0), Vec2::new(12.0, 0.0), None, None, Some("cut")).unwrap();
let profiles = find_profiles(&sk);
assert!(!profiles.is_empty());
assert!(profiles.iter().all(|p| p.whole_curve_boundaries().is_none()));
// Serialized profiles cannot forge this runtime certificate.
let copy: Profile = serde_json::from_str(&serde_json::to_string(annulus).unwrap()).unwrap();
assert!(copy.whole_curve_boundaries().is_none());
}
}
+98
View File
@@ -0,0 +1,98 @@
#![allow(clippy::unwrap_used, clippy::panic)]
use crate::{Constraint, ConstraintKind, CurveKind, Sketch};
use solvecraft_geom::Vec2;
fn point(sk: &mut Sketch, x: f64, y: f64) -> usize {
sk.add_point(Vec2::new(x, y), None).unwrap()
}
fn on(sk: &mut Sketch, p: usize, c: usize) {
sk.add_constraint(ConstraintKind::PointOnCurve { p, c }, None).unwrap();
}
#[test]
fn virtual_corner_extensions_keep_constraint_order_and_distinct_curves() {
let mut sk = Sketch::new();
let p = point(&mut sk, 10., 10.);
let horizontal = sk.add_line(Vec2::new(0., 10.), Vec2::new(8., 10.), None, None, None).unwrap();
let vertical = sk.add_line(Vec2::new(10., 0.), Vec2::new(10., 7.), None, None, None).unwrap();
on(&mut sk, p, vertical);
on(&mut sk, p, vertical);
assert!(sk.virtual_corners().is_empty(), "one curve is not a sharp corner even with duplicate constraints");
on(&mut sk, p, horizontal);
assert_eq!(sk.virtual_corners(), vec![(p, vec![(Vec2::new(10., 10.), Vec2::new(10., 7.)), (Vec2::new(10., 10.), Vec2::new(8., 10.))])]);
let copy = serde_json::from_str::<Sketch>(&serde_json::to_string(&sk).unwrap()).unwrap();
assert_eq!(copy.virtual_corners(), sk.virtual_corners());
}
#[test]
fn virtual_corner_inside_lines_stays_visible_without_extensions_and_origin_is_excluded() {
let mut sk = Sketch::new();
let p = point(&mut sk, 1., 1.);
let a = sk.add_line(Vec2::new(0., 1.), Vec2::new(2., 1.), None, None, None).unwrap();
let b = sk.add_line(Vec2::new(1., 0.), Vec2::new(1., 2.), None, None, None).unwrap();
for c in [a, b] {
on(&mut sk, p, c);
on(&mut sk, 0, c);
}
assert_eq!(sk.virtual_corners(), vec![(p, vec![])]);
}
#[test]
fn curve_centres_and_spline_interior_points_are_used_and_invalid_incidence_is_safe() {
let mut sk = Sketch::new();
let centre = point(&mut sk, 10., 10.);
let interior = point(&mut sk, 20., 20.);
let a = sk.add_line(Vec2::new(0., 10.), Vec2::new(8., 10.), None, None, None).unwrap();
let b = sk.add_line(Vec2::new(10., 0.), Vec2::new(10., 8.), None, None, None).unwrap();
sk.add_arc(Vec2::new(10., 10.), Vec2::new(11., 10.), Vec2::new(10., 11.), [Some(centre), None, None], None).unwrap();
let first = point(&mut sk, 19., 19.);
let last = point(&mut sk, 21., 21.);
let mut spline = sk.curves[a].clone();
spline.id = "interior_spline".into();
spline.kind = CurveKind::Spline { pts: vec![first, interior, last], control: false, degree: 3, periodic: false };
sk.curves.push(spline);
for p in [centre, interior] {
for c in [a, b] {
on(&mut sk, p, c);
}
}
for (p, c) in [(usize::MAX, a), (centre, usize::MAX)] {
sk.constraints.push(Constraint {
id: format!("damaged{p}{c}"),
kind: ConstraintKind::PointOnCurve { p, c },
param: None,
driven: false,
text: None,
});
}
assert!(sk.virtual_corners().is_empty());
}
#[test]
fn many_virtual_corners_are_indexed_in_point_order() {
let mut sk = Sketch::new();
let mut expected = Vec::new();
for i in 0..3500 {
let x = i as f64 * 3. + 1.;
let p = point(&mut sk, x, 1.);
let a = sk.add_line(Vec2::new(x - 1., 1.), Vec2::new(x - 0.5, 1.), None, None, None).unwrap();
let b = sk.add_line(Vec2::new(x, 0.), Vec2::new(x, 0.5), None, None, None).unwrap();
on(&mut sk, p, a);
on(&mut sk, p, b);
expected.push((p, vec![(Vec2::new(x, 1.), Vec2::new(x - 0.5, 1.)), (Vec2::new(x, 1.), Vec2::new(x, 0.5))]));
}
assert_eq!(sk.virtual_corners(), expected);
}
#[test]
fn oversized_reference_input_is_refused_without_scanning_or_mutating_it() {
let mut sk = Sketch::new();
let a = sk.add_line(Vec2::new(0., 1.), Vec2::new(2., 1.), None, None, None).unwrap();
let before = sk.points.clone();
sk.curves[a].kind = CurveKind::Spline { pts: vec![usize::MAX; 1_000_001], control: false, degree: 3, periodic: false };
assert!(sk.virtual_corners().is_empty());
assert_eq!(sk.points, before);
assert_eq!(sk.curves[a].kind.point_ids().len(), 1_000_001);
}
+3 -3
View File
@@ -195,7 +195,7 @@ impl Sketch {
return self.add_link(kind, source, geom);
}
let id = if geom.is_empty() {
let id = self.fresh("j");
let id = self.fresh("j")?;
self.links.push(Link { id: id.clone(), kind, source, face_name: None, auto_face: false, lost: false });
id
} else {
@@ -217,7 +217,7 @@ impl Sketch {
for (k, pts) in ws.into_iter().enumerate() {
let wid = match old.get(k) {
Some(o) => o.clone(),
None => self.fresh("w"),
None => self.fresh("w")?,
};
self.wires.push(Wire { id: wid, pts, space: WireSpace::Component, link: Some(id.to_string()), fit: Vec::new() });
}
@@ -233,7 +233,7 @@ impl Sketch {
pub fn add_drawn_wire_in(&mut self, fit: &[Vec3], space: WireSpace) -> Result<String> {
let pts = drawn_polyline(fit)?;
self.wire_budget(None, 1, pts.len() + fit.len())?;
let id = self.fresh(if fit.len() == 1 { "q" } else { "w" });
let id = self.fresh(if fit.len() == 1 { "q" } else { "w" })?;
self.wires.push(Wire { id: id.clone(), pts, space, link: None, fit: fit.to_vec() });
Ok(id)
}