- domain/keyShape.ts に、キーの矩形・外接矩形・和の外形の計算と、異形キーのプリセット表(今はISO Enterだけ)を置いた - キャンバスと選択枠は、2つ目の矩形を持つキーを和の外形のpathで描く。範囲選択と囲み矩形も両方の矩形で取る - プロパティ欄に「異形キー」のチェックと、2つ目の矩形の欄(X2・Y2・Width2・Height2)を置いた - ツールバーに「異形キーを足す」selectを置いた。1Uと同じ規則の位置に、外接矩形の左上を合わせて足す - 検証: vue-tsc 0件、Vitest 106件(うち新規5件)、Playwright PHY 31件(うち新規1件)、prettier --check Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xmqw9x7EoryY6zf116i3yM
492 lines
18 KiB
TypeScript
492 lines
18 KiB
TypeScript
import type { ClusterNode, KeyNode, Node, PhyFile, Size, Transform, Variant, VariantGroupNode } from "./phy";
|
|
import { apply, compose, inverse, matrix } from "./geometry";
|
|
import { createNodeId } from "./ids";
|
|
import { keyCorners } from "./keyShape";
|
|
|
|
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<string, Container> {
|
|
const m = new Map<string, Container>();
|
|
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<typeof createNodeId>[0], gen?: () => string) {
|
|
return createNodeId(kind, gen);
|
|
}
|
|
/** 新しいPHYを返す。T-11。 */
|
|
export function addKey(
|
|
f: PhyFile,
|
|
at: Transform,
|
|
size: Size,
|
|
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 addKeyToParent(
|
|
f: PhyFile,
|
|
parentId: string | null,
|
|
at: Transform,
|
|
size: Size,
|
|
profile = f.profiles[0]!.id,
|
|
gen?: () => string,
|
|
): PhyFile {
|
|
const o = mutable(f),
|
|
target = parentNodes(o, parentId);
|
|
target.push({ type: "key", id: nodeId("key", gen), profile, transform: { ...at }, size: { ...size } });
|
|
return o;
|
|
}
|
|
|
|
/** 指定コンテナへ部品を追加する。 */
|
|
export function addComponentToParent(
|
|
f: PhyFile,
|
|
parentId: string | null,
|
|
at: Transform,
|
|
partGroup?: string,
|
|
gen?: () => string,
|
|
): PhyFile {
|
|
const o = mutable(f),
|
|
target = parentNodes(o, parentId);
|
|
target.push({
|
|
type: "component",
|
|
id: nodeId("component", gen),
|
|
transform: { ...at },
|
|
...(partGroup ? { partGroup } : {}),
|
|
});
|
|
return o;
|
|
}
|
|
|
|
/** root / cluster / variant の指定先にノード群を挿入する。 */
|
|
export function insertNodes(f: PhyFile, parentId: string | null, nodes: Node[], index?: number): PhyFile {
|
|
const o = mutable(f),
|
|
target = parentNodes(o, parentId);
|
|
target.splice(index ?? target.length, 0, ...structuredClone(nodes));
|
|
return o;
|
|
}
|
|
|
|
function parentNodes(f: PhyFile, parentId: string | null): Node[] {
|
|
if (!parentId) return f.root;
|
|
// variantのIDは containers に子の入れ物として載るので、find ではノードとして引けない
|
|
const n = containers(f)
|
|
.get(parentId)
|
|
?.nodes.find((x) => x.id === parentId);
|
|
if (n?.type === "cluster") return n.children;
|
|
if (n?.type === "variantGroup") throw new Error("Choose a variant as the parent");
|
|
return findVariant(f, parentId).children;
|
|
}
|
|
/** ローカル座標の差分でノードを移動する。 */
|
|
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<typeof matrix>): { x: number; y: number }[] {
|
|
if (n.type === "key") {
|
|
return keyCorners(n.size).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 {
|
|
return createVariantGroupInParent(f, null, name, at, variants, gen);
|
|
}
|
|
/** 指定コンテナへVariantGroupを作成する。T-11。 */
|
|
export function createVariantGroupInParent(
|
|
f: PhyFile,
|
|
parentId: string | null,
|
|
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 ?? []),
|
|
}));
|
|
parentNodes(o, parentId).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 = cloneNodesWithNewIds([n], gen)[0]!;
|
|
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;
|
|
}
|
|
/** ノード群を複製し、配下のIDと閉じたdefault参照を振り直す。T-11。 */
|
|
export function cloneNodesWithNewIds(nodes: Node[], gen?: () => string): Node[] {
|
|
const copies = structuredClone(nodes);
|
|
const remap = new Map<string, string>();
|
|
const renew = (x: Node) => {
|
|
const oldId = x.id;
|
|
x.id = nodeId(x.type, gen);
|
|
remap.set(oldId, x.id);
|
|
if (x.type === "cluster") x.children.forEach(renew);
|
|
if (x.type === "variantGroup")
|
|
for (const v of x.children) {
|
|
const oldVariantId = v.id;
|
|
v.id = nodeId("variant", gen);
|
|
remap.set(oldVariantId, v.id);
|
|
v.children.forEach(renew);
|
|
}
|
|
};
|
|
copies.forEach(renew);
|
|
const remapDefaults = (x: Node) => {
|
|
if (x.type === "cluster") x.children.forEach(remapDefaults);
|
|
if (x.type === "variantGroup") {
|
|
x.default = remap.get(x.default) ?? x.default;
|
|
x.children.forEach((v) => v.children.forEach(remapDefaults));
|
|
}
|
|
};
|
|
copies.forEach(remapDefaults);
|
|
return copies;
|
|
}
|