- Cutting optimization (Guillotine FFDH) with mixed/homogeneous modes - Remnant management with persistence (cutting_plan_sheet.remnants JSONB) - Work instruction creation linked via batch_no/cutting_plan_id Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
978 lines
35 KiB
TypeScript
978 lines
35 KiB
TypeScript
/**
|
||
* 절단 배치(Bin Packing) 알고리즘
|
||
* - 면적형 2D: 2-stage Guillotine (FFDH 변형) — 직선 절단 보장 (유리/판재 절단)
|
||
* - 면적형 동일품목 우선 배치 — 각 품목별로 독립된 원판 (다른 품목과 안 섞임)
|
||
* - 길이형 1D: FFD (packLength)
|
||
*/
|
||
|
||
export type CutType = "area" | "length";
|
||
export type PackMode = "mixed" | "homo";
|
||
export type Dir = "무관" | "가로" | "세로";
|
||
|
||
export interface Material {
|
||
code: string;
|
||
name: string;
|
||
id?: string;
|
||
width?: number;
|
||
height?: number;
|
||
length?: number;
|
||
stock?: number;
|
||
unit?: string;
|
||
}
|
||
|
||
export interface PlanItem {
|
||
name: string; // 품목명 (item_info.item_name)
|
||
code?: string; // 품목코드 (item_info.item_number)
|
||
item_id?: string; // item_info.id (cutting_plan_item.item_id 저장용)
|
||
width: number;
|
||
height: number;
|
||
length?: number;
|
||
qty: number;
|
||
dir: Dir;
|
||
color: string;
|
||
placed?: number;
|
||
}
|
||
|
||
export interface Placement {
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
color: string;
|
||
name: string;
|
||
itemIdx: number;
|
||
rotated: boolean;
|
||
}
|
||
|
||
export interface Shelf {
|
||
y: number;
|
||
h: number;
|
||
currentX: number;
|
||
}
|
||
|
||
export interface RemnantItem {
|
||
id: string;
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
status: "keep" | "discard";
|
||
}
|
||
|
||
export interface Sheet {
|
||
id: number;
|
||
matW: number;
|
||
matH: number;
|
||
matCode: string;
|
||
matName: string;
|
||
shelves: Shelf[];
|
||
placements: Placement[];
|
||
// 사용자가 편집한 자투리. undefined이면 placements로부터 자동 추출 (extractInitialRemnants).
|
||
remnants?: RemnantItem[];
|
||
}
|
||
|
||
export interface Segment {
|
||
len: number;
|
||
color: string;
|
||
name: string;
|
||
itemIdx: number;
|
||
startX: number;
|
||
}
|
||
|
||
export interface Pipe {
|
||
id: number;
|
||
matLen: number;
|
||
matCode: string;
|
||
matName: string;
|
||
remaining: number;
|
||
segments: Segment[];
|
||
}
|
||
|
||
export interface SheetGroup {
|
||
count: number;
|
||
repIdx: number;
|
||
representative: Sheet;
|
||
indices: number[];
|
||
}
|
||
|
||
export interface PipeGroup {
|
||
count: number;
|
||
repIdx: number;
|
||
representative: Pipe;
|
||
indices: number[];
|
||
}
|
||
|
||
export interface AreaResult {
|
||
sheets: Sheet[];
|
||
sheetGroups?: SheetGroup[];
|
||
}
|
||
|
||
export interface LengthResult {
|
||
pipes: Pipe[];
|
||
pipeGroups?: PipeGroup[];
|
||
}
|
||
|
||
export const COLORS = [
|
||
"#3b82f6", "#10b981", "#f59e0b", "#8b5cf6", "#ef4444",
|
||
"#06b6d4", "#ec4899", "#84cc16", "#f97316", "#0ea5e9",
|
||
];
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// 면적형 2D 배치 — 2-stage Guillotine (FFDH 기반)
|
||
// ─────────────────────────────────────────────────────────
|
||
// 모든 절단선이 원판 가장자리에서 가장자리까지 직선으로 이어지도록 보장.
|
||
// 1단계: 원판을 가로 shelf로 분할 (shelf 간 = 가로 직선 절단)
|
||
// 2단계: 각 shelf 안에서 piece들을 좌→우 배치 (shelf 내 = 세로 직선 절단)
|
||
|
||
type PieceInput = {
|
||
w: number; h: number; canRot: boolean;
|
||
color: string; name: string; itemIdx: number;
|
||
};
|
||
|
||
type FitResult = { w: number; h: number; rotated: boolean };
|
||
|
||
// shelf 시작 piece용 — 둘 다 fit이면 height 큰 쪽 (shelf height 최대화)
|
||
function fitForShelfStart(p: PieceInput, maxH: number, maxW: number): FitResult | null {
|
||
const a = p.w <= maxW && p.h <= maxH ? { w: p.w, h: p.h, rotated: false } : null;
|
||
const b = p.canRot && p.h <= maxW && p.w <= maxH ? { w: p.h, h: p.w, rotated: true } : null;
|
||
if (a && b) return a.h >= b.h ? a : b;
|
||
return a || b;
|
||
}
|
||
|
||
// shelf 내부 piece용 — 둘 다 fit이면 width 작은 쪽 (남은 width 보존)
|
||
function fitInShelf(p: PieceInput, shelfH: number, availW: number): FitResult | null {
|
||
const a = p.w <= availW && p.h <= shelfH ? { w: p.w, h: p.h, rotated: false } : null;
|
||
const b = p.canRot && p.h <= availW && p.w <= shelfH ? { w: p.h, h: p.w, rotated: true } : null;
|
||
if (a && b) return a.w <= b.w ? a : b;
|
||
return a || b;
|
||
}
|
||
|
||
function packGuillotineSingle(
|
||
primary: Material, pieces: PieceInput[], kerf: number,
|
||
sortFn: (a: PieceInput, b: PieceInput) => number, startId: number
|
||
): Sheet[] {
|
||
const matW = primary.width || 0;
|
||
const matH = primary.height || 0;
|
||
if (!matW || !matH || !pieces.length) return [];
|
||
|
||
const remaining = [...pieces].sort(sortFn);
|
||
const sheets: Sheet[] = [];
|
||
let sheetId = startId;
|
||
|
||
while (remaining.length > 0) {
|
||
const sheet: Sheet = {
|
||
id: sheetId++, matW, matH,
|
||
matCode: primary.code, matName: primary.name,
|
||
shelves: [], placements: [],
|
||
};
|
||
let currentY = 0;
|
||
let placedAny = false;
|
||
|
||
while (currentY < matH) {
|
||
const remH = matH - currentY;
|
||
let firstIdx = -1;
|
||
let firstFit: FitResult | null = null;
|
||
for (let i = 0; i < remaining.length; i++) {
|
||
const fit = fitForShelfStart(remaining[i], remH, matW);
|
||
if (fit) { firstIdx = i; firstFit = fit; break; }
|
||
}
|
||
if (firstIdx === -1 || !firstFit) break;
|
||
|
||
const first = remaining[firstIdx];
|
||
const shelfH = firstFit.h;
|
||
sheet.placements.push({
|
||
x: 0, y: currentY, w: firstFit.w, h: firstFit.h,
|
||
color: first.color, name: first.name, itemIdx: first.itemIdx,
|
||
rotated: firstFit.rotated,
|
||
});
|
||
let currentX = firstFit.w + kerf;
|
||
remaining.splice(firstIdx, 1);
|
||
placedAny = true;
|
||
|
||
let i = 0;
|
||
while (i < remaining.length) {
|
||
const availW = matW - currentX;
|
||
if (availW <= 0) break;
|
||
const fit = fitInShelf(remaining[i], shelfH, availW);
|
||
if (!fit) { i++; continue; }
|
||
const p = remaining[i];
|
||
sheet.placements.push({
|
||
x: currentX, y: currentY, w: fit.w, h: fit.h,
|
||
color: p.color, name: p.name, itemIdx: p.itemIdx,
|
||
rotated: fit.rotated,
|
||
});
|
||
currentX += fit.w + kerf;
|
||
remaining.splice(i, 1);
|
||
}
|
||
|
||
currentY += shelfH + kerf;
|
||
}
|
||
|
||
if (!placedAny) break;
|
||
sheets.push(sheet);
|
||
}
|
||
|
||
return sheets;
|
||
}
|
||
|
||
// 4가지 정렬 전략 비교 후 best 선택 (원판 수 최소 → 이용률 최대)
|
||
function runGuillotine(primary: Material, pieces: PieceInput[], kerf: number, startId: number = 1): Sheet[] {
|
||
const matW = primary.width || 0;
|
||
const matH = primary.height || 0;
|
||
if (!matW || !matH || !pieces.length) return [];
|
||
const totalArea = matW * matH;
|
||
|
||
const sorts = [
|
||
(a: PieceInput, b: PieceInput) => Math.max(b.w, b.h) - Math.max(a.w, a.h), // 긴 변 큰 순
|
||
(a: PieceInput, b: PieceInput) => b.h - a.h || b.w - a.w, // 높이 큰 순
|
||
(a: PieceInput, b: PieceInput) => b.w - a.w || b.h - a.h, // 폭 큰 순
|
||
(a: PieceInput, b: PieceInput) => b.w * b.h - a.w * a.h, // 면적 큰 순
|
||
];
|
||
|
||
let best: Sheet[] = [];
|
||
let bestScore: [number, number] = [Infinity, -Infinity];
|
||
|
||
for (const sortFn of sorts) {
|
||
const sheets = packGuillotineSingle(primary, pieces, kerf, sortFn, startId);
|
||
if (!sheets.length) continue;
|
||
const count = sheets.length;
|
||
const usedArea = sheets.reduce((s, sh) => s + sh.placements.reduce((ss, p) => ss + p.w * p.h, 0), 0);
|
||
const util = usedArea / (count * totalArea);
|
||
const score: [number, number] = [count, -util];
|
||
if (
|
||
score[0] < bestScore[0] ||
|
||
(score[0] === bestScore[0] && score[1] < bestScore[1])
|
||
) {
|
||
best = sheets;
|
||
bestScore = score;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function expandPieces(items: PlanItem[]): PieceInput[] {
|
||
const out: PieceInput[] = [];
|
||
items.forEach((item, itemIdx) => {
|
||
const qty = parseInt(String(item.qty)) || 0;
|
||
for (let i = 0; i < qty; i++) {
|
||
out.push({
|
||
w: item.width, h: item.height,
|
||
canRot: item.dir === "무관",
|
||
color: item.color, name: item.name, itemIdx,
|
||
});
|
||
}
|
||
});
|
||
return out;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// packArea — 혼합 최적 (모든 품목 합쳐서 guillotine packing)
|
||
// ─────────────────────────────────────────────────────────
|
||
export function packArea(mats: Material[], items: PlanItem[], kerf: number): AreaResult {
|
||
if (!mats.length || !items.length) return { sheets: [] };
|
||
const primary = mats[0];
|
||
const pieces = expandPieces(items);
|
||
return { sheets: runGuillotine(primary, pieces, kerf, 1) };
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// packAreaHomogeneous — 동일 품목 우선 + 잔여는 혼합 최적
|
||
// ─────────────────────────────────────────────────────────
|
||
// 1) 각 품목별로 한 판 가득 채울 수 있는 만큼 단독 판 생성 (floor(qty / maxCap))
|
||
// 2) 가득 못 채우는 잔여 수량은 모든 품목 모아서 혼합 packing (효율 추구)
|
||
export function packAreaHomogeneous(mats: Material[], items: PlanItem[], kerf: number): AreaResult {
|
||
if (!mats.length || !items.length) return { sheets: [] };
|
||
const primary = mats[0];
|
||
const matW = primary.width || 0;
|
||
const matH = primary.height || 0;
|
||
if (!matW || !matH) return { sheets: [] };
|
||
|
||
const allSheets: Sheet[] = [];
|
||
let sheetId = 1;
|
||
const remQty = items.map((i) => parseInt(String(i.qty)) || 0);
|
||
|
||
// 1) 각 품목 가득 찬 단독 판 생성
|
||
items.forEach((item, idx) => {
|
||
const qty = remQty[idx];
|
||
if (!qty || !item.width || !item.height) return;
|
||
|
||
// 실제 maxCap 측정 — runGuillotine 8전략으로 한 판 최대 수용량
|
||
const itemArea = item.width * item.height;
|
||
const upper = Math.max(1, Math.ceil((matW * matH) / itemArea) + 5);
|
||
const testPieces: PieceInput[] = [];
|
||
const trialN = Math.min(qty, upper);
|
||
for (let i = 0; i < trialN; i++) {
|
||
testPieces.push({
|
||
w: item.width, h: item.height,
|
||
canRot: item.dir === "무관",
|
||
color: item.color, name: item.name, itemIdx: idx,
|
||
});
|
||
}
|
||
const trialSheets = runGuillotine(primary, testPieces, kerf, 0);
|
||
const maxCap = trialSheets[0]?.placements.length || 0;
|
||
if (maxCap === 0) return;
|
||
|
||
const fullSheets = Math.floor(qty / maxCap);
|
||
if (fullSheets === 0) return; // 한 판도 못 채움 → 전부 잔여로
|
||
|
||
const usedQty = fullSheets * maxCap;
|
||
const fullPieces: PieceInput[] = [];
|
||
for (let i = 0; i < usedQty; i++) {
|
||
fullPieces.push({
|
||
w: item.width, h: item.height,
|
||
canRot: item.dir === "무관",
|
||
color: item.color, name: item.name, itemIdx: idx,
|
||
});
|
||
}
|
||
const itemSheets = runGuillotine(primary, fullPieces, kerf, sheetId);
|
||
sheetId += itemSheets.length;
|
||
allSheets.push(...itemSheets);
|
||
remQty[idx] = qty - usedQty;
|
||
});
|
||
|
||
// 2) 잔여 수량을 모아서 혼합 packing (효율 추구)
|
||
const remPieces: PieceInput[] = [];
|
||
items.forEach((item, idx) => {
|
||
const q = remQty[idx];
|
||
if (!q || !item.width || !item.height) return;
|
||
for (let i = 0; i < q; i++) {
|
||
remPieces.push({
|
||
w: item.width, h: item.height,
|
||
canRot: item.dir === "무관",
|
||
color: item.color, name: item.name, itemIdx: idx,
|
||
});
|
||
}
|
||
});
|
||
if (remPieces.length) {
|
||
const mixSheets = runGuillotine(primary, remPieces, kerf, sheetId);
|
||
allSheets.push(...mixSheets);
|
||
}
|
||
|
||
return { sheets: allSheets };
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// 자투리 형태 추출 (직사각형 단위)
|
||
// ─────────────────────────────────────────────────────────
|
||
// 원판에서 빈 공간을 직사각형 자투리들로 분할.
|
||
// guillotine 배치 결과를 가정하고 shelf 우측 / shelf 내부 piece 위 / 마지막 shelf 아래 자투리 추출.
|
||
|
||
export interface Remnant {
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
}
|
||
|
||
export function computeRemnants(sheet: Sheet, kerf: number = 0): Remnant[] {
|
||
const matW = sheet.matW;
|
||
const matH = sheet.matH;
|
||
if (!sheet.placements.length) {
|
||
return [{ x: 0, y: 0, w: matW, h: matH }];
|
||
}
|
||
|
||
// piece 점유 영역을 kerf 만큼 확장 (칼날 폭은 자투리 아님)
|
||
// 가장자리(0, matW, matH)는 자르지 않으므로 그쪽으로는 확장하지 않음
|
||
const expanded = sheet.placements.map((p) => ({
|
||
x: Math.max(0, p.x - kerf),
|
||
y: Math.max(0, p.y - kerf),
|
||
x2: Math.min(matW, p.x + p.w + kerf),
|
||
y2: Math.min(matH, p.y + p.h + kerf),
|
||
}));
|
||
|
||
// 1) 모든 piece의 y 경계로 horizontal strip 분할 (확장된 좌표)
|
||
const ySet = new Set<number>([0, matH]);
|
||
for (const p of expanded) {
|
||
if (p.y > 0 && p.y < matH) ySet.add(p.y);
|
||
if (p.y2 > 0 && p.y2 < matH) ySet.add(p.y2);
|
||
}
|
||
const ys = [...ySet].sort((a, b) => a - b);
|
||
|
||
// 2) 각 strip 안에서 piece가 차지하지 않는 x 구간 추출
|
||
const stripRects: Remnant[] = [];
|
||
for (let i = 0; i < ys.length - 1; i++) {
|
||
const y1 = ys[i];
|
||
const y2 = ys[i + 1];
|
||
const sh = y2 - y1;
|
||
if (sh < 1) continue;
|
||
|
||
// 이 strip에 걸치는 piece들의 x 범위 (확장된 좌표)
|
||
const occupied: { x1: number; x2: number }[] = [];
|
||
for (const p of expanded) {
|
||
if (p.y < y2 && p.y2 > y1) {
|
||
occupied.push({ x1: p.x, x2: p.x2 });
|
||
}
|
||
}
|
||
occupied.sort((a, b) => a.x1 - b.x1);
|
||
|
||
// 빈 x 구간 찾기 (occupied 사이의 gap + 좌우 끝)
|
||
let cursor = 0;
|
||
for (const o of occupied) {
|
||
if (o.x1 > cursor + 0.5) {
|
||
stripRects.push({ x: cursor, y: y1, w: o.x1 - cursor, h: sh });
|
||
}
|
||
cursor = Math.max(cursor, o.x2);
|
||
}
|
||
if (cursor < matW - 0.5) {
|
||
stripRects.push({ x: cursor, y: y1, w: matW - cursor, h: sh });
|
||
}
|
||
}
|
||
|
||
// 3) 세로 인접 + 같은 x 범위 자투리 합치기 (반복 적용)
|
||
const verticallyMerged = mergeAdjacent(stripRects, "vertical");
|
||
// 4) 가로 인접 + 같은 y 범위 자투리 합치기 (위에서 합쳐진 결과 위에)
|
||
const finalRects = mergeAdjacent(verticallyMerged, "horizontal");
|
||
|
||
// 너무 좁은 조각 제거 (kerf 이하는 절단 시 사라지는 영역, 미세 오차)
|
||
const minDim = Math.max(1, kerf);
|
||
return finalRects.filter((r) => r.w > minDim && r.h > minDim);
|
||
}
|
||
|
||
// 인접 사각형 합치기 — direction별로 같은 축 정렬되고 인접하면 병합
|
||
function mergeAdjacent(rects: Remnant[], direction: "vertical" | "horizontal"): Remnant[] {
|
||
const out: Remnant[] = rects.map((r) => ({ ...r }));
|
||
let changed = true;
|
||
while (changed) {
|
||
changed = false;
|
||
for (let i = 0; i < out.length && !changed; i++) {
|
||
for (let j = i + 1; j < out.length; j++) {
|
||
const a = out[i];
|
||
const b = out[j];
|
||
if (direction === "vertical") {
|
||
// 같은 x 범위 + 세로 인접 (a 아래에 b 또는 b 아래에 a)
|
||
if (Math.abs(a.x - b.x) < 0.5 && Math.abs(a.w - b.w) < 0.5) {
|
||
if (Math.abs(a.y + a.h - b.y) < 0.5) {
|
||
out[i] = { x: a.x, y: a.y, w: a.w, h: a.h + b.h };
|
||
out.splice(j, 1);
|
||
changed = true;
|
||
break;
|
||
}
|
||
if (Math.abs(b.y + b.h - a.y) < 0.5) {
|
||
out[i] = { x: a.x, y: b.y, w: a.w, h: a.h + b.h };
|
||
out.splice(j, 1);
|
||
changed = true;
|
||
break;
|
||
}
|
||
}
|
||
} else {
|
||
// 같은 y 범위 + 가로 인접
|
||
if (Math.abs(a.y - b.y) < 0.5 && Math.abs(a.h - b.h) < 0.5) {
|
||
if (Math.abs(a.x + a.w - b.x) < 0.5) {
|
||
out[i] = { x: a.x, y: a.y, w: a.w + b.w, h: a.h };
|
||
out.splice(j, 1);
|
||
changed = true;
|
||
break;
|
||
}
|
||
if (Math.abs(b.x + b.w - a.x) < 0.5) {
|
||
out[i] = { x: b.x, y: a.y, w: a.w + b.w, h: a.h };
|
||
out.splice(j, 1);
|
||
changed = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// computeRemnants 결과에 ID + 기본 status를 부여해서 RemnantItem[]로 변환.
|
||
// 사용자가 자투리를 편집하지 않은 sheet에서 초기값 생성용.
|
||
export function extractInitialRemnants(sheet: Sheet, idPrefix: string = "", kerf: number = 0): RemnantItem[] {
|
||
return computeRemnants(sheet, kerf).map((rm, i) => ({
|
||
id: `${idPrefix}r${i}`,
|
||
x: rm.x,
|
||
y: rm.y,
|
||
w: rm.w,
|
||
h: rm.h,
|
||
status: "discard",
|
||
}));
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// 자투리 그룹화 — 변 공유하는 자투리들을 한 덩어리로 묶음 (Union-Find)
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
export interface RemnantGroup {
|
||
groupId: number;
|
||
rects: RemnantItem[];
|
||
}
|
||
|
||
function sharesEdgeBox(
|
||
a: { x: number; y: number; w: number; h: number },
|
||
b: { x: number; y: number; w: number; h: number }
|
||
): boolean {
|
||
const eps = 0.5;
|
||
// 가로 인접 (수직 변 공유)
|
||
const horizAdj = Math.abs(a.x + a.w - b.x) < eps || Math.abs(b.x + b.w - a.x) < eps;
|
||
const yOverlap = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
|
||
if (horizAdj && yOverlap > eps) return true;
|
||
// 세로 인접 (수평 변 공유)
|
||
const vertAdj = Math.abs(a.y + a.h - b.y) < eps || Math.abs(b.y + b.h - a.y) < eps;
|
||
const xOverlap = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
|
||
if (vertAdj && xOverlap > eps) return true;
|
||
return false;
|
||
}
|
||
|
||
export function computeRemnantGroups(rects: RemnantItem[]): RemnantGroup[] {
|
||
const n = rects.length;
|
||
if (n === 0) return [];
|
||
const parent = Array.from({ length: n }, (_, i) => i);
|
||
const find = (i: number): number => {
|
||
while (parent[i] !== i) {
|
||
parent[i] = parent[parent[i]];
|
||
i = parent[i];
|
||
}
|
||
return i;
|
||
};
|
||
const union = (i: number, j: number) => {
|
||
const ri = find(i), rj = find(j);
|
||
if (ri !== rj) parent[ri] = rj;
|
||
};
|
||
for (let i = 0; i < n; i++) {
|
||
for (let j = i + 1; j < n; j++) {
|
||
if (sharesEdgeBox(rects[i], rects[j])) union(i, j);
|
||
}
|
||
}
|
||
const groupMap = new Map<number, RemnantItem[]>();
|
||
for (let i = 0; i < n; i++) {
|
||
const root = find(i);
|
||
const arr = groupMap.get(root) || [];
|
||
arr.push(rects[i]);
|
||
groupMap.set(root, arr);
|
||
}
|
||
return [...groupMap.values()].map((arr, gi) => ({ groupId: gi, rects: arr }));
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// 그룹 외곽선 (polygon) 계산 — 사각형들의 union outline
|
||
// ─────────────────────────────────────────────────────────
|
||
// 알고리즘: 모든 사각형의 4변을 방향성(시계방향) segment로 추출 →
|
||
// 격자점 기준 unit segment로 분해 → 같은 위치 반대 방향 segment 쌍 제거 →
|
||
// 남은 segment를 head-tail 매칭으로 polygon path 구성 (구멍 포함 가능).
|
||
|
||
export interface OutlinePath {
|
||
points: { x: number; y: number }[];
|
||
}
|
||
|
||
export function computeGroupOutline(rects: { x: number; y: number; w: number; h: number }[]): OutlinePath[] {
|
||
if (rects.length === 0) return [];
|
||
|
||
// 1) x, y 격자 좌표 모음
|
||
const xSet = new Set<number>();
|
||
const ySet = new Set<number>();
|
||
rects.forEach((r) => {
|
||
xSet.add(r.x); xSet.add(r.x + r.w);
|
||
ySet.add(r.y); ySet.add(r.y + r.h);
|
||
});
|
||
const xs = [...xSet].sort((a, b) => a - b);
|
||
const ys = [...ySet].sort((a, b) => a - b);
|
||
const xi = (x: number) => xs.indexOf(x);
|
||
const yi = (y: number) => ys.indexOf(y);
|
||
|
||
// 2) 격자 셀 occupancy (셀 [i, j] 가 채워져 있는지)
|
||
const occ: boolean[][] = [];
|
||
for (let j = 0; j < ys.length - 1; j++) {
|
||
const row: boolean[] = [];
|
||
const cy = (ys[j] + ys[j + 1]) / 2;
|
||
for (let i = 0; i < xs.length - 1; i++) {
|
||
const cx = (xs[i] + xs[i + 1]) / 2;
|
||
const o = rects.some((r) => cx >= r.x && cx <= r.x + r.w && cy >= r.y && cy <= r.y + r.h);
|
||
row.push(o);
|
||
}
|
||
occ.push(row);
|
||
}
|
||
|
||
// 3) 각 셀의 4변 중 외부 경계인 것만 directed edge로 등록 (시계방향)
|
||
// 격자점은 (xs[i], ys[j]). 셀 [i, j]는 (xs[i], ys[j])-(xs[i+1], ys[j+1]).
|
||
// 셀이 채워져 있고 인접 셀이 비어있으면 그 변은 외곽.
|
||
type EdgeKey = string;
|
||
const edges = new Map<EdgeKey, { from: [number, number]; to: [number, number] }>();
|
||
const addEdge = (fi: number, fj: number, ti: number, tj: number) => {
|
||
const key = `${fi},${fj}-${ti},${tj}`;
|
||
edges.set(key, { from: [fi, fj], to: [ti, tj] });
|
||
};
|
||
const cellOcc = (i: number, j: number) =>
|
||
j >= 0 && j < occ.length && i >= 0 && i < occ[0].length ? occ[j][i] : false;
|
||
|
||
for (let j = 0; j < occ.length; j++) {
|
||
for (let i = 0; i < occ[0].length; i++) {
|
||
if (!occ[j][i]) continue;
|
||
// top edge (셀 위쪽이 비어있음 → 시계방향: left→right at y=ys[j])
|
||
if (!cellOcc(i, j - 1)) addEdge(i, j, i + 1, j);
|
||
// right edge (셀 오른쪽이 비어있음 → 시계방향: top→bottom at x=xs[i+1])
|
||
if (!cellOcc(i + 1, j)) addEdge(i + 1, j, i + 1, j + 1);
|
||
// bottom edge (셀 아래가 비어있음 → 시계방향: right→left at y=ys[j+1])
|
||
if (!cellOcc(i, j + 1)) addEdge(i + 1, j + 1, i, j + 1);
|
||
// left edge (셀 왼쪽이 비어있음 → 시계방향: bottom→top at x=xs[i])
|
||
if (!cellOcc(i - 1, j)) addEdge(i, j + 1, i, j);
|
||
}
|
||
}
|
||
|
||
// 4) 같은 from 격자점에서 시작하는 edge 인덱스
|
||
const fromMap = new Map<string, { from: [number, number]; to: [number, number] }[]>();
|
||
edges.forEach((e) => {
|
||
const k = `${e.from[0]},${e.from[1]}`;
|
||
const arr = fromMap.get(k) || [];
|
||
arr.push(e);
|
||
fromMap.set(k, arr);
|
||
});
|
||
|
||
// 5) edge들을 head-tail로 연결해서 닫힌 polygon들 추출
|
||
const paths: OutlinePath[] = [];
|
||
const used = new Set<string>();
|
||
edges.forEach((startEdge, startKey) => {
|
||
if (used.has(startKey)) return;
|
||
const points: { x: number; y: number }[] = [];
|
||
let cur = startEdge;
|
||
let curKey = startKey;
|
||
while (cur && !used.has(curKey)) {
|
||
used.add(curKey);
|
||
points.push({ x: xs[cur.from[0]], y: ys[cur.from[1]] });
|
||
const nextKeyPrefix = `${cur.to[0]},${cur.to[1]}`;
|
||
const candidates = fromMap.get(nextKeyPrefix) || [];
|
||
const next = candidates.find((e) => !used.has(`${e.from[0]},${e.from[1]}-${e.to[0]},${e.to[1]}`));
|
||
if (!next) break;
|
||
cur = next;
|
||
curKey = `${next.from[0]},${next.from[1]}-${next.to[0]},${next.to[1]}`;
|
||
}
|
||
if (points.length >= 3) paths.push({ points: simplifyCollinear(points) });
|
||
});
|
||
return paths;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// 자투리 그룹 재분할 — 그룹의 union 영역을 다른 전략으로 직사각형 분해
|
||
// ─────────────────────────────────────────────────────────
|
||
// strategy:
|
||
// "h" — 가로 strip 우선 (기본 자동 추출과 같음, 세로 인접 합침)
|
||
// "v" — 세로 strip 우선 (가로 인접 합침)
|
||
// "max" — Maximum Empty Rectangle 반복 (가장 큰 사각형부터 추출)
|
||
|
||
export function decomposeUnion(
|
||
rects: { x: number; y: number; w: number; h: number }[],
|
||
strategy: "h" | "v" | "max"
|
||
): { x: number; y: number; w: number; h: number }[] {
|
||
if (rects.length === 0) return [];
|
||
|
||
// 격자 좌표
|
||
const xs = [...new Set(rects.flatMap((r) => [r.x, r.x + r.w]))].sort((a, b) => a - b);
|
||
const ys = [...new Set(rects.flatMap((r) => [r.y, r.y + r.h]))].sort((a, b) => a - b);
|
||
|
||
// 격자 occupancy
|
||
const cols = xs.length - 1;
|
||
const rows = ys.length - 1;
|
||
const occ: boolean[][] = [];
|
||
for (let j = 0; j < rows; j++) {
|
||
const row: boolean[] = [];
|
||
const cy = (ys[j] + ys[j + 1]) / 2;
|
||
for (let i = 0; i < cols; i++) {
|
||
const cx = (xs[i] + xs[i + 1]) / 2;
|
||
const o = rects.some((r) => cx >= r.x && cx <= r.x + r.w && cy >= r.y && cy <= r.y + r.h);
|
||
row.push(o);
|
||
}
|
||
occ.push(row);
|
||
}
|
||
|
||
if (strategy === "h") {
|
||
// 각 row strip에서 occupied 셀들을 가로 인접으로 합침
|
||
const out: { x: number; y: number; w: number; h: number }[] = [];
|
||
for (let j = 0; j < rows; j++) {
|
||
let i = 0;
|
||
while (i < cols) {
|
||
if (!occ[j][i]) { i++; continue; }
|
||
let i2 = i;
|
||
while (i2 < cols && occ[j][i2]) i2++;
|
||
out.push({ x: xs[i], y: ys[j], w: xs[i2] - xs[i], h: ys[j + 1] - ys[j] });
|
||
i = i2;
|
||
}
|
||
}
|
||
return mergeAdjacentLocal(out, "vertical");
|
||
}
|
||
|
||
if (strategy === "v") {
|
||
// 각 column strip에서 occupied 셀들을 세로 인접으로 합침
|
||
const out: { x: number; y: number; w: number; h: number }[] = [];
|
||
for (let i = 0; i < cols; i++) {
|
||
let j = 0;
|
||
while (j < rows) {
|
||
if (!occ[j][i]) { j++; continue; }
|
||
let j2 = j;
|
||
while (j2 < rows && occ[j2][i]) j2++;
|
||
out.push({ x: xs[i], y: ys[j], w: xs[i + 1] - xs[i], h: ys[j2] - ys[j] });
|
||
j = j2;
|
||
}
|
||
}
|
||
return mergeAdjacentLocal(out, "horizontal");
|
||
}
|
||
|
||
// "max" — Maximum Empty Rectangle 반복
|
||
const out: { x: number; y: number; w: number; h: number }[] = [];
|
||
const occCopy = occ.map((row) => [...row]);
|
||
while (true) {
|
||
const best = findLargestRectangleInGrid(occCopy, xs, ys);
|
||
if (!best || best.w * best.h < 1) break;
|
||
out.push(best);
|
||
// 사용한 영역 점유 해제
|
||
for (let j = 0; j < rows; j++) {
|
||
for (let i = 0; i < cols; i++) {
|
||
const cx = (xs[i] + xs[i + 1]) / 2;
|
||
const cy = (ys[j] + ys[j + 1]) / 2;
|
||
if (cx >= best.x && cx <= best.x + best.w && cy >= best.y && cy <= best.y + best.h) {
|
||
occCopy[j][i] = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// 격자에서 가장 큰 직사각형 (모든 셀 occupied) 찾기 — Histogram 기반 O(rows × cols)
|
||
function findLargestRectangleInGrid(
|
||
occ: boolean[][],
|
||
xs: number[],
|
||
ys: number[]
|
||
): { x: number; y: number; w: number; h: number } | null {
|
||
const rows = occ.length;
|
||
if (rows === 0) return null;
|
||
const cols = occ[0].length;
|
||
if (cols === 0) return null;
|
||
|
||
// 각 셀 위쪽 연속 occupied 셀 수 (히스토그램)
|
||
const heights: number[][] = [];
|
||
for (let j = 0; j < rows; j++) {
|
||
const row: number[] = [];
|
||
for (let i = 0; i < cols; i++) {
|
||
row.push(occ[j][i] ? (j > 0 ? heights[j - 1][i] + 1 : 1) : 0);
|
||
}
|
||
heights.push(row);
|
||
}
|
||
|
||
let bestArea = 0;
|
||
let bestRect: { x: number; y: number; w: number; h: number } | null = null;
|
||
|
||
for (let j = 0; j < rows; j++) {
|
||
// 각 행마다 히스토그램에서 가장 큰 직사각형 (실제 mm 면적 기준)
|
||
const stack: number[] = [];
|
||
for (let i = 0; i <= cols; i++) {
|
||
const cur = i === cols ? 0 : heights[j][i];
|
||
while (stack.length && (i === cols || heights[j][stack[stack.length - 1]] > cur)) {
|
||
const top = stack.pop()!;
|
||
const left = stack.length ? stack[stack.length - 1] + 1 : 0;
|
||
const right = i - 1;
|
||
const cellH = heights[j][top];
|
||
// 실제 좌표
|
||
const x = xs[left];
|
||
const w = xs[right + 1] - xs[left];
|
||
const y = ys[j - cellH + 1];
|
||
const h = ys[j + 1] - ys[j - cellH + 1];
|
||
const area = w * h;
|
||
if (area > bestArea) {
|
||
bestArea = area;
|
||
bestRect = { x, y, w, h };
|
||
}
|
||
}
|
||
stack.push(i);
|
||
}
|
||
}
|
||
return bestRect;
|
||
}
|
||
|
||
// 인접 합치기 (decomposeUnion 전용 로컬 버전 — coord 비교만)
|
||
function mergeAdjacentLocal(
|
||
rects: { x: number; y: number; w: number; h: number }[],
|
||
direction: "vertical" | "horizontal"
|
||
): { x: number; y: number; w: number; h: number }[] {
|
||
const out = rects.map((r) => ({ ...r }));
|
||
let changed = true;
|
||
while (changed) {
|
||
changed = false;
|
||
for (let i = 0; i < out.length && !changed; i++) {
|
||
for (let j = i + 1; j < out.length; j++) {
|
||
const a = out[i];
|
||
const b = out[j];
|
||
if (direction === "vertical") {
|
||
if (Math.abs(a.x - b.x) < 0.5 && Math.abs(a.w - b.w) < 0.5) {
|
||
if (Math.abs(a.y + a.h - b.y) < 0.5) {
|
||
out[i] = { x: a.x, y: a.y, w: a.w, h: a.h + b.h };
|
||
out.splice(j, 1);
|
||
changed = true;
|
||
break;
|
||
}
|
||
if (Math.abs(b.y + b.h - a.y) < 0.5) {
|
||
out[i] = { x: a.x, y: b.y, w: a.w, h: a.h + b.h };
|
||
out.splice(j, 1);
|
||
changed = true;
|
||
break;
|
||
}
|
||
}
|
||
} else {
|
||
if (Math.abs(a.y - b.y) < 0.5 && Math.abs(a.h - b.h) < 0.5) {
|
||
if (Math.abs(a.x + a.w - b.x) < 0.5) {
|
||
out[i] = { x: a.x, y: a.y, w: a.w + b.w, h: a.h };
|
||
out.splice(j, 1);
|
||
changed = true;
|
||
break;
|
||
}
|
||
if (Math.abs(b.x + b.w - a.x) < 0.5) {
|
||
out[i] = { x: b.x, y: a.y, w: a.w + b.w, h: a.h };
|
||
out.splice(j, 1);
|
||
changed = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// 같은 직선 위 연속점 제거 (꼭짓점만 남김)
|
||
function simplifyCollinear(points: { x: number; y: number }[]): { x: number; y: number }[] {
|
||
if (points.length < 3) return points;
|
||
const out: { x: number; y: number }[] = [];
|
||
const n = points.length;
|
||
for (let i = 0; i < n; i++) {
|
||
const prev = points[(i - 1 + n) % n];
|
||
const cur = points[i];
|
||
const next = points[(i + 1) % n];
|
||
const collinearH = prev.y === cur.y && cur.y === next.y;
|
||
const collinearV = prev.x === cur.x && cur.x === next.x;
|
||
if (!collinearH && !collinearV) out.push(cur);
|
||
}
|
||
return out.length >= 3 ? out : points;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// 길이형 1D 배치 (FFD)
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
export function packLength(mats: Material[], items: PlanItem[], kerf: number): LengthResult {
|
||
const pieces: { len: number; name: string; color: string; itemIdx: number }[] = [];
|
||
items.forEach((item, idx) => {
|
||
const q = parseInt(String(item.qty)) || 0;
|
||
const len = item.length || 0;
|
||
for (let i = 0; i < q; i++) pieces.push({ len, name: item.name, color: item.color, itemIdx: idx });
|
||
});
|
||
pieces.sort((a, b) => b.len - a.len);
|
||
|
||
const pipes: Pipe[] = [];
|
||
for (const p of pieces) {
|
||
let placed = false;
|
||
for (const pipe of pipes) {
|
||
if (pipe.remaining >= p.len) {
|
||
pipe.segments.push({
|
||
len: p.len, color: p.color, name: p.name, itemIdx: p.itemIdx,
|
||
startX: pipe.matLen - pipe.remaining,
|
||
});
|
||
pipe.remaining -= p.len + kerf;
|
||
placed = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!placed) {
|
||
const mat = mats.find((m) => m && (m.length || 0) >= p.len);
|
||
if (!mat) continue;
|
||
pipes.push({
|
||
id: pipes.length + 1,
|
||
matLen: mat.length || 0,
|
||
matCode: mat.code,
|
||
matName: mat.name,
|
||
remaining: (mat.length || 0) - p.len - kerf,
|
||
segments: [{ len: p.len, color: p.color, name: p.name, itemIdx: p.itemIdx, startX: 0 }],
|
||
});
|
||
}
|
||
}
|
||
return { pipes };
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// 동일 배치 그룹화
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
export function computeSheetGroups(sheets: Sheet[]): SheetGroup[] {
|
||
const groups: SheetGroup[] = [];
|
||
const used = new Set<number>();
|
||
sheets.forEach((sheet, si) => {
|
||
if (used.has(si)) return;
|
||
const indices = [si];
|
||
sheets.forEach((other, oi) => {
|
||
if (oi <= si || used.has(oi)) return;
|
||
if (isSameSheetLayout(sheet, other)) { indices.push(oi); used.add(oi); }
|
||
});
|
||
used.add(si);
|
||
groups.push({ count: indices.length, repIdx: si, representative: sheet, indices });
|
||
});
|
||
return groups;
|
||
}
|
||
|
||
function isSameSheetLayout(a: Sheet, b: Sheet): boolean {
|
||
if (a.matCode !== b.matCode || a.placements.length !== b.placements.length) return false;
|
||
return a.placements.every((pa, i) => {
|
||
const pb = b.placements[i];
|
||
return (
|
||
pa.itemIdx === pb.itemIdx && pa.w === pb.w && pa.h === pb.h &&
|
||
Math.abs(pa.x - pb.x) < 0.5 && Math.abs(pa.y - pb.y) < 0.5
|
||
);
|
||
});
|
||
}
|
||
|
||
export function computePipeGroups(pipes: Pipe[]): PipeGroup[] {
|
||
const groups: PipeGroup[] = [];
|
||
const used = new Set<number>();
|
||
pipes.forEach((pipe, pi) => {
|
||
if (used.has(pi)) return;
|
||
const indices = [pi];
|
||
pipes.forEach((other, oi) => {
|
||
if (oi <= pi || used.has(oi)) return;
|
||
if (isSamePipeLayout(pipe, other)) { indices.push(oi); used.add(oi); }
|
||
});
|
||
used.add(pi);
|
||
groups.push({ count: indices.length, repIdx: pi, representative: pipe, indices });
|
||
});
|
||
return groups;
|
||
}
|
||
|
||
function isSamePipeLayout(a: Pipe, b: Pipe): boolean {
|
||
if (a.matCode !== b.matCode || a.segments.length !== b.segments.length) return false;
|
||
return a.segments.every((sa, i) => {
|
||
const sb = b.segments[i];
|
||
return sa.itemIdx === sb.itemIdx && sa.len === sb.len;
|
||
});
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// 통계
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
export function computeAreaStats(result: AreaResult) {
|
||
const sheets = result.sheets;
|
||
const count = sheets.length;
|
||
const totalPieces = sheets.reduce((s, sh) => s + sh.placements.length, 0);
|
||
const rotated = sheets.reduce(
|
||
(s, sh) => s + sh.placements.filter((p) => p.rotated).length, 0
|
||
);
|
||
const totalMat = sheets.reduce((s, sh) => s + sh.matW * sh.matH, 0);
|
||
const usedArea = sheets.reduce(
|
||
(s, sh) => s + sh.placements.reduce((ss, p) => ss + p.w * p.h, 0), 0
|
||
);
|
||
const util = totalMat > 0 ? (usedArea / totalMat) * 100 : 0;
|
||
const loss = totalMat - usedArea;
|
||
return { count, totalPieces, rotated, util, loss, usedArea, totalMat };
|
||
}
|
||
|
||
export function computeLengthStats(result: LengthResult) {
|
||
const pipes = result.pipes;
|
||
const count = pipes.length;
|
||
const totalPieces = pipes.reduce((s, p) => s + p.segments.length, 0);
|
||
const totalLen = pipes.reduce((s, p) => s + p.matLen, 0);
|
||
const usedLen = pipes.reduce(
|
||
(s, p) => s + p.segments.reduce((ss, seg) => ss + seg.len, 0), 0
|
||
);
|
||
const util = totalLen > 0 ? (usedLen / totalLen) * 100 : 0;
|
||
const loss = totalLen - usedLen;
|
||
return { count, totalPieces, util, loss, usedLen, totalLen };
|
||
}
|