diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 9647d8a..c69f4c3 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": { @@ -683,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", @@ -1132,9 +1152,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…", @@ -1146,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.", @@ -1186,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 0cf32b7..106adce 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": { @@ -683,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", @@ -1130,9 +1150,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…", @@ -1144,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.", @@ -1184,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 ed6af6f..eb0bf28 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": { @@ -683,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": "无法对绘图进行分组", @@ -1132,9 +1152,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…", @@ -1146,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": "虚线轮廓显示了投影在原材料(板材)表面上的接合处。", @@ -1186,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_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/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 b509db9..f8db37b 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}; @@ -45,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 { @@ -65,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(), } } } @@ -601,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")); }); @@ -624,10 +632,44 @@ 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.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 b0f2dbd..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)); 18] = [ + 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), @@ -2494,6 +2498,13 @@ 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 + }), + ("used-area cost", |app| app.project.cutting_plan_used_area_cost = true), ("whole project scope", |app| { app.project.cutting_plan_selection_preference = Some(false) }), @@ -2618,6 +2629,8 @@ 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; }