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
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user