/** * 절단 배치(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([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(); 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(); const ySet = new Set(); 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(); 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(); 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(); 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(); 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(); 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 }; }