Files
Beyond-KLE/mock/src/domain/phyCommands.ts
T
ewdernaandClaude Opus 5.5 fccbc28bee mock: 仮決定に沿ってモックを実装する(PHY Editor・IDX・ELC・MAP・Generator)
provisional/decisions.md の仮決定(T-1〜T-28)に従い、mock/ に Vue 3 + Vite + TypeScript のモックを置いた。
T-3 の1〜3(回転したクラスター内のドラッグと保存の往復、IDX・MAPとの連携、ELCと出力の対応表)まで。疑似デバイスとの往復(T-24)は未着手。

- KLE JSONの入出力は、KLE本家の serial.js の deserialize の結果を期待値とする fixture(tests/fixtures/kle-oracle.json)で照合した。本家のコードは取り込んでいない(T-7)
- 基本キーコードの表は QMK の data/constants/keycodes/keycodes_0.0.1_basic.hjson(1e340cd)から抽出した
- 実装は Codex(Luna)、レビューは Codex(Sol)。検証: Vitest 53件、Playwright 20件、vue-tsc、vite build
- TypeScript は vue-tsc が 7 系に未対応のため 6 系に固定した
- 操作性の改善(ELCの提供者の表示、一括配線とGeneratorのピン設定の引き継ぎ、キー内の文字のはみ出し)は別途行う

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AATqjRyp6rrnwTNdYw8XBT
2026-09-25 22:42:30 +09:00

427 lines
16 KiB
TypeScript

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<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: { 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<typeof matrix>): { 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;
}