use super::super::ir::Bbox;
use super::Segment;
use super::corner::{Mod, apply_mod};
use super::geometry::{
self, MirrorAxis, P, PathSeg, Subpath, arc_mid, bearing_dir, dir_bearing, dist, geometry_bbox,
rotate_about, to_d,
};
use crate::error::Error;
use crate::resolve::{ResolvedCall, ResolvedInst, ResolvedValue};
use crate::span::Span;
#[derive(Debug)]
pub struct Folded {
pub d: String,
pub geometry: Bbox,
pub segments: Vec<(String, Segment)>,
pub mirror_axes: Vec<MirrorAxis>,
pub subs: Vec<Subpath>,
pub view: super::breaks::ViewMap,
pub cuts: Vec<super::breaks::CutEdge>,
#[allow(
dead_code,
reason = "asserted against desugar's openness check in tests"
)]
pub fused: bool,
pub revolved: bool,
pub edges: Vec<(P, P)>,
pub threads: Vec<(P, P)>,
pub thread_specs: Vec<(String, f64)>,
}
pub fn fold(inst: &ResolvedInst, scale: f64) -> Result<Folded, Error> {
let span = inst.span;
let Some(draw) = inst.attrs.get("draw") else {
return Err(Error::at(span, "'|sketch|' requires 'draw'"));
};
let items: Vec<&ResolvedValue> = match draw {
ResolvedValue::Tuple(items) => items.iter().collect(),
one => vec![one],
};
let mut pen = Pen::new(span);
for item in items {
match item {
ResolvedValue::PenCall { call, segment } => pen.call(call, segment.as_deref())?,
_ => {
return Err(Error::at(
span,
"'draw' holds pen calls and ':segment' points — see SPEC 15.3",
));
}
}
}
let (mut subs, mut segments) = pen.finish()?;
let mut mirror_axes = Vec::new();
let mut fused = false;
let mut revolve = None;
if let Some(v) = inst.attrs.get("revolve") {
if inst.attrs.get("mirror").is_some() {
return Err(Error::at(
span,
"a sketch takes 'revolve:' or 'mirror:', not both",
));
}
let axis = parse_revolve(v, span)?;
fused |= geometry::mirror(&mut subs, axis);
mirror_axes.push(axis);
revolve = Some(axis);
}
if let Some(v) = inst.attrs.get("mirror") {
for axis in parse_mirror(v, span)? {
fused |= geometry::mirror(&mut subs, axis);
mirror_axes.push(axis);
}
}
if scale != 1.0 {
geometry::scale(&mut subs, scale);
for (_, p) in &mut segments {
*p = p.scaled(scale);
}
}
let (view, cuts) = super::breaks::apply(inst, &mut subs, scale, span)?;
let mut edges = match revolve {
Some(axis) => super::edges::spans(&subs, axis),
None => Vec::new(),
};
let dressing = super::threads::dress(inst, &segments, &subs, revolve, &view, scale, span)?;
edges.extend(dressing.ends);
let d = to_d(&subs);
Ok(Folded {
geometry: geometry_bbox(&d),
d,
segments,
mirror_axes,
subs,
view,
cuts,
fused,
revolved: revolve.is_some(),
edges,
threads: dressing.minors,
thread_specs: dressing.specs,
})
}
fn parse_revolve(v: &ResolvedValue, span: Span) -> Result<MirrorAxis, Error> {
match v {
ResolvedValue::Ident(s) if s == "x-axis" => Ok(MirrorAxis { bearing: 90.0 }),
ResolvedValue::Ident(s) if s == "y-axis" => Ok(MirrorAxis { bearing: 0.0 }),
_ => Err(Error::at(span, "'revolve' takes x-axis or y-axis")),
}
}
fn is_builtin_point(name: &str) -> bool {
matches!(
name,
"top"
| "bottom"
| "left"
| "right"
| "center"
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right"
)
}
fn parse_mirror(v: &ResolvedValue, span: Span) -> Result<Vec<MirrorAxis>, Error> {
let one = |item: &ResolvedValue| -> Result<MirrorAxis, Error> {
match item {
ResolvedValue::Ident(s) if s == "x-axis" => Ok(MirrorAxis { bearing: 90.0 }),
ResolvedValue::Ident(s) if s == "y-axis" => Ok(MirrorAxis { bearing: 0.0 }),
ResolvedValue::Number(b) => Ok(MirrorAxis { bearing: *b }),
_ => Err(Error::at(
span,
"'mirror' takes x-axis, y-axis, or a bearing",
)),
}
};
match v {
ResolvedValue::Tuple(items) => items.iter().map(one).collect(),
item => Ok(vec![one(item)?]),
}
}
type Segments = Vec<(String, Segment)>;
struct Pen {
span: Span,
subs: Vec<Subpath>,
cur: Vec<PathSeg>,
start: Option<P>,
pos: P,
heading: Option<f64>,
pending: Option<(Mod, Option<String>)>,
just_closed: bool,
segments: Vec<(String, Segment)>,
}
impl Pen {
fn new(span: Span) -> Self {
Pen {
span,
subs: Vec::new(),
cur: Vec::new(),
start: None,
pos: (0.0, 0.0),
heading: None,
pending: None,
just_closed: false,
segments: Vec::new(),
}
}
fn err(&self, msg: impl Into<String>) -> Error {
Error::at(self.span, msg.into())
}
fn segment(&mut self, name: &str, segment: Segment) -> Result<(), Error> {
if is_builtin_point(name) {
return Err(self.err(format!(
"':{name}' is a built-in anchor — pick another name"
)));
}
if self.segments.iter().any(|(n, _)| n == name) {
return Err(self.err(format!("':{name}' is already named in this 'draw:'")));
}
self.segments.push((name.to_string(), segment));
Ok(())
}
fn call(&mut self, call: &ResolvedCall, segment: Option<&str>) -> Result<(), Error> {
if self.just_closed
&& !matches!(
call.name.as_str(),
"fillet" | "chamfer" | "move" | "circle" | "point"
)
{
return Err(self.err("after close(), start the next subpath with move()"));
}
match call.name.as_str() {
"move" => {
let [x, y] = self.nums::<2>(call, "'move' takes (x, y)")?;
if segment.is_some() {
return Err(
self.err("'move' takes no segment — name its landing with point():name")
);
}
self.flush()?;
self.start = Some((x, y));
self.pos = (x, y);
}
"left" | "right" | "up" | "down" => {
let [len] = self.nums::<1>(call, "an orthogonal run takes a length")?;
let bearing = match call.name.as_str() {
"up" => 0.0,
"right" => 90.0,
"down" => 180.0,
_ => 270.0,
};
self.run(bearing_scaled(bearing, len), Some(bearing), segment)?;
}
"line" => {
let [dx, dy] = self.nums::<2>(call, "'line' takes (dx, dy)")?;
self.run((dx, dy), Some(dir_bearing((dx, dy))), segment)?;
}
"angle" => {
let [deg, len] = self.nums::<2>(call, "'angle' takes (deg, n)")?;
self.run(bearing_scaled(deg, len), Some(deg), segment)?;
}
"arc" => match call.args.len() {
3 => {
let [dx, dy, r] =
self.nums::<3>(call, "'arc' takes (dx, dy, r) or (r, deg)")?;
self.arc_to((dx, dy), r, segment)?;
}
2 => {
let [r, deg] = self.nums::<2>(call, "'arc' takes (dx, dy, r) or (r, deg)")?;
self.arc_turn(r, deg, segment)?;
}
_ => return Err(self.err("'arc' takes (dx, dy, r) or (r, deg)")),
},
"curve" => {
let [dx1, dy1, dx2, dy2, dx, dy] =
self.nums::<6>(call, "'curve' takes (dx1, dy1, dx2, dy2, dx, dy)")?;
let from = self.started()?;
let c1 = (from.0 + dx1, from.1 + dy1);
let c2 = (from.0 + dx2, from.1 + dy2);
let to = (from.0 + dx, from.1 + dy);
self.push_seg(PathSeg::Cubic { from, c1, c2, to })?;
let tangent = (to.0 - c2.0, to.1 - c2.1);
if dist(tangent, (0.0, 0.0)) > 1e-9 {
self.heading = Some(dir_bearing(tangent));
}
self.pos = to;
if let Some(nm) = segment {
self.segment(nm, Segment::Edge(from, to))?;
}
}
"point" => {
self.nums::<0>(call, "'point()' takes no arguments")?;
if self.start.is_none() && !self.just_closed {
return Err(self.err("the pen starts with move(x, y)"));
}
let Some(nm) = segment else {
return Err(
self.err("'point()' names the pen's position — attach a ':segment'")
);
};
self.segment(nm, Segment::Point(self.pos))?;
}
"fillet" | "chamfer" => {
let msg = format!("'{}' modifies the corner between two segments", call.name);
let [v] = self.nums::<1>(call, &msg)?;
let m = if call.name == "fillet" {
Mod::Fillet(v)
} else {
Mod::Chamfer(v)
};
if self.pending.is_some() {
return Err(self.err(msg));
}
if self.just_closed {
let name = self.apply_cyclic(m, segment)?;
if let Some((nm, p)) = name {
self.segment(&nm, p)?;
}
} else {
if self.cur.is_empty() {
return Err(self.err(msg));
}
self.pending = Some((m, segment.map(str::to_string)));
}
}
"circle" => {
let [r] = self.nums::<1>(call, "'circle' takes a radius")?;
if r <= 0.0 {
return Err(self.err("'circle' takes a radius > 0"));
}
if self.start.is_none() && !self.just_closed {
return Err(self.err("the pen starts with move(x, y)"));
}
let (cx, cy) = self.pos;
let (w, e) = ((cx - r, cy), (cx + r, cy));
self.subs.push(Subpath {
segs: vec![
PathSeg::Arc {
from: w,
to: e,
r,
large: false,
sweep: true,
},
PathSeg::Arc {
from: e,
to: w,
r,
large: false,
sweep: true,
},
],
closed: true,
});
if let Some(nm) = segment {
self.segment(
nm,
Segment::Circle {
center: self.pos,
r,
},
)?;
}
}
"close" => {
if self.cur.is_empty() {
return Err(self.err("close() needs a drawn subpath"));
}
let start = self.start.expect("cur non-empty implies a start");
let seam = PathSeg::Line {
from: self.pos,
to: start,
};
if dist(self.pos, start) > 1e-9 {
self.push_seg(seam)?; self.heading = Some(dir_bearing((start.0 - self.pos.0, start.1 - self.pos.1)));
} else if let Some((m, _)) = self.pending {
return Err(self.err(format!(
"'{}' modifies the corner between two segments",
m.word()
)));
}
if let Some(nm) = segment {
self.segment(nm, Segment::Edge(self.pos, start))?;
}
self.pos = start;
let segs = std::mem::take(&mut self.cur);
self.subs.push(Subpath { segs, closed: true });
self.start = None;
self.just_closed = true;
}
other => return Err(self.err(format!("unknown draw call '{other}'"))),
}
Ok(())
}
fn run(&mut self, delta: P, bearing: Option<f64>, segment: Option<&str>) -> Result<(), Error> {
let from = self.started()?;
let to = (from.0 + delta.0, from.1 + delta.1);
self.push_seg(PathSeg::Line { from, to })?;
self.pos = to;
if bearing.is_some() {
self.heading = bearing;
}
if let Some(nm) = segment {
self.segment(nm, Segment::Edge(from, to))?;
}
Ok(())
}
fn arc_to(&mut self, delta: P, r: f64, segment: Option<&str>) -> Result<(), Error> {
let from = self.started()?;
let to = (from.0 + delta.0, from.1 + delta.1);
let chord = dist(from, to);
if chord < 1e-9 {
return Err(self.err("'arc' needs a non-zero chord — a full turn is 'circle(r)'"));
}
let ra = r.abs();
if ra < chord / 2.0 - 1e-9 {
return Err(self.err(format!(
"arc radius {} is smaller than half the chord",
geometry::n(ra)
)));
}
let sweep = r > 0.0;
let ra = ra.max(chord / 2.0);
let m = ((from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0);
let centre = geometry::arc_center(from, to, ra, false, sweep);
self.push_seg(PathSeg::Arc {
from,
to,
r: ra,
large: false,
sweep,
})?;
self.pos = to;
let rad = (to.0 - centre.0, to.1 - centre.1);
let tangent = if sweep {
(-rad.1, rad.0)
} else {
(rad.1, -rad.0)
};
self.heading = Some(dir_bearing(tangent));
if let Some(nm) = segment {
let mid = arc_mid(centre, m, ra, from, sweep);
self.segment(nm, Segment::Arc { mid, r: ra })?;
}
Ok(())
}
fn arc_turn(&mut self, r: f64, deg: f64, segment: Option<&str>) -> Result<(), Error> {
let from = self.started()?;
if r <= 0.0 {
return Err(self.err("'arc(r, deg)' takes a radius > 0"));
}
if deg == 0.0 || deg.abs() >= 360.0 {
return Err(self.err("'arc(r, deg)' sweeps within (-360, 360), not 0"));
}
let Some(heading) = self.heading else {
return Err(self.err("'arc(r, deg)' continues a heading — draw a run first"));
};
let hv = bearing_dir(heading);
let cw = deg > 0.0;
let centre = if cw {
(from.0 - hv.1 * r, from.1 + hv.0 * r)
} else {
(from.0 + hv.1 * r, from.1 - hv.0 * r)
};
let to = rotate_about(from, centre, deg);
self.push_seg(PathSeg::Arc {
from,
to,
r,
large: deg.abs() > 180.0,
sweep: cw,
})?;
self.pos = to;
self.heading = Some((heading + deg).rem_euclid(360.0));
if let Some(nm) = segment {
let mid = rotate_about(from, centre, deg / 2.0);
self.segment(nm, Segment::Arc { mid, r })?;
}
Ok(())
}
fn push_seg(&mut self, seg: PathSeg) -> Result<(), Error> {
self.just_closed = false;
let seg = match self.pending.take() {
None => seg,
Some((m, name)) => {
let prev = self.cur.pop().expect("pending implies a previous segment");
let (prev, mid, next, segment) = apply_mod(m, prev, seg, self.span)?;
self.cur.push(prev);
self.cur.push(mid);
if let Some(nm) = name {
self.segment(&nm, segment)?;
}
next
}
};
self.cur.push(seg);
Ok(())
}
fn apply_cyclic(
&mut self,
m: Mod,
segment: Option<&str>,
) -> Result<Option<(String, Segment)>, Error> {
let sub = self.subs.last_mut().expect("just_closed implies a subpath");
if sub.segs.len() < 2 {
return Err(self.err(format!(
"'{}' modifies the corner between two segments",
m.word()
)));
}
let last = sub.segs.pop().expect("len checked");
let first = sub.segs.remove(0);
let (last, mid, first, prod) = apply_mod(m, last, first, self.span)?;
sub.segs.insert(0, first);
sub.segs.push(last);
sub.segs.push(mid);
Ok(segment.map(|nm| (nm.to_string(), prod)))
}
fn started(&self) -> Result<P, Error> {
if self.start.is_none() {
return Err(self.err("the pen starts with move(x, y)"));
}
Ok(self.pos)
}
fn flush(&mut self) -> Result<(), Error> {
if let Some((m, _)) = self.pending {
return Err(self.err(format!(
"'{}' modifies the corner between two segments",
m.word()
)));
}
if !self.cur.is_empty() {
let segs = std::mem::take(&mut self.cur);
self.subs.push(Subpath {
segs,
closed: false,
});
}
self.start = None;
self.just_closed = false;
Ok(())
}
fn finish(mut self) -> Result<(Vec<Subpath>, Segments), Error> {
self.flush()?;
if self.subs.iter().all(|s| s.segs.is_empty()) {
return Err(self.err("'draw' draws nothing — add a pen run"));
}
Ok((self.subs, self.segments))
}
fn nums<const N: usize>(&self, call: &ResolvedCall, usage: &str) -> Result<[f64; N], Error> {
if call.args.len() != N {
return Err(self.err(usage.to_string()));
}
let mut out = [0.0; N];
for (slot, arg) in out.iter_mut().zip(&call.args) {
*slot = arg
.as_number()
.ok_or_else(|| self.err(format!("a pen argument is a number — {usage}")))?;
}
Ok(out)
}
}
fn bearing_scaled(bearing: f64, len: f64) -> P {
let d = bearing_dir(bearing);
(d.0 * len, d.1 * len)
}
#[cfg(test)]
mod tests {
use super::*;
fn program(src: &str) -> Result<crate::resolve::Program, crate::error::Error> {
let toks = crate::lexer::lex(src)?;
let file = crate::syntax::parser::parse(src, &toks)?;
let lowered = crate::desugar::desugar(&file)?;
crate::resolve::resolve_with_theme(&lowered, &[])
}
fn folded(style: &str) -> Folded {
let src = format!("|sketch#s| {{ {style} }}\n");
let program = program(&src).expect("pipeline");
fold(&program.scene.nodes[0], 1.0).expect("fold")
}
fn fold_err(style: &str) -> String {
let src = format!("|sketch#s| {{ {style} }}\n");
match program(&src) {
Err(e) => e.message,
Ok(p) => {
fold(&p.scene.nodes[0], 1.0)
.expect_err("expected a fold error")
.message
}
}
}
#[test]
fn point_names_a_station_and_needs_its_segment() {
let f = folded("draw: move(-10, 0) right(20) point():v right(20) down(5);");
assert!(matches!(
f.segments.iter().find(|(n, _)| n == "v"),
Some((_, Segment::Point((10.0, 0.0))))
));
assert_eq!(
fold_err("draw: move(0, 0) right(10) point() down(5);"),
"'point()' names the pen's position — attach a ':segment'"
);
assert_eq!(
fold_err("draw: point():v move(0, 0) right(10);"),
"the pen starts with move(x, y)"
);
}
#[test]
fn a_rectangle_profile_folds_and_closes() {
let f = folded("draw: move(0, 0) right(40) down(20) left(40) close();");
assert_eq!(f.d, "M 0 0 L 40 0 L 40 20 L 0 20 Z");
assert_eq!(
(
f.geometry.min_x,
f.geometry.min_y,
f.geometry.max_x,
f.geometry.max_y
),
(0.0, 0.0, 40.0, 20.0)
);
}
#[test]
fn verbs_are_visual_and_y_grows_down() {
let f = folded("draw: move(0, 0) up(10) right(5);");
assert_eq!(f.d, "M 0 0 L 0 -10 L 5 -10");
}
#[test]
fn segments_collect_the_drawn_vocabulary() {
let f = folded("draw: move(0, 0) right(40):flat point():station down(10) circle(4):bore;");
let get = |n: &str| {
f.segments
.iter()
.find(|(name, _)| name == n)
.map(|(_, p)| *p)
.expect("named")
};
assert_eq!(get("flat"), Segment::Edge((0.0, 0.0), (40.0, 0.0)));
assert_eq!(get("station"), Segment::Point((40.0, 0.0)));
assert_eq!(
get("bore"),
Segment::Circle {
center: (40.0, 10.0),
r: 4.0
}
);
}
#[test]
fn chamfer_trims_both_legs() {
let f = folded("draw: move(0, 0) right(20) chamfer(5) down(20);");
assert_eq!(f.d, "M 0 0 L 15 0 L 20 5 L 20 20");
}
#[test]
fn fillet_drops_a_tangent_arc() {
let f = folded("draw: move(0, 0) right(20) fillet(5) down(20);");
assert_eq!(f.d, "M 0 0 L 15 0 A 5 5 0 0 1 20 5 L 20 20");
}
#[test]
fn cyclic_fillet_rounds_through_close() {
let f = folded("draw: move(0, 0) right(20) down(20) left(20) fillet(4) close();");
assert!(f.d.contains("A 4 4"), "seam corner rounded: {}", f.d);
let g = folded("draw: move(0, 0) right(20) down(20) left(20) close() fillet(4);");
assert!(g.d.contains("A 4 4"), "first corner rounded: {}", g.d);
}
#[test]
fn tangent_arc_turns_the_heading() {
let f = folded("draw: move(0, 0) right(10) arc(10, 90) right(5);");
assert_eq!(f.d, "M 0 0 L 10 0 A 10 10 0 0 1 20 10 L 25 10");
}
#[test]
fn relative_arc_picks_the_sweep_by_sign() {
let cw = folded("draw: move(0, 0) arc(10, 0, 5);");
assert_eq!(cw.d, "M 0 0 A 5 5 0 0 1 10 0");
let ccw = folded("draw: move(0, 0) arc(10, 0, -5);");
assert_eq!(ccw.d, "M 0 0 A 5 5 0 0 0 10 0");
}
#[test]
fn open_subpath_fuses_under_mirror() {
let f = folded("draw: move(-10, 0) up(5) right(20) down(5); mirror: x-axis;");
assert!(f.d.ends_with("Z"), "fused = closed: {}", f.d);
assert_eq!(f.d.matches('M').count(), 1, "one fused subpath: {}", f.d);
assert!(f.d.contains("L 10 5") && f.d.contains("L -10 5"), "{}", f.d);
assert_eq!(
(f.geometry.min_y, f.geometry.max_y),
(-5.0, 5.0),
"symmetric about the axis"
);
}
#[test]
fn closed_subpath_duplicates_under_mirror() {
let f = folded("draw: move(0, -10) circle(3); mirror: x-axis;");
assert_eq!(
f.d.matches('M').count(),
2,
"seed + reflected copy: {}",
f.d
);
assert_eq!((f.geometry.min_y, f.geometry.max_y), (-13.0, 13.0));
}
#[test]
fn fold_errors_speak_spec() {
assert!(fold_err("draw: right(10);").contains("starts with move"));
assert!(fold_err("draw: move(0, 0) wiggle(3);").contains("unknown draw call 'wiggle'"));
assert!(
fold_err("draw: move(0, 0) arc(100, 0, 2);").contains("smaller than half the chord")
);
assert!(
fold_err("draw: move(0, 0) fillet(3) right(5);")
.contains("corner between two segments")
);
assert!(fold_err("draw: move(0, 0) right(5) fillet(9) down(5);").contains("does not fit"));
assert!(fold_err("draw: move(0, 0) right(5):left;").contains("built-in anchor"));
assert!(fold_err("draw: move(0, 0) right(5):a up(2):a;").contains("already named"));
assert!(fold_err("draw: move(0, 0) arc(4, 90);").contains("continues a heading"));
assert!(fold_err("draw: move(0, 0):spot right(4);").contains("takes no segment"));
assert!(
fold_err("draw: move(0, 0) right(4); mirror: sideways;")
.contains("x-axis, y-axis, or a bearing")
);
}
}