diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 9647d8a..a73c9f7 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -630,7 +630,14 @@ "currency.uae_dirham": "UAE dirham", "currency.us_dollar": "US dollar", "currency.vietnamese_dong": "Vietnamese dong", + "cutting_layout.edge_trim_must_be_0_to_100_mm": "Edge trim must be 0 to 100 mm.", "cutting_layout.stock_dimensions_must_be_1_1_000_000": "Stock dimensions must be 1-1,000,000 mm and saw kerf 0-100 mm.", + "cutting_layout.strategy_fewest_sheets": "Fewest sheets", + "cutting_layout.strategy_fewest_sheets_hint": "Tries several piece orders and cut directions and keeps the layout with the fewest sheets.", + "cutting_layout.strategy_largest_offcut": "Largest offcut", + "cutting_layout.strategy_largest_offcut_hint": "Uses the fewest sheets, then prefers the layout with the largest reusable offcut.", + "cutting_layout.strategy_rip_first": "Rip first", + "cutting_layout.strategy_rip_first_hint": "Makes full-length rips along the stock length first, then crosscuts inside each strip.", "cutting_plan.approx_nominal": "Approx. {stock} nominal", "cutting_plan.board": "BOARD", "cutting_plan.boards_required": { @@ -1132,9 +1139,12 @@ "projects.could_not_save_project": "Could not save project: {error}", "projects.create_a_pdf_with_quantities_and_finished_cut": "Create a PDF with quantities and finished cut sizes.", "projects.create_one_label_per_piece_with_its_cutting": "Create one label per piece with its cutting-plan mark, name, and finished thickness, width, and length. Print at actual size on A4 paper.", + "projects.cut_layout_strategy": "Layout strategy", "projects.cutting_plan_pdf_opened_in_a_new_tab": "Cutting plan PDF opened in a new tab", "projects.cutting_plan_pdf_saved_and_opened": "Cutting plan PDF saved and opened", "projects.discard_changes": "Discard changes", + "projects.edge_trim": "Edge trim", + "projects.edge_trim_hint": "Removes this margin from each edge of sheet goods. Applies only to sheets at least 600 mm in both directions; narrower stock such as lumber is never trimmed.", "projects.exporting_geometry": "Exporting geometry…", "projects.exporting_screenshot": "Exporting screenshot…", "projects.generate_pdf": "Generate PDF…", diff --git a/assets/i18n/pt_BR.json b/assets/i18n/pt_BR.json index 0cf32b7..15e48a7 100644 --- a/assets/i18n/pt_BR.json +++ b/assets/i18n/pt_BR.json @@ -630,7 +630,14 @@ "currency.uae_dirham": "Dirham dos Emirados Árabes", "currency.us_dollar": "Dólar americano", "currency.vietnamese_dong": "Dong vietnamita", + "cutting_layout.edge_trim_must_be_0_to_100_mm": "A margem da borda deve ficar entre 0 e 100 mm.", "cutting_layout.stock_dimensions_must_be_1_1_000_000": "As medidas da matéria-prima devem ficar entre 1 e 1.000.000 mm e a espessura da serra entre 0 e 100 mm.", + "cutting_layout.strategy_fewest_sheets": "Menos chapas", + "cutting_layout.strategy_fewest_sheets_hint": "Testa várias ordens de peças e direções de corte e mantém o layout com menos chapas.", + "cutting_layout.strategy_largest_offcut": "Maior sobra", + "cutting_layout.strategy_largest_offcut_hint": "Usa o menor número de chapas e, entre elas, prefere o layout com a maior sobra reaproveitável.", + "cutting_layout.strategy_rip_first": "Cortes longitudinais primeiro", + "cutting_layout.strategy_rip_first_hint": "Faz primeiro cortes longitudinais inteiros ao longo do comprimento da chapa e depois os transversais dentro de cada faixa.", "cutting_plan.approx_nominal": "Aprox. {stock} nominal", "cutting_plan.board": "TÁBUA", "cutting_plan.boards_required": { @@ -1130,9 +1137,12 @@ "projects.could_not_save_project": "Não foi possível salvar o projeto: {error}", "projects.create_a_pdf_with_quantities_and_finished_cut": "Gera um PDF com as quantidades e as medidas finais de corte.", "projects.create_one_label_per_piece_with_its_cutting": "Cria uma etiqueta por peça com a marca do plano de corte, o nome e a espessura, largura e comprimento finais. Imprima em tamanho real em papel A4.", + "projects.cut_layout_strategy": "Estratégia de disposição", "projects.cutting_plan_pdf_opened_in_a_new_tab": "PDF do plano de corte aberto em uma nova aba", "projects.cutting_plan_pdf_saved_and_opened": "PDF do plano de corte salvo e aberto", "projects.discard_changes": "Descartar alterações", + "projects.edge_trim": "Margem da borda", + "projects.edge_trim_hint": "Remove essa margem de cada borda das chapas. Só se aplica a chapas com pelo menos 600 mm nas duas direções; madeira mais estreita, como sarrafos, nunca é aparada.", "projects.exporting_geometry": "Exportando geometria…", "projects.exporting_screenshot": "Exportando captura de tela…", "projects.generate_pdf": "Gerar PDF…", diff --git a/assets/i18n/zh_CN.json b/assets/i18n/zh_CN.json index ed6af6f..55685b0 100644 --- a/assets/i18n/zh_CN.json +++ b/assets/i18n/zh_CN.json @@ -630,7 +630,14 @@ "currency.uae_dirham": "阿联酋迪拉姆", "currency.us_dollar": "美元", "currency.vietnamese_dong": "越南盾", + "cutting_layout.edge_trim_must_be_0_to_100_mm": "边缘修整必须在 0–100 mm 之间。", "cutting_layout.stock_dimensions_must_be_1_1_000_000": "原料尺寸必须在 1–1,000,000 mm 之间,锯缝必须在 0–100 mm 之间。", + "cutting_layout.strategy_fewest_sheets": "板材最少", + "cutting_layout.strategy_fewest_sheets_hint": "尝试多种零件顺序和切割方向,保留所需板材最少的排料。", + "cutting_layout.strategy_largest_offcut": "最大余料", + "cutting_layout.strategy_largest_offcut_hint": "先保证板材数量最少,再优先选择可复用余料最大的排料。", + "cutting_layout.strategy_rip_first": "先纵切", + "cutting_layout.strategy_rip_first_hint": "先沿板材长度方向做整长纵切,再在每个条带内横切。", "cutting_plan.approx_nominal": "约 {stock} 标称", "cutting_plan.board": "板材", "cutting_plan.boards_required": { @@ -1132,9 +1139,12 @@ "projects.could_not_save_project": "无法保存项目:{error}", "projects.create_a_pdf_with_quantities_and_finished_cut": "生成包含数量和成品下料尺寸的 PDF。", "projects.create_one_label_per_piece_with_its_cutting": "为每个部件生成一个标签,包含其下料方案编号、名称以及成品厚度、宽度和长度。请按实际尺寸在 A4 纸上打印。", + "projects.cut_layout_strategy": "排料策略", "projects.cutting_plan_pdf_opened_in_a_new_tab": "下料方案 PDF 已在新标签页中打开", "projects.cutting_plan_pdf_saved_and_opened": "下料方案 PDF 已保存并打开", "projects.discard_changes": "放弃更改", + "projects.edge_trim": "边缘修整", + "projects.edge_trim_hint": "从板材每条边去除此宽度。仅适用于两个方向都至少 600 mm 的板材;较窄的木料(如型材)不修边。", "projects.exporting_geometry": "正在导出几何体…", "projects.exporting_screenshot": "正在导出截图…", "projects.generate_pdf": "生成 PDF…", diff --git a/crates/benchup-app/src/cutting_layout.rs b/crates/benchup-app/src/cutting_layout.rs index eb145a8..2e6a941 100644 --- a/crates/benchup-app/src/cutting_layout.rs +++ b/crates/benchup-app/src/cutting_layout.rs @@ -1,6 +1,40 @@ -//! Deterministic, kerf-aware guillotine stock packing. +//! Deterministic, kerf-aware guillotine stock packing with edge trim. +//! Every strategy only makes straight, edge-to-edge cuts. use benchup_core::tr; -use std::collections::BTreeMap; +use std::{cmp::Reverse, collections::BTreeMap}; + +/// Factory edges exist only on sheet goods, so narrower stock (dimensional lumber) is never trimmed. +const SHEET_MIN_MM: f64 = 600.; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Strategy { + /// Best of several piece orders and split rules, keeping the fewest sheets. + #[default] + Fewest, + /// Full-length rips along the stock length first, then crosscuts inside each strip. + RipFirst, + /// Fewest sheets, preferring the layout with the largest reusable offcut. + LargestOffcut, +} + +impl Strategy { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Fewest => tr!("cutting_layout.strategy_fewest_sheets"), + Self::RipFirst => tr!("cutting_layout.strategy_rip_first"), + Self::LargestOffcut => tr!("cutting_layout.strategy_largest_offcut"), + } + } + + pub(crate) fn hint(self) -> &'static str { + match self { + Self::Fewest => tr!("cutting_layout.strategy_fewest_sheets_hint"), + Self::RipFirst => tr!("cutting_layout.strategy_rip_first_hint"), + Self::LargestOffcut => tr!("cutting_layout.strategy_largest_offcut_hint"), + } + } +} #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(default)] @@ -12,6 +46,9 @@ pub(crate) struct Settings { pub allow_rotation: bool, pub respect_grain: bool, pub show_joinery: bool, + pub strategy: Strategy, + /// Unusable factory edge removed from each side of sheet goods. + pub edge_trim_mm: f64, } impl Default for Settings { fn default() -> Self { @@ -23,6 +60,8 @@ impl Default for Settings { allow_rotation: true, respect_grain: true, show_joinery: true, + strategy: Strategy::Fewest, + edge_trim_mm: 0., } } } @@ -40,6 +79,9 @@ impl Settings { { return Err(tr!("cutting_layout.stock_dimensions_must_be_1_1_000_000").into()); } + if !(0. ..=100.).contains(&self.edge_trim_mm) { + return Err(tr!("cutting_layout.edge_trim_must_be_0_to_100_mm").into()); + } Ok(()) } } @@ -67,25 +109,34 @@ pub(crate) struct Placement { pub rotated: bool, pub part_index: usize, } +#[derive(Clone, Copy, Debug)] +struct Free { + rect: Rect, + /// A band from a rip that may only be crosscut, never ripped again. + strip: bool, +} #[derive(Debug)] pub(crate) struct Stock { pub length: u64, pub width: u64, pub placements: Vec, - free: Vec, + usable: Rect, + free: Vec, } impl Stock { - fn new(length: u64, width: u64) -> Self { + fn new(length: u64, width: u64, trim: u64) -> Self { + let usable = Rect { + x: trim, + y: trim, + length: length.saturating_sub(2 * trim), + width: width.saturating_sub(2 * trim), + }; Self { length, width, placements: Vec::new(), - free: vec![Rect { - x: 0, - y: 0, - length, - width, - }], + usable, + free: vec![Free { rect: usable, strip: false }], } } } @@ -99,30 +150,80 @@ pub(crate) fn tenths(mm: f64) -> u64 { (mm * 10.).round() as u64 } +#[derive(Clone, Copy)] +enum Order { + Area, + Length, + Width, +} + +#[derive(Clone, Copy)] +enum Split { + /// The leftover strip runs the full length; the piece's band keeps its width. + Strips, + /// The leftover runs the full width; the piece's band keeps its length. + Crosscut, + /// Rips run along the stock length across the full sheet; their bands are only crosscut. + RipFirst, +} + /// Each split is a full straight cut across the current free rectangle. Kerf is /// reserved between retained pieces; a cut at the stock edge needs no extra gap. pub(crate) fn sheets(pieces: &[Piece], settings: Settings) -> Layout { + let settings = if settings.sheet_width_mm.min(settings.sheet_length_mm) < SHEET_MIN_MM { + Settings { + edge_trim_mm: 0., + ..settings + } + } else { + settings + }; + let candidates: Vec<(Order, Split)> = match settings.strategy { + Strategy::RipFirst => vec![(Order::Width, Split::RipFirst)], + Strategy::Fewest | Strategy::LargestOffcut => vec![ + (Order::Area, Split::Strips), + (Order::Length, Split::Strips), + (Order::Width, Split::Strips), + (Order::Area, Split::Crosscut), + (Order::Length, Split::Crosscut), + (Order::Width, Split::Crosscut), + ], + }; + let kerf = tenths(settings.kerf_mm); + let mut best: Option<((u64, u64), Layout)> = None; + for (order, split) in candidates { + let layout = pack(pieces, settings, order, split); + let score = layout.score(settings.strategy, kerf); + if best.as_ref().is_none_or(|(previous, _)| score < *previous) { + best = Some((score, layout)); + } + } + best.map(|(_, layout)| layout).unwrap_or_default() +} + +fn pack(pieces: &[Piece], settings: Settings, order: Order, split: Split) -> Layout { let length = tenths(settings.sheet_length_mm); let width = tenths(settings.sheet_width_mm); + let trim = tenths(settings.edge_trim_mm); let kerf = tenths(settings.kerf_mm); + let usable = (length.saturating_sub(2 * trim), width.saturating_sub(2 * trim)); let mut pieces = pieces.to_vec(); - pieces.sort_by_key(|p| { - ( - std::cmp::Reverse(p.length as u128 * p.width as u128), - std::cmp::Reverse(p.length), + pieces.sort_by_key(|p| match order { + Order::Area => ( + Reverse(p.length as u128 * p.width as u128), + Reverse(p.length), p.mark, - ) + ), + Order::Length => (Reverse(p.length as u128), Reverse(p.width), p.mark), + Order::Width => (Reverse(p.width as u128), Reverse(p.length), p.mark), }); let mut layout = Layout::default(); for piece in pieces { - let can_rotate = settings.allow_rotation && !(settings.respect_grain && piece.grain_along_length); - let orientations = [ - (piece.length, piece.width, false), - (piece.width, piece.length, true), - ]; - if !orientations.iter().any(|(l, w, rotated)| { - (!*rotated || can_rotate) && *l <= length && *w <= width - }) { + let fits: Vec<_> = orientations(&piece, settings) + .into_iter() + .filter(|(l, w, _)| *l <= usable.0 && *w <= usable.1) + .collect(); + if fits.is_empty() { layout.oversized.push(piece); continue; } @@ -131,14 +232,13 @@ pub(crate) fn sheets(pieces: &[Piece], settings: Settings) -> Layout { let mut best = None; for (stock_index, stock) in layout.stock.iter().enumerate() { for (free_index, free) in stock.free.iter().enumerate() { - for &(l, w, rotated) in &orientations { - if rotated && !can_rotate || l > free.length || w > free.width - { + for &(l, w, rotated) in &fits { + if l > free.rect.length || w > free.rect.width { continue; } let score = ( - free.length * free.width - l * w, - (free.length - l).min(free.width - w), + free.rect.length * free.rect.width - l * w, + (free.rect.length - l).min(free.rect.width - w), ); if best .as_ref() @@ -149,51 +249,162 @@ pub(crate) fn sheets(pieces: &[Piece], settings: Settings) -> Layout { } } } - let (stock_index, free_index, l, w, rotated) = if let Some((s, f, l, w, r, _)) = best { - (s, f, l, w, r) - } else { - let &(l, w, r) = orientations - .iter() - .find(|(l, w, r)| { - (!*r || can_rotate) && *l <= length && *w <= width - }) - .unwrap(); - layout.stock.push(Stock::new(length, width)); - (layout.stock.len() - 1, 0, l, w, r) + let (stock_index, free_index, l, w, rotated) = match best { + Some((s, f, l, w, r, _)) => (s, f, l, w, r), + None => { + let &(l, w, r) = &fits[0]; + layout.stock.push(Stock::new(length, width, trim)); + (layout.stock.len() - 1, 0, l, w, r) + } }; let stock = &mut layout.stock[stock_index]; let free = stock.free.remove(free_index); stock.placements.push(Placement { mark: piece.mark, rect: Rect { - x: free.x, - y: free.y, + x: free.rect.x, + y: free.rect.y, length: l, width: w, }, rotated, part_index, }); - // Horizontal cut first, then a vertical cut inside the lower band. - if free.length > l + kerf { - stock.free.push(Rect { - x: free.x + l + kerf, - y: free.y, - length: free.length - l - kerf, - width: w, - }); + stock.free.extend(leftovers(free, l, w, kerf, split)); + } + } + layout +} + +/// Free rectangles left after cutting an l x w piece from the corner of `free`. +fn leftovers(free: Free, l: u64, w: u64, kerf: u64, split: Split) -> Vec { + let Rect { x, y, length, width } = free.rect; + let right_fits = length > l + kerf; + let top_fits = width > w + kerf; + let right = |band: u64, strip: bool| Free { + rect: Rect { + x: x + l + kerf, + y, + length: length - l - kerf, + width: band, + }, + strip, + }; + let top = |run: u64, strip: bool| Free { + rect: Rect { + x, + y: y + w + kerf, + length: run, + width: width - w - kerf, + }, + strip, + }; + let mut out = Vec::with_capacity(2); + match split { + Split::Strips => { + if right_fits { + out.push(right(w, false)); } - if free.width > w + kerf { - stock.free.push(Rect { - x: free.x, - y: free.y + w + kerf, - length: free.length, - width: free.width - w - kerf, - }); + if top_fits { + out.push(top(length, false)); + } + } + Split::Crosscut => { + if right_fits { + out.push(right(width, false)); + } + if top_fits { + out.push(top(l, false)); + } + } + Split::RipFirst => { + if free.strip { + if right_fits { + out.push(right(width, true)); + } + } else { + if right_fits { + out.push(right(w, true)); + } + if top_fits { + out.push(top(length, false)); + } } } } - layout + out +} + +/// Grained parts keep their length along the stock length when grain is respected. +fn orientations(piece: &Piece, settings: Settings) -> Vec<(u64, u64, bool)> { + let unrotated = (piece.length, piece.width, false); + if settings.allow_rotation && !(settings.respect_grain && piece.grain_along_length) { + vec![unrotated, (piece.width, piece.length, true)] + } else { + vec![unrotated] + } +} + +/// The largest empty rectangle on a sheet, with each cut's kerf counted as lost. +/// Exhaustive over the placement grid, O(rows^2 * cols) per sheet. Fine for shop-sized layouts; a maximal-rectangle sweep would be needed if sheets get huge. +fn largest_offcut(stock: &Stock, kerf: u64) -> u64 { + let Rect { x: x0, y: y0, length, width } = stock.usable; + let (x1, y1) = (x0 + length, y0 + width); + let blocks: Vec<[u64; 4]> = stock + .placements + .iter() + .map(|p| { + let r = p.rect; + [r.x, r.y, (r.x + r.length + kerf).min(x1), (r.y + r.width + kerf).min(y1)] + }) + .collect(); + let mut xs = vec![x0, x1]; + let mut ys = vec![y0, y1]; + for [a, b, c, d] in &blocks { + xs.extend([*a, *c]); + ys.extend([*b, *d]); + } + xs.sort_unstable(); + xs.dedup(); + ys.sort_unstable(); + ys.dedup(); + let (cols, rows) = (xs.len() - 1, ys.len() - 1); + let mut occupied = vec![vec![false; cols]; rows]; + for [a, b, c, d] in &blocks { + for (row, cells) in occupied.iter_mut().enumerate() { + if ys[row] < *b || ys[row + 1] > *d { + continue; + } + for (col, cell) in cells.iter_mut().enumerate() { + if xs[col] >= *a && xs[col + 1] <= *c { + *cell = true; + } + } + } + } + // Every row band from `top` to `bottom` is tested as a stack of free columns. + let mut best = 0; + for top in 0..rows { + let mut free = vec![true; cols]; + for bottom in top..rows { + for (col, cell) in free.iter_mut().enumerate() { + *cell &= !occupied[bottom][col]; + } + let height = ys[bottom + 1] - ys[top]; + let mut run = None; + for col in 0..=cols { + match (col < cols && free[col], run) { + (true, None) => run = Some(col), + (false, Some(start)) => { + best = best.max((xs[col] - xs[start]) * height); + run = None; + } + _ => {} + } + } + } + } + best } impl Layout { @@ -203,6 +414,27 @@ impl Layout { .map(|p| (p.mark, p.quantity)) .collect() } + + /// Lower is better: sheet count first, then either the number of offcut + /// fragments or, for the largest-offcut strategy, the biggest reusable offcut. + fn score(&self, strategy: Strategy, kerf: u64) -> (u64, u64) { + let sheets = self.stock.len() as u64; + match strategy { + Strategy::LargestOffcut => { + let largest = self + .stock + .iter() + .map(|stock| largest_offcut(stock, kerf)) + .max() + .unwrap_or(0); + (sheets, u64::MAX - largest) + } + _ => ( + sheets, + self.stock.iter().map(|stock| stock.free.len() as u64).sum(), + ), + } + } } #[cfg(test)] diff --git a/crates/benchup-app/src/cutting_layout_tests.rs b/crates/benchup-app/src/cutting_layout_tests.rs index 1e363fd..9c7d40a 100644 --- a/crates/benchup-app/src/cutting_layout_tests.rs +++ b/crates/benchup-app/src/cutting_layout_tests.rs @@ -9,6 +9,12 @@ fn piece(mark: usize, length: u64, width: u64, quantity: usize) -> Piece { grain_along_length: false, } } +fn grained(mark: usize, length: u64, width: u64) -> Piece { + Piece { + grain_along_length: true, + ..piece(mark, length, width, 1) + } +} fn settings(length: f64, width: f64, kerf: f64) -> Settings { Settings { sheet_length_mm: length, @@ -60,25 +66,41 @@ fn rotation_is_opt_in_and_oversized_pieces_never_enlarge_stock() { assert_eq!(sheets(&huge, options).oversized, huge); } -#[test] -fn sheet_layouts_conserve_quantities_keep_pieces_in_bounds_and_reserve_kerf() { - let options = Settings { - allow_rotation: true, - ..Settings::default() - }; - let cuts: Vec<_> = (0..30) - .map(|i| { - piece( - i, - 1000 + ((i * 197) % 9000) as u64, - 500 + ((i * 313) % 6000) as u64, - i % 3 + 1, - ) - }) +/// Rip first: each rip opens a strip at the usable stock edge. Strips tile the usable +/// width from the top, and every part sits at its own strip's y and inside its height. +/// A part from another strip cannot reach into a strip's band. Returns the strip count. +fn assert_two_stage_rips(stock: &Stock, kerf: u64) -> usize { + let usable = stock.usable; + let mut rips: Vec = stock + .placements + .iter() + .filter(|p| p.rect.x == usable.x) + .map(|p| p.rect) .collect(); - let layout = sheets(&cuts, options); - assert!(layout.oversized.is_empty()); - let kerf = tenths(options.kerf_mm); + rips.sort_by_key(|r| r.y); + let mut next = usable.y; + for rip in &rips { + assert_eq!(rip.y, next, "strip starts where the previous strip ends"); + next = rip.y + rip.width + kerf; + } + for p in &stock.placements { + assert!( + p.rect.x >= usable.x && p.rect.x + p.rect.length <= usable.x + usable.length, + "piece {p:?} leaves the usable length" + ); + let strip = rips + .iter() + .find(|strip| strip.y == p.rect.y) + .unwrap_or_else(|| panic!("piece {p:?} is not at a strip edge")); + assert!( + p.rect.y + p.rect.width <= strip.y + strip.width, + "piece {p:?} leaves its strip" + ); + } + rips.len() +} + +fn assert_packs_every_piece(cuts: &[Piece], layout: &Layout, kerf: u64) { let mut counts = BTreeMap::new(); for stock in &layout.stock { for (index, p) in stock.placements.iter().enumerate() { @@ -108,8 +130,257 @@ fn sheet_layouts_conserve_quantities_keep_pieces_in_bounds_and_reserve_kerf() { } } for cut in cuts { - assert_eq!(counts[&cut.mark], cut.quantity); + assert_eq!(counts.get(&cut.mark).copied().unwrap_or(0), cut.quantity); + } +} + +#[test] +fn sheet_layouts_conserve_quantities_keep_pieces_in_bounds_and_reserve_kerf() { + let cuts: Vec<_> = (0..30) + .map(|i| { + piece( + i, + 1000 + ((i * 197) % 9000) as u64, + 500 + ((i * 313) % 6000) as u64, + i % 3 + 1, + ) + }) + .collect(); + for strategy in [Strategy::Fewest, Strategy::RipFirst, Strategy::LargestOffcut] { + let options = Settings { + allow_rotation: true, + strategy, + ..Settings::default() + }; + let layout = sheets(&cuts, options); + assert!(layout.oversized.is_empty()); + assert_packs_every_piece(&cuts, &layout, tenths(options.kerf_mm)); + } +} + +#[test] +fn best_of_strategy_is_deterministic_and_never_uses_more_sheets_than_the_current_packer() { + let cuts: Vec<_> = (0..40) + .map(|i| { + piece( + i, + 800 + ((i * 411) % 14000) as u64, + 300 + ((i * 257) % 7000) as u64, + i % 4 + 1, + ) + }) + .collect(); + let options = Settings { + allow_rotation: true, + ..Settings::default() + }; + let first = sheets(&cuts, options); + let second = sheets(&cuts, options); + assert_eq!(first.stock.len(), second.stock.len()); + for (a, b) in first.stock.iter().zip(&second.stock) { + assert_eq!(a.placements.len(), b.placements.len()); + for (p, q) in a.placements.iter().zip(&b.placements) { + assert_eq!((p.mark, p.rect, p.rotated), (q.mark, q.rect, q.rotated)); + } } + let current = pack(&cuts, options, Order::Area, Split::Strips); + assert!(first.stock.len() <= current.stock.len()); + let largest = sheets( + &cuts, + Settings { + strategy: Strategy::LargestOffcut, + ..options + }, + ); + assert_eq!(largest.stock.len(), first.stock.len()); +} + +#[test] +fn rip_first_makes_full_length_rips_then_crosscuts_each_strip() { + let cuts = [ + piece(0, 12000, 4000, 2), + piece(1, 6000, 3000, 3), + piece(2, 9000, 2000, 2), + ]; + let options = Settings { + allow_rotation: false, + strategy: Strategy::RipFirst, + ..settings(2000., 2000., 0.) + }; + let layout = sheets(&cuts, options); + assert!(layout.oversized.is_empty()); + let placed: usize = layout.stock.iter().map(|s| s.placements.len()).sum(); + assert_eq!(placed, 7); + let strips: usize = layout.stock.iter().map(|stock| assert_two_stage_rips(stock, 0)).sum(); + assert!(strips >= 2); +} + +#[test] +fn rip_first_rips_run_the_full_stock_length() { + // The 600 mm rip leaves the band above it at full stock length, so the 2000 mm part + // still fits on the same sheet. A shorter rip would push it onto a second sheet. + let cuts = [piece(0, 6000, 4000, 1), piece(1, 20000, 3000, 1)]; + let options = Settings { + allow_rotation: false, + strategy: Strategy::RipFirst, + ..settings(2000., 2000., 0.) + }; + let layout = sheets(&cuts, options); + assert_eq!(layout.stock.len(), 1); + assert_two_stage_rips(&layout.stock[0], 0); + let long = layout.stock[0].placements.iter().find(|p| p.mark == 1).unwrap(); + assert_eq!((long.rect.x, long.rect.y, long.rect.length), (0, 4000, 20000)); +} + +#[test] +fn largest_offcut_is_at_least_as_large_as_fewest_sheets_and_counts_the_real_empty_rectangle() { + let cuts: Vec<_> = (0..25) + .map(|i| piece(i, 2000 + ((i * 531) % 9000) as u64, 1500 + ((i * 277) % 4000) as u64, i % 3 + 1)) + .collect(); + let options = Settings { + allow_rotation: true, + ..Settings::default() + }; + let offcut = |layout: &Layout| { + layout + .stock + .iter() + .map(|stock| largest_offcut(stock, tenths(options.kerf_mm))) + .max() + .unwrap_or(0) + }; + let fewest = sheets(&cuts, options); + let largest = sheets( + &cuts, + Settings { + strategy: Strategy::LargestOffcut, + ..options + }, + ); + assert_eq!(largest.stock.len(), fewest.stock.len()); + assert!(offcut(&largest) >= offcut(&fewest)); + // One part in the corner of a 1000 x 1000 mm sheet leaves a full 1000 x 700 mm strip. + let single = sheets( + &[piece(0, 3000, 3000, 1)], + Settings { + sheet_length_mm: 1000., + sheet_width_mm: 1000., + kerf_mm: 0., + allow_rotation: false, + ..Settings::default() + }, + ); + assert_eq!(largest_offcut(&single.stock[0], 0), 70_000_000); +} + +#[test] +fn grained_parts_never_rotate_when_grain_is_respected() { + let options = Settings { + sheet_length_mm: 1000., + sheet_width_mm: 800., + allow_rotation: true, + ..Settings::default() + }; + // Only fits turned across the grain, so it is reported instead of rotated. + let along = [grained(0, 7000, 9000)]; + let locked = sheets(&along, options); + assert!(locked.stock.is_empty()); + assert_eq!(locked.oversized, along); + let free = sheets( + &along, + Settings { + respect_grain: false, + ..options + }, + ); + assert!(free.oversized.is_empty()); + assert!(free.stock[0].placements[0].rotated); + // Ungrained parts may turn to fit. + let turned = sheets(&[piece(1, 4000, 9000, 1)], options); + assert!(turned.stock[0].placements[0].rotated); +} + +#[test] +fn edge_trim_keeps_every_piece_inside_the_usable_stock() { + let options = Settings { + edge_trim_mm: 20., + allow_rotation: false, + ..settings(1000., 800., 3.) + }; + let cuts: Vec<_> = (0..6).map(|i| piece(i, 2000, 700, 1)).collect(); + let layout = sheets(&cuts, options); + assert_eq!(layout.stock.len(), 1); + for stock in &layout.stock { + for p in &stock.placements { + assert!(p.rect.x >= 200 && p.rect.y >= 200); + assert!(p.rect.x + p.rect.length <= stock.length - 200); + assert!(p.rect.y + p.rect.width <= stock.width - 200); + } + } + assert!(options.validate().is_ok()); + assert!(Settings { edge_trim_mm: 500., ..options }.validate().is_err()); +} + +#[test] +fn edge_trim_does_not_apply_to_full_width_lumber() { + // An 89 mm rail from 89 mm stock spans the whole board, so trim must not reject it. + let options = Settings { + sheet_width_mm: 89., + sheet_length_mm: 2440., + kerf_mm: 0., + allow_rotation: false, + edge_trim_mm: 5., + ..Settings::default() + }; + assert!(options.validate().is_ok()); + let rails = [piece(0, 24400, 890, 2)]; + let layout = sheets(&rails, options); + assert!(layout.oversized.is_empty()); + assert_eq!(layout.stock.len(), 2); + for stock in &layout.stock { + assert_eq!(stock.placements[0].rect, Rect { x: 0, y: 0, length: 24400, width: 890 }); + } +} + +#[test] +fn edge_trim_combined_with_kerf_keeps_pieces_inside_and_spaced() { + let options = Settings { + edge_trim_mm: 10., + kerf_mm: 3., + allow_rotation: false, + ..settings(2440., 1220., 3.) + }; + let cuts = [piece(0, 6000, 3000, 4), piece(1, 2000, 1000, 6)]; + let layout = sheets(&cuts, options); + assert!(layout.oversized.is_empty()); + let kerf = tenths(options.kerf_mm); + let trim = tenths(options.edge_trim_mm); + for stock in &layout.stock { + for (index, p) in stock.placements.iter().enumerate() { + let r = p.rect; + assert!(r.x >= trim && r.y >= trim); + assert!(r.x + r.length + trim <= stock.length); + assert!(r.y + r.width + trim <= stock.width); + for other in &stock.placements[index + 1..] { + let q = other.rect; + assert!( + r.x + r.length + kerf <= q.x + || q.x + q.length + kerf <= r.x + || r.y + r.width + kerf <= q.y + || q.y + q.width + kerf <= r.y + ); + } + } + } +} + +#[test] +fn settings_saved_before_strategy_and_trim_keep_todays_defaults() { + let old = r#"{"sheet_width_mm":1220.0,"sheet_length_mm":2440.0,"board_length_mm":2440.0,"kerf_mm":3.0,"allow_rotation":true}"#; + let restored: Settings = serde_json::from_str(old).unwrap(); + assert_eq!(restored, Settings::default()); + assert_eq!(restored.strategy, Strategy::Fewest); + assert_eq!(restored.edge_trim_mm, 0.); } #[test] @@ -156,6 +427,10 @@ fn invalid_stock_settings_are_rejected() { kerf_mm: f64::INFINITY, ..Settings::default() }, + Settings { + edge_trim_mm: -1., + ..Settings::default() + }, ] { assert!(options.validate().is_err()); } diff --git a/crates/benchup-app/src/projects.rs b/crates/benchup-app/src/projects.rs index b509db9..4cc109f 100644 --- a/crates/benchup-app/src/projects.rs +++ b/crates/benchup-app/src/projects.rs @@ -1,5 +1,6 @@ use benchup_core::tr; use super::*; +use crate::cutting_layout::Strategy; use crate::project_files::{self, Outcome}; use std::{path::PathBuf, sync::mpsc::TryRecvError}; @@ -624,6 +625,25 @@ impl BenchUpApp { .on_hover_text(tr!("projects.align_part_grain_with_stock_length")); ui.checkbox(&mut self.project.cutting_plan_layout.show_joinery, tr!("projects.show_wood_joinery")) .on_hover_text(tr!("projects.show_joinery_as_dashed_outlines")); + ui.horizontal(|ui| { + ui.text_label(tr!("projects.cut_layout_strategy")); + egui::ComboBox::from_id_salt("cut_layout_strategy") + .selected_text(self.project.cutting_plan_layout.strategy.label()) + .show_ui(ui, |ui| { + for strategy in [Strategy::Fewest, Strategy::RipFirst, Strategy::LargestOffcut] { + ui.selectable_value(&mut self.project.cutting_plan_layout.strategy, strategy, strategy.label()) + .on_hover_text(strategy.hint()); + } + }) + .response + .on_hover_text(self.project.cutting_plan_layout.strategy.hint()); + }); + ui.horizontal(|ui| { + ui.text_label(tr!("projects.edge_trim")); + ui.add(egui::DragValue::new(&mut self.project.cutting_plan_layout.edge_trim_mm) + .speed(0.1).range(0. ..=100.).suffix(tr!("projects.mm"))) + .on_hover_text(tr!("projects.edge_trim_hint")); + }); } ui.text_weak(tr!("projects.only_visible_parts_with_assigned_stock_materials_are")); if report == CutReport::CuttingPlan { diff --git a/crates/benchup-app/src/sidebar.rs b/crates/benchup-app/src/sidebar.rs index b0f2dbd..4716f80 100644 --- a/crates/benchup-app/src/sidebar.rs +++ b/crates/benchup-app/src/sidebar.rs @@ -2460,7 +2460,7 @@ mod preference_tests { #[test] fn each_additional_option_saves_independently_without_a_model_edit() { - let options: [(&str, fn(&mut BenchUpApp)); 18] = [ + let options: [(&str, fn(&mut BenchUpApp)); 20] = [ ("face center snapping", |app| app.snap_to_face_center = false), ("axis snapping", |app| app.doc.snap_to_axes = false), ("edge snapping", |app| app.snap_to_edge = false), @@ -2494,6 +2494,12 @@ mod preference_tests { ("saw kerf", |app| { app.project.cutting_plan_layout.kerf_mm = 4.5 }), + ("edge trim", |app| { + app.project.cutting_plan_layout.edge_trim_mm = 3. + }), + ("layout strategy", |app| { + app.project.cutting_plan_layout.strategy = crate::cutting_layout::Strategy::LargestOffcut + }), ("whole project scope", |app| { app.project.cutting_plan_selection_preference = Some(false) }), @@ -2618,6 +2624,7 @@ mod preference_tests { "{}", r#"{"cutting_plan":{"layout":{"kerf_mm":5.0}}}"#, r#"{"cutting_plan":{"group_stock":false,"layout":{"kerf_mm":4.5}}}"#, + r#"{"cutting_plan":{"layout":{"edge_trim_mm":2.0,"strategy":"rip_first"}}}"#, ] { let preferences: InspectorPreferences = serde_json::from_str(json).unwrap(); assert!(preferences.snapping.is_none());