use super::geometry::{MirrorAxis, P, PathSeg, Subpath};
use super::{Segment, breaks::ViewMap};
use crate::error::Error;
use crate::resolve::{ResolvedInst, ResolvedValue};
use crate::span::Span;
pub(super) const THREAD_DEPTH: f64 = 0.61343;
const EPS: f64 = 1e-3;
pub(super) struct Dressing {
pub minors: Vec<(P, P)>,
pub ends: Vec<(P, P)>,
pub specs: Vec<(String, f64)>,
}
pub(super) fn dress(
inst: &ResolvedInst,
segments: &[(String, Segment)],
subs: &[Subpath],
revolve: Option<MirrorAxis>,
view: &ViewMap,
scale: f64,
span: Span,
) -> Result<Dressing, Error> {
let mut out = Dressing {
minors: Vec::new(),
ends: Vec::new(),
specs: Vec::new(),
};
let Some(v) = inst.attrs.get("thread") else {
return Ok(out);
};
let Some(axis) = revolve else {
return Err(Error::at(
span,
"'thread' dresses a revolved profile — add 'revolve: x-axis'",
));
};
for (name, pitch) in parse(v, span)? {
let seg = find_segment(&name, segments, span)?;
let (a, b) = match seg {
Segment::Edge(a, b) if parallel(a, b, axis) => (a, b),
_ => {
return Err(Error::at(
span,
format!(
"'thread' runs along the axis — '{name}' must be a straight run parallel to it"
),
));
}
};
out.specs.push((name, pitch));
let u = axis.dir();
let perp = (-u.1, u.0);
let station = |p: P| p.0 * u.0 + p.1 * u.1;
let off = |p: P| p.0 * perp.0 + p.1 * perp.1;
let (da, db) = (view.map(a), view.map(b));
let (lo, hi) = order(station(da), station(db));
let level = off(a);
let mut drawn: Option<(f64, f64)> = None;
let mut continues = [false, false]; for sub in subs {
for seg in &sub.segs {
let PathSeg::Line { from, to } = *seg else {
continue;
};
if (off(from) - level).abs() > EPS || (off(to) - level).abs() > EPS {
continue;
}
let (s0, s1) = order(station(from), station(to));
if s0 < lo - EPS && s1 > lo - EPS {
continues[0] = true;
}
if s1 > hi + EPS && s0 < hi + EPS {
continues[1] = true;
}
let (c0, c1) = (s0.max(lo), s1.min(hi));
if c1 - c0 > EPS {
drawn = Some(match drawn {
Some((d0, d1)) => (d0.min(c0), d1.max(c1)),
None => (c0, c1),
});
}
}
}
let (t0, t1) = drawn.unwrap_or((lo, hi));
let depth = THREAD_DEPTH * pitch * scale;
let minor = level - level.signum() * depth;
let line = |o: f64| -> (P, P) {
(
(u.0 * t0 + perp.0 * o, u.1 * t0 + perp.1 * o),
(u.0 * t1 + perp.0 * o, u.1 * t1 + perp.1 * o),
)
};
out.minors.push(line(minor));
out.minors.push(line(-minor));
let radius = level.abs();
for (i, s) in [(0, lo), (1, hi)] {
if continues[i] {
out.ends.push((
(u.0 * s - perp.0 * radius, u.1 * s - perp.1 * radius),
(u.0 * s + perp.0 * radius, u.1 * s + perp.1 * radius),
));
}
}
}
Ok(out)
}
fn parse(v: &ResolvedValue, span: Span) -> Result<Vec<(String, f64)>, Error> {
let bad = || {
Error::at(
span,
"'thread' takes a segment and its pitch — 'thread: m8 1.5'",
)
};
let one = |item: &ResolvedValue| -> Result<(String, f64), Error> {
let ResolvedValue::Tuple(parts) = item else {
return Err(bad());
};
let [ResolvedValue::Ident(name), pitch] = parts.as_slice() else {
return Err(bad());
};
match pitch.as_number() {
Some(p) if p > 0.0 => Ok((name.clone(), p)),
_ => Err(bad()),
}
};
match v {
ResolvedValue::List(items) => items.iter().map(one).collect(),
item => Ok(vec![one(item)?]),
}
}
fn find_segment(name: &str, segments: &[(String, Segment)], span: Span) -> Result<Segment, Error> {
if let Some((_, seg)) = segments.iter().find(|(n, _)| n == name) {
return Ok(*seg);
}
let mut msg = format!("no segment '{name}' in this 'draw:'");
let mut near: Vec<&str> = segments.iter().map(|(n, _)| n.as_str()).collect();
near.sort_by_key(|n| usize::abs_diff(n.len(), name.len()));
let near: Vec<String> = near.iter().take(2).map(|n| format!("'{n}'")).collect();
if !near.is_empty() {
msg.push_str(&format!("; did you mean {}?", near.join(", ")));
}
Err(Error::at(span, msg))
}
fn parallel(a: P, b: P, axis: MirrorAxis) -> bool {
let d = (b.0 - a.0, b.1 - a.1);
let len = d.0.hypot(d.1);
if len < 1e-9 {
return false;
}
let u = axis.dir();
((d.0 * u.0 + d.1 * u.1) / len).abs() > 1.0 - 1e-9
}
fn order(a: f64, b: f64) -> (f64, f64) {
if a <= b { (a, b) } else { (b, a) }
}
#[cfg(test)]
mod tests {
use super::super::testutil::{by_id, laid, layout_err, texts};
use super::*;
use crate::resolve::{NodeKind, ResolvedValue};
const BAR: &str = "|sketch#bar| { draw: move(-150, 0) up(10) chamfer(1.5) right(40):m20 point():a right(260) chamfer(1.5) down(10); revolve: x-axis; thread: m20 1.5 }\n";
fn lines_of(nodes: &[crate::layout::PlacedNode], id: &str, ty: &str) -> Vec<(P, P)> {
by_id(nodes, id)
.children
.iter()
.filter(|c| c.type_chain.iter().any(|t| t == ty))
.filter_map(|c| {
let ResolvedValue::List(pts) = c.attrs.get("points")? else {
return None;
};
let p = |v: &ResolvedValue| {
let ResolvedValue::Tuple(xy) = v else {
panic!("a point");
};
(xy[0].as_number().unwrap(), xy[1].as_number().unwrap())
};
Some((p(&pts[0]), p(&pts[1])))
})
.collect()
}
#[test]
fn a_thread_draws_its_minors_and_end_line() {
let l = laid(&format!("{{ layout: drawing; scale: 1 }}\n{BAR}"));
let minors = lines_of(&l.nodes, "bar", "threadline");
assert_eq!(minors.len(), 2, "{minors:?}");
let m = 10.0 - THREAD_DEPTH * 1.5;
for (a, b) in &minors {
assert!((a.0 - -148.5).abs() < 1e-6 && (b.0 - -110.0).abs() < 1e-6);
assert!((a.1.abs() - m).abs() < 1e-6, "minor level: {}", a.1);
}
let edges = lines_of(&l.nodes, "bar", "shoulder");
assert!(
edges.iter().any(|(a, b)| (a.0 - -110.0).abs() < 1e-6
&& (a.1 - -10.0).abs() < 1e-6
&& (b.1 - 10.0).abs() < 1e-6),
"thread-end line at the run's inner end: {edges:?}"
);
assert!(
!edges.iter().any(|(a, _)| (a.0 - -150.0).abs() < 1e-6),
"no end line at the chamfered entry: {edges:?}"
);
}
#[test]
fn a_bare_leader_composes_the_thread_spec() {
let l = laid(&format!(
"{{ layout: drawing; scale: 1 }}\n{BAR}bar:m20 <- {{ side: top }}\n"
));
assert!(
texts(&l.nodes).iter().any(|(t, ..)| t == "M20×1.5"),
"composed spec: {:?}",
texts(&l.nodes)
);
}
#[test]
fn a_bare_leader_off_a_thread_still_needs_its_text() {
assert_eq!(
layout_err(
"{ layout: drawing; scale: 1 }\n|rect#a| { width: 10 }\na <- { side: top }\n"
),
"a leader needs its text — 'bolt <- \"THRU\"'"
);
}
#[test]
fn a_hole_thread_draws_the_internal_arc_and_an_oval_the_external() {
let l = laid(
"{ layout: drawing; scale: 2 }\n|rect#plate| { width: 60; height: 40 } [\n |hole#tap| { width: 6.75; thread: 1 }\n]\n",
);
let arc = by_id(&l.nodes, "tap")
.children
.iter()
.find(|c| c.kind == NodeKind::Path)
.expect("the ¾ arc");
let r = 6.75 + 0.54125 * 1.0 * 2.0;
assert!(
(arc.bbox.w() / 2.0 - (r + 0.5)).abs() < 1e-6,
"major radius + half stroke: {}",
arc.bbox.w() / 2.0
);
let ext = laid(
"{ layout: drawing; scale: 2 }\n|oval#stud| { width: 8; fill: none; thread: 1.25 }\n",
);
let arc = by_id(&ext.nodes, "stud")
.children
.iter()
.find(|c| c.kind == NodeKind::Path)
.expect("the ¾ arc");
let r = 8.0 - THREAD_DEPTH * 1.25 * 2.0;
assert!(
(arc.bbox.w() / 2.0 - (r + 0.5)).abs() < 1e-6,
"minor radius + half stroke: {}",
arc.bbox.w() / 2.0
);
}
#[test]
fn thread_errors_follow_spec_20() {
let sketch = |style: &str| {
format!(
"{{ layout: drawing; scale: 1 }}\n|sketch#s| {{ draw: move(-20, 0) up(5) right(40):m8 down(5); {style} }}\n"
)
};
assert_eq!(
layout_err(&sketch("revolve: x-axis; thread: m8")),
"'thread' takes a segment and its pitch — 'thread: m8 1.5'"
);
assert_eq!(
layout_err(&sketch("mirror: x-axis; thread: m8 1.5")),
"'thread' dresses a revolved profile — add 'revolve: x-axis'"
);
assert_eq!(
layout_err(&sketch("revolve: x-axis; thread: m9 1.5")),
"no segment 'm9' in this 'draw:'; did you mean 'm8'?"
);
assert_eq!(
layout_err(
"{ layout: drawing; scale: 1 }\n|sketch#s| { draw: move(-20, 0) up(5):wall right(40) down(5); revolve: x-axis; thread: wall 1.5 }\n"
),
"'thread' runs along the axis — 'wall' must be a straight run parallel to it"
);
assert_eq!(
layout_err("{ layout: drawing }\n|rect#a| { width: 10; thread: 1.5 }\n"),
"'thread' dresses a '|sketch|' segment or a round feature"
);
}
}