feat: preserve adaptive sketch profiles and curved modeling workflows
This commit is contained in:
1 parent
4c4a0449a4
commit
4e595deff2
62 files changed
+7114
-362
No files matched your search
@@ -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;
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user