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;
mod parse;
use parse::{Pen, parse_mirror, parse_revolve};
#[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,
})
}
#[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")
);
}
#[test]
fn fillet_and_chamfer_take_a_curved_leg() {
let f = folded("draw: move(-30, 0) right(30) fillet(6) arc(30, -30, 40);");
assert_eq!(f.d.matches(" A ").count(), 2, "line→arc fillet: {}", f.d);
let g = folded("draw: move(0, -40) arc(30, 30, 40) chamfer(3) right(30);");
assert_eq!(
g.d.matches(" A ").count(),
1,
"arc chamfer keeps one arc: {}",
g.d
);
let e = fold_err("draw: move(-4, 0) right(4) fillet(9) arc(6, -6, 8);");
assert!(e.contains("fit") || e.contains("large"), "got: {e}");
}
}