use super::tree::{Flat, HoleBendKind, SheetTree};
use crate::NurbsCurve;
type Seg = ([f64; 2], [f64; 2]);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LoopRole {
Cut,
Fold,
BendInternal,
}
#[derive(Clone, Debug)]
pub struct Polyline {
pub points: Vec<[f64; 2]>,
pub closed: bool,
pub role: LoopRole,
}
#[derive(Clone, Debug)]
pub struct Annotation {
pub pos: [f64; 2],
pub rotation_deg: f64,
pub text: String,
}
#[derive(Clone, Debug)]
pub struct FlatPattern {
pub polylines: Vec<Polyline>,
pub annotations: Vec<Annotation>,
}
#[derive(Clone, Copy)]
struct Placement {
o: [f64; 2],
u: [f64; 2],
v: [f64; 2],
}
impl Placement {
fn identity() -> Self {
Placement { o: [0.0, 0.0], u: [1.0, 0.0], v: [0.0, 1.0] }
}
fn map(&self, p: [f64; 2]) -> [f64; 2] {
[
self.o[0] + p[0] * self.u[0] + p[1] * self.v[0],
self.o[1] + p[0] * self.u[1] + p[1] * self.v[1],
]
}
fn map_dir(&self, d: [f64; 2]) -> [f64; 2] {
[
d[0] * self.u[0] + d[1] * self.v[0],
d[0] * self.u[1] + d[1] * self.v[1],
]
}
}
pub fn build(tree: &SheetTree) -> Result<FlatPattern, String> {
if tree.thickness <= 0.0 {
return Err(format!(
"sheet-metal flat pattern: thickness must be positive, got {}",
tree.thickness
));
}
let mut out = FlatPattern { polylines: Vec::new(), annotations: Vec::new() };
walk(&tree.root, Placement::identity(), tree.thickness, None, &mut out)?;
if out.polylines.is_empty() {
return Err("sheet-metal flat pattern: tree produced no geometry".into());
}
Ok(out)
}
fn walk(
flat: &Flat,
place: Placement,
thickness: f64,
seam: Option<Seg>,
out: &mut FlatPattern,
) -> Result<(), String> {
let n = flat.outline.len();
emit_outline(flat, place, seam, out)?;
for hole in &flat.holes {
out.polylines.push(loop_polyline(&hole.outer, place)?);
for island in &hole.islands {
out.polylines.push(loop_polyline(island, place)?);
}
}
let mut children: Vec<(&Flat, Placement, Seg)> = Vec::new();
for index in 0..n {
let Some(bend) = flat.edges.get(index).and_then(|edge| edge.bend.as_ref()) else {
continue;
};
let a = flat.outline[index];
let b = flat.outline[(index + 1) % n];
let allowance = bend.allowance(thickness);
emit_bend(
a,
b,
allowance,
bend.angle_deg,
bend.inside_radius,
&bend.child,
place,
&mut children,
out,
);
}
for hole_bend in &flat.hole_bends {
let HoleBendKind::Straight { child } = &hole_bend.kind else {
continue; };
let hole = flat.holes.get(hole_bend.hole).ok_or_else(|| {
format!(
"sheet-metal flat pattern: hole bend `{}` references missing hole {} on flat `{}`",
hole_bend.id, hole_bend.hole, flat.id
)
})?;
let curve = hole.outer.get(hole_bend.segment).ok_or_else(|| {
format!(
"sheet-metal flat pattern: hole bend `{}` references missing segment {} of hole {}",
hole_bend.id, hole_bend.segment, hole_bend.hole
)
})?;
let (mut a, mut b) = curve_endpoints_2d(curve)?;
if hole_bend.reversed {
std::mem::swap(&mut a, &mut b);
}
let allowance = bend_allowance(
hole_bend.angle_deg,
hole_bend.inside_radius,
hole_bend.k_factor,
thickness,
);
emit_bend(
a,
b,
allowance,
hole_bend.angle_deg,
hole_bend.inside_radius,
child,
place,
&mut children,
out,
);
}
for (child, child_place, child_seam) in children {
walk(child, child_place, thickness, Some(child_seam), out)?;
}
Ok(())
}
fn emit_outline(
flat: &Flat,
place: Placement,
seam: Option<Seg>,
out: &mut FlatPattern,
) -> Result<(), String> {
let n = flat.outline.len();
if n == 0 {
return Ok(());
}
let suppressed: Vec<bool> = (0..n)
.map(|i| {
let is_bend = flat
.edges
.get(i)
.and_then(|edge| edge.bend.as_ref())
.is_some();
let is_seam = seam.is_some_and(|s| {
let pa = place.map(flat.outline[i]);
let pb = place.map(flat.outline[(i + 1) % n]);
seg_matches(pa, pb, s)
});
is_bend || is_seam
})
.collect();
if suppressed.iter().all(|&drop| !drop) {
let outline = flat.densified_outline()?;
out.polylines.push(Polyline {
points: outline.iter().map(|p| place.map(*p)).collect(),
closed: true,
role: LoopRole::Cut,
});
return Ok(());
}
let start = suppressed.iter().position(|&drop| drop).unwrap();
let mut run: Vec<[f64; 2]> = Vec::new();
for step in 0..n {
let i = (start + 1 + step) % n;
if suppressed[i] {
flush_run(&mut run, place, out);
} else {
let pts = edge_points(flat, i)?;
if run.is_empty() {
run.extend(pts);
} else {
run.extend(pts.into_iter().skip(1));
}
}
}
flush_run(&mut run, place, out);
Ok(())
}
fn flush_run(run: &mut Vec<[f64; 2]>, place: Placement, out: &mut FlatPattern) {
if run.len() >= 2 {
out.polylines.push(Polyline {
points: run.iter().map(|p| place.map(*p)).collect(),
closed: false,
role: LoopRole::Cut,
});
}
run.clear();
}
fn edge_points(flat: &Flat, i: usize) -> Result<Vec<[f64; 2]>, String> {
let n = flat.outline.len();
let start = flat.outline[i];
let end = flat.outline[(i + 1) % n];
match flat
.edges
.get(i)
.and_then(|edge| flat.outline_curves.get(&edge.id))
{
Some(curve) => {
let [t0, t1] = curve.domain()?;
let mut pts = Vec::with_capacity(17);
for step in 0..=16 {
let t = t0 + (t1 - t0) * (step as f64) / 16.0;
let p = curve.evaluate(t)?;
pts.push([p.x, p.y]);
}
Ok(pts)
}
None => Ok(vec![start, end]),
}
}
fn seg_matches(pa: [f64; 2], pb: [f64; 2], seg: Seg) -> bool {
let (s0, s1) = seg;
(near(pa, s0) && near(pb, s1)) || (near(pa, s1) && near(pb, s0))
}
fn near(p: [f64; 2], q: [f64; 2]) -> bool {
(p[0] - q[0]).abs() < 1e-6 && (p[1] - q[1]).abs() < 1e-6
}
fn emit_bend<'a>(
a: [f64; 2],
b: [f64; 2],
allowance: f64,
angle_deg: f64,
inside_radius: f64,
child: &'a Flat,
place: Placement,
children: &mut Vec<(&'a Flat, Placement, Seg)>,
out: &mut FlatPattern,
) {
let e = normalized([b[0] - a[0], b[1] - a[1]]);
if e == [0.0, 0.0] {
return; }
let out_dir = [e[1], -e[0]]; let a2 = [a[0] + out_dir[0] * allowance, a[1] + out_dir[1] * allowance];
let b2 = [b[0] + out_dir[0] * allowance, b[1] + out_dir[1] * allowance];
if allowance > 1e-9 {
out.polylines.push(open_line(place.map(a), place.map(a2), LoopRole::Cut));
out.polylines.push(open_line(place.map(b), place.map(b2), LoopRole::Cut));
out.polylines
.push(open_line(place.map(a), place.map(b), LoopRole::BendInternal));
out.polylines
.push(open_line(place.map(a2), place.map(b2), LoopRole::BendInternal));
let half = allowance * 0.5;
let c_a = [a[0] + out_dir[0] * half, a[1] + out_dir[1] * half];
let c_b = [b[0] + out_dir[0] * half, b[1] + out_dir[1] * half];
out.polylines
.push(open_line(place.map(c_a), place.map(c_b), LoopRole::Fold));
let mid = place.map([(c_a[0] + c_b[0]) * 0.5, (c_a[1] + c_b[1]) * 0.5]);
let dir = place.map_dir(e);
let rotation_deg = readable_angle(dir[1].atan2(dir[0]).to_degrees());
out.annotations.push(Annotation {
pos: mid,
rotation_deg,
text: bend_label(angle_deg, inside_radius),
});
}
let seam = (place.map(a2), place.map(b2));
children.push((
child,
Placement {
o: place.map(a2),
u: place.map_dir(out_dir),
v: place.map_dir(e),
},
seam,
));
}
fn open_line(p0: [f64; 2], p1: [f64; 2], role: LoopRole) -> Polyline {
Polyline { points: vec![p0, p1], closed: false, role }
}
fn bend_label(angle_deg: f64, inside_radius: f64) -> String {
let dir = if angle_deg < 0.0 { "UP" } else { "DOWN" };
format!(
"{dir} {}° R{}",
trim_number(angle_deg.abs()),
trim_number(inside_radius)
)
}
fn trim_number(v: f64) -> String {
let s = format!("{v:.2}");
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
trimmed.to_string()
}
fn readable_angle(mut deg: f64) -> f64 {
deg %= 360.0;
if deg > 180.0 {
deg -= 360.0;
} else if deg <= -180.0 {
deg += 360.0;
}
if deg > 90.0 {
deg -= 180.0;
} else if deg <= -90.0 {
deg += 180.0;
}
deg
}
fn loop_polyline(curves: &[NurbsCurve], place: Placement) -> Result<Polyline, String> {
let mut points = Vec::new();
for curve in curves {
let [t0, t1] = curve.domain()?;
for step in 0..16 {
let t = t0 + (t1 - t0) * (step as f64) / 16.0;
let p = curve.evaluate(t)?;
points.push(place.map([p.x, p.y]));
}
}
if points.len() < 3 {
return Err("sheet-metal flat pattern: degenerate hole loop".into());
}
Ok(Polyline { points, closed: true, role: LoopRole::Cut })
}
fn curve_endpoints_2d(curve: &NurbsCurve) -> Result<([f64; 2], [f64; 2]), String> {
let [t0, t1] = curve.domain()?;
let p0 = curve.evaluate(t0)?;
let p1 = curve.evaluate(t1)?;
Ok(([p0.x, p0.y], [p1.x, p1.y]))
}
fn bend_allowance(angle_deg: f64, inside_radius: f64, k_factor: f64, thickness: f64) -> f64 {
let mid = (inside_radius + thickness * 0.5).max(1e-6);
let neutral = mid + (k_factor - 0.5) * thickness;
angle_deg.to_radians().abs() * neutral
}
fn normalized(d: [f64; 2]) -> [f64; 2] {
let len = (d[0] * d[0] + d[1] * d[1]).sqrt();
if len > 1e-12 {
[d[0] / len, d[1] / len]
} else {
[0.0, 0.0]
}
}
fn layer(role: LoopRole) -> &'static str {
match role {
LoopRole::Cut => "CUT",
LoopRole::Fold => "FOLD",
LoopRole::BendInternal => "TANGENT",
}
}
const BEND_LAYER: &str = "BEND";
const CUT_RGB: u32 = 0x00B3A4;
fn dxf_style(role: LoopRole) -> (i32, Option<u32>, &'static str) {
match role {
LoopRole::Cut => (3, Some(CUT_RGB), "CONTINUOUS"), LoopRole::Fold => (1, None, "PHANTOM"), LoopRole::BendInternal => (4, None, "DASHED"), }
}
fn dxf_color_ltype(role: LoopRole) -> String {
let (aci, tc, lt) = dxf_style(role);
let mut s = format!("6\n{lt}\n62\n{aci}\n");
if let Some(c) = tc {
s.push_str(&format!("420\n{c}\n"));
}
s
}
fn bounds(polys: &[Polyline]) -> Option<[f64; 4]> {
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut max_y = f64::NEG_INFINITY;
for poly in polys {
for p in &poly.points {
min_x = min_x.min(p[0]);
min_y = min_y.min(p[1]);
max_x = max_x.max(p[0]);
max_y = max_y.max(p[1]);
}
}
if min_x.is_finite() {
Some([min_x, min_y, max_x, max_y])
} else {
None
}
}
fn text_height(b: &[f64; 4]) -> f64 {
let span = (b[2] - b[0]).max(b[3] - b[1]).max(1.0);
(span * 0.05).max(1.0)
}
pub fn to_dxf(fp: &FlatPattern) -> String {
let bbox = bounds(&fp.polylines);
let span = bbox
.map(|b| (b[2] - b[0]).max(b[3] - b[1]).max(1.0))
.unwrap_or(1.0);
let ltscale = (span * 0.01).max(0.1);
let height = bbox.map(|b| text_height(&b)).unwrap_or(1.0);
let mut s = String::new();
s.push_str("0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1009\n");
s.push_str(&format!("9\n$LTSCALE\n40\n{ltscale:.3}\n"));
s.push_str("0\nENDSEC\n");
s.push_str("0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nLTYPE\n70\n3\n");
s.push_str("0\nLTYPE\n2\nCONTINUOUS\n70\n64\n3\nSolid line\n72\n65\n73\n0\n40\n0.0\n");
s.push_str(
"0\nLTYPE\n2\nDASHED\n70\n64\n3\nDashed __ __ __\n72\n65\n73\n2\n40\n0.75\n49\n0.5\n49\n-0.25\n",
);
s.push_str(
"0\nLTYPE\n2\nPHANTOM\n70\n64\n3\nPhantom ___ _ _ ___\n72\n65\n73\n6\n40\n2.5\n49\n1.25\n49\n-0.25\n49\n0.25\n49\n-0.25\n49\n0.25\n49\n-0.25\n",
);
s.push_str("0\nENDTAB\n0\nENDSEC\n");
s.push_str("0\nSECTION\n2\nENTITIES\n");
for poly in &fp.polylines {
let layer = layer(poly.role);
s.push_str("0\nPOLYLINE\n8\n");
s.push_str(layer);
s.push('\n');
s.push_str(&dxf_color_ltype(poly.role));
s.push_str("66\n1\n70\n");
s.push_str(if poly.closed { "1" } else { "0" });
s.push('\n');
for p in &poly.points {
s.push_str("0\nVERTEX\n8\n");
s.push_str(layer);
s.push_str(&format!("\n10\n{:.6}\n20\n{:.6}\n30\n0.0\n", p[0], p[1]));
}
s.push_str("0\nSEQEND\n");
}
for ann in &fp.annotations {
let text = ann.text.replace('°', "%%d");
s.push_str("0\nTEXT\n8\n");
s.push_str(BEND_LAYER);
s.push_str("\n62\n2\n");
s.push_str(&format!("10\n{:.6}\n20\n{:.6}\n30\n0.0\n", ann.pos[0], ann.pos[1]));
s.push_str(&format!("40\n{height:.6}\n1\n{text}\n"));
s.push_str(&format!("50\n{:.6}\n72\n1\n", ann.rotation_deg));
s.push_str(&format!("11\n{:.6}\n21\n{:.6}\n31\n0.0\n", ann.pos[0], ann.pos[1]));
}
s.push_str("0\nENDSEC\n0\nEOF\n");
s
}
pub fn to_svg(fp: &FlatPattern) -> String {
let Some(b @ [min_x, min_y, max_x, max_y]) = bounds(&fp.polylines) else {
return String::from(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"0mm\" height=\"0mm\" viewBox=\"0 0 0 0\"></svg>\n",
);
};
let span = (max_x - min_x).max(max_y - min_y).max(1.0);
let margin = span * 0.02;
let width = (max_x - min_x) + 2.0 * margin;
let height = (max_y - min_y) + 2.0 * margin;
let stroke = (span * 0.003).max(0.05);
let dash = (span * 0.02).max(0.5);
let font = text_height(&b);
let long = dash * 2.0;
let short = dash * 0.5;
let phantom =
format!("{long:.6} {dash:.6} {short:.6} {dash:.6} {short:.6} {dash:.6}");
let dashed = format!("{:.6} {:.6}", dash, dash * 0.6);
let sx = |x: f64| x - min_x + margin;
let sy = |y: f64| max_y - y + margin;
let mut s = String::new();
s.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width:.6}mm\" height=\"{height:.6}mm\" viewBox=\"0 0 {width:.6} {height:.6}\">\n"
));
s.push_str(&format!(
"<rect x=\"0\" y=\"0\" width=\"{width:.6}\" height=\"{height:.6}\" fill=\"#151515\"/>\n"
));
s.push_str(&format!("<g fill=\"none\" stroke-width=\"{stroke:.6}\">\n"));
for poly in &fp.polylines {
if poly.points.is_empty() {
continue;
}
let mut d = String::new();
for (i, p) in poly.points.iter().enumerate() {
d.push_str(if i == 0 { "M " } else { " L " });
d.push_str(&format!("{:.6} {:.6}", sx(p[0]), sy(p[1])));
}
if poly.closed {
d.push_str(" Z");
}
match poly.role {
LoopRole::Cut => {
s.push_str(&format!("<path d=\"{d}\" stroke=\"#00b3a4\"/>\n"));
}
LoopRole::Fold => {
s.push_str(&format!(
"<path d=\"{d}\" stroke=\"#ff4d4d\" stroke-dasharray=\"{phantom}\"/>\n"
));
}
LoopRole::BendInternal => {
s.push_str(&format!(
"<path d=\"{d}\" stroke=\"#22ccff\" stroke-dasharray=\"{dashed}\"/>\n"
));
}
}
}
for ann in &fp.annotations {
s.push_str(&format!(
"<text transform=\"translate({:.6} {:.6}) rotate({:.6})\" \
text-anchor=\"middle\" dy=\"{:.6}\" fill=\"#ffd400\" font-size=\"{font:.6}\">{}</text>\n",
sx(ann.pos[0]),
sy(ann.pos[1]),
-ann.rotation_deg,
-font * 0.3,
ann.text,
));
}
s.push_str("</g>\n</svg>\n");
s
}