PrintCal / src /lib /engine /imposition.ts
phucsd's picture
fix(digital-print): khong bu hao in nhanh, chuan hoa binh bai A4/A5/A6/Namecard va phoi giay INTC
24d18ef
Raw History Blame Contribute Delete
23.3 kB
import { BoxCoordinate, DigitalMode, ImpositionResult, PaperCutBlock, PaperCutLine, PaperCutScheme, PaperType, PrintTech } from '@/types';
interface ImpositionOptions {
widthMm: number;
heightMm: number;
bleedMm?: number;
paperType: PaperType;
printTech: PrintTech;
digitalMode?: DigitalMode;
preferredMachineSheet?: { widthMm: number; heightMm: number; name: string };
gripperMarginMm?: number;
}
interface EvaluationCandidate {
name: string;
sheetWidthMm: number;
sheetHeightMm: number;
cutsFromParent: number;
cutScheme: PaperCutScheme;
gripperMarginMm: number;
}
export function calculateImposition({
widthMm,
heightMm,
bleedMm = 2,
paperType,
printTech,
digitalMode = 'with_paper',
preferredMachineSheet,
gripperMarginMm = 10,
}: ImpositionOptions): ImpositionResult {
const parentW = paperType.parentWidthCm * 10;
const parentH = paperType.parentHeightCm * 10;
// 1. Sinh danh sách các phương án cắt xả giấy mẹ
const candidates: EvaluationCandidate[] = [];
if (preferredMachineSheet) {
// Máy in cụ thể được chỉ định
const cuts = calculateCutsFromParent(parentW, parentH, preferredMachineSheet.widthMm, preferredMachineSheet.heightMm);
const cutScheme = determineCutScheme(parentW, parentH, preferredMachineSheet.widthMm, preferredMachineSheet.heightMm, cuts);
candidates.push({
name: preferredMachineSheet.name,
sheetWidthMm: preferredMachineSheet.widthMm,
sheetHeightMm: preferredMachineSheet.heightMm,
cutsFromParent: cuts,
cutScheme,
gripperMarginMm,
});
} else if (printTech === 'digital') {
// In nhanh KTS: Theo chuẩn Bảng Giá In Nhanh Kèm Giấy & In Gia Công Konica C12000
let digitalCandidates: { name: string; w: number; h: number; cuts: number }[] = [];
if (digitalMode === 'with_paper') {
// BẢNG GIÁ IN NHANH KÈM GIẤY: Khổ 325 x 430 mm (chính) & 325 x 355 mm (Fort)
digitalCandidates = [
{ name: 'Khổ in kèm giấy (325 x 430 mm)', w: 430, h: 325, cuts: 1 },
{ name: 'Khổ in kèm giấy (325 x 355 mm)', w: 355, h: 325, cuts: 1 },
];
} else {
// BẢNG GIÁ IN GIA CÔNG KONICA C12000 / C12010S: 5 khổ in máy
digitalCandidates = [
{ name: 'Khổ Konica A4 chuẩn (210 x 297 mm)', w: 297, h: 210, cuts: 1 },
{ name: 'Khổ Konica 330 x 355 mm', w: 355, h: 330, cuts: 1 },
{ name: 'Khổ Konica A3 chuẩn (297 x 420 mm)', w: 420, h: 297, cuts: 1 },
{ name: 'Khổ Konica A3+ (330 x 483 mm)', w: 483, h: 330, cuts: 1 },
{ name: 'Khổ Konica Banner (330 x 1200 mm)', w: 1200, h: 330, cuts: 1 },
];
}
for (const dc of digitalCandidates) {
const isWithPaper = digitalMode === 'with_paper';
const cutScheme: PaperCutScheme = isWithPaper
? {
parentWidthMm: dc.w,
parentHeightMm: dc.h,
cutType: 'chia_1',
cutDescription: 'Phôi giấy cắt sẵn khổ chuẩn của xưởng INTC (đã gồm trong đơn giá in, không cần xả từ khổ mẹ)',
cutsCount: 1,
cutSheetWidthMm: dc.w,
cutSheetHeightMm: dc.h,
wasteAreaPercent: 0,
cutLines: [],
blocks: [
{
index: 1,
x: 0,
y: 0,
w: dc.w,
h: dc.h,
name: `Phôi in trọn gói (${dc.w} × ${dc.h} mm)`,
},
],
}
: determineCutScheme(parentW, parentH, dc.w, dc.h, calculateCutsFromParent(parentW, parentH, dc.w, dc.h));
candidates.push({
name: dc.name,
sheetWidthMm: dc.w,
sheetHeightMm: dc.h,
cutsFromParent: isWithPaper ? 1 : calculateCutsFromParent(parentW, parentH, dc.w, dc.h),
cutScheme,
gripperMarginMm: 2.5,
});
}
} else {
// In Offset: Các phương án chia khổ giấy mẹ chuẩn công nghiệp
// Phương án 0: Nguyên khổ (Chia 1) - ví dụ 65x86 (860 x 650 mm)
if (Math.max(parentW, parentH) <= 920) {
const w1 = Math.max(parentW, parentH);
const h1 = Math.min(parentW, parentH);
candidates.push({
name: `Khổ nguyên ${Math.round(parentW/10)}x${Math.round(parentH/10)}cm (Bắt nhíp ${w1/10}cm)`,
sheetWidthMm: w1,
sheetHeightMm: h1,
cutsFromParent: 1,
cutScheme: buildFullCutScheme(parentW, parentH),
gripperMarginMm,
});
}
// Phương án 1: Chia 2 (Cắt đôi)
// Cắt đôi chiều dài (thường tạo ra khổ 430 x 650 mm từ 65x86)
const longSide = Math.max(parentW, parentH);
const shortSide = Math.min(parentW, parentH);
const cut2_h = Math.floor(longSide / 2);
const cut2_w = shortSide;
candidates.push({
name: `Khổ chia 2 (${cut2_w} x ${cut2_h} mm)`,
sheetWidthMm: Math.max(cut2_w, cut2_h),
sheetHeightMm: Math.min(cut2_w, cut2_h),
cutsFromParent: 2,
cutScheme: buildCut2Scheme(parentW, parentH),
gripperMarginMm,
});
// Phương án 2: Chia 4 (Cắt chữ thập)
const cut4_w = Math.floor(parentW / 2);
const cut4_h = Math.floor(parentH / 2);
candidates.push({
name: `Khổ chia 4 (${cut4_w} x ${cut4_h} mm)`,
sheetWidthMm: Math.max(cut4_w, cut4_h),
sheetHeightMm: Math.min(cut4_w, cut4_h),
cutsFromParent: 4,
cutScheme: buildCut4Scheme(parentW, parentH),
gripperMarginMm,
});
// Phương án 3: Chia 8 (Cắt 8 - cho ấn phẩm nhỏ như namecard, tag...)
const cut8_1 = Math.floor(shortSide / 2);
const cut8_2 = Math.floor(longSide / 4);
candidates.push({
name: `Khổ chia 8 (${cut8_1} x ${cut8_2} mm)`,
sheetWidthMm: Math.max(cut8_1, cut8_2),
sheetHeightMm: Math.min(cut8_1, cut8_2),
cutsFromParent: 8,
cutScheme: buildCut8Scheme(parentW, parentH),
gripperMarginMm,
});
}
// 2. Đánh giá tất cả phương án để tìm phương án tối ưu nhất
let bestCandidateResult: {
candidate: EvaluationCandidate;
ups: number;
cols: number;
rows: number;
isRotated: boolean;
boxes: BoxCoordinate[];
efficiency: number;
usableW: number;
usableH: number;
} | null = null;
for (const cand of candidates) {
const layout = evaluateImpositionLayout({
sheetW: cand.sheetWidthMm,
sheetH: cand.sheetHeightMm,
itemW: widthMm,
itemH: heightMm,
bleedMm,
gripperMarginMm: cand.gripperMarginMm,
isDigital: printTech === 'digital',
});
if (printTech === 'digital') {
if (digitalMode === 'with_paper') {
// In nhanh kèm giấy: Chọn khổ phôi có số con (ups) trên tờ in lớn nhất
// Nếu số con bằng nhau, ưu tiên 325x430 mm (hoặc 325x355 mm nếu là Fort F180/F230 vì rẻ hơn)
if (!bestCandidateResult) {
bestCandidateResult = { candidate: cand, ...layout };
} else {
if (layout.ups > bestCandidateResult.ups) {
bestCandidateResult = { candidate: cand, ...layout };
} else if (layout.ups === bestCandidateResult.ups && layout.ups > 0) {
const isFortSpecial = paperType.code.toUpperCase().includes('F180') || paperType.code.toUpperCase().includes('F230');
const isCand355 = cand.name.includes('355');
if (isFortSpecial && isCand355) {
bestCandidateResult = { candidate: cand, ...layout };
}
}
}
} else {
// In gia công Konica C12000:
// Chọn khổ máy in có layout.ups > 0 và chi phí ước tính trên mỗi con thành phẩm là thấp nhất
// (Tránh trường hợp in tờ rơi A4 mà lại chọn nhầm khổ banner 1.2 mét)
const approxClick = getApproxKonicaClick(cand.sheetWidthMm, cand.sheetHeightMm);
const costPerPiece = layout.ups > 0 ? approxClick / layout.ups : Infinity;
if (!bestCandidateResult) {
bestCandidateResult = { candidate: cand, ...layout };
} else {
const prevClick = getApproxKonicaClick(bestCandidateResult.candidate.sheetWidthMm, bestCandidateResult.candidate.sheetHeightMm);
const prevCostPerPiece = bestCandidateResult.ups > 0 ? prevClick / bestCandidateResult.ups : Infinity;
if (layout.ups > 0 && costPerPiece < prevCostPerPiece) {
bestCandidateResult = { candidate: cand, ...layout };
}
}
}
} else {
// In Offset: Ưu tiên tổng số con sinh ra từ 1 tờ giấy mẹ
const totalUps = layout.ups * cand.cutsFromParent;
if (!bestCandidateResult) {
bestCandidateResult = { candidate: cand, ...layout };
} else {
const currentBestTotal = bestCandidateResult.ups * bestCandidateResult.candidate.cutsFromParent;
if (totalUps > currentBestTotal) {
bestCandidateResult = { candidate: cand, ...layout };
} else if (totalUps === currentBestTotal) {
if (cand.cutsFromParent > bestCandidateResult.candidate.cutsFromParent) {
bestCandidateResult = { candidate: cand, ...layout };
}
}
}
}
}
// Fallback nếu không có con nào vừa
if (!bestCandidateResult || bestCandidateResult.ups === 0) {
const fallbackCand = candidates[0];
bestCandidateResult = {
candidate: fallbackCand,
ups: 1,
cols: 1,
rows: 1,
isRotated: false,
boxes: [
{
index: 1,
x: 10,
y: 10,
w: widthMm + bleedMm * 2,
h: heightMm + bleedMm * 2,
trimW: widthMm,
trimH: heightMm,
bleedMm,
isRotated: false,
},
],
efficiency: 10,
usableW: fallbackCand.sheetWidthMm,
usableH: fallbackCand.sheetHeightMm,
};
}
const { candidate, ups, cols, rows, isRotated, boxes, efficiency, usableW, usableH } = bestCandidateResult;
const isWithPaper = printTech === 'digital' && digitalMode === 'with_paper';
return {
parentSheet: {
widthCm: isWithPaper ? candidate.sheetWidthMm / 10 : paperType.parentWidthCm,
heightCm: isWithPaper ? candidate.sheetHeightMm / 10 : paperType.parentHeightCm,
name: isWithPaper
? `Phôi giấy INTC (${candidate.sheetWidthMm / 10}x${candidate.sheetHeightMm / 10}cm)`
: `${paperType.name} (${paperType.parentWidthCm}x${paperType.parentHeightCm}cm)`,
},
printSheet: {
widthMm: candidate.sheetWidthMm,
heightMm: candidate.sheetHeightMm,
name: candidate.name,
},
upsPerPrintSheet: ups,
cutsPerParentSheet: isWithPaper ? 1 : candidate.cutsFromParent,
totalUpsPerParentSheet: isWithPaper ? ups : ups * candidate.cutsFromParent,
gripperMarginMm: candidate.gripperMarginMm,
usableWidthMm: usableW,
usableHeightMm: usableH,
cols,
rows,
isRotated,
boxes,
sheetEfficiencyPercent: Math.round(efficiency * 10) / 10,
cutScheme: candidate.cutScheme,
};
}
function getApproxKonicaClick(w: number, h: number): number {
const maxDim = Math.max(w, h);
if (maxDim <= 300) return 800; // A4
if (maxDim <= 360) return 1000; // 330x355
if (maxDim <= 425) return 1600; // A3
if (maxDim <= 500) return 2500; // 330x483 (A3+)
return 7000; // 330x1200 Banner
}
// -----------------------------------------------------------------------------------------
// THUẬT TOÁN XẾP BÌNH BÀI CHUẨN XÉNG CÔNG NGHIỆP TRÊN TỜ IN MÁY
// -----------------------------------------------------------------------------------------
interface LayoutEvalParams {
sheetW: number;
sheetH: number;
itemW: number;
itemH: number;
bleedMm: number;
gripperMarginMm: number;
isDigital?: boolean;
}
interface LayoutResult {
ups: number;
cols: number;
rows: number;
isRotated: boolean;
boxes: BoxCoordinate[];
efficiency: number;
usableW: number;
usableH: number;
}
function evaluateImpositionLayout({
sheetW,
sheetH,
itemW,
itemH,
bleedMm,
gripperMarginMm,
isDigital = false,
}: LayoutEvalParams): LayoutResult {
// Quy ước máy in:
// Cạnh dài: width = max(sheetW, sheetH), Cạnh ngắn: height = min(sheetW, sheetH)
const W = Math.max(sheetW, sheetH);
const H = Math.min(sheetW, sheetH);
let marginSide = 4;
let marginTail = 4;
let marginGripper = gripperMarginMm;
if (isDigital) {
// Máy in kỹ thuật số: Không có càng kẹp nhíp cơ học 10mm như offset.
// Lề trắng máy in (non-printable margin) chỉ 2.5mm xung quanh 4 mép.
marginSide = 2.5;
marginTail = 2.5;
marginGripper = 2.5;
}
const usableW = W - marginSide * 2;
const usableH = H - (marginGripper + marginTail);
if (usableW < itemW && usableW < itemH) {
return { ups: 0, cols: 0, rows: 0, isRotated: false, boxes: [], efficiency: 0, usableW, usableH };
}
// --- Cách 1: Xếp Thẳng (Không xoay) ---
let cols1 = 0;
let rows1 = 0;
if (isDigital) {
// In nhanh: Các con xếp sát nhau (cắt 1 nhát chung dao). Tràn lề ăn vào biên giấy ngoài cùng.
cols1 = Math.max(0, Math.floor(usableW / itemW));
rows1 = Math.max(0, Math.floor(usableH / itemH));
} else {
// In offset: Chừa lề kẹp nhíp và đường tràn lề
cols1 = Math.max(0, Math.floor((usableW - 2 * bleedMm) / itemW));
rows1 = Math.max(0, Math.floor((usableH - 2 * bleedMm) / itemH));
}
const ups1 = cols1 * rows1;
// --- Cách 2: Xếp Xoay 90 độ ---
let cols2 = 0;
let rows2 = 0;
if (isDigital) {
cols2 = Math.max(0, Math.floor(usableW / itemH));
rows2 = Math.max(0, Math.floor(usableH / itemW));
} else {
cols2 = Math.max(0, Math.floor((usableW - 2 * bleedMm) / itemH));
rows2 = Math.max(0, Math.floor((usableH - 2 * bleedMm) / itemW));
}
const ups2 = cols2 * rows2;
// Chọn phương án tốt nhất giữa Xếp Thẳng (ups1) và Xếp Xoay 90° (ups2)
let bestUps = ups1;
let bestCols = cols1;
let bestRows = rows1;
let isRotated = false;
if (ups2 > bestUps) {
bestUps = ups2;
bestCols = cols2;
bestRows = rows2;
isRotated = true;
}
// Tạo danh sách tọa độ các hộp sản phẩm
const boxes: BoxCoordinate[] = [];
const boxW = isRotated ? itemH : itemW;
const boxH = isRotated ? itemW : itemH;
// Canh giữa cụm con trong tờ in
const blockW = bestCols * boxW;
const blockH = bestRows * boxH;
const startX = isDigital
? Math.max(0, Math.round(((W - blockW) / 2) * 10) / 10)
: marginSide + Math.max(0, Math.floor((usableW - (blockW + 2 * bleedMm)) / 2)) + bleedMm;
const startY = isDigital
? Math.max(0, Math.round(((H - blockH) / 2) * 10) / 10)
: marginTail + Math.max(0, Math.floor((usableH - (blockH + 2 * bleedMm)) / 2)) + bleedMm;
let idx = 1;
for (let r = 0; r < bestRows; r++) {
for (let c = 0; c < bestCols; c++) {
boxes.push({
index: idx++,
x: Math.round((startX + c * boxW) * 10) / 10,
y: Math.round((startY + r * boxH) * 10) / 10,
w: boxW,
h: boxH,
trimW: itemW,
trimH: itemH,
bleedMm,
isRotated,
});
}
}
const totalItemArea = itemW * itemH * bestUps;
const sheetArea = W * H;
const efficiency = sheetArea > 0 ? (totalItemArea / sheetArea) * 100 : 0;
return {
ups: bestUps,
cols: bestCols,
rows: bestRows,
isRotated,
boxes,
efficiency,
usableW,
usableH,
};
}
// -----------------------------------------------------------------------------------------
// SINH SƠ ĐỒ CẮT GIẤY MẸ TRỰC QUAN (PAPER CUTTING SCHEME)
// -----------------------------------------------------------------------------------------
export function determineCutScheme(
parentW: number,
parentH: number,
printW: number,
printH: number,
cuts: number
): PaperCutScheme {
const pw = Math.max(parentW, parentH);
const ph = Math.min(parentW, parentH);
if (cuts <= 1) return buildFullCutScheme(pw, ph);
if (cuts === 2) return buildCut2Scheme(pw, ph);
if (cuts === 4) return buildCut4Scheme(pw, ph);
if (cuts === 8) return buildCut8Scheme(pw, ph);
return buildSimpleCutScheme(pw, ph, printW, printH, cuts);
}
function buildFullCutScheme(parentW: number, parentH: number): PaperCutScheme {
const pw = Math.max(parentW, parentH);
const ph = Math.min(parentW, parentH);
return {
parentWidthMm: pw,
parentHeightMm: ph,
cutType: 'chia_1',
cutDescription: `In nguyên khổ ${pw} x ${ph} mm (không cần xén trước khi in)`,
cutsCount: 1,
cutSheetWidthMm: pw,
cutSheetHeightMm: ph,
wasteAreaPercent: 0,
cutLines: [],
blocks: [
{
index: 1,
x: 0,
y: 0,
w: pw,
h: ph,
name: `Tờ in #1 (${pw} x ${ph} mm)`,
},
],
};
}
function buildCut2Scheme(parentW: number, parentH: number): PaperCutScheme {
const pw = Math.max(parentW, parentH); // ví dụ 860mm
const ph = Math.min(parentW, parentH); // ví dụ 650mm
const cutW = Math.floor(pw / 2); // 430mm
return {
parentWidthMm: pw,
parentHeightMm: ph,
cutType: 'chia_2',
cutDescription: `Xén đôi chiều ${pw}mm thành 2 tờ ${ph} x ${cutW} mm (Chia 2)`,
cutsCount: 2,
cutSheetWidthMm: ph,
cutSheetHeightMm: cutW,
wasteAreaPercent: 0,
cutLines: [
{
x1: cutW,
y1: 0,
x2: cutW,
y2: ph,
type: 'vertical',
label: `Xén dọc: ${cutW}mm`,
},
],
blocks: [
{
index: 1,
x: 0,
y: 0,
w: cutW,
h: ph,
name: `Tờ in #1 (${ph} x ${cutW} mm)`,
},
{
index: 2,
x: cutW,
y: 0,
w: pw - cutW,
h: ph,
name: `Tờ in #2 (${ph} x ${cutW} mm)`,
},
],
};
}
function buildCut4Scheme(parentW: number, parentH: number): PaperCutScheme {
const pw = Math.max(parentW, parentH); // ví dụ 860 hoặc 1090
const ph = Math.min(parentW, parentH); // ví dụ 650 hoặc 790
const halfW = Math.floor(pw / 2); // 430
const halfH = Math.floor(ph / 2); // 325
return {
parentWidthMm: pw,
parentHeightMm: ph,
cutType: 'chia_4',
cutDescription: `Xén chữ thập (Chia 4) thành 4 tờ ${halfW} x ${halfH} mm`,
cutsCount: 4,
cutSheetWidthMm: halfW,
cutSheetHeightMm: halfH,
wasteAreaPercent: 0,
cutLines: [
{
x1: halfW,
y1: 0,
x2: halfW,
y2: ph,
type: 'vertical',
label: `Xén dọc: ${halfW}mm`,
},
{
x1: 0,
y1: halfH,
x2: pw,
y2: halfH,
type: 'horizontal',
label: `Xén ngang: ${halfH}mm`,
},
],
blocks: [
{ index: 1, x: 0, y: 0, w: halfW, h: halfH, name: `Tờ in #1 (${halfW} x ${halfH} mm)` },
{ index: 2, x: halfW, y: 0, w: pw - halfW, h: halfH, name: `Tờ in #2 (${halfW} x ${halfH} mm)` },
{ index: 3, x: 0, y: halfH, w: halfW, h: ph - halfH, name: `Tờ in #3 (${halfW} x ${halfH} mm)` },
{ index: 4, x: halfW, y: halfH, w: pw - halfW, h: ph - halfH, name: `Tờ in #4 (${halfW} x ${halfH} mm)` },
],
};
}
function buildCut8Scheme(parentW: number, parentH: number): PaperCutScheme {
const pw = Math.max(parentW, parentH); // ví dụ 860
const ph = Math.min(parentW, parentH); // ví dụ 650
const colW = Math.floor(pw / 4); // 215
const rowH = Math.floor(ph / 2); // 325
const blocks: PaperCutBlock[] = [];
const cutLines: PaperCutLine[] = [
{ x1: colW, y1: 0, x2: colW, y2: ph, type: 'vertical', label: `Xén: ${colW}mm` },
{ x1: colW * 2, y1: 0, x2: colW * 2, y2: ph, type: 'vertical', label: `Xén: ${colW * 2}mm` },
{ x1: colW * 3, y1: 0, x2: colW * 3, y2: ph, type: 'vertical', label: `Xén: ${colW * 3}mm` },
{ x1: 0, y1: rowH, x2: pw, y2: rowH, type: 'horizontal', label: `Xén ngang: ${rowH}mm` },
];
let idx = 1;
for (let r = 0; r < 2; r++) {
for (let c = 0; c < 4; c++) {
const curW = c === 3 ? pw - colW * 3 : colW;
const curH = r === 1 ? ph - rowH : rowH;
blocks.push({
index: idx++,
x: c * colW,
y: r * rowH,
w: curW,
h: curH,
name: `Tờ in #${idx - 1} (${rowH} x ${colW} mm)`,
});
}
}
return {
parentWidthMm: pw,
parentHeightMm: ph,
cutType: 'chia_8',
cutDescription: `Xén chia 8 thành 8 tờ ${rowH} x ${colW} mm`,
cutsCount: 8,
cutSheetWidthMm: rowH,
cutSheetHeightMm: colW,
wasteAreaPercent: 0,
cutLines,
blocks,
};
}
function buildSimpleCutScheme(
parentW: number,
parentH: number,
printW: number,
printH: number,
cuts: number
): PaperCutScheme {
const pw = Math.max(parentW, parentH);
const ph = Math.min(parentW, parentH);
// So sánh 2 chiều xếp trên tờ mẹ
const c1 = Math.floor(pw / printW);
const r1 = Math.floor(ph / printH);
const tot1 = c1 * r1;
const c2 = Math.floor(pw / printH);
const r2 = Math.floor(ph / printW);
const tot2 = c2 * r2;
const useRotated = tot2 > tot1;
const pieceW = useRotated ? printH : printW;
const pieceH = useRotated ? printW : printH;
const cols = useRotated ? c2 : c1;
const rows = useRotated ? r2 : r1;
const actualCols = Math.max(1, cols);
const actualRows = Math.max(1, rows);
const blocks: PaperCutBlock[] = [];
const cutLines: PaperCutLine[] = [];
let idx = 1;
for (let r = 0; r < actualRows; r++) {
for (let c = 0; c < actualCols; c++) {
if (idx <= cuts) {
blocks.push({
index: idx++,
x: c * pieceW,
y: r * pieceH,
w: pieceW,
h: pieceH,
name: `Tờ in #${idx - 1} (${pieceW} x ${pieceH} mm)`,
});
}
}
}
// Tạo cut lines
for (let c = 1; c < actualCols; c++) {
cutLines.push({
x1: c * pieceW,
y1: 0,
x2: c * pieceW,
y2: Math.min(ph, actualRows * pieceH),
type: 'vertical',
label: `Xén: ${c * pieceW}mm`,
});
}
for (let r = 1; r < actualRows; r++) {
cutLines.push({
x1: 0,
y1: r * pieceH,
x2: Math.min(pw, actualCols * pieceW),
y2: r * pieceH,
type: 'horizontal',
label: `Xén: ${r * pieceH}mm`,
});
}
const usedArea = cuts * pieceW * pieceH;
const totalArea = pw * ph;
const wastePercent = totalArea > 0 ? Math.max(0, Math.round(((totalArea - usedArea) / totalArea) * 100)) : 0;
return {
parentWidthMm: pw,
parentHeightMm: ph,
cutType: cuts === 2 ? 'chia_2' : cuts === 4 ? 'chia_4' : cuts === 8 ? 'chia_8' : 'custom',
cutDescription: `Cắt ${cuts} tờ in (${pieceW} x ${pieceH} mm) từ tờ giấy mẹ ${pw} x ${ph} mm`,
cutsCount: cuts,
cutSheetWidthMm: pieceW,
cutSheetHeightMm: pieceH,
wasteAreaPercent: wastePercent,
cutLines,
blocks,
};
}
function calculateCutsFromParent(parentW: number, parentH: number, printW: number, printH: number): number {
const cuts1 = Math.floor(parentW / printW) * Math.floor(parentH / printH);
const cuts2 = Math.floor(parentW / printH) * Math.floor(parentH / printW);
return Math.max(cuts1, cuts2, 1);
}