phucsd commited on
Commit
5aa5ca5
·
1 Parent(s): 9c296e4

fix(imposition): correct imposition calculation, dual-tab print and paper cutting diagrams

Browse files
src/app/page.tsx CHANGED
@@ -724,7 +724,7 @@ export default function HomePage() {
724
  }`}
725
  >
726
  <LayoutGrid className="w-3.5 h-3.5" />
727
- Sơ Đồ Cắt Giấy 2D
728
  </button>
729
 
730
  <button
 
724
  }`}
725
  >
726
  <LayoutGrid className="w-3.5 h-3.5" />
727
+ Sơ Đồ Bình Bài & Cắt Giấy
728
  </button>
729
 
730
  <button
src/components/ImpositionVisualizer.tsx CHANGED
@@ -1,8 +1,8 @@
1
  'use client';
2
 
3
- import React from 'react';
4
  import { ImpositionResult } from '@/types';
5
- import { Layers, Maximize2 } from 'lucide-react';
6
 
7
  interface ImpositionVisualizerProps {
8
  imposition: ImpositionResult;
@@ -16,92 +16,340 @@ export const ImpositionVisualizer: React.FC<ImpositionVisualizerProps> = ({
16
  productWidthMm,
17
  productHeightMm,
18
  }) => {
 
 
19
  const {
20
  printSheet,
21
  parentSheet,
22
  upsPerPrintSheet,
23
  cutsPerParentSheet,
 
24
  gripperMarginMm,
25
  cols,
26
  rows,
27
  boxes,
28
  sheetEfficiencyPercent,
29
  isRotated,
 
30
  } = imposition;
31
 
32
- const svgWidth = printSheet.widthMm;
33
- const svgHeight = printSheet.heightMm;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
  return (
36
  <div className="space-y-3">
37
- {/* Vùng vẽ đồ họa SVG hiển thị khổ in và các con */}
38
- <div className="relative bg-slate-900/95 rounded-xl p-4 flex items-center justify-center min-h-[220px] max-h-[260px] overflow-hidden border border-slate-800">
39
- <svg
40
- viewBox={`0 0 ${svgWidth} ${svgHeight}`}
41
- className="w-full max-h-[220px] drop-shadow-xl border border-slate-700 rounded bg-white transition-all duration-300"
42
- style={{ aspectRatio: `${svgWidth} / ${svgHeight}` }}
43
- >
44
- {/* Lề kẹp nhíp máy in (Gripper) */}
45
- <defs>
46
- <pattern id="gripperStripe" width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
47
- <line x1="0" y1="0" x2="0" y2="8" stroke="#fbbf24" strokeWidth="3" />
48
- <line x1="4" y1="0" x2="4" y2="8" stroke="#f59e0b" strokeWidth="3" />
49
- </pattern>
50
- </defs>
51
-
52
- {/* Nền tờ in */}
53
- <rect x="0" y="0" width={svgWidth} height={svgHeight} fill="#ffffff" />
54
-
55
- {/* Dải lề nhíp đáy */}
56
- <rect
57
- x="0"
58
- y={svgHeight - gripperMarginMm}
59
- width={svgWidth}
60
- height={gripperMarginMm}
61
- fill="url(#gripperStripe)"
62
- opacity="0.8"
63
- />
64
- <text
65
- x={svgWidth / 2}
66
- y={svgHeight - gripperMarginMm / 2 + 3}
67
- textAnchor="middle"
68
- fill="#92400e"
69
- fontSize={Math.max(9, Math.min(14, svgWidth / 35))}
70
- fontWeight="bold"
71
  >
72
- Lề kẹp nhíp ({gripperMarginMm}mm{printSheet.widthMm >= 800 ? ' • Bắt nhíp chiều 86' : ''})
73
- </text>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
- {/* Các con sản phẩm */}
76
- {boxes.map((b) => (
77
- <g key={b.index}>
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  <rect
79
- x={b.x}
80
- y={b.y}
81
- width={b.w}
82
- height={b.h}
83
- fill="#eff6ff"
84
- stroke="#3b82f6"
 
 
 
 
 
 
 
85
  strokeWidth="1"
86
- rx="1"
87
  />
88
  <text
89
- x={b.x + b.w / 2}
90
- y={b.y + b.h / 2 + 4}
91
  textAnchor="middle"
92
- fill="#1d4ed8"
93
- fontSize={Math.max(9, Math.min(13, b.w / 6))}
94
  fontWeight="bold"
95
  >
96
- #{b.index}
97
  </text>
98
- </g>
99
- ))}
100
- </svg>
101
- </div>
102
 
103
- {/* Thông số bình trang vắn tắt */}
104
- <div className="grid grid-cols-3 gap-2 text-center text-xs">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  <div className="bg-slate-50 border border-slate-200/80 rounded-lg py-2 px-1">
106
  <span className="text-[11px] text-slate-500 block">Số con / tờ in</span>
107
  <span className="text-sm font-bold text-blue-700">{upsPerPrintSheet} con</span>
@@ -109,15 +357,21 @@ export const ImpositionVisualizer: React.FC<ImpositionVisualizerProps> = ({
109
  </div>
110
 
111
  <div className="bg-slate-50 border border-slate-200/80 rounded-lg py-2 px-1">
112
- <span className="text-[11px] text-slate-500 block">Cắt từ tờ mẹ</span>
113
  <span className="text-sm font-bold text-indigo-700">Chia {cutsPerParentSheet}</span>
114
  <span className="text-[10px] text-slate-400 block">{parentSheet.widthCm}×{parentSheet.heightCm}cm</span>
115
  </div>
116
 
117
  <div className="bg-slate-50 border border-slate-200/80 rounded-lg py-2 px-1">
 
 
 
 
 
 
118
  <span className="text-[11px] text-slate-500 block">Tận dụng giấy</span>
119
  <span className="text-sm font-bold text-emerald-700">{sheetEfficiencyPercent}%</span>
120
- <span className="text-[10px] text-slate-400 block">{printSheet.widthMm}×{printSheet.heightMm}mm</span>
121
  </div>
122
  </div>
123
  </div>
 
1
  'use client';
2
 
3
+ import React, { useState } from 'react';
4
  import { ImpositionResult } from '@/types';
5
+ import { Layers, Scissors, Printer, CheckCircle2, RotateCw } from 'lucide-react';
6
 
7
  interface ImpositionVisualizerProps {
8
  imposition: ImpositionResult;
 
16
  productWidthMm,
17
  productHeightMm,
18
  }) => {
19
+ const [activeTab, setActiveTab] = useState<'imposition' | 'paperCut'>('imposition');
20
+
21
  const {
22
  printSheet,
23
  parentSheet,
24
  upsPerPrintSheet,
25
  cutsPerParentSheet,
26
+ totalUpsPerParentSheet,
27
  gripperMarginMm,
28
  cols,
29
  rows,
30
  boxes,
31
  sheetEfficiencyPercent,
32
  isRotated,
33
+ cutScheme,
34
  } = imposition;
35
 
36
+ // Kích thước SVG tờ in máy
37
+ const printW = printSheet.widthMm;
38
+ const printH = printSheet.heightMm;
39
+
40
+ // Kích thước SVG tờ giấy mẹ
41
+ const parentW = cutScheme?.parentWidthMm || parentSheet.widthCm * 10;
42
+ const parentH = cutScheme?.parentHeightMm || parentSheet.heightCm * 10;
43
+
44
+ // Bảng màu cho các tờ in khi xén từ tờ mẹ
45
+ const blockColors = [
46
+ { bg: '#eff6ff', stroke: '#3b82f6', text: '#1d4ed8' }, // blue
47
+ { bg: '#f0fdf4', stroke: '#22c55e', text: '#15803d' }, // green
48
+ { bg: '#fefce8', stroke: '#eab308', text: '#a16207' }, // yellow
49
+ { bg: '#faf5ff', stroke: '#a855f7', text: '#7e22ce' }, // purple
50
+ { bg: '#fff7ed', stroke: '#f97316', text: '#c2410c' }, // orange
51
+ { bg: '#f0fdfa', stroke: '#14b8a6', text: '#0f766e' }, // teal
52
+ { bg: '#fff1f2', stroke: '#f43f5e', text: '#be123c' }, // rose
53
+ { bg: '#eef2ff', stroke: '#6366f1', text: '#4338ca' }, // indigo
54
+ ];
55
 
56
  return (
57
  <div className="space-y-3">
58
+ {/* Sub-tab chuyển đổi giữa Sơ đồ bình bài và Sơ đồ cắt giấy mẹ */}
59
+ <div className="flex items-center justify-between gap-2 border-b border-slate-200 pb-2">
60
+ <div className="flex items-center gap-1.5 bg-slate-100 p-1 rounded-lg text-xs font-medium">
61
+ <button
62
+ type="button"
63
+ onClick={() => setActiveTab('imposition')}
64
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md transition-all ${
65
+ activeTab === 'imposition'
66
+ ? 'bg-white text-blue-700 font-semibold shadow-2xs'
67
+ : 'text-slate-600 hover:text-slate-900'
68
+ }`}
69
+ >
70
+ <Printer className="w-3.5 h-3.5" />
71
+ Sơ Đồ Bình Bài (Tờ In Máy)
72
+ </button>
73
+
74
+ <button
75
+ type="button"
76
+ onClick={() => setActiveTab('paperCut')}
77
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md transition-all ${
78
+ activeTab === 'paperCut'
79
+ ? 'bg-white text-amber-700 font-semibold shadow-2xs'
80
+ : 'text-slate-600 hover:text-slate-900'
81
+ }`}
 
 
 
 
 
 
 
 
 
 
82
  >
83
+ <Scissors className="w-3.5 h-3.5" />
84
+ Sơ Đồ Cắt Giấy Mẹ ({cutsPerParentSheet > 1 ? `Chia ${cutsPerParentSheet}` : 'Nguyên Khổ'})
85
+ </button>
86
+ </div>
87
+
88
+ <span className="text-[11px] font-medium text-slate-500 hidden sm:inline-block">
89
+ {activeTab === 'imposition'
90
+ ? `Khổ in: ${printW}×${printH}mm`
91
+ : `Khổ mẹ: ${parentW}×${parentH}mm`}
92
+ </span>
93
+ </div>
94
+
95
+ {/* VÙNG 1: SƠ ĐỒ BÌNH BÀI TRÊN TỜ IN MÁY */}
96
+ {activeTab === 'imposition' && (
97
+ <div className="space-y-2">
98
+ <div className="relative bg-slate-900/95 rounded-xl p-3 flex flex-col items-center justify-center min-h-[220px] max-h-[270px] overflow-hidden border border-slate-800 shadow-inner">
99
+ <svg
100
+ viewBox={`0 0 ${printW} ${printH}`}
101
+ className="w-full max-h-[230px] drop-shadow-xl border border-slate-700 rounded bg-white transition-all duration-300 select-none"
102
+ style={{ aspectRatio: `${printW} / ${printH}` }}
103
+ >
104
+ <defs>
105
+ {/* Hoa văn sọc vàng cho lề kẹp nhíp */}
106
+ <pattern
107
+ id="gripperStripe"
108
+ width="10"
109
+ height="10"
110
+ patternUnits="userSpaceOnUse"
111
+ patternTransform="rotate(45)"
112
+ >
113
+ <line x1="0" y1="0" x2="0" y2="10" stroke="#fde047" strokeWidth="3" />
114
+ <line x1="5" y1="0" x2="5" y2="10" stroke="#eab308" strokeWidth="3" />
115
+ </pattern>
116
+ </defs>
117
 
118
+ {/* Tờ in nền trắng */}
119
+ <rect x="0" y="0" width={printW} height={printH} fill="#ffffff" />
120
+
121
+ {/* Đường biên vùng in hữu dụng (vùng an toàn) */}
122
+ <rect
123
+ x="4"
124
+ y="4"
125
+ width={printW - 8}
126
+ height={printH - (gripperMarginMm + 4)}
127
+ fill="none"
128
+ stroke="#cbd5e1"
129
+ strokeWidth="0.8"
130
+ strokeDasharray="4 3"
131
+ />
132
+
133
+ {/* Dải lề kẹp nhíp (Gripper margin) ở cạnh đáy */}
134
  <rect
135
+ x="0"
136
+ y={printH - gripperMarginMm}
137
+ width={printW}
138
+ height={gripperMarginMm}
139
+ fill="url(#gripperStripe)"
140
+ opacity="0.9"
141
+ />
142
+ <line
143
+ x1="0"
144
+ y1={printH - gripperMarginMm}
145
+ x2={printW}
146
+ y2={printH - gripperMarginMm}
147
+ stroke="#ca8a04"
148
  strokeWidth="1"
 
149
  />
150
  <text
151
+ x={printW / 2}
152
+ y={printH - gripperMarginMm / 2 + 3}
153
  textAnchor="middle"
154
+ fill="#854d0e"
155
+ fontSize={Math.max(9, Math.min(13, printW / 38))}
156
  fontWeight="bold"
157
  >
158
+ ▼ Lề kẹp nhíp: {gripperMarginMm}mm ({printW >= 800 ? 'Bắt nhíp chiều 86cm' : `Bắt nhíp chiều ${Math.round(printW / 10)}cm`})
159
  </text>
 
 
 
 
160
 
161
+ {/* Danh sách các con sản phẩm (Ups) */}
162
+ {boxes.map((b) => {
163
+ const isVerySmall = b.w < 50 || b.h < 35;
164
+ const fontSize = Math.max(9, Math.min(13, b.w / 5));
165
+
166
+ return (
167
+ <g key={b.index}>
168
+ {/* Ô sản phẩm */}
169
+ <rect
170
+ x={b.x}
171
+ y={b.y}
172
+ width={b.w}
173
+ height={b.h}
174
+ fill="#eff6ff"
175
+ stroke="#2563eb"
176
+ strokeWidth="0.9"
177
+ />
178
+
179
+ {/* Dấu xén (Crop marks) nhỏ ở góc nếu kích thước vừa */}
180
+ {!isVerySmall && (
181
+ <>
182
+ <line x1={b.x} y1={b.y + 4} x2={b.x} y2={b.y} stroke="#93c5fd" strokeWidth="0.8" />
183
+ <line x1={b.x} y1={b.y} x2={b.x + 4} y2={b.y} stroke="#93c5fd" strokeWidth="0.8" />
184
+ <line x1={b.x + b.w - 4} y1={b.y} x2={b.x + b.w} y2={b.y} stroke="#93c5fd" strokeWidth="0.8" />
185
+ <line x1={b.x + b.w} y1={b.y} x2={b.x + b.w} y2={b.y + 4} stroke="#93c5fd" strokeWidth="0.8" />
186
+ </>
187
+ )}
188
+
189
+ {/* Số thứ tự con */}
190
+ <text
191
+ x={b.x + b.w / 2}
192
+ y={isVerySmall ? b.y + b.h / 2 + 3 : b.y + b.h / 2 - 2}
193
+ textAnchor="middle"
194
+ fill="#1d4ed8"
195
+ fontSize={fontSize}
196
+ fontWeight="bold"
197
+ >
198
+ #{b.index}
199
+ </text>
200
+
201
+ {/* Kích thước con (nếu con đủ lớn) */}
202
+ {!isVerySmall && (
203
+ <text
204
+ x={b.x + b.w / 2}
205
+ y={b.y + b.h / 2 + fontSize * 0.9}
206
+ textAnchor="middle"
207
+ fill="#60a5fa"
208
+ fontSize={Math.max(7, fontSize * 0.75)}
209
+ >
210
+ {Math.round(b.w)}×{Math.round(b.h)}mm
211
+ </text>
212
+ )}
213
+ </g>
214
+ );
215
+ })}
216
+ </svg>
217
+ </div>
218
+
219
+ <div className="flex items-center justify-between text-[11px] text-slate-500 px-1">
220
+ <span className="flex items-center gap-1">
221
+ <span className="w-2.5 h-2.5 bg-blue-500 rounded-2xs inline-block" />
222
+ Sản phẩm thành phẩm ({productWidthMm}×{productHeightMm}mm)
223
+ {isRotated && (
224
+ <span className="text-amber-600 font-semibold inline-flex items-center gap-0.5 ml-1">
225
+ <RotateCw className="w-3 h-3" /> Xoay 90°
226
+ </span>
227
+ )}
228
+ </span>
229
+ <span className="flex items-center gap-1">
230
+ <span className="w-2.5 h-2.5 bg-amber-400 rounded-2xs inline-block" />
231
+ Lề kẹp nhíp ({gripperMarginMm}mm)
232
+ </span>
233
+ </div>
234
+ </div>
235
+ )}
236
+
237
+ {/* VÙNG 2: SƠ ĐỒ CẮT XẢ GIẤY MẸ */}
238
+ {activeTab === 'paperCut' && (
239
+ <div className="space-y-2">
240
+ <div className="relative bg-slate-900/95 rounded-xl p-3 flex flex-col items-center justify-center min-h-[220px] max-h-[270px] overflow-hidden border border-slate-800 shadow-inner">
241
+ <svg
242
+ viewBox={`0 0 ${parentW} ${parentH}`}
243
+ className="w-full max-h-[230px] drop-shadow-xl border border-slate-700 rounded bg-white transition-all duration-300 select-none"
244
+ style={{ aspectRatio: `${parentW} / ${parentH}` }}
245
+ >
246
+ {/* Tờ giấy mẹ nền ngoài */}
247
+ <rect x="0" y="0" width={parentW} height={parentH} fill="#f1f5f9" stroke="#94a3b8" strokeWidth="1" />
248
+
249
+ {/* Các block tờ in con sau khi xén */}
250
+ {(cutScheme?.blocks || []).map((block, idx) => {
251
+ const color = blockColors[idx % blockColors.length];
252
+ const fontSize = Math.max(12, Math.min(22, block.w / 18));
253
+
254
+ return (
255
+ <g key={block.index}>
256
+ <rect
257
+ x={block.x}
258
+ y={block.y}
259
+ width={block.w}
260
+ height={block.h}
261
+ fill={color.bg}
262
+ stroke={color.stroke}
263
+ strokeWidth="1.2"
264
+ />
265
+
266
+ {/* Tiêu đề tờ in con */}
267
+ <text
268
+ x={block.x + block.w / 2}
269
+ y={block.y + block.h / 2 - fontSize * 0.4}
270
+ textAnchor="middle"
271
+ fill={color.text}
272
+ fontSize={fontSize}
273
+ fontWeight="bold"
274
+ >
275
+ {block.name.split('(')[0].trim() || `Tờ in #${block.index}`}
276
+ </text>
277
+
278
+ {/* Kích thước khổ xén */}
279
+ <text
280
+ x={block.x + block.w / 2}
281
+ y={block.y + block.h / 2 + fontSize * 0.8}
282
+ textAnchor="middle"
283
+ fill="#475569"
284
+ fontSize={Math.max(10, fontSize * 0.75)}
285
+ fontWeight="medium"
286
+ >
287
+ {Math.round(block.w)} × {Math.round(block.h)} mm ({upsPerPrintSheet} con)
288
+ </text>
289
+ </g>
290
+ );
291
+ })}
292
+
293
+ {/* Các đường xén giấy (Cắt dao xén) màu đỏ nét đứt */}
294
+ {(cutScheme?.cutLines || []).map((line, idx) => {
295
+ const midX = (line.x1 + line.x2) / 2;
296
+ const midY = (line.y1 + line.y2) / 2;
297
+
298
+ return (
299
+ <g key={idx}>
300
+ <line
301
+ x1={line.x1}
302
+ y1={line.y1}
303
+ x2={line.x2}
304
+ y2={line.y2}
305
+ stroke="#dc2626"
306
+ strokeWidth="2"
307
+ strokeDasharray="6 4"
308
+ />
309
+
310
+ {/* Biểu tượng cây kéo & nhãn đường xén */}
311
+ <g transform={`translate(${midX}, ${midY})`}>
312
+ <rect
313
+ x="-45"
314
+ y="-10"
315
+ width="90"
316
+ height="20"
317
+ rx="4"
318
+ fill="#fee2e2"
319
+ stroke="#ef4444"
320
+ strokeWidth="1"
321
+ />
322
+ <text
323
+ x="0"
324
+ y="4"
325
+ textAnchor="middle"
326
+ fill="#991b1b"
327
+ fontSize="10"
328
+ fontWeight="bold"
329
+ >
330
+ ✂ {line.label || 'Đường xén'}
331
+ </text>
332
+ </g>
333
+ </g>
334
+ );
335
+ })}
336
+ </svg>
337
+ </div>
338
+
339
+ <div className="bg-amber-50/80 border border-amber-200/80 rounded-lg p-2 flex items-center justify-between text-xs text-amber-900">
340
+ <span className="flex items-center gap-1.5 font-medium">
341
+ <Scissors className="w-3.5 h-3.5 text-amber-600 shrink-0" />
342
+ {cutScheme?.cutDescription || `Cắt xả từ tờ mẹ ${parentSheet.widthCm}×${parentSheet.heightCm}cm`}
343
+ </span>
344
+ <span className="font-semibold text-amber-800 shrink-0">
345
+ 1 tờ mẹ = {cutsPerParentSheet} tờ in ({totalUpsPerParentSheet} con)
346
+ </span>
347
+ </div>
348
+ </div>
349
+ )}
350
+
351
+ {/* THÔNG SỐ BÌNH TRANG VẮN TẮT */}
352
+ <div className="grid grid-cols-3 sm:grid-cols-4 gap-2 text-center text-xs">
353
  <div className="bg-slate-50 border border-slate-200/80 rounded-lg py-2 px-1">
354
  <span className="text-[11px] text-slate-500 block">Số con / tờ in</span>
355
  <span className="text-sm font-bold text-blue-700">{upsPerPrintSheet} con</span>
 
357
  </div>
358
 
359
  <div className="bg-slate-50 border border-slate-200/80 rounded-lg py-2 px-1">
360
+ <span className="text-[11px] text-slate-500 block">Cắt xả tờ mẹ</span>
361
  <span className="text-sm font-bold text-indigo-700">Chia {cutsPerParentSheet}</span>
362
  <span className="text-[10px] text-slate-400 block">{parentSheet.widthCm}×{parentSheet.heightCm}cm</span>
363
  </div>
364
 
365
  <div className="bg-slate-50 border border-slate-200/80 rounded-lg py-2 px-1">
366
+ <span className="text-[11px] text-slate-500 block">Tổng con / tờ mẹ</span>
367
+ <span className="text-sm font-bold text-violet-700">{totalUpsPerParentSheet} con</span>
368
+ <span className="text-[10px] text-slate-400 block">{upsPerPrintSheet} × {cutsPerParentSheet}</span>
369
+ </div>
370
+
371
+ <div className="bg-slate-50 border border-slate-200/80 rounded-lg py-2 px-1 col-span-3 sm:col-span-1">
372
  <span className="text-[11px] text-slate-500 block">Tận dụng giấy</span>
373
  <span className="text-sm font-bold text-emerald-700">{sheetEfficiencyPercent}%</span>
374
+ <span className="text-[10px] text-slate-400 block">Khổ {printW}×{printH}mm</span>
375
  </div>
376
  </div>
377
  </div>
src/lib/engine/imposition.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { BoxCoordinate, ImpositionResult, PaperType, PrintTech } from '@/types';
2
 
3
  interface ImpositionOptions {
4
  widthMm: number;
@@ -10,6 +10,15 @@ interface ImpositionOptions {
10
  gripperMarginMm?: number;
11
  }
12
 
 
 
 
 
 
 
 
 
 
13
  export function calculateImposition({
14
  widthMm,
15
  heightMm,
@@ -19,98 +28,103 @@ export function calculateImposition({
19
  preferredMachineSheet,
20
  gripperMarginMm = 10,
21
  }: ImpositionOptions): ImpositionResult {
22
- // Kích thước có chừa xén lề (Bleed)
23
- const itemW = widthMm + bleedMm * 2;
24
- const itemH = heightMm + bleedMm * 2;
25
-
26
- const parentW_mm = paperType.parentWidthCm * 10;
27
- const parentH_mm = paperType.parentHeightCm * 10;
28
 
29
- // Xác định các khổ in có thể cắt từ tờ giấy mẹ
30
- let candidatePrintSheets: {
31
- name: string;
32
- widthMm: number;
33
- heightMm: number;
34
- cutsFromParent: number;
35
- }[] = [];
36
 
37
  if (preferredMachineSheet) {
38
- // Máy in cụ thể được chọn
39
- const cuts = calculateCutsFromParent(parentW_mm, parentH_mm, preferredMachineSheet.widthMm, preferredMachineSheet.heightMm);
40
- candidatePrintSheets.push({
 
41
  name: preferredMachineSheet.name,
42
- widthMm: preferredMachineSheet.widthMm,
43
- heightMm: preferredMachineSheet.heightMm,
44
  cutsFromParent: cuts,
 
 
45
  });
46
  } else if (printTech === 'digital') {
47
- // In nhanh kỹ thuật số thường in khổ A3+ (330x488mm) hoặc A3 (320x440mm)
48
- candidatePrintSheets = [
49
- { name: 'Khổ A3+ (330 x 488 mm)', widthMm: 330, heightMm: 488, cutsFromParent: 4 },
50
- { name: 'Khổ A3 chuẩn (297 x 420 mm)', widthMm: 297, heightMm: 420, cutsFromParent: 4 },
51
- { name: 'Khổ A4 chuẩn (210 x 297 mm)', widthMm: 210, heightMm: 297, cutsFromParent: 8 },
52
  ];
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  } else {
54
- // In Offset: tính các phương án chia khổ giấy mẹ (chia 1, chia 2, chia 4, chia 8)
55
- // Trường hợp 0: Khổ nguyên 65x86cm (Bắt nhíp chiều 86) - cho ấn phẩm khổ lớn
56
- if (Math.max(parentW_mm, parentH_mm) <= 900) {
57
- const fullW = Math.max(parentW_mm, parentH_mm); // chiều 860
58
- const fullH = Math.min(parentW_mm, parentH_mm); // chiều 650
59
- candidatePrintSheets.push({
60
- name: `Khổ 65x86cm (Bắt nhíp chiều 86)`,
61
- widthMm: fullW,
62
- heightMm: fullH,
63
  cutsFromParent: 1,
 
 
64
  });
65
  }
66
 
67
- // Trường hợp 1: Chia 4 (vd 65x86 -> 4 tờ 325 x 430 mm; 79x109 -> 4 tờ 395 x 545 mm)
68
- const cut4_w = Math.floor(parentW_mm / 2);
69
- const cut4_h = Math.floor(parentH_mm / 2);
70
- candidatePrintSheets.push({
71
- name: `Khổ chia 4 (${cut4_w} x ${cut4_h} mm)`,
72
- widthMm: cut4_w,
73
- heightMm: cut4_h,
74
- cutsFromParent: 4,
 
 
 
 
 
75
  });
76
 
77
- // Trường hợp 2: Chia 2 (vd 65x86 -> 2 tờ 650 x 430 mm; 79x109 -> 2 tờ 545 x 790 mm)
78
- // Cắt ngang
79
- const cut2_1_w = parentW_mm;
80
- const cut2_1_h = Math.floor(parentH_mm / 2);
81
- candidatePrintSheets.push({
82
- name: `Khổ chia 2 (${cut2_1_w} x ${cut2_1_h} mm)`,
83
- widthMm: cut2_1_w,
84
- heightMm: cut2_1_h,
85
- cutsFromParent: 2,
 
86
  });
87
- // Cắt dọc
88
- const cut2_2_w = Math.floor(parentW_mm / 2);
89
- const cut2_2_h = parentH_mm;
90
- if (cut2_2_w !== cut2_1_w || cut2_2_h !== cut2_1_h) {
91
- candidatePrintSheets.push({
92
- name: `Khổ chia 2 dọc (${cut2_2_w} x ${cut2_2_h} mm)`,
93
- widthMm: cut2_2_w,
94
- heightMm: cut2_2_h,
95
- cutsFromParent: 2,
96
- });
97
- }
98
 
99
- // Trường hợp 3: Chia 8 (cho sp nhỏ namecard, thẻ tag)
100
- const cut8_w = Math.floor(parentW_mm / 2);
101
- const cut8_h = Math.floor(parentH_mm / 4);
102
- candidatePrintSheets.push({
103
- name: `Khổ chia 8 (${cut8_w} x ${cut8_h} mm)`,
104
- widthMm: cut8_w,
105
- heightMm: cut8_h,
106
  cutsFromParent: 8,
 
 
107
  });
108
  }
109
 
110
- // Đánh giá phương án nào cho tổng số con / tờ giấy mẹ cao nhất
111
- let bestResult: {
112
- printSheet: { widthMm: number; heightMm: number; name: string };
113
- cutsFromParent: number;
114
  ups: number;
115
  cols: number;
116
  rows: number;
@@ -121,50 +135,63 @@ export function calculateImposition({
121
  usableH: number;
122
  } | null = null;
123
 
124
- for (const candidate of candidatePrintSheets) {
125
- const evalResult = evaluateSheetImposition(
126
- candidate.widthMm,
127
- candidate.heightMm,
128
- itemW,
129
- itemH,
130
- gripperMarginMm
131
- );
132
-
133
- const totalUpsPerParent = evalResult.ups * candidate.cutsFromParent;
134
-
135
- if (!bestResult || totalUpsPerParent > (bestResult.ups * bestResult.cutsFromParent)) {
136
- bestResult = {
137
- printSheet: candidate,
138
- cutsFromParent: candidate.cutsFromParent,
139
- ups: evalResult.ups,
140
- cols: evalResult.cols,
141
- rows: evalResult.rows,
142
- isRotated: evalResult.isRotated,
143
- boxes: evalResult.boxes,
144
- efficiency: evalResult.efficiency,
145
- usableW: evalResult.usableW,
146
- usableH: evalResult.usableH,
147
- };
148
  }
149
  }
150
 
151
- // Fallback an toàn nếu chưa tìm được
152
- if (!bestResult || bestResult.ups === 0) {
153
- const fallbackSheet = candidatePrintSheets[0];
154
- bestResult = {
155
- printSheet: fallbackSheet,
156
- cutsFromParent: fallbackSheet.cutsFromParent,
157
  ups: 1,
158
  cols: 1,
159
  rows: 1,
160
  isRotated: false,
161
- boxes: [{ x: 10, y: gripperMarginMm + 5, w: itemW, h: itemH, index: 1 }],
 
 
 
 
 
 
 
 
 
 
 
 
162
  efficiency: 10,
163
- usableW: fallbackSheet.widthMm,
164
- usableH: fallbackSheet.heightMm,
165
  };
166
  }
167
 
 
 
168
  return {
169
  parentSheet: {
170
  widthCm: paperType.parentWidthCm,
@@ -172,61 +199,109 @@ export function calculateImposition({
172
  name: `${paperType.name} (${paperType.parentWidthCm}x${paperType.parentHeightCm}cm)`,
173
  },
174
  printSheet: {
175
- widthMm: bestResult.printSheet.widthMm,
176
- heightMm: bestResult.printSheet.heightMm,
177
- name: bestResult.printSheet.name,
178
  },
179
- upsPerPrintSheet: bestResult.ups,
180
- cutsPerParentSheet: bestResult.cutsFromParent,
181
- totalUpsPerParentSheet: bestResult.ups * bestResult.cutsFromParent,
182
- gripperMarginMm,
183
- usableWidthMm: bestResult.usableW,
184
- usableHeightMm: bestResult.usableH,
185
- cols: bestResult.cols,
186
- rows: bestResult.rows,
187
- isRotated: bestResult.isRotated,
188
- boxes: bestResult.boxes,
189
- sheetEfficiencyPercent: Math.round(bestResult.efficiency * 10) / 10,
 
190
  };
191
  }
192
 
193
- // Tính số con tối đa trên 1 khổ in máy kèm tọa độ hiển thị trực quan
194
- function evaluateSheetImposition(
195
- sheetW: number,
196
- sheetH: number,
197
- itemW: number,
198
- itemH: number,
199
- gripperMarginMm: number
200
- ) {
201
- // Lề máy in: 1 cạnh chừa nhíp (gripper) = 10mm, các cạnh còn lại chừa mép xén = 5mm
202
- const usableW = sheetW - 10; // lề trái 5mm, lề phải 5mm
203
- const usableH = sheetH - (gripperMarginMm + 5); // lề nhíp dưới 10mm, lề trên 5mm
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
 
205
  if (usableW < itemW && usableW < itemH) {
206
  return { ups: 0, cols: 0, rows: 0, isRotated: false, boxes: [], efficiency: 0, usableW, usableH };
207
  }
208
 
209
- // Cách 1: Xếp thẳng (Không xoay)
210
- const cols1 = Math.floor(usableW / itemW);
211
- const rows1 = Math.floor(usableH / itemH);
 
 
212
  const ups1 = cols1 * rows1;
213
 
214
- // Cách 2: Xếp xoay 90 độ
215
- const cols2 = Math.floor(usableW / itemH);
216
- const rows2 = Math.floor(usableH / itemW);
 
217
  const ups2 = cols2 * rows2;
218
 
219
- const isRotated = ups2 > ups1;
220
- const bestCols = isRotated ? cols2 : cols1;
221
- const bestRows = isRotated ? rows2 : rows1;
222
- const bestUps = isRotated ? ups2 : ups1;
 
 
 
 
 
 
 
 
 
 
 
223
  const boxW = isRotated ? itemH : itemW;
224
  const boxH = isRotated ? itemW : itemH;
225
 
226
- // Tính toán tọa độ chính xác của từng con (để vẽ lên SVG / Canvas)
227
- const boxes: BoxCoordinate[] = [];
228
- const startX = 5; // lề mép trái
229
- const startY = 5; // lề mép trên (chừa nhíp ở dưới đáy)
 
230
 
231
  let idx = 1;
232
  for (let r = 0; r < bestRows; r++) {
@@ -237,13 +312,17 @@ function evaluateSheetImposition(
237
  y: Math.round((startY + r * boxH) * 10) / 10,
238
  w: boxW,
239
  h: boxH,
 
 
 
 
240
  });
241
  }
242
  }
243
 
244
- const itemArea = itemW * itemH * bestUps;
245
- const sheetArea = sheetW * sheetH;
246
- const efficiency = sheetArea > 0 ? (itemArea / sheetArea) * 100 : 0;
247
 
248
  return {
249
  ups: bestUps,
@@ -257,9 +336,273 @@ function evaluateSheetImposition(
257
  };
258
  }
259
 
260
- // Tính số tờ in có thể cắt ra từ tờ giấy mẹ
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  function calculateCutsFromParent(parentW: number, parentH: number, printW: number, printH: number): number {
262
- // Thử 2 hướng cắt: thẳng và xoay
263
  const cuts1 = Math.floor(parentW / printW) * Math.floor(parentH / printH);
264
  const cuts2 = Math.floor(parentW / printH) * Math.floor(parentH / printW);
265
  return Math.max(cuts1, cuts2, 1);
 
1
+ import { BoxCoordinate, ImpositionResult, PaperCutBlock, PaperCutLine, PaperCutScheme, PaperType, PrintTech } from '@/types';
2
 
3
  interface ImpositionOptions {
4
  widthMm: number;
 
10
  gripperMarginMm?: number;
11
  }
12
 
13
+ interface EvaluationCandidate {
14
+ name: string;
15
+ sheetWidthMm: number;
16
+ sheetHeightMm: number;
17
+ cutsFromParent: number;
18
+ cutScheme: PaperCutScheme;
19
+ gripperMarginMm: number;
20
+ }
21
+
22
  export function calculateImposition({
23
  widthMm,
24
  heightMm,
 
28
  preferredMachineSheet,
29
  gripperMarginMm = 10,
30
  }: ImpositionOptions): ImpositionResult {
31
+ const parentW = paperType.parentWidthCm * 10;
32
+ const parentH = paperType.parentHeightCm * 10;
 
 
 
 
33
 
34
+ // 1. Sinh danh sách các phương án cắt xả giấy mẹ
35
+ const candidates: EvaluationCandidate[] = [];
 
 
 
 
 
36
 
37
  if (preferredMachineSheet) {
38
+ // Máy in cụ thể được chỉ định
39
+ const cuts = calculateCutsFromParent(parentW, parentH, preferredMachineSheet.widthMm, preferredMachineSheet.heightMm);
40
+ const cutScheme = determineCutScheme(parentW, parentH, preferredMachineSheet.widthMm, preferredMachineSheet.heightMm, cuts);
41
+ candidates.push({
42
  name: preferredMachineSheet.name,
43
+ sheetWidthMm: preferredMachineSheet.widthMm,
44
+ sheetHeightMm: preferredMachineSheet.heightMm,
45
  cutsFromParent: cuts,
46
+ cutScheme,
47
+ gripperMarginMm,
48
  });
49
  } else if (printTech === 'digital') {
50
+ // In nhanh KTS: các khổ in máy tiêu chuẩn A3+, A3, A4
51
+ const digitalCandidates = [
52
+ { name: 'Khổ A3+ (330 x 488 mm)', w: 488, h: 330, cuts: 4 },
53
+ { name: 'Khổ A3 chuẩn (297 x 420 mm)', w: 420, h: 297, cuts: 4 },
54
+ { name: 'Khổ A4 chuẩn (210 x 297 mm)', w: 297, h: 210, cuts: 8 },
55
  ];
56
+
57
+ for (const dc of digitalCandidates) {
58
+ const cuts = calculateCutsFromParent(parentW, parentH, dc.w, dc.h);
59
+ const cutScheme = determineCutScheme(parentW, parentH, dc.w, dc.h, cuts);
60
+ candidates.push({
61
+ name: dc.name,
62
+ sheetWidthMm: dc.w,
63
+ sheetHeightMm: dc.h,
64
+ cutsFromParent: cuts,
65
+ cutScheme,
66
+ gripperMarginMm: 5,
67
+ });
68
+ }
69
  } else {
70
+ // In Offset: Các phương án chia khổ giấy mẹ chuẩn công nghiệp
71
+ // Phương án 0: Nguyên khổ (Chia 1) - ví dụ 65x86 (860 x 650 mm)
72
+ if (Math.max(parentW, parentH) <= 920) {
73
+ const w1 = Math.max(parentW, parentH);
74
+ const h1 = Math.min(parentW, parentH);
75
+ candidates.push({
76
+ name: `Khổ nguyên ${Math.round(parentW/10)}x${Math.round(parentH/10)}cm (Bắt nhíp ${w1/10}cm)`,
77
+ sheetWidthMm: w1,
78
+ sheetHeightMm: h1,
79
  cutsFromParent: 1,
80
+ cutScheme: buildFullCutScheme(parentW, parentH),
81
+ gripperMarginMm,
82
  });
83
  }
84
 
85
+ // Phương án 1: Chia 2 (Cắt đôi)
86
+ // Cắt đôi chiều dài (thường tạo ra khổ 430 x 650 mm từ 65x86)
87
+ const longSide = Math.max(parentW, parentH);
88
+ const shortSide = Math.min(parentW, parentH);
89
+ const cut2_h = Math.floor(longSide / 2);
90
+ const cut2_w = shortSide;
91
+ candidates.push({
92
+ name: `Khổ chia 2 (${cut2_w} x ${cut2_h} mm)`,
93
+ sheetWidthMm: Math.max(cut2_w, cut2_h),
94
+ sheetHeightMm: Math.min(cut2_w, cut2_h),
95
+ cutsFromParent: 2,
96
+ cutScheme: buildCut2Scheme(parentW, parentH),
97
+ gripperMarginMm,
98
  });
99
 
100
+ // Phương án 2: Chia 4 (Cắt chữ thập)
101
+ const cut4_w = Math.floor(parentW / 2);
102
+ const cut4_h = Math.floor(parentH / 2);
103
+ candidates.push({
104
+ name: `Khổ chia 4 (${cut4_w} x ${cut4_h} mm)`,
105
+ sheetWidthMm: Math.max(cut4_w, cut4_h),
106
+ sheetHeightMm: Math.min(cut4_w, cut4_h),
107
+ cutsFromParent: 4,
108
+ cutScheme: buildCut4Scheme(parentW, parentH),
109
+ gripperMarginMm,
110
  });
 
 
 
 
 
 
 
 
 
 
 
111
 
112
+ // Phương án 3: Chia 8 (Cắt 8 - cho ấn phẩm nhỏ như namecard, tag...)
113
+ const cut8_1 = Math.floor(shortSide / 2);
114
+ const cut8_2 = Math.floor(longSide / 4);
115
+ candidates.push({
116
+ name: `Khổ chia 8 (${cut8_1} x ${cut8_2} mm)`,
117
+ sheetWidthMm: Math.max(cut8_1, cut8_2),
118
+ sheetHeightMm: Math.min(cut8_1, cut8_2),
119
  cutsFromParent: 8,
120
+ cutScheme: buildCut8Scheme(parentW, parentH),
121
+ gripperMarginMm,
122
  });
123
  }
124
 
125
+ // 2. Đánh giá tất cả phương án để tìm phương án cho ra tổng số con trên tờ mẹ cao nhất
126
+ let bestCandidateResult: {
127
+ candidate: EvaluationCandidate;
 
128
  ups: number;
129
  cols: number;
130
  rows: number;
 
135
  usableH: number;
136
  } | null = null;
137
 
138
+ for (const cand of candidates) {
139
+ const layout = evaluateImpositionLayout({
140
+ sheetW: cand.sheetWidthMm,
141
+ sheetH: cand.sheetHeightMm,
142
+ itemW: widthMm,
143
+ itemH: heightMm,
144
+ bleedMm,
145
+ gripperMarginMm: cand.gripperMarginMm,
146
+ });
147
+
148
+ const totalUps = layout.ups * cand.cutsFromParent;
149
+
150
+ if (!bestCandidateResult) {
151
+ bestCandidateResult = { candidate: cand, ...layout };
152
+ } else {
153
+ const currentBestTotal = bestCandidateResult.ups * bestCandidateResult.candidate.cutsFromParent;
154
+ if (totalUps > currentBestTotal) {
155
+ bestCandidateResult = { candidate: cand, ...layout };
156
+ } else if (totalUps === currentBestTotal) {
157
+ // Nếu tổng số con bằng nhau, ưu tiên phương án tờ in nhỏ hơn (chia 4 hoặc chia 2) để tiết kiệm chi phí in máy
158
+ if (cand.cutsFromParent > bestCandidateResult.candidate.cutsFromParent) {
159
+ bestCandidateResult = { candidate: cand, ...layout };
160
+ }
161
+ }
162
  }
163
  }
164
 
165
+ // Fallback nếu kh��ng có con nào vừa
166
+ if (!bestCandidateResult || bestCandidateResult.ups === 0) {
167
+ const fallbackCand = candidates[0];
168
+ bestCandidateResult = {
169
+ candidate: fallbackCand,
 
170
  ups: 1,
171
  cols: 1,
172
  rows: 1,
173
  isRotated: false,
174
+ boxes: [
175
+ {
176
+ index: 1,
177
+ x: 10,
178
+ y: 10,
179
+ w: widthMm + bleedMm * 2,
180
+ h: heightMm + bleedMm * 2,
181
+ trimW: widthMm,
182
+ trimH: heightMm,
183
+ bleedMm,
184
+ isRotated: false,
185
+ },
186
+ ],
187
  efficiency: 10,
188
+ usableW: fallbackCand.sheetWidthMm,
189
+ usableH: fallbackCand.sheetHeightMm,
190
  };
191
  }
192
 
193
+ const { candidate, ups, cols, rows, isRotated, boxes, efficiency, usableW, usableH } = bestCandidateResult;
194
+
195
  return {
196
  parentSheet: {
197
  widthCm: paperType.parentWidthCm,
 
199
  name: `${paperType.name} (${paperType.parentWidthCm}x${paperType.parentHeightCm}cm)`,
200
  },
201
  printSheet: {
202
+ widthMm: candidate.sheetWidthMm,
203
+ heightMm: candidate.sheetHeightMm,
204
+ name: candidate.name,
205
  },
206
+ upsPerPrintSheet: ups,
207
+ cutsPerParentSheet: candidate.cutsFromParent,
208
+ totalUpsPerParentSheet: ups * candidate.cutsFromParent,
209
+ gripperMarginMm: candidate.gripperMarginMm,
210
+ usableWidthMm: usableW,
211
+ usableHeightMm: usableH,
212
+ cols,
213
+ rows,
214
+ isRotated,
215
+ boxes,
216
+ sheetEfficiencyPercent: Math.round(efficiency * 10) / 10,
217
+ cutScheme: candidate.cutScheme,
218
  };
219
  }
220
 
221
+ // -----------------------------------------------------------------------------------------
222
+ // THUẬT TOÁN XẾP BÌNH BÀI CHUẨN XÉNG CÔNG NGHIỆP TRÊN TỜ IN MÁY
223
+ // -----------------------------------------------------------------------------------------
224
+ interface LayoutEvalParams {
225
+ sheetW: number;
226
+ sheetH: number;
227
+ itemW: number;
228
+ itemH: number;
229
+ bleedMm: number;
230
+ gripperMarginMm: number;
231
+ }
232
+
233
+ interface LayoutResult {
234
+ ups: number;
235
+ cols: number;
236
+ rows: number;
237
+ isRotated: boolean;
238
+ boxes: BoxCoordinate[];
239
+ efficiency: number;
240
+ usableW: number;
241
+ usableH: number;
242
+ }
243
+
244
+ function evaluateImpositionLayout({
245
+ sheetW,
246
+ sheetH,
247
+ itemW,
248
+ itemH,
249
+ bleedMm,
250
+ gripperMarginMm,
251
+ }: LayoutEvalParams): LayoutResult {
252
+ // Quy ước máy in:
253
+ // Cạnh dài là cạnh bắt nhíp: width = max(sheetW, sheetH), height = min(sheetW, sheetH)
254
+ const W = Math.max(sheetW, sheetH);
255
+ const H = Math.min(sheetW, sheetH);
256
+
257
+ // Lề kẹp nhíp ở cạnh đáy (gripperMarginMm = 10mm)
258
+ // Lề đuôi (đối diện nhíp): 4mm
259
+ // Lề 2 bên biên: 4mm mỗi bên (tổng 8mm)
260
+ const marginSide = 4;
261
+ const marginTail = 4;
262
+ const usableW = W - marginSide * 2;
263
+ const usableH = H - (gripperMarginMm + marginTail);
264
 
265
  if (usableW < itemW && usableW < itemH) {
266
  return { ups: 0, cols: 0, rows: 0, isRotated: false, boxes: [], efficiency: 0, usableW, usableH };
267
  }
268
 
269
+ // --- Cách 1: Xếp Thẳng (Không xoay) ---
270
+ // Chiều ngang chứa C1 cột thành phẩm, có tràn lề 2mm ở 2 mép ngoài cùng
271
+ // Công thức: C1 * itemW + 2 * bleedMm <= usableW
272
+ const cols1 = Math.max(0, Math.floor((usableW - 2 * bleedMm) / itemW));
273
+ const rows1 = Math.max(0, Math.floor((usableH - 2 * bleedMm) / itemH));
274
  const ups1 = cols1 * rows1;
275
 
276
+ // --- Cách 2: Xếp Xoay 90 độ ---
277
+ // Chiều ngang xếp theo itemH, chiều dọc xếp theo itemW
278
+ const cols2 = Math.max(0, Math.floor((usableW - 2 * bleedMm) / itemH));
279
+ const rows2 = Math.max(0, Math.floor((usableH - 2 * bleedMm) / itemW));
280
  const ups2 = cols2 * rows2;
281
 
282
+ // Chọn phương án tốt nhất giữa Xếp Thẳng (ups1) và Xếp Xoay 90° (ups2)
283
+ let bestUps = ups1;
284
+ let bestCols = cols1;
285
+ let bestRows = rows1;
286
+ let isRotated = false;
287
+
288
+ if (ups2 > bestUps) {
289
+ bestUps = ups2;
290
+ bestCols = cols2;
291
+ bestRows = rows2;
292
+ isRotated = true;
293
+ }
294
+
295
+ // Tạo danh sách tọa độ các hộp sản phẩm
296
+ const boxes: BoxCoordinate[] = [];
297
  const boxW = isRotated ? itemH : itemW;
298
  const boxH = isRotated ? itemW : itemH;
299
 
300
+ // Canh giữa cụm con trong vùng in hữu dụng
301
+ const blockW = bestCols * boxW + 2 * bleedMm;
302
+ const blockH = bestRows * boxH + 2 * bleedMm;
303
+ const startX = marginSide + Math.max(0, Math.floor((usableW - blockW) / 2)) + bleedMm;
304
+ const startY = marginTail + Math.max(0, Math.floor((usableH - blockH) / 2)) + bleedMm;
305
 
306
  let idx = 1;
307
  for (let r = 0; r < bestRows; r++) {
 
312
  y: Math.round((startY + r * boxH) * 10) / 10,
313
  w: boxW,
314
  h: boxH,
315
+ trimW: itemW,
316
+ trimH: itemH,
317
+ bleedMm,
318
+ isRotated,
319
  });
320
  }
321
  }
322
 
323
+ const totalItemArea = itemW * itemH * bestUps;
324
+ const sheetArea = W * H;
325
+ const efficiency = sheetArea > 0 ? (totalItemArea / sheetArea) * 100 : 0;
326
 
327
  return {
328
  ups: bestUps,
 
336
  };
337
  }
338
 
339
+ // -----------------------------------------------------------------------------------------
340
+ // SINH SƠ ĐỒ CẮT GIẤY MẸ TRỰC QUAN (PAPER CUTTING SCHEME)
341
+ // -----------------------------------------------------------------------------------------
342
+
343
+ export function determineCutScheme(
344
+ parentW: number,
345
+ parentH: number,
346
+ printW: number,
347
+ printH: number,
348
+ cuts: number
349
+ ): PaperCutScheme {
350
+ const pw = Math.max(parentW, parentH);
351
+ const ph = Math.min(parentW, parentH);
352
+
353
+ if (cuts <= 1) return buildFullCutScheme(pw, ph);
354
+ if (cuts === 2) return buildCut2Scheme(pw, ph);
355
+ if (cuts === 4) return buildCut4Scheme(pw, ph);
356
+ if (cuts === 8) return buildCut8Scheme(pw, ph);
357
+ return buildSimpleCutScheme(pw, ph, printW, printH, cuts);
358
+ }
359
+
360
+ function buildFullCutScheme(parentW: number, parentH: number): PaperCutScheme {
361
+ const pw = Math.max(parentW, parentH);
362
+ const ph = Math.min(parentW, parentH);
363
+
364
+ return {
365
+ parentWidthMm: pw,
366
+ parentHeightMm: ph,
367
+ cutType: 'chia_1',
368
+ cutDescription: `In nguyên khổ ${pw} x ${ph} mm (không cần xén trước khi in)`,
369
+ cutsCount: 1,
370
+ cutSheetWidthMm: pw,
371
+ cutSheetHeightMm: ph,
372
+ wasteAreaPercent: 0,
373
+ cutLines: [],
374
+ blocks: [
375
+ {
376
+ index: 1,
377
+ x: 0,
378
+ y: 0,
379
+ w: pw,
380
+ h: ph,
381
+ name: `Tờ in #1 (${pw} x ${ph} mm)`,
382
+ },
383
+ ],
384
+ };
385
+ }
386
+
387
+ function buildCut2Scheme(parentW: number, parentH: number): PaperCutScheme {
388
+ const pw = Math.max(parentW, parentH); // ví dụ 860mm
389
+ const ph = Math.min(parentW, parentH); // ví dụ 650mm
390
+ const cutW = Math.floor(pw / 2); // 430mm
391
+
392
+ return {
393
+ parentWidthMm: pw,
394
+ parentHeightMm: ph,
395
+ cutType: 'chia_2',
396
+ cutDescription: `Xén đôi chiều ${pw}mm thành 2 tờ ${ph} x ${cutW} mm (Chia 2)`,
397
+ cutsCount: 2,
398
+ cutSheetWidthMm: ph,
399
+ cutSheetHeightMm: cutW,
400
+ wasteAreaPercent: 0,
401
+ cutLines: [
402
+ {
403
+ x1: cutW,
404
+ y1: 0,
405
+ x2: cutW,
406
+ y2: ph,
407
+ type: 'vertical',
408
+ label: `Xén dọc: ${cutW}mm`,
409
+ },
410
+ ],
411
+ blocks: [
412
+ {
413
+ index: 1,
414
+ x: 0,
415
+ y: 0,
416
+ w: cutW,
417
+ h: ph,
418
+ name: `Tờ in #1 (${ph} x ${cutW} mm)`,
419
+ },
420
+ {
421
+ index: 2,
422
+ x: cutW,
423
+ y: 0,
424
+ w: pw - cutW,
425
+ h: ph,
426
+ name: `Tờ in #2 (${ph} x ${cutW} mm)`,
427
+ },
428
+ ],
429
+ };
430
+ }
431
+
432
+ function buildCut4Scheme(parentW: number, parentH: number): PaperCutScheme {
433
+ const pw = Math.max(parentW, parentH); // ví dụ 860 hoặc 1090
434
+ const ph = Math.min(parentW, parentH); // ví dụ 650 hoặc 790
435
+ const halfW = Math.floor(pw / 2); // 430
436
+ const halfH = Math.floor(ph / 2); // 325
437
+
438
+ return {
439
+ parentWidthMm: pw,
440
+ parentHeightMm: ph,
441
+ cutType: 'chia_4',
442
+ cutDescription: `Xén chữ thập (Chia 4) thành 4 tờ ${halfW} x ${halfH} mm`,
443
+ cutsCount: 4,
444
+ cutSheetWidthMm: halfW,
445
+ cutSheetHeightMm: halfH,
446
+ wasteAreaPercent: 0,
447
+ cutLines: [
448
+ {
449
+ x1: halfW,
450
+ y1: 0,
451
+ x2: halfW,
452
+ y2: ph,
453
+ type: 'vertical',
454
+ label: `Xén dọc: ${halfW}mm`,
455
+ },
456
+ {
457
+ x1: 0,
458
+ y1: halfH,
459
+ x2: pw,
460
+ y2: halfH,
461
+ type: 'horizontal',
462
+ label: `Xén ngang: ${halfH}mm`,
463
+ },
464
+ ],
465
+ blocks: [
466
+ { index: 1, x: 0, y: 0, w: halfW, h: halfH, name: `Tờ in #1 (${halfW} x ${halfH} mm)` },
467
+ { index: 2, x: halfW, y: 0, w: pw - halfW, h: halfH, name: `Tờ in #2 (${halfW} x ${halfH} mm)` },
468
+ { index: 3, x: 0, y: halfH, w: halfW, h: ph - halfH, name: `Tờ in #3 (${halfW} x ${halfH} mm)` },
469
+ { index: 4, x: halfW, y: halfH, w: pw - halfW, h: ph - halfH, name: `Tờ in #4 (${halfW} x ${halfH} mm)` },
470
+ ],
471
+ };
472
+ }
473
+
474
+ function buildCut8Scheme(parentW: number, parentH: number): PaperCutScheme {
475
+ const pw = Math.max(parentW, parentH); // ví dụ 860
476
+ const ph = Math.min(parentW, parentH); // ví dụ 650
477
+ const colW = Math.floor(pw / 4); // 215
478
+ const rowH = Math.floor(ph / 2); // 325
479
+
480
+ const blocks: PaperCutBlock[] = [];
481
+ const cutLines: PaperCutLine[] = [
482
+ { x1: colW, y1: 0, x2: colW, y2: ph, type: 'vertical', label: `Xén: ${colW}mm` },
483
+ { x1: colW * 2, y1: 0, x2: colW * 2, y2: ph, type: 'vertical', label: `Xén: ${colW * 2}mm` },
484
+ { x1: colW * 3, y1: 0, x2: colW * 3, y2: ph, type: 'vertical', label: `Xén: ${colW * 3}mm` },
485
+ { x1: 0, y1: rowH, x2: pw, y2: rowH, type: 'horizontal', label: `Xén ngang: ${rowH}mm` },
486
+ ];
487
+
488
+ let idx = 1;
489
+ for (let r = 0; r < 2; r++) {
490
+ for (let c = 0; c < 4; c++) {
491
+ const curW = c === 3 ? pw - colW * 3 : colW;
492
+ const curH = r === 1 ? ph - rowH : rowH;
493
+ blocks.push({
494
+ index: idx++,
495
+ x: c * colW,
496
+ y: r * rowH,
497
+ w: curW,
498
+ h: curH,
499
+ name: `Tờ in #${idx - 1} (${rowH} x ${colW} mm)`,
500
+ });
501
+ }
502
+ }
503
+
504
+ return {
505
+ parentWidthMm: pw,
506
+ parentHeightMm: ph,
507
+ cutType: 'chia_8',
508
+ cutDescription: `Xén chia 8 thành 8 tờ ${rowH} x ${colW} mm`,
509
+ cutsCount: 8,
510
+ cutSheetWidthMm: rowH,
511
+ cutSheetHeightMm: colW,
512
+ wasteAreaPercent: 0,
513
+ cutLines,
514
+ blocks,
515
+ };
516
+ }
517
+
518
+ function buildSimpleCutScheme(
519
+ parentW: number,
520
+ parentH: number,
521
+ printW: number,
522
+ printH: number,
523
+ cuts: number
524
+ ): PaperCutScheme {
525
+ const pw = Math.max(parentW, parentH);
526
+ const ph = Math.min(parentW, parentH);
527
+
528
+ // So sánh 2 chiều xếp trên tờ mẹ
529
+ const c1 = Math.floor(pw / printW);
530
+ const r1 = Math.floor(ph / printH);
531
+ const tot1 = c1 * r1;
532
+
533
+ const c2 = Math.floor(pw / printH);
534
+ const r2 = Math.floor(ph / printW);
535
+ const tot2 = c2 * r2;
536
+
537
+ const useRotated = tot2 > tot1;
538
+ const pieceW = useRotated ? printH : printW;
539
+ const pieceH = useRotated ? printW : printH;
540
+ const cols = useRotated ? c2 : c1;
541
+ const rows = useRotated ? r2 : r1;
542
+
543
+ const actualCols = Math.max(1, cols);
544
+ const actualRows = Math.max(1, rows);
545
+
546
+ const blocks: PaperCutBlock[] = [];
547
+ const cutLines: PaperCutLine[] = [];
548
+
549
+ let idx = 1;
550
+ for (let r = 0; r < actualRows; r++) {
551
+ for (let c = 0; c < actualCols; c++) {
552
+ if (idx <= cuts) {
553
+ blocks.push({
554
+ index: idx++,
555
+ x: c * pieceW,
556
+ y: r * pieceH,
557
+ w: pieceW,
558
+ h: pieceH,
559
+ name: `Tờ in #${idx - 1} (${pieceW} x ${pieceH} mm)`,
560
+ });
561
+ }
562
+ }
563
+ }
564
+
565
+ // Tạo cut lines
566
+ for (let c = 1; c < actualCols; c++) {
567
+ cutLines.push({
568
+ x1: c * pieceW,
569
+ y1: 0,
570
+ x2: c * pieceW,
571
+ y2: Math.min(ph, actualRows * pieceH),
572
+ type: 'vertical',
573
+ label: `Xén: ${c * pieceW}mm`,
574
+ });
575
+ }
576
+ for (let r = 1; r < actualRows; r++) {
577
+ cutLines.push({
578
+ x1: 0,
579
+ y1: r * pieceH,
580
+ x2: Math.min(pw, actualCols * pieceW),
581
+ y2: r * pieceH,
582
+ type: 'horizontal',
583
+ label: `Xén: ${r * pieceH}mm`,
584
+ });
585
+ }
586
+
587
+ const usedArea = cuts * pieceW * pieceH;
588
+ const totalArea = pw * ph;
589
+ const wastePercent = totalArea > 0 ? Math.max(0, Math.round(((totalArea - usedArea) / totalArea) * 100)) : 0;
590
+
591
+ return {
592
+ parentWidthMm: pw,
593
+ parentHeightMm: ph,
594
+ cutType: cuts === 2 ? 'chia_2' : cuts === 4 ? 'chia_4' : cuts === 8 ? 'chia_8' : 'custom',
595
+ cutDescription: `Cắt ${cuts} tờ in (${pieceW} x ${pieceH} mm) từ tờ giấy mẹ ${pw} x ${ph} mm`,
596
+ cutsCount: cuts,
597
+ cutSheetWidthMm: pieceW,
598
+ cutSheetHeightMm: pieceH,
599
+ wasteAreaPercent: wastePercent,
600
+ cutLines,
601
+ blocks,
602
+ };
603
+ }
604
+
605
  function calculateCutsFromParent(parentW: number, parentH: number, printW: number, printH: number): number {
 
606
  const cuts1 = Math.floor(parentW / printW) * Math.floor(parentH / printH);
607
  const cuts2 = Math.floor(parentW / printH) * Math.floor(parentH / printW);
608
  return Math.max(cuts1, cuts2, 1);
src/types/index.ts CHANGED
@@ -101,11 +101,46 @@ export interface CalculationInput {
101
  vatPercent?: number;
102
  }
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  export interface BoxCoordinate {
105
  x: number;
106
  y: number;
107
  w: number;
108
  h: number;
 
 
 
 
109
  index: number;
110
  }
111
 
@@ -131,6 +166,7 @@ export interface ImpositionResult {
131
  isRotated: boolean;
132
  boxes: BoxCoordinate[];
133
  sheetEfficiencyPercent: number;
 
134
  }
135
 
136
  export interface FinishingCostDetail {
 
101
  vatPercent?: number;
102
  }
103
 
104
+ export interface PaperCutLine {
105
+ x1: number;
106
+ y1: number;
107
+ x2: number;
108
+ y2: number;
109
+ type: 'vertical' | 'horizontal';
110
+ label?: string;
111
+ }
112
+
113
+ export interface PaperCutBlock {
114
+ index: number;
115
+ x: number;
116
+ y: number;
117
+ w: number;
118
+ h: number;
119
+ name: string;
120
+ }
121
+
122
+ export interface PaperCutScheme {
123
+ parentWidthMm: number;
124
+ parentHeightMm: number;
125
+ cutType: 'chia_1' | 'chia_2' | 'chia_4' | 'chia_6' | 'chia_8' | 'custom';
126
+ cutDescription: string;
127
+ cutsCount: number;
128
+ cutSheetWidthMm: number;
129
+ cutSheetHeightMm: number;
130
+ blocks: PaperCutBlock[];
131
+ wasteAreaPercent: number;
132
+ cutLines?: PaperCutLine[];
133
+ }
134
+
135
  export interface BoxCoordinate {
136
  x: number;
137
  y: number;
138
  w: number;
139
  h: number;
140
+ trimW?: number;
141
+ trimH?: number;
142
+ bleedMm?: number;
143
+ isRotated?: boolean;
144
  index: number;
145
  }
146
 
 
166
  isRotated: boolean;
167
  boxes: BoxCoordinate[];
168
  sheetEfficiencyPercent: number;
169
+ cutScheme?: PaperCutScheme;
170
  }
171
 
172
  export interface FinishingCostDetail {