Files
vexplor/frontend/lib/cutting/packing.ts
DDD1542 28c1c8c029 feat: Add cutting plan management for COMPANY_30
- 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>
2026-04-23 14:03:45 +09:00

978 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 절단 배치(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 };
}