import type { ClusterNode, KeyNode, Node, PhyFile, Transform, Variant, VariantGroupNode } from "./phy"; import { apply, compose, inverse, matrix } from "./geometry"; import { createNodeId } from "./ids"; const ident = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; type Container = { nodes: Node[]; parentWorld: typeof ident; parentRotation: number }; function containers(f: PhyFile): Map { const m = new Map(); const rec = (ns: Node[], w: typeof ident, angle: number) => { for (const n of ns) { m.set(n.id, { nodes: ns, parentWorld: w, parentRotation: angle }); const nw = compose(w, matrix(n.transform)); const nextAngle = angle + n.transform.rotation; if (n.type === "cluster") rec(n.children, nw, nextAngle); if (n.type === "variantGroup") for (const v of n.children) { m.set(v.id, { nodes: v.children, parentWorld: nw, parentRotation: nextAngle }); rec(v.children, nw, nextAngle); } } }; rec(f.root, ident, 0); return m; } function find(f: PhyFile, id: string): Node { const c = containers(f).get(id); const n = c?.nodes.find((x) => x.id === id); if (!n) throw new Error(`Unknown node: ${id}`); return n; } function world(f: PhyFile, id: string): typeof ident { const c = containers(f).get(id); const n = c?.nodes.find((x) => x.id === id); if (!n) throw new Error(`Unknown node: ${id}`); return compose(c!.parentWorld, matrix(n.transform)); } function worldRotation(f: PhyFile, id: string): number { const c = containers(f).get(id); const n = c?.nodes.find((x) => x.id === id); if (!n || !c) throw new Error(`Unknown node: ${id}`); return c.parentRotation + n.transform.rotation; } function mutable(f: PhyFile): PhyFile { return structuredClone(f); } function nodeId(kind: Parameters[0], gen?: () => string) { return createNodeId(kind, gen); } /** 新しいPHYを返す。T-11。 */ export function addKey( f: PhyFile, at: Transform, size: { w: number; h: number }, profile = f.profiles[0]!.id, gen?: () => string, ): PhyFile { const o = mutable(f); o.root.push({ type: "key", id: nodeId("key", gen), profile, transform: { ...at }, size: { ...size } }); return o; } /** 新しいPHYを返す。T-11。 */ export function addComponent(f: PhyFile, at: Transform, partGroup?: string, gen?: () => string): PhyFile { const o = mutable(f); o.root.push({ type: "component", id: nodeId("component", gen), transform: { ...at }, ...(partGroup === undefined ? {} : { partGroup }), }); return o; } /** ローカル座標の差分でノードを移動する。 */ export function moveNode(f: PhyFile, id: string, dx: number, dy: number): PhyFile { const o = mutable(f), n = find(o, id); n.transform.x += dx; n.transform.y += dy; return o; } /** ノードを回転する。 */ export function rotateNode(f: PhyFile, id: string, delta: number): PhyFile { const o = mutable(f), n = find(o, id); n.transform.rotation += delta; return o; } /** キーのサイズを変更する。 */ export function resizeKey(f: PhyFile, id: string, size: KeyNode["size"]): PhyFile { const o = mutable(f), n = find(o, id); if (n.type !== "key") throw new Error(`${id} is not a key`); n.size = { ...size }; return o; } /** 同じ親のノードを新クラスターにまとめ、絶対変換を保つ。T-11。 */ export function clusterNodes(f: PhyFile, ids: string[], name = "Cluster", gen?: () => string): PhyFile { if (ids.length < 1) throw new Error("Select at least one node"); const o = mutable(f), cs = containers(o), first = cs.get(ids[0]!); if (!first || ids.some((id) => cs.get(id)?.nodes !== first.nodes)) throw new Error("Selected nodes must be siblings"); const selected = ids.map((id) => first.nodes.find((n) => n.id === id)!); if (selected.some((n) => !n)) throw new Error("Unknown selection"); const angles = selected.map((n) => world(o, n.id)); const selectedAngles = selected.map((n) => first.parentRotation + n.transform.rotation); const angle = selectedAngles[0]!; const same = selectedAngles.every((candidate) => Math.abs(candidate - angle) < 1e-9); const basis = matrix({ x: 0, y: 0, rotation: same ? angle : 0 }), basisInverse = inverse(basis); const shapePoints = selected.flatMap((n, i) => boundsPoints(n, angles[i]!)); if (shapePoints.length === 0) angles.forEach((m) => shapePoints.push({ x: m.e, y: m.f })); const localOrigins = shapePoints.map((p) => apply(basisInverse, p)); const min = { x: Math.min(...localOrigins.map((p) => p.x)), y: Math.min(...localOrigins.map((p) => p.y)) }; const origin = apply(basis, min); const wt = matrix({ x: origin.x, y: origin.y, rotation: same ? angle : 0 }); const local = compose(inverse(first.parentWorld), wt); const id = nodeId("cluster", gen), cluster: ClusterNode = { type: "cluster", id, name, transform: { x: local.e, y: local.f, rotation: (same ? angle : 0) - first.parentRotation }, children: [], }; const inv = inverse(wt); cluster.children = selected.map((n) => { const w = world(o, n.id), rel = compose(inv, w); return { ...n, transform: { x: rel.e, y: rel.f, rotation: selectedAngles[selected.indexOf(n)]! - (same ? angle : 0) }, } as Node; }); const indices = selected.map((n) => first.nodes.indexOf(n)).sort((a, b) => a - b); const insert = indices[0]!; first.nodes.splice(0, first.nodes.length, ...first.nodes.filter((n) => !ids.includes(n.id))); first.nodes.splice(insert, 0, cluster); return o; } /** clusterの子を同じ親へ戻し、絶対変換を保つ。 */ export function uncluster(f: PhyFile, id: string): PhyFile { const o = mutable(f), cs = containers(o), c = cs.get(id), cluster = c?.nodes.find((n) => n.id === id); if (!cluster || cluster.type !== "cluster") throw new Error(`${id} is not a cluster`); const clusterWorldRotation = c!.parentRotation + cluster.transform.rotation; const cw = compose(c!.parentWorld, matrix(cluster.transform)), converted = cluster.children.map((n) => { const w = compose(cw, matrix(n.transform)), rel = compose(inverse(c!.parentWorld), w); return { ...n, transform: { x: rel.e, y: rel.f, rotation: clusterWorldRotation + n.transform.rotation - c!.parentRotation, }, } as Node; }); const index = c!.nodes.indexOf(cluster); c!.nodes.splice(index, 1, ...converted); return o; } /** ノードを任意の親/rootへ移し、絶対変換を保つ。T-27。 */ export function reparent(f: PhyFile, id: string, newParentId: string | null, index?: number): PhyFile { const o = mutable(f), cs = containers(o), src = cs.get(id), n = src?.nodes.find((x) => x.id === id); if (!n) throw new Error(`Unknown node: ${id}`); const oldW = world(o, id), oldRotation = worldRotation(o, id); let target: Node[] = o.root, parentW = ident, parentRotation = 0; if (newParentId) { const p = find(o, newParentId); if (p.type !== "cluster") throw new Error("Target parent must be a cluster; use reparentToVariant for variant children"); if (containsNode(n, newParentId)) throw new Error("Cannot reparent a node into its descendant"); target = p.children; parentW = world(o, p.id); parentRotation = worldRotation(o, p.id); } const oldIndex = src!.nodes.indexOf(n), sameParent = target === src!.nodes; src!.nodes.splice(oldIndex, 1); const rel = compose(inverse(parentW), oldW); n.transform = { x: rel.e, y: rel.f, rotation: oldRotation - parentRotation }; let at = index ?? target.length; if (sameParent && index !== undefined && oldIndex < index) at--; target.splice(Math.max(0, Math.min(at, target.length)), 0, n); return o; } /** variant内へ移動するreparent。T-27。 */ export function reparentToVariant(f: PhyFile, id: string, variantId: string, index?: number): PhyFile { const o = mutable(f), cs = containers(o), src = cs.get(id), n = src?.nodes.find((x) => x.id === id); const v = findVariant(o, variantId); if (!n || !src) throw new Error(`Unknown node: ${id}`); if (containsNode(n, variantId)) throw new Error("Cannot reparent a node into its descendant"); const old = world(o, id), oldRotation = worldRotation(o, id), parent = srcVariantWorld(o, variantId), rel = compose(inverse(parent), old); const parentRotation = srcVariantRotation(o, variantId); src.nodes.splice(src.nodes.indexOf(n), 1); n.transform = { x: rel.e, y: rel.f, rotation: oldRotation - parentRotation }; v.children.splice(index ?? v.children.length, 0, n); return o; } function containsNode(n: Node, id: string): boolean { if (n.id === id) return true; if (n.type === "cluster") return n.children.some((c) => containsNode(c, id)); if (n.type === "variantGroup") return n.children.some((v) => v.id === id || v.children.some((c) => containsNode(c, id))); return false; } function boundsPoints(n: Node, worldMatrix: ReturnType): { x: number; y: number }[] { if (n.type === "key") { const points = [ { 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.w2 !== undefined || n.size.h2 !== undefined || n.size.x2 !== undefined || n.size.y2 !== undefined ) { const x = n.size.x2 ?? 0; const y = n.size.y2 ?? 0; const w = n.size.w2 ?? n.size.w; const h = n.size.h2 ?? n.size.h; points.push({ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }); } return points.map((point) => apply(worldMatrix, point)); } if (n.type === "component") return [{ x: worldMatrix.e, y: worldMatrix.f }]; if (n.type === "cluster") return n.children.flatMap((child) => boundsPoints(child, compose(worldMatrix, matrix(child.transform)))); const selected = n.children.find((variant) => variant.id === n.default); return selected ? selected.children.flatMap((child) => boundsPoints(child, compose(worldMatrix, matrix(child.transform)))) : [{ x: worldMatrix.e, y: worldMatrix.f }]; } function findVariant(f: PhyFile, id: string): Variant { for (const n of f.root) { const r = walk(n, id); if (r) return r; } throw new Error(`Unknown variant: ${id}`); } function walk(n: Node, id: string): Variant | undefined { if (n.type === "variantGroup") { for (const v of n.children) { if (v.id === id) return v; for (const c of v.children) { const r = walk(c, id); if (r) return r; } } } if (n.type === "cluster") for (const c of n.children) { const r = walk(c, id); if (r) return r; } return undefined; } function srcVariantWorld(f: PhyFile, id: string): typeof ident { const rec = (ns: Node[], w: typeof ident): typeof ident | undefined => { for (const n of ns) { const nw = compose(w, matrix(n.transform)); if (n.type === "variantGroup") for (const v of n.children) { if (v.id === id) return nw; const r = rec(v.children, nw); if (r) return r; } if (n.type === "cluster") { const r = rec(n.children, nw); if (r) return r; } } return undefined; }; const x = rec(f.root, ident); if (!x) throw new Error(`Unknown variant ${id}`); return x; } function srcVariantRotation(f: PhyFile, id: string): number { const rec = (nodes: Node[], parentRotation: number): number | undefined => { for (const n of nodes) { const angle = parentRotation + n.transform.rotation; if (n.type === "variantGroup") for (const v of n.children) { if (v.id === id) return angle; const found = rec(v.children, angle); if (found !== undefined) return found; } if (n.type === "cluster") { const found = rec(n.children, angle); if (found !== undefined) return found; } } return undefined; }; const found = rec(f.root, 0); if (found === undefined) throw new Error(`Unknown variant ${id}`); return found; } /** クラスターの座標枠を変え、配下の絶対位置を保つ。PHY-P-13仮決定。 */ export function reframe(f: PhyFile, id: string, frame: Transform): PhyFile { const o = mutable(f), n = find(o, id); if (n.type !== "cluster") throw new Error(`${id} is not a cluster`); const c = containers(o).get(id)!, oldRotation = c.parentRotation + n.transform.rotation, old = compose(c.parentWorld, matrix(n.transform)), next = compose(c.parentWorld, matrix(frame)), correction = compose(inverse(next), old); n.children = n.children.map((ch) => { const t = compose(correction, matrix(ch.transform)); return { ...ch, transform: { x: t.e, y: t.f, rotation: oldRotation + ch.transform.rotation - (c.parentRotation + frame.rotation), }, } as Node; }); n.transform = { ...frame }; return o; } /** VariantGroupを作成する。T-11。 */ export function createVariantGroup( f: PhyFile, name: string, at: Transform, variants: [{ name: string; children?: Node[] }, { name: string; children?: Node[] }], gen?: () => string, ): PhyFile { const o = mutable(f), children = variants.map((x) => ({ type: "variant" as const, id: nodeId("variant", gen), name: x.name, children: structuredClone(x.children ?? []), })); o.root.push({ type: "variantGroup", id: nodeId("variantGroup", gen), name, transform: { ...at }, default: children[0]!.id, children, }); return o; } /** Variantを追加する。T-11。 */ export function addVariant(f: PhyFile, groupId: string, name: string, gen?: () => string): PhyFile { const o = mutable(f), g = find(o, groupId); if (g.type !== "variantGroup") throw new Error("Not a variant group"); g.children.push({ type: "variant", id: nodeId("variant", gen), name, children: [] }); return o; } /** Variantを削除する。子が1つになる場合は拒否し、default削除時は先頭へ移す。T-15。 */ export function deleteVariant(f: PhyFile, groupId: string, variantId: string): PhyFile { const o = mutable(f), g = find(o, groupId); if (g.type !== "variantGroup") throw new Error("Not a variant group"); if (g.children.length <= 2) throw new Error("Cannot leave a VariantGroup with fewer than two variants"); const i = g.children.findIndex((v) => v.id === variantId); if (i < 0) throw new Error("Unknown variant"); g.children.splice(i, 1); if (g.default === variantId) g.default = g.children[0]!.id; return o; } /** default Variantを変更する。 */ export function setDefaultVariant(f: PhyFile, groupId: string, variantId: string): PhyFile { const o = mutable(f), g = find(o, groupId); if (g.type !== "variantGroup" || !g.children.some((v) => v.id === variantId)) throw new Error("Invalid group or variant"); g.default = variantId; return o; } /** ノードを削除する。 */ export function deleteNode(f: PhyFile, id: string): PhyFile { const o = mutable(f), c = containers(o).get(id); if (!c) throw new Error(`Unknown node: ${id}`); const i = c.nodes.findIndex((n) => n.id === id); c.nodes.splice(i, 1); return o; } /** ノードと配下を複製し、すべてのIDを新しくする。T-11。 */ export function duplicateNode(f: PhyFile, id: string, gen?: () => string): PhyFile { const o = mutable(f), n = find(o, id), copy = structuredClone(n); const renew = (x: Node) => { x.id = nodeId(x.type, gen); if (x.type === "cluster") x.children.forEach(renew); if (x.type === "variantGroup") for (const v of x.children) { v.id = nodeId("variant", gen); v.children.forEach(renew); } }; renew(copy); copy.transform.x += 0.25; copy.transform.y += 0.25; const c = containers(o).get(id)!; c.nodes.splice(c.nodes.indexOf(n) + 1, 0, copy); return o; }