From 86edf3f4132bc661e434526b6f1c1cb7ee509267 Mon Sep 17 00:00:00 2001 From: Maaxion Date: Fri, 13 Nov 2026 11:40:59 +0200 Subject: [PATCH 1/2] Improve sheet cutting layouts with strategies and edge trim Try several piece orders and split rules and keep the layout with the fewest sheets. Add a layout strategy setting (fewest sheets, rip first, largest offcut) and an edge trim margin for sheet goods, both in the cut dialog and saved with the other layout settings. --- assets/i18n/en.json | 10 + assets/i18n/pt_BR.json | 10 + assets/i18n/zh_CN.json | 10 + crates/benchup-app/src/cutting_layout.rs | 346 +++++++++++++++--- .../benchup-app/src/cutting_layout_tests.rs | 313 +++++++++++++++- crates/benchup-app/src/projects.rs | 20 + crates/benchup-app/src/sidebar.rs | 9 +- 7 files changed, 641 insertions(+), 77 deletions(-) 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()); From b8d9bfd8cd5d3b48a06ebc905d507f15927bbd63 Mon Sep 17 00:00:00 2001 From: Maaxion Date: Fri, 13 Nov 2026 12:48:55 +0200 Subject: [PATCH 2/2] Preview the cutting plan with costs before generating the PDF Show the cut tables, sheet layouts with grain and joinery, oversized cuts and sheet costs in the cutting plan dialog. The preview and the time estimates share one layout view. --- assets/i18n/en.json | 15 + assets/i18n/pt_BR.json | 15 + assets/i18n/zh_CN.json | 15 + crates/benchup-app/src/app.rs | 2 + crates/benchup-app/src/cutting_plan.rs | 158 +++++-- crates/benchup-app/src/cutting_plan_tests.rs | 29 ++ crates/benchup-app/src/cutting_view.rs | 432 +++++++++++++++++++ crates/benchup-app/src/cutting_view_tests.rs | 199 +++++++++ crates/benchup-app/src/projects.rs | 26 +- crates/benchup-app/src/sidebar.rs | 8 +- crates/benchup-app/src/stock_settings.rs | 12 + crates/benchup-app/src/time_estimates.rs | 22 +- 12 files changed, 887 insertions(+), 46 deletions(-) create mode 100644 crates/benchup-app/src/cutting_view.rs create mode 100644 crates/benchup-app/src/cutting_view_tests.rs diff --git a/assets/i18n/en.json b/assets/i18n/en.json index a73c9f7..c69f4c3 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -690,6 +690,19 @@ "cutting_plan.the_project_name_is_too_long_to_fit": "The project name is too long to fit above the workshop labels.", "cutting_plan.unnamed_part": "Unnamed part", "cutting_plan.workshop_labels": "Workshop Labels", + "cutting_view.calculating": "Calculating layout…", + "cutting_view.costs": "Costs", + "cutting_view.dimensions": "{} × {} × {}", + "cutting_view.layout_failed": "The cutting layout could not be calculated.", + "cutting_view.mark": "Mark", + "cutting_view.material": "Material", + "cutting_view.parts": "Parts", + "cutting_view.sheets": "Sheets", + "cutting_view.size": "Size", + "cutting_view.total": "Total", + "cutting_view.used_area_amount": "Used area ({currency})", + "cutting_view.used_area_note": "Used area prices each sheet by the area cut from it. Offcuts kept for later are not counted.", + "cutting_view.whole_sheets_amount": "Whole sheets ({currency})", "desktop_files.missing_project_output_path": "Missing BenchUp project output path", "desktop_files.missing_sketchup_file_path": "Missing SketchUp file path", "drawing_groups.the_drawing_could_not_be_grouped": "The drawing could not be grouped", @@ -1156,6 +1169,7 @@ "projects.geometry_recovered_from_a_damaged_sketchup_archive": "Geometry recovered from a damaged SketchUp archive. Some materials may be missing.", "projects.respect_wood_grain_direction": "Respect wood grain direction", "projects.align_part_grain_with_stock_length": "Align each part's assigned grain with the length of the stock.", + "projects.show_used_area_cost": "Show used-area cost", "projects.show_wood_joinery": "Show wood joinery", "projects.show_joinery_as_dashed_outlines": "Show joinery cuts as fine dashed outlines on each piece.", "cutting_plan.joinery_dashed_outlines": "Dashed outlines show joinery projected onto the stock face.", @@ -1196,6 +1210,7 @@ "projects.selected_parts_assemblies_visible_only": "Selected parts / assemblies (visible only)", "projects.the_file_operation_did_not_complete": "The file operation did not complete", "projects.this_project_belongs_to_connect_to_that_server": "This project belongs to {server}. Connect to that server in Account before opening it.", + "projects.used_area_cost_hint": "Adds a column that prices only the area cut from each sheet. Offcuts kept for later are not counted.", "projects.whole_project_visible_only": "Whole project (visible only)", "projects.wood_joint": "Wood joint #{id}: {error}", "projects.woodworking_cutting_plan": "Woodworking cutting plan", diff --git a/assets/i18n/pt_BR.json b/assets/i18n/pt_BR.json index 15e48a7..106adce 100644 --- a/assets/i18n/pt_BR.json +++ b/assets/i18n/pt_BR.json @@ -690,6 +690,19 @@ "cutting_plan.the_project_name_is_too_long_to_fit": "O nome do projeto é longo demais para caber acima das etiquetas de oficina.", "cutting_plan.unnamed_part": "Peça sem nome", "cutting_plan.workshop_labels": "Etiquetas de oficina", + "cutting_view.calculating": "Calculando o plano de corte…", + "cutting_view.costs": "Custos", + "cutting_view.dimensions": "{} × {} × {}", + "cutting_view.layout_failed": "Não foi possível calcular o plano de corte.", + "cutting_view.mark": "Marca", + "cutting_view.material": "Material", + "cutting_view.parts": "Peças", + "cutting_view.sheets": "Chapas", + "cutting_view.size": "Medida", + "cutting_view.total": "Total", + "cutting_view.used_area_amount": "Área usada ({currency})", + "cutting_view.used_area_note": "A área usada precifica cada chapa pela área cortada dela. Sobras guardadas para uso futuro não são contadas.", + "cutting_view.whole_sheets_amount": "Chapas inteiras ({currency})", "drawing_groups.the_drawing_could_not_be_grouped": "Não foi possível agrupar o desenho", "entity_gizmo.axis_lengths": "Comprimentos nos eixos: {}. {}. {}.", "entity_gizmo.object_lenghts": "Comprimentos do objeto", @@ -1154,6 +1167,7 @@ "projects.geometry_recovered_from_a_damaged_sketchup_archive": "Geometria recuperada de um arquivo SketchUp danificado. Alguns materiais podem estar faltando.", "projects.respect_wood_grain_direction": "Respeitar a direção do veio", "projects.align_part_grain_with_stock_length": "Alinha o veio atribuído a cada peça com o comprimento da matéria-prima.", + "projects.show_used_area_cost": "Mostrar custo pela área usada", "projects.show_wood_joinery": "Mostrar encaixes", "projects.show_joinery_as_dashed_outlines": "Mostra os cortes de encaixe como contornos tracejados finos em cada peça.", "cutting_plan.joinery_dashed_outlines": "Contornos tracejados mostram os encaixes projetados na face da matéria-prima.", @@ -1194,6 +1208,7 @@ "projects.selected_parts_assemblies_visible_only": "Peças / conjuntos selecionados (somente visíveis)", "projects.the_file_operation_did_not_complete": "A operação de arquivo não foi concluída", "projects.this_project_belongs_to_connect_to_that_server": "Este projeto pertence a {server}. Conecte-se a esse servidor em Conta antes de abri-lo.", + "projects.used_area_cost_hint": "Adiciona uma coluna que precifica apenas a área cortada de cada chapa. Sobras guardadas para uso futuro não são contadas.", "projects.whole_project_visible_only": "Projeto inteiro (somente visíveis)", "projects.wood_joint": "Encaixe #{id}: {error}", "projects.woodworking_cutting_plan": "Plano de corte de marcenaria", diff --git a/assets/i18n/zh_CN.json b/assets/i18n/zh_CN.json index 55685b0..eb0bf28 100644 --- a/assets/i18n/zh_CN.json +++ b/assets/i18n/zh_CN.json @@ -690,6 +690,19 @@ "cutting_plan.the_project_name_is_too_long_to_fit": "项目名称过长,无法放在车间标签上方。", "cutting_plan.unnamed_part": "未命名部件", "cutting_plan.workshop_labels": "车间标签", + "cutting_view.calculating": "正在计算排料…", + "cutting_view.costs": "成本", + "cutting_view.dimensions": "{} × {} × {}", + "cutting_view.layout_failed": "无法计算排料。", + "cutting_view.mark": "编号", + "cutting_view.material": "材料", + "cutting_view.parts": "部件", + "cutting_view.sheets": "板材", + "cutting_view.size": "尺寸", + "cutting_view.total": "合计", + "cutting_view.used_area_amount": "按用料面积({currency})", + "cutting_view.used_area_note": "按用料面积计价时,每张板材按其实际裁下的面积计算。留作以后使用的余料不计入。", + "cutting_view.whole_sheets_amount": "整张板材({currency})", "desktop_files.missing_project_output_path": "缺少 BenchUp 项目输出路径", "desktop_files.missing_sketchup_file_path": "缺少 SketchUp 文件路径", "drawing_groups.the_drawing_could_not_be_grouped": "无法对绘图进行分组", @@ -1156,6 +1169,7 @@ "projects.geometry_recovered_from_a_damaged_sketchup_archive": "从损坏的 SketchUp 归档文件中恢复的几何体。部分材质可能已丢失。", "projects.respect_wood_grain_direction": "遵循木纹方向", "projects.align_part_grain_with_stock_length": "使各构件的木纹方向与原材料(板材)的长度方向对齐。", + "projects.show_used_area_cost": "显示按用料面积计算的成本", "projects.show_wood_joinery": "显示木构接合处", "projects.show_joinery_as_dashed_outlines": "在各构件上以细虚线轮廓显示接合切口。", "cutting_plan.joinery_dashed_outlines": "虚线轮廓显示了投影在原材料(板材)表面上的接合处。", @@ -1196,6 +1210,7 @@ "projects.selected_parts_assemblies_visible_only": "所选部件 / 装配组(仅可见者)", "projects.the_file_operation_did_not_complete": "文件操作未完成", "projects.this_project_belongs_to_connect_to_that_server": "该项目属于 {server}。打开前请先在“账户”中连接到该服务器。", + "projects.used_area_cost_hint": "添加一列,仅按每张板材裁下的面积计价。留作以后使用的余料不计入。", "projects.whole_project_visible_only": "整个项目(仅可见者)", "projects.wood_joint": "木工接合 #{id}:{error}", "projects.woodworking_cutting_plan": "木工下料方案", diff --git a/crates/benchup-app/src/app.rs b/crates/benchup-app/src/app.rs index ed9ae5f..2c3f9ca 100644 --- a/crates/benchup-app/src/app.rs +++ b/crates/benchup-app/src/app.rs @@ -47,6 +47,8 @@ mod arc_tool; mod context_menu; #[path = "currency.rs"] mod currency; +#[path = "cutting_view.rs"] +mod cutting_view; #[path = "display_scene.rs"] pub(crate) mod display_scene; #[path = "construction_guides.rs"] diff --git a/crates/benchup-app/src/cutting_plan.rs b/crates/benchup-app/src/cutting_plan.rs index 29c024b..e3afdcc 100644 --- a/crates/benchup-app/src/cutting_plan.rs +++ b/crates/benchup-app/src/cutting_plan.rs @@ -41,6 +41,58 @@ pub(crate) struct CuttingPlan { pub(crate) joinery_omitted: bool, } +/// The in-app cutting plan uses the PDF's marks, sheets and quantities. +pub(crate) struct CutView { + pub sections: Vec, + pub skipped: usize, + pub joinery_omitted: bool, +} + +pub(crate) struct MaterialSection { + pub material_id: Option, + pub material: benchup_core::StockMaterial, + pub rows: Vec, + pub layout: cutting_layout::Layout, + /// Net cut area in square metres. + pub area: f64, +} + +impl MaterialSection { + pub fn sheets(&self) -> usize { + self.layout.stock.len() + } + + pub fn excluded(&self) -> usize { + self.layout.oversized.iter().map(|piece| piece.quantity).sum() + } + + /// Square metres of pieces placed on this material's sheets. + pub fn used_area(&self) -> f64 { + self.layout + .stock + .iter() + .flat_map(|stock| &stock.placements) + .map(|p| p.rect.length as f64 * p.rect.width as f64) + .sum::() + / 100_000_000. + } + + /// Square metres of one sheet. + pub fn sheet_area(&self) -> f64 { + self.material.dimensions[1] as f64 * self.material.dimensions[2] as f64 / 100_000_000. + } +} + +pub(crate) struct CutRow { + pub mark: usize, + pub names: Vec, + pub dimensions: [u64; 3], + pub rectangular: bool, + pub grain: Grain, + pub joinery: BTreeMap>, + pub quantity: usize, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] pub(crate) enum Grain { #[default] @@ -299,7 +351,8 @@ impl CuttingPlan { groups } - pub fn material_requirements(&self, settings: LayoutSettings) -> Vec { + /// Shared by the in-app preview, estimates and PDF so all three use one layout. + pub fn view(&self, settings: LayoutSettings) -> CutView { let mut materials: BTreeMap> = BTreeMap::new(); for (mark, group) in self.ordered_groups().into_iter().enumerate() { materials @@ -307,16 +360,53 @@ impl CuttingPlan { .or_default() .push((mark, group)); } - materials + CutView { + sections: materials + .into_iter() + .map(|((material_id, material), groups)| { + let layout = stock_layout(&material, &groups, settings); + let area = groups + .iter() + .map(|(_, g)| { + g.dimensions[1] as f64 * g.dimensions[2] as f64 * g.parts.len() as f64 + / 100_000_000. + }) + .sum(); + let rows = groups + .iter() + .map(|&(mark, group)| CutRow { + mark, + names: group_names(group).into_iter().map(str::to_owned).collect(), + dimensions: group.dimensions, + rectangular: group.rectangular, + grain: group.grain, + joinery: group.joinery.clone(), + quantity: group.parts.len(), + }) + .collect(); + MaterialSection { + material_id, + material, + rows, + layout, + area, + } + }) + .collect(), + skipped: self.skipped, + joinery_omitted: self.joinery_omitted, + } + } + + pub fn material_requirements(&self, settings: LayoutSettings) -> Vec { + self.view(settings) + .sections .into_iter() - .map(|((material_id, material), groups)| { - let layout = stock_layout(&material, &groups, settings); - MaterialRequirement { - material_id, - material, - quantity: layout.stock.len(), - excluded: layout.oversized.iter().map(|piece| piece.quantity).sum(), - } + .map(|section| MaterialRequirement { + quantity: section.sheets(), + excluded: section.excluded(), + material_id: section.material_id, + material: section.material, }) .collect() } @@ -489,7 +579,7 @@ impl CuttingPlan { } } -fn piece_mark(index: usize) -> String { +pub(crate) fn piece_mark(index: usize) -> String { format!("P{:03}", index + 1) } @@ -577,8 +667,33 @@ fn cut_row(pdf: &mut Pdf, index: usize, group: &CutGroup, colored: bool) { } } +/// Offset of a joint end from the placed part's top-left corner, in tenths of a millimetre. +pub(crate) fn joinery_offset(dimensions: [u64; 3], rotated: bool, [length, width]: [u64; 2]) -> [u64; 2] { + if rotated { + [dimensions[1] - width, length] + } else { + [length, width] + } +} + +/// Whether a sheet holds a part with grain along the stock length, and a part with joinery. +pub(crate) fn sheet_legend<'a>( + stock: &cutting_layout::Stock, + group: impl Fn(usize) -> Option<(Grain, &'a BTreeMap>)>, +) -> (bool, bool) { + stock.placements.iter().fold((false, false), |(grained, joined), placement| { + match group(placement.mark) { + Some((grain, joinery)) => ( + grained || grain == Grain::AlongLength, + joined || joinery.contains_key(&placement.part_index), + ), + None => (grained, joined), + } + }) +} + /// Golden-angle hues are deterministic across stock groups and pages. -fn mark_color(mark: usize) -> [f64; 3] { +pub(crate) fn mark_color(mark: usize) -> [f64; 3] { let hue = (mark as f64 * 0.618033988749895).fract() * 6.; let fraction = hue.fract(); let low = 0.42; @@ -698,12 +813,11 @@ fn draw_layout( } else { 48. }; - let has_grain = settings.respect_grain && stock.placements.iter().any(|placement| { - groups[&placement.mark].grain == Grain::AlongLength - }); - let has_joinery = settings.show_joinery && stock.placements.iter().any(|placement| { - groups[&placement.mark].joinery.contains_key(&placement.part_index) + let (grained, joined) = sheet_legend(stock, |mark| { + groups.get(&mark).map(|group| (group.grain, &group.joinery)) }); + let has_grain = settings.respect_grain && grained; + let has_joinery = settings.show_joinery && joined; let legend_height = 16. * f64::from(u8::from(has_grain) + u8::from(has_joinery)); pdf.ensure(height + 115. + legend_height); pdf.heading( @@ -745,12 +859,8 @@ fn draw_layout( if settings.show_joinery { let group = groups[&placement.mark]; if let Some(lines) = group.joinery.get(&placement.part_index) { - let position = |[length, width]: [u64; 2]| { - let [u, v] = if placement.rotated { - [group.dimensions[1] - width, length] - } else { - [length, width] - }; + let position = |joint: [u64; 2]| { + let [u, v] = joinery_offset(group.dimensions, placement.rotated, joint); [x + u as f64 * x_scale, y + h - v as f64 * y_scale] }; pdf.joinery_lines(lines.iter().map(|line| line.map(position))); @@ -970,7 +1080,7 @@ fn is_box( sides.len() == 6 && boundaries.values().all(|count| *count == 2) } -fn metric_length(tenths: u64) -> String { +pub(crate) fn metric_length(tenths: u64) -> String { if tenths > 1000 { let value = format!("{}.{:02}", tenths / 100, tenths % 100); return tr!("cutting_plan.cm", value.trim_end_matches('0').trim_end_matches('.')); diff --git a/crates/benchup-app/src/cutting_plan_tests.rs b/crates/benchup-app/src/cutting_plan_tests.rs index f5c5c73..92ad0ec 100644 --- a/crates/benchup-app/src/cutting_plan_tests.rs +++ b/crates/benchup-app/src/cutting_plan_tests.rs @@ -1078,3 +1078,32 @@ fn material_ids_keep_identical_catalog_entries_in_separate_cut_groups() { assert_ne!(required[0].material_id, required[1].material_id); assert!(required.iter().all(|item| item.quantity == 1)); } + +#[test] +fn view_agrees_with_the_estimates_and_pdf_on_sheets_and_oversized_cuts() { + let group = |dimensions: [u64; 3], parts: usize| CutGroup { + material_id: None, + material: material(), + dimensions, + rectangular: true, + grain: Default::default(), + joinery: Default::default(), + parts: vec!["Part".into(); parts], + }; + let plan = CuttingPlan { + groups: vec![group([180, 6000, 20000], 3), group([180, 13000, 30000], 1)], + skipped: 0, + joinery_omitted: false, + }; + let settings = LayoutSettings::default(); + let view = plan.view(settings); + let required = plan.material_requirements(settings); + assert_eq!(view.sections.len(), required.len()); + let section = &view.sections[0]; + assert_eq!(section.sheets(), required[0].quantity); + assert_eq!(section.excluded(), required[0].excluded); + assert_eq!(section.excluded(), 1); + assert_eq!(section.layout.oversized[0].mark, 1); + let pdf = String::from_utf8(plan.pdf("Cabinet", false, true)).unwrap(); + assert!(pdf.contains("DOES NOT FIT SELECTED STOCK")); +} diff --git a/crates/benchup-app/src/cutting_view.rs b/crates/benchup-app/src/cutting_view.rs new file mode 100644 index 0000000..460f011 --- /dev/null +++ b/crates/benchup-app/src/cutting_view.rs @@ -0,0 +1,432 @@ +//! In-app cutting plan preview. Marks, sheets and quantities come from CuttingPlan::view, +//! so the preview and the PDF draw the same layout. +use super::{currency, stock_settings}; +use crate::cutting_layout::{self, Settings as LayoutSettings}; +use crate::cutting_plan::{self, CutRow, CutView, Grain, metric_length, mark_color, piece_mark}; +use crate::inspector_table::{Column, InspectorTable}; +use crate::natural_text::TextUi as _; +use benchup_core::{Model, tr}; +use eframe::egui::{self, Align2, Color32, FontId, Rect, Sense, Shape, Stroke, Vec2}; +use std::{ + collections::BTreeSet, + sync::{ + Arc, + mpsc::{Receiver, TryRecvError}, + }, +}; + +type Key = (u64, BTreeSet, LayoutSettings); +type CutResult = Result; + +#[derive(Default)] +pub(crate) struct Cache { + current: Option<(Key, CutResult)>, + pending: Option<(Key, Receiver)>, +} + +impl Cache { + /// Packing runs off the UI thread. The last finished layout stays on screen + /// until a newer one arrives. Prices stay outside the cache, so catalog edits + /// update without repacking. + pub(crate) fn view( + &mut self, + ctx: &egui::Context, + revision: u64, + model: &Arc, + selected: &BTreeSet, + settings: LayoutSettings, + ) -> Option<&CutResult> { + let wanted = |key: &Key| key.0 == revision && key.1 == *selected && key.2 == settings; + // A layout that finishes late for an older key must not replace one that already matches. + let current_matches = self.current.as_ref().is_some_and(|(key, _)| wanted(key)); + if let Some((key, receiver)) = self.pending.take() { + match receiver.try_recv() { + Ok(result) => { + if !current_matches { + self.current = Some((key, result)); + } + } + Err(TryRecvError::Empty) => self.pending = Some((key, receiver)), + // The worker ended without a result. Show that for this key instead of retrying every frame. + Err(TryRecvError::Disconnected) => { + if !current_matches { + self.current = Some((key, Err(tr!("cutting_view.layout_failed").into()))); + } + } + } + } + // One layout runs at a time. A newer key waits until the running layout finishes. + if self.pending.is_none() && !self.current.as_ref().is_some_and(|(key, _)| wanted(key)) { + self.start(ctx, model, (revision, selected.clone(), settings)); + } + self.current.as_ref().map(|(_, result)| result) + } + + fn start(&mut self, ctx: &egui::Context, model: &Arc, key: Key) { + #[cfg(not(target_arch = "wasm32"))] + { + let (sender, receiver) = std::sync::mpsc::channel(); + let ctx = ctx.clone(); + let model = model.clone(); + let (selected, settings) = (key.1.clone(), key.2); + benchup_core::i18n::spawn_localized(move || { + let _ = sender.send(compute(&model, &selected, settings)); + ctx.request_repaint(); + }); + self.pending = Some((key, receiver)); + } + // Browsers have no worker threads here, so the layout is computed inline and shows this frame. + #[cfg(target_arch = "wasm32")] + { + let _ = ctx; + let result = compute(model, &key.1, key.2); + self.current = Some((key, result)); + } + } +} + +fn compute(model: &Model, selected: &BTreeSet, settings: LayoutSettings) -> CutResult { + settings + .validate() + .and_then(|()| cutting_plan::CuttingPlan::collect_with_layout(model, selected, settings)) + .map(|plan| plan.view(settings)) +} + +pub(crate) fn show( + ui: &mut egui::Ui, + view: Option<&CutResult>, + catalog: &stock_settings::Settings, + kerf_mm: f64, + used_cost: bool, +) { + let view = match view { + Some(Ok(view)) => view, + Some(Err(message)) => { + ui.text_label(message.as_str()); + return; + } + None => { + ui.text_weak(tr!("cutting_view.calculating")); + return; + } + }; + if view.skipped > 0 { + ui.text_weak(tr!("cutting_plan.omitted_items", count = view.skipped)); + } + if view.joinery_omitted { + ui.text_weak(tr!("cutting_plan.joinery_omitted")); + } + let kerf = metric_length(cutting_layout::tenths(kerf_mm)); + for (index, section) in view.sections.iter().enumerate() { + ui.add_space(8.); + let [t, w, l] = section.material.dimensions.map(metric_length); + ui.text_heading(tr!( + "cutting_plan.material_x_x", + section.material.name.as_str(), + t.as_str(), + w.as_str(), + l.as_str() + )); + let sheets = section.sheets(); + let pieces: usize = section.rows.iter().map(|row| row.quantity).sum(); + ui.text_label(tr!( + "cutting_plan.stock_required", + count = sheets, + kerf = kerf.as_str() + )); + ui.text_label(tr!( + "cutting_plan.net_cut_area_m2", + pieces = tr!("common.pieces", count = pieces), + area = section.area + )); + let mut table = InspectorTable::new( + ui.available_width(), + [ + Column::fixed(64.), + Column::flex(1.), + Column::fixed(176.), + Column::fixed(80.), + ], + ) + .striped(); + table.header( + ui, + [ + tr!("cutting_view.mark"), + tr!("cutting_view.parts"), + tr!("cutting_view.size"), + tr!("time_estimates.quantity"), + ], + ); + for row in §ion.rows { + table.row(ui, (index, row.mark), |column, cell| match column { + 0 => { + cell.horizontal(|ui| { + swatch(ui, row.mark); + ui.text_label(piece_mark(row.mark)); + }); + } + 1 => { + cell.text_label(row.names.join(", ")); + } + 2 => { + cell.text_label(size_text(row)); + if row.grain == Grain::ThroughThickness { + cell.text_weak(tr!("cutting_plan.grain_through_thickness")); + } + if !row.rectangular { + cell.text_weak(tr!("cutting_plan.check_shape_bounding_blank_only")); + } + } + _ => { + cell.text_label(row.quantity.to_string()); + } + }); + } + for (number, stock) in section.layout.stock.iter().enumerate() { + ui.text_label(tr!( + "cutting_plan.stock_page_heading", + tr!("cutting_plan.stock"), + number + 1, + sheets + )); + let (grained, joined) = cutting_plan::sheet_legend(stock, |mark| { + row_for(§ion.rows, mark).map(|row| (row.grain, &row.joinery)) + }); + if grained { + ui.text_weak(tr!("cutting_plan.grain_along_stock_length")); + } + if joined { + ui.text_weak(tr!("cutting_plan.joinery_dashed_outlines")); + } + draw_sheet(ui, stock, §ion.rows); + } + if !section.layout.oversized.is_empty() { + ui.text_heading(tr!("cutting_plan.does_not_fit_selected_stock")); + for piece in §ion.layout.oversized { + ui.text_label(tr!( + "cutting_plan.oversized_cut", + count = piece.quantity, + mark = piece_mark(piece.mark), + width = metric_length(piece.width), + length = metric_length(piece.length) + )); + } + } + } + show_costs(ui, view, catalog, used_cost); +} + +fn row_for(rows: &[CutRow], mark: usize) -> Option<&CutRow> { + rows.iter().find(|row| row.mark == mark) +} + +fn size_text(row: &CutRow) -> String { + let [t, w, l] = row.dimensions.map(metric_length); + tr!("cutting_view.dimensions", t, w, l) +} + +fn cost_table( + ui: &mut egui::Ui, + columns: [Column; N], + headers: &[String], + rows: &[Vec], +) { + let mut table = InspectorTable::new(ui.available_width(), columns).striped(); + table.header(ui, std::array::from_fn(|column| headers[column].as_str())); + for (index, row) in rows.iter().enumerate() { + table.row(ui, index, |column, cell| { + cell.text_label(row[column].as_str()); + }); + } +} + +struct CostRow { + material: String, + sheets: usize, + price: Option, + whole: Option, + used: Option, +} + +#[derive(Default)] +struct CostSummary { + rows: Vec, + whole_total: f64, + used_total: f64, + unpriced: usize, +} + +/// Unpriced sheets add nothing to the totals and are counted for the note. +fn cost_summary(view: &CutView, catalog: &stock_settings::Settings) -> CostSummary { + let mut summary = CostSummary::default(); + for section in &view.sections { + let sheets = section.sheets(); + let price = catalog.wood_price(section.material_id, §ion.material); + let whole = price.map(|price| sheets as f64 * price); + // Each sheet is priced by the fraction of its area that the pieces cut from it use. + // Stock with no area holds no sheets, so nothing is used. + let sheet_area = section.sheet_area(); + let used = price.map(|price| { + if sheet_area > 0. { + price * section.used_area() / sheet_area + } else { + 0. + } + }); + summary.whole_total += whole.unwrap_or(0.); + summary.used_total += used.unwrap_or(0.); + if price.is_none() { + summary.unpriced += sheets; + } + summary.rows.push(CostRow { + material: section.material.name.clone(), + sheets, + price, + whole, + used, + }); + } + summary +} + +fn show_costs(ui: &mut egui::Ui, view: &CutView, catalog: &stock_settings::Settings, used_cost: bool) { + let name = catalog.currency.as_str(); + let code = currency::code(name); + ui.add_space(12.); + ui.text_heading(tr!("cutting_view.costs")); + let amount = |value: Option| { + value.map_or_else( + || tr!("time_estimates.unavailable").to_owned(), + |value| currency::format_value(name, value), + ) + }; + let summary = cost_summary(view, catalog); + let mut headers = vec![ + tr!("cutting_view.material").to_owned(), + tr!("cutting_view.sheets").to_owned(), + tr!("time_estimates.unit_price", currency = code).to_owned(), + tr!("cutting_view.whole_sheets_amount", currency = code).to_owned(), + ]; + let mut rows: Vec> = summary + .rows + .iter() + .map(|row| { + let mut cells = vec![ + row.material.clone(), + row.sheets.to_string(), + amount(row.price), + amount(row.whole), + ]; + if used_cost { + cells.push(amount(row.used)); + } + cells + }) + .collect(); + let mut total = vec![ + tr!("cutting_view.total").to_owned(), + String::new(), + String::new(), + currency::format_value(name, summary.whole_total), + ]; + if used_cost { + headers.push(tr!("cutting_view.used_area_amount", currency = code).to_owned()); + total.push(currency::format_value(name, summary.used_total)); + } + rows.push(total); + // InspectorTable spaces every column, so the hidden used-area column is left out rather than zero-width. + if used_cost { + cost_table( + ui, + [ + Column::flex(1.), + Column::fixed(80.), + Column::fixed(128.), + Column::fixed(176.), + Column::fixed(176.), + ], + &headers, + &rows, + ); + ui.text_weak(tr!("cutting_view.used_area_note")); + } else { + cost_table( + ui, + [ + Column::flex(1.), + Column::fixed(80.), + Column::fixed(128.), + Column::fixed(176.), + ], + &headers, + &rows, + ); + } + if summary.unpriced > 0 { + ui.text_weak(tr!("time_estimates.stock_pieces_without_price", count = summary.unpriced)); + } + if view.sections.iter().any(|section| section.excluded() > 0) { + ui.text_weak(tr!("cutting_plan.stock_count_covers_fitting_cuts_only_oversized_cuts")); + } +} + +fn color(mark: usize) -> Color32 { + let [r, g, b] = mark_color(mark); + Color32::from_rgb((r * 255.) as u8, (g * 255.) as u8, (b * 255.) as u8) +} + +fn swatch(ui: &mut egui::Ui, mark: usize) { + let (rect, _) = ui.allocate_exact_size(Vec2::splat(10.), Sense::hover()); + ui.painter().rect_filled(rect, 2., color(mark)); +} + +fn draw_sheet(ui: &mut egui::Ui, stock: &cutting_layout::Stock, rows: &[CutRow]) { + // Fill the dialog width, capped in height so narrow stock stays on screen. + let scale = (ui.available_width() / stock.length as f32).min(480. / stock.width as f32); + let (rect, _) = ui.allocate_exact_size( + Vec2::new(stock.length as f32 * scale, stock.width as f32 * scale), + Sense::hover(), + ); + let painter = ui.painter_at(rect); + painter.rect_filled(rect, 0., Color32::from_gray(235)); + for placement in &stock.placements { + let r = placement.rect; + let piece = Rect::from_min_size( + rect.min + Vec2::new(r.x as f32 * scale, r.y as f32 * scale), + Vec2::new(r.length as f32 * scale, r.width as f32 * scale), + ); + painter.rect_filled(piece, 0., color(placement.mark)); + let Some(row) = row_for(rows, placement.mark) else { + continue; + }; + if let Some(lines) = row.joinery.get(&placement.part_index) { + let position = |joint: [u64; 2]| { + let [u, v] = cutting_plan::joinery_offset(row.dimensions, placement.rotated, joint); + piece.min + Vec2::new(u as f32 * scale, v as f32 * scale) + }; + for line in lines { + let points = [position(line[0]), position(line[1])]; + painter.extend(Shape::dashed_line( + &points, + Stroke::new(1., Color32::from_gray(60)), + 4., + 3., + )); + } + } + if piece.width() >= 22. && piece.height() >= 12. { + painter.text( + piece.center(), + Align2::CENTER_CENTER, + piece_mark(placement.mark), + FontId::proportional(11.), + Color32::from_gray(20), + ); + } + } +} + +#[cfg(test)] +#[path = "cutting_view_tests.rs"] +mod tests; diff --git a/crates/benchup-app/src/cutting_view_tests.rs b/crates/benchup-app/src/cutting_view_tests.rs new file mode 100644 index 0000000..999329b --- /dev/null +++ b/crates/benchup-app/src/cutting_view_tests.rs @@ -0,0 +1,199 @@ +use super::*; +use crate::cutting_layout::Piece; +use crate::cutting_plan::MaterialSection; +use std::time::Duration; + +/// A 100 x 1000 mm board, so one piece of 1000 x 50 mm uses half of each sheet. +fn board_catalog(price: f64) -> stock_settings::Settings { + let stock = stock_settings::Stock { + thickness_mm: 10., + width_mm: 100., + length_mm: 1000., + price, + ..Default::default() + }; + stock_settings::Settings { + wood: vec![stock], + ..Default::default() + } +} + +fn section(catalog: &stock_settings::Settings, pieces: &[Piece]) -> MaterialSection { + let stock = &catalog.wood[0]; + let layout = cutting_layout::sheets( + pieces, + LayoutSettings { + sheet_width_mm: stock.width_mm, + sheet_length_mm: stock.length_mm, + kerf_mm: 0., + allow_rotation: false, + ..LayoutSettings::default() + }, + ); + MaterialSection { + material_id: Some(stock.id), + material: stock.definition(), + rows: Vec::new(), + layout, + area: 0., + } +} + +fn piece(length: u64, width: u64, quantity: usize) -> Piece { + Piece { + mark: 0, + length, + width, + quantity, + grain_along_length: false, + } +} + +#[test] +fn cost_totals_price_whole_sheets_and_the_area_cut_from_them() { + let catalog = board_catalog(10.); + let view = CutView { + sections: vec![section(&catalog, &[piece(10000, 500, 1)])], + skipped: 0, + joinery_omitted: false, + }; + let summary = cost_summary(&view, &catalog); + assert_eq!(summary.rows.len(), 1); + assert_eq!(summary.rows[0].sheets, 1); + assert_eq!(summary.rows[0].whole, Some(10.)); + assert!((summary.rows[0].used.unwrap() - 5.).abs() < 1e-9); + assert!((summary.whole_total - 10.).abs() < 1e-9); + assert!((summary.used_total - 5.).abs() < 1e-9); + assert_eq!(summary.unpriced, 0); +} + +#[test] +fn zero_price_is_a_price_of_zero_as_in_the_time_estimates() { + let catalog = board_catalog(0.); + let view = CutView { + sections: vec![section(&catalog, &[piece(10000, 500, 2)])], + skipped: 0, + joinery_omitted: false, + }; + let summary = cost_summary(&view, &catalog); + assert_eq!(summary.rows[0].price, Some(0.)); + assert_eq!(summary.rows[0].whole, Some(0.)); + assert_eq!(summary.rows[0].used, Some(0.)); + assert_eq!(summary.unpriced, 0); + assert_eq!(summary.whole_total, 0.); + assert_eq!(summary.used_total, 0.); +} + +#[test] +fn stock_without_area_costs_nothing_instead_of_nan() { + let mut catalog = board_catalog(10.); + catalog.wood[0].width_mm = 0.; + let view = CutView { + sections: vec![section(&catalog, &[piece(10000, 500, 1)])], + skipped: 0, + joinery_omitted: false, + }; + let summary = cost_summary(&view, &catalog); + assert_eq!(summary.rows[0].sheets, 0); + assert_eq!(summary.rows[0].whole, Some(0.)); + assert_eq!(summary.rows[0].used, Some(0.)); + assert_eq!(summary.whole_total, 0.); + assert_eq!(summary.used_total, 0.); +} + +#[test] +fn sheets_of_a_material_missing_from_the_catalog_are_unpriced() { + let catalog = board_catalog(10.); + let mut missing = section(&catalog, &[piece(10000, 500, 1)]); + missing.material_id = None; + let view = CutView { + sections: vec![missing], + skipped: 0, + joinery_omitted: false, + }; + let summary = cost_summary(&view, &catalog); + assert_eq!(summary.rows[0].whole, None); + assert_eq!(summary.unpriced, 1); +} + +fn settle_for( + cache: &mut Cache, + ctx: &egui::Context, + revision: u64, + model: &Arc, + selected: &BTreeSet, + settings: LayoutSettings, +) { + for _ in 0..1000 { + cache.view(ctx, revision, model, selected, settings); + let finished = cache.current.as_ref().is_some_and(|(key, _)| { + key.0 == revision && key.1 == *selected && key.2 == settings + }); + if cache.pending.is_none() && finished { + return; + } + std::thread::sleep(Duration::from_millis(5)); + } + panic!("the cutting layout did not finish"); +} + +fn settle(cache: &mut Cache, ctx: &egui::Context, revision: u64, model: &Arc) { + settle_for(cache, ctx, revision, model, &BTreeSet::new(), LayoutSettings::default()); +} + +#[test] +fn cache_repacks_only_when_the_document_revision_or_scope_changes() { + let ctx = egui::Context::default(); + let model = Arc::new(Model::default()); + let mut cache = Cache::default(); + settle(&mut cache, &ctx, 1, &model); + // The same revision and scope reuse the finished layout without a new worker. + cache.view(&ctx, 1, &model, &BTreeSet::new(), LayoutSettings::default()); + assert!(cache.pending.is_none()); + // A document edit starts a new layout while the previous one stays visible. + assert!(cache.view(&ctx, 2, &model, &BTreeSet::new(), LayoutSettings::default()).is_some()); + assert!(cache.pending.is_some()); + settle(&mut cache, &ctx, 2, &model); +} + +#[test] +fn layout_settings_and_selection_are_part_of_the_cache_key() { + let ctx = egui::Context::default(); + let model = Arc::new(Model::default()); + let mut cache = Cache::default(); + settle(&mut cache, &ctx, 1, &model); + let kerf = LayoutSettings { + kerf_mm: 4., + ..LayoutSettings::default() + }; + cache.view(&ctx, 1, &model, &BTreeSet::new(), kerf); + assert!(cache.pending.as_ref().is_some_and(|(key, _)| key.2 == kerf)); + settle_for(&mut cache, &ctx, 1, &model, &BTreeSet::new(), kerf); + let selected = BTreeSet::from([7_u64]); + cache.view(&ctx, 1, &model, &selected, LayoutSettings::default()); + assert!(cache.pending.as_ref().is_some_and(|(key, _)| key.1 == selected)); +} + +#[test] +fn a_late_layout_for_an_older_key_does_not_replace_the_matching_one() { + let ctx = egui::Context::default(); + let model = Arc::new(Model::default()); + let mut cache = Cache::default(); + let default = LayoutSettings::default(); + settle(&mut cache, &ctx, 1, &model); + let kerf = LayoutSettings { + kerf_mm: 4., + ..default + }; + // Change to kerf 4, then back to the settled layout before the kerf job finishes. + cache.view(&ctx, 1, &model, &BTreeSet::new(), kerf); + for _ in 0..1000 { + cache.view(&ctx, 1, &model, &BTreeSet::new(), default); + if cache.pending.is_none() { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + assert!(cache.pending.is_none()); + assert!(cache.current.as_ref().is_some_and(|(key, _)| key.2 == default)); +} diff --git a/crates/benchup-app/src/projects.rs b/crates/benchup-app/src/projects.rs index 4cc109f..f8db37b 100644 --- a/crates/benchup-app/src/projects.rs +++ b/crates/benchup-app/src/projects.rs @@ -46,6 +46,8 @@ pub(super) struct Project { // Both reports share scope so their marks can be printed for the same parts. pub cutting_plan_selection_preference: Option, pub cutting_plan_layout: crate::cutting_layout::Settings, + pub cutting_plan_used_area_cost: bool, + pub cut_view: super::cutting_view::Cache, } impl Default for Project { fn default() -> Self { @@ -66,6 +68,8 @@ impl Default for Project { cut_report: None, cutting_plan_selection_preference: None, cutting_plan_layout: crate::cutting_layout::Settings::default(), + cutting_plan_used_area_cost: false, + cut_view: Default::default(), } } } @@ -602,12 +606,15 @@ impl BenchUpApp { tr!("projects.create_one_label_per_piece_with_its_cutting"), ), }; - let response = Modal::new(id, 480.).show(ctx, |modal| { + let width = if report == CutReport::CuttingPlan { 880. } else { 480. }; + let cut_inputs = (report == CutReport::CuttingPlan) + .then(|| (self.document_revision(ctx), self.document_model.shared())); + let response = Modal::new(id, width).show(ctx, |modal| { modal.header(|ui| { ui.text_heading(title); ui.text_weak(description); }); - modal.body(|ui| { + modal.body_with_scroll(egui::ScrollArea::vertical(), |ui| { ui.add_enabled_ui(!self.selected.is_empty(), |ui| { ui.radio_value(&mut selection, true, tr!("projects.selected_parts_assemblies_visible_only")); }); @@ -644,10 +651,25 @@ impl BenchUpApp { .speed(0.1).range(0. ..=100.).suffix(tr!("projects.mm"))) .on_hover_text(tr!("projects.edge_trim_hint")); }); + ui.checkbox(&mut self.project.cutting_plan_used_area_cost, tr!("projects.show_used_area_cost")) + .on_hover_text(tr!("projects.used_area_cost_hint")); } ui.text_weak(tr!("projects.only_visible_parts_with_assigned_stock_materials_are")); if report == CutReport::CuttingPlan { ui.text_weak(tr!("projects.layouts_use_assigned_stock_dimensions_and_saw_kerf")); + ui.separator(); + if let Some((revision, model)) = &cut_inputs { + let empty = BTreeSet::new(); + let scope = if selection { &self.selected } else { &empty }; + let view = self.project.cut_view.view(ctx, *revision, model, scope, self.project.cutting_plan_layout); + super::cutting_view::show( + ui, + view, + &self.general_settings.stock, + self.project.cutting_plan_layout.kerf_mm, + self.project.cutting_plan_used_area_cost, + ); + } } }); modal.footer(|ui| { diff --git a/crates/benchup-app/src/sidebar.rs b/crates/benchup-app/src/sidebar.rs index 4716f80..3df5125 100644 --- a/crates/benchup-app/src/sidebar.rs +++ b/crates/benchup-app/src/sidebar.rs @@ -509,6 +509,7 @@ impl Default for SnappingPreferences { struct CuttingPlanPreferences { layout: crate::cutting_layout::Settings, selection: Option, + used_area_cost: bool, } impl Default for CuttingPlanPreferences { @@ -516,6 +517,7 @@ impl Default for CuttingPlanPreferences { Self { layout: Default::default(), selection: None, + used_area_cost: false, } } } @@ -809,6 +811,7 @@ impl BenchUpApp { Default::default() }; self.project.cutting_plan_selection_preference = preferences.cutting_plan.selection; + self.project.cutting_plan_used_area_cost = preferences.cutting_plan.used_area_cost; if let Some(snapping) = &preferences.snapping { self.snap_to_edge = snapping.geometry && snapping.edges; self.snap_to_face = snapping.geometry && snapping.faces; @@ -905,6 +908,7 @@ impl BenchUpApp { cutting_plan: CuttingPlanPreferences { layout: self.project.cutting_plan_layout, selection: self.project.cutting_plan_selection_preference, + used_area_cost: self.project.cutting_plan_used_area_cost, }, time_estimates: self.time_estimates.clone(), joinery_rates: self.joinery_rates.clone(), @@ -2460,7 +2464,7 @@ mod preference_tests { #[test] fn each_additional_option_saves_independently_without_a_model_edit() { - let options: [(&str, fn(&mut BenchUpApp)); 20] = [ + let options: [(&str, fn(&mut BenchUpApp)); 21] = [ ("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), @@ -2500,6 +2504,7 @@ mod preference_tests { ("layout strategy", |app| { app.project.cutting_plan_layout.strategy = crate::cutting_layout::Strategy::LargestOffcut }), + ("used-area cost", |app| app.project.cutting_plan_used_area_cost = true), ("whole project scope", |app| { app.project.cutting_plan_selection_preference = Some(false) }), @@ -2625,6 +2630,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"}}}"#, + r#"{"cutting_plan":{"used_area_cost":true,"layout":{"used_area_cost":true}}}"#, ] { let preferences: InspectorPreferences = serde_json::from_str(json).unwrap(); assert!(preferences.snapping.is_none()); diff --git a/crates/benchup-app/src/stock_settings.rs b/crates/benchup-app/src/stock_settings.rs index 0577bcd..71fd88e 100644 --- a/crates/benchup-app/src/stock_settings.rs +++ b/crates/benchup-app/src/stock_settings.rs @@ -125,6 +125,18 @@ impl Default for Settings { } impl Settings { + /// Unit price of the catalog stock behind a cut material, when its size still matches. + pub(super) fn wood_price( + &self, + material_id: Option, + material: &benchup_core::StockMaterial, + ) -> Option { + material_id + .and_then(|id| self.wood.iter().find(|stock| stock.id == id)) + .filter(|stock| stock.definition().dimensions == material.dimensions) + .map(|stock| stock.price) + } + pub(super) fn normalize(&mut self) { currency::normalize(&mut self.currency); let mut used = BTreeSet::new(); diff --git a/crates/benchup-app/src/time_estimates.rs b/crates/benchup-app/src/time_estimates.rs index 8974e92..c38773a 100644 --- a/crates/benchup-app/src/time_estimates.rs +++ b/crates/benchup-app/src/time_estimates.rs @@ -1257,13 +1257,7 @@ impl BenchUpApp { rows: Vec::new(), }; for required in &plan.requirements { - let price = required - .material_id - .and_then(|id| catalog.wood.iter().find(|stock| stock.id == id)) - .filter(|stock| { - stock.definition().dimensions == required.material.dimensions - }) - .map(|stock| stock.price); + let price = catalog.wood_price(required.material_id, &required.material); total += required.quantity as f64 * price.unwrap_or(0.); partial |= price.is_none() || required.excluded > 0; let mut item = required.material.name.clone(); @@ -1461,11 +1455,7 @@ impl BenchUpApp { .requirements .iter() .map(|required| { - let price = required - .material_id - .and_then(|id| catalog.wood.iter().find(|stock| stock.id == id)) - .filter(|stock| stock.definition().dimensions == required.material.dimensions) - .map(|stock| stock.price); + let price = catalog.wood_price(required.material_id, &required.material); (required, price) }) .collect(); @@ -1739,13 +1729,7 @@ impl BenchUpApp { let mut total = finish_total; let mut partial = plan.skipped > 0 || finish_partial; for required in &plan.requirements { - let price = required - .material_id - .and_then(|id| catalog.wood.iter().find(|stock| stock.id == id)) - .filter(|stock| { - stock.definition().dimensions == required.material.dimensions - }) - .map(|stock| stock.price); + let price = catalog.wood_price(required.material_id, &required.material); total += required.quantity as f64 * price.unwrap_or(0.); partial |= price.is_none() || required.excluded > 0; }