import type { Node, PhyFile, Transform } from "../../domain/phy"; import { apply, compose, inverse, matrix, type Affine } from "../../domain/geometry"; export function normalizeSelection(file: PhyFile, ids: string[]): string[] { const parent = new Map(), nodes = new Map(); const walk = (items: Node[], p: string | null) => items.forEach((n) => { parent.set(n.id, p); nodes.set(n.id, n); if (n.type === "cluster") walk(n.children, n.id); if (n.type === "variantGroup") n.children.forEach((v) => { parent.set(v.id, n.id); walk(v.children, v.id); }); }); walk(file.root, null); const out: string[] = []; for (const id of ids) { if (!nodes.has(id) || out.includes(id)) continue; const ancestors = new Set(); let p = parent.get(id) ?? null; while (p) { ancestors.add(p); p = parent.get(p) ?? null; } const keep = out.filter((x) => !ancestors.has(x) && !isDescendant(parent, x, id)); out.splice(0, out.length, ...keep, id); } return out; } function isDescendant(parent: Map, candidate: string, ancestor: string): boolean { let p = parent.get(candidate) ?? null; while (p) { if (p === ancestor) return true; p = parent.get(p) ?? null; } return false; } export function longPressColumn(keyId: string, ancestorsNearestFirst: string[], focusId?: string): string[] { const list = [keyId, ...ancestorsNearestFirst]; if (focusId) { const i = list.indexOf(focusId); if (i >= 0) list.splice(i); } return list; } export function tabOrder(items: T[]): T[] { const sorted = [...items].sort((a, b) => a.y - b.y), rows: T[][] = []; for (const item of sorted) { const row = rows.at(-1); if (!row || item.y - row[0]!.y >= 0.5) rows.push([item]); else row.push(item); } return rows.flatMap((row) => row.sort((a, b) => a.x - b.x)); } /** 1つの走査結果からノードの親と挿入先の器を引けるようにする。 */ export function treeIndex(roots: Node[]) { const nodes = new Map(); const parents = new Map(); const containers = new Map(); const variants = new Map(); const visit = (items: Node[], parent: string | null, container: string | null) => { for (const n of items) { nodes.set(n.id, n); parents.set(n.id, parent); containers.set(n.id, container); if (n.type === "cluster") visit(n.children, n.id, n.id); if (n.type === "variantGroup") { for (const v of n.children) { variants.set(v.id, n.id); visit(v.children, n.id, v.id); } } } }; visit(roots, null, null); return { nodes, parents, containers, variants, selectableParent(id: string) { return parents.get(id) ?? null; }, insertionContainer(id: string) { return containers.get(id) ?? null; }, groupForVariant(id: string) { return variants.get(id) ?? null; }, }; } export function treeNodes(roots: Node[]): Node[] { return [...treeIndex(roots).nodes.values()]; } export function structuralNodes(roots: Node[]): Extract[] { return treeNodes(roots).filter( (node): node is Extract => node.type === "cluster" || node.type === "variantGroup", ); } export function removeFocusedSubtree(nodes: Node[], focusedId: string): Node[] { const result: Node[] = []; for (const node of nodes) { if (node.id === focusedId) continue; if (node.type === "cluster") { const children = removeFocusedSubtree(node.children, focusedId); result.push({ ...node, children }); } else if (node.type === "variantGroup") { result.push({ ...node, children: node.children.map((variant) => ({ ...variant, children: removeFocusedSubtree(variant.children, focusedId), })), }); } else { result.push(node); } } return result; } export function visibleNodeIds(root: Node | undefined, shownVariants: Record): Set { const result = new Set(); const visit = (node: Node) => { result.add(node.id); if (node.type === "cluster") node.children.forEach(visit); if (node.type === "variantGroup") { const variant = node.children.find((item) => item.id === (shownVariants[node.id] ?? node.default)); variant?.children.forEach(visit); } }; if (root) visit(root); return result; } export function selectableParent(roots: Node[], id: string): string | null { return treeIndex(roots).selectableParent(id); } export function insertionContainer( roots: Node[], parentId: string | null, shownVariants: Record = {}, ): string | null { if (parentId === null) return null; const index = treeIndex(roots); const node = index.nodes.get(parentId); if (node?.type === "cluster") return node.id; if (node?.type === "variantGroup") return shownVariants[node.id] ?? node.default; return index.variants.get(parentId) === undefined ? null : parentId; } export function snap(value: number, step: number): number { return Math.round(value / step) * step; } export const GRID_STEPS = [1, 0.25, 0.125, 1 / 12] as const; export function nextGrid(step: number, direction: 1 | -1): number { const i = GRID_STEPS.findIndex((x) => Math.abs(x - step) < 1e-9); return GRID_STEPS[(i + direction + GRID_STEPS.length) % GRID_STEPS.length]!; } export function resolveKey( e: Pick, ): string | null { const mod = e.ctrlKey || e.metaKey; if (e.code === "Digit1" && e.shiftKey) return "fit"; if (e.code === "Digit2" && e.shiftKey) return "selection-fit"; if (mod && e.key.toLowerCase() === "z") return e.shiftKey ? "redo" : "undo"; if (mod && e.key.toLowerCase() === "y") return "redo"; if (mod && e.key.toLowerCase() === "s") return "save"; if (mod && e.key.toLowerCase() === "g") return e.shiftKey ? "ungroup" : "group"; if (mod && ["a", "c", "x", "v", "d"].includes(e.key.toLowerCase())) return e.key.toLowerCase(); return e.key; } export type Box = { x: number; y: number; width: number; height: number }; export function obbAabb(points: { x: number; y: number }[]): Box { if (!points.length) return { x: 0, y: 0, width: 0, height: 0 }; const xs = points.map((p) => p.x), ys = points.map((p) => p.y), x = Math.min(...xs), y = Math.min(...ys); return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y }; } export function pasteParent(selectedParents: (string | null)[], focus: string | null): string | null { return selectedParents.length ? selectedParents[0]! : focus; } /** 子のローカル形状を親座標でまとめる。回転親に追従する選択枠用。 */ export function localBounds(nodes: Node[], variants: Record = {}): Box { const points: { x: number; y: number }[] = []; const visit = (items: Node[], parent: Affine) => items.forEach((n) => { const w = compose(parent, matrix(n.transform)); if (n.type === "key") { const corners = [ { x: 0, y: 0 }, { x: n.size.w, y: 0 }, { x: n.size.w, y: n.size.h }, { x: 0, y: n.size.h }, ]; if ( n.size.x2 !== undefined || n.size.y2 !== undefined || n.size.w2 !== undefined || n.size.h2 !== undefined ) { const x = n.size.x2 ?? 0, y = n.size.y2 ?? 0, ww = n.size.w2 ?? n.size.w, hh = n.size.h2 ?? n.size.h; corners.push({ x, y }, { x: x + ww, y }, { x: x + ww, y: y + hh }, { x, y: y + hh }); } points.push(...corners.map((p) => apply(w, p))); } else if (n.type === "component") { const c = apply(w, { x: 0, y: 0 }); points.push({ x: c.x - 0.16, y: c.y - 0.16 }, { x: c.x + 0.16, y: c.y + 0.16 }); } else if (n.type === "cluster") visit(n.children, w); else { const v = n.children.find((x) => x.id === (variants[n.id] ?? n.default)); if (v) visit(v.children, w); } }); visit(nodes, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); return obbAabb(points); } export type SelectionFrame = { id: string; type: Node["type"]; transform: Affine; box: Box; selected: boolean; candidate: boolean; }; /** 可視ノードの選択枠をワールド行列とローカル矩形で返す。 */ export function selectionFrames( roots: Node[], selected: string[], variants: Record, candidate: string | null, parent: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, ): SelectionFrame[] { const frames: SelectionFrame[] = []; const visit = (nodes: Node[], ancestor: Affine) => { for (const node of nodes) { const transform = compose(ancestor, matrix(node.transform)); if (node.type === "key" && (selected.includes(node.id) || candidate === node.id)) { frames.push({ id: node.id, type: node.type, transform, box: { x: 0, y: 0, width: node.size.w, height: node.size.h }, selected: selected.includes(node.id), candidate: candidate === node.id, }); } else if (node.type === "cluster") { if (selected.includes(node.id) || candidate === node.id) frames.push({ id: node.id, type: node.type, transform, box: localBounds(node.children, variants), selected: selected.includes(node.id), candidate: candidate === node.id, }); visit(node.children, transform); } else if (node.type === "variantGroup") { const variant = node.children.find((item) => item.id === (variants[node.id] ?? node.default)); if (selected.includes(node.id) || candidate === node.id) frames.push({ id: node.id, type: node.type, transform, box: localBounds(variant?.children ?? [], variants), selected: selected.includes(node.id), candidate: candidate === node.id, }); if (variant) visit(variant.children, transform); } } }; visit(roots, parent); return frames; } export type VariantBadge = { group: Extract; x: number; y: number; size: number; }; /** 表示中のVariantGroupのバッジを、OBBの右上に正立で置く。大きさは画面上で固定する。 */ export function variantBadges( roots: Node[], variants: Record, zoom: number, parent: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, ): VariantBadge[] { const badges: VariantBadge[] = []; const size = 20 / zoom; const visit = (nodes: Node[], ancestor: Affine) => { for (const node of nodes) { const transform = compose(ancestor, matrix(node.transform)); if (node.type === "cluster") visit(node.children, transform); else if (node.type === "variantGroup") { const variant = node.children.find((item) => item.id === (variants[node.id] || node.default)); const box = localBounds(variant?.children ?? [], variants); const corner = apply(transform, { x: box.x + box.width + 4 / zoom, y: box.y - size - 4 / zoom }); badges.push({ group: node, x: corner.x, y: corner.y, size }); if (variant) visit(variant.children, transform); } } }; visit(roots, parent); return badges; } /** ワールド位置を貼り先の親ローカルへ戻し、親軸で1刻みずらす。T-11/T-27。 */ export function pasteLocalTransform(sourceWorld: Affine, parentWorld: Affine, step: number): Transform { const local = compose(inverse(parentWorld), sourceWorld); return { x: local.e + step, y: local.f + step, rotation: (Math.atan2(local.b, local.a) * 180) / Math.PI }; }