/** ARRAY 계산 · 물음(337 라 · 설계 1-3) — 직사각형 열 × 행 · 원형 항목 · 채울 각도 · 항목 회전 */ import type { Box, Point } from '@flatten-js/core'; import type { OptionSpec, PromptSpec } from '../commands/prompt'; import type { Entity } from '../entities/Entity'; import { cloneKeepingLook } from './move-tool.helpers'; import type { PickedContext } from './transform.helpers'; export interface ArrayValues extends PickedContext { columns: number; rows: number; columnSpacing: number; rowSpacing: number; center: Point | null; items: number; /** 채울 각도(도 · + 반시계) */ fill: number; rotateItems: boolean; } const opt = (key: string, kw: string, label: string, en = key): OptionSpec => ({ key, kw, en, label, }); export const ARRAY_OPT = { RECTANGULAR: opt('RECTANGULAR', 'R', '직사각형'), POLAR: opt('POLAR', 'PO', '원형'), PATH: opt('PATH', 'PA', '경로'), COUNT: opt('COUNT', 'COU', '개수'), SPACING: opt('SPACING', 'S', '간격두기'), COLUMNS: opt('COLUMNS', 'COL', '열'), ROWS: opt('ROWS', 'R', '행'), POLAR_ROWS: opt('ROWS', 'ROW', '행'), ITEMS: opt('ITEMS', 'I', '항목'), ANGLE: opt('ANGLE', 'A', '사이의 각도'), FILL: opt('FILL', 'F', '채울 각도'), ROTATE: opt('ROTATE', 'ROT', '항목 회전'), EXIT: opt('EXIT', 'X', '종료'), ASSOCIATIVE: opt('ASSOCIATIVE', 'AS', '연관'), BASE: opt('BASE', 'B', '기준점'), LEVELS: opt('LEVELS', 'L', '수준'), AXIS: opt('AXIS', 'A', '회전축'), }; const O = ARRAY_OPT; const COUNT_RULE = { int: true, min: 1, max: 32767 } as const; /** 항목 사이 각 — 채울 각이 한 바퀴면 항목 수로 나눔 · 아니면 사이 수로 */ export const angleBetween = (values: Pick): number => { if (Math.abs(values.fill) >= 360) return values.fill / values.items; return values.items > 1 ? values.fill / (values.items - 1) : 0; }; const value = (text: string, remember: string, initial: number, format?: 'int' | 'ang') => ({ text, default: { kind: 'value' as const, remember, initial, ...(format ? { format } : {}) }, }); /** 물음 — 함수 꼴은 지금 값(빈 context 면 처음 값) */ export const ARRAY_PROMPT = { type: (fallback: string): PromptSpec => ({ text: '배열 유형 입력', options: [O.RECTANGULAR, O.POLAR], absent: [O.PATH], default: { kind: 'option', key: fallback }, }), center: { text: '배열의 중심점 지정', absent: [O.BASE, O.AXIS] } satisfies PromptSpec, editRect: { text: '그립을 선택하여 배열을 편집하거나', options: [O.COUNT, O.SPACING, O.COLUMNS, O.ROWS, O.EXIT], absent: [O.ASSOCIATIVE, O.BASE, O.LEVELS], default: { kind: 'option', key: O.EXIT.key }, } satisfies PromptSpec, editPolar: { text: '그립을 선택하여 배열을 편집하거나', options: [O.ITEMS, O.ANGLE, O.FILL, O.ROTATE, O.EXIT], absent: [O.ASSOCIATIVE, O.BASE, O.POLAR_ROWS, O.LEVELS], default: { kind: 'option', key: O.EXIT.key }, } satisfies PromptSpec, columns: (v: Partial): PromptSpec => ({ ...value('열 수 입력', 'ARRAY.COLS', v.columns ?? 4, 'int'), number: COUNT_RULE, }), rows: (v: Partial): PromptSpec => ({ ...value('행 수 입력', 'ARRAY.ROWS', v.rows ?? 3, 'int'), number: COUNT_RULE, }), columnSpacing: (v: Partial): PromptSpec => value('열 사이의 거리 지정', 'ARRAY.COLDIST', v.columnSpacing ?? 1), rowSpacing: (v: Partial): PromptSpec => value('행 사이의 거리 지정', 'ARRAY.ROWDIST', v.rowSpacing ?? 1), items: (v: Partial): PromptSpec => ({ ...value('배열의 항목 수 입력', 'ARRAY.ITEMS', v.items ?? 6, 'int'), number: COUNT_RULE, }), angle: (v: Partial): PromptSpec => value( '항목 사이의 각도 지정', 'ARRAY.ANGLE', angleBetween({ items: v.items ?? 6, fill: v.fill ?? 360 }), 'ang' ), fill: (v: Partial): PromptSpec => value('채울 각도 지정(+=ccw, -=cw)', 'ARRAY.FILL', v.fill ?? 360, 'ang'), rotateItems: (v: Partial): PromptSpec => ({ text: '배열된 항목을 회전하시겠습니까?', options: [opt('YES', 'Y', '예'), opt('NO', 'N', '아니오')], default: { kind: 'option', key: v.rotateItems === false ? 'NO' : 'YES' }, }), }; /** 고른 것 범위 — 처음 간격 = 폭 · 높이의 1.5 배(0 이면 다른 쪽 · 1) */ export function initialSpacing(originals: Entity[]): { column: number; row: number } { const box = originals.reduce((all, entity) => { const next = entity.getBoundingBox(); return all ? all.merge(next) : next; }, null); const width = box ? box.xmax - box.xmin : 0; const height = box ? box.ymax - box.ymin : 0; const fallback = Math.max(width, height) || 1; return { column: 1.5 * (width || fallback), row: 1.5 * (height || fallback) }; } /** 고른 것 범위 가운데 — 항목 회전 끔일 때 옮김 기준 */ function selectionCenter(originals: Entity[]): Point | null { const boxes = originals.map((entity) => entity.getBoundingBox()); if (!boxes.length) return null; return boxes.reduce((all, next) => all.merge(next)).center; } /** 직사각형 — (0,0) 원본 빼고 열 × 행 사본 */ export function rectangularCopies(values: ArrayValues): Entity[] { const copies: Entity[] = []; for (let row = 0; row < values.rows; row++) { for (let column = 0; column < values.columns; column++) { if (!row && !column) continue; for (const entity of values.originals) { const copy = cloneKeepingLook(entity); copy.move(column * values.columnSpacing, row * values.rowSpacing); copies.push(copy); } } } return copies; } /** 원형 — 중심 둘레 항목 수 − 1 사본 · 항목 회전 끔이면 돌지 않고 자리만 */ export function polarCopies(values: ArrayValues, center = values.center): Entity[] { if (!center) return []; const step = (angleBetween(values) * Math.PI) / 180; const anchor = selectionCenter(values.originals); const copies: Entity[] = []; for (let index = 1; index < values.items; index++) { const radians = step * index; for (const entity of values.originals) { const copy = cloneKeepingLook(entity); if (values.rotateItems || !anchor) { copy.rotate(center, radians); } else { const moved = anchor.rotate(radians, center); copy.move(moved.x - anchor.x, moved.y - anchor.y); } copies.push(copy); } } return copies; }