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:
2026-09-25 22:42:30 +09:00
co-authored by Claude Opus 5.5
parent 937fc27dbd
commit fccbc28bee
59 changed files with 15509 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
import type { ProjectFile } from "./project";
import type { Node, Variant, VariantGroupNode } from "./phy";
export type CascadeReference = { layer: "idx" | "elc" | "map" | "ledChains"; location: string; id: string };
type DeleteTarget =
{ kind: "node"; node: Node } | { kind: "variant"; variant: Variant; group: VariantGroupNode };
function descendants(node: Node | Variant): string[] {
const ids = [node.id];
if (node.type === "cluster") node.children.forEach((child) => ids.push(...descendants(child)));
if (node.type === "variantGroup") for (const variant of node.children) ids.push(...descendants(variant));
if (node.type === "variant") node.children.forEach((child) => ids.push(...descendants(child)));
return ids;
}
/** Lists all saved references to a PHY node, Variant, and its descendants. T-15. */
export function listDeleteReferences(p: ProjectFile, nodeId: string): CascadeReference[] {
const target = findTarget(p.phy.root, nodeId);
if (!target) throw new Error(`Unknown node: ${nodeId}`);
const ids = new Set(descendants(target.kind === "node" ? target.node : target.variant));
const out: CascadeReference[] = [];
p.idx.order.forEach((id) => {
if (ids.has(id)) out.push({ layer: "idx", location: "/idx/order", id });
});
p.elc.keys.forEach((w, i) => {
if (ids.has(w.key)) out.push({ layer: "elc", location: `/elc/keys/${i}`, id: w.key });
});
p.elc.ledChains?.forEach((chain) =>
chain.order.forEach((id) => {
if (ids.has(id)) out.push({ layer: "ledChains", location: `/elc/ledChains/${chain.id}`, id });
}),
);
p.map.layers.forEach((layer) =>
layer.keymap.forEach((assignment) => {
if (ids.has(assignment.keyId))
out.push({ layer: "map", location: `/map/layers/${layer.name}`, id: assignment.keyId });
}),
);
return out;
}
/** Removes a PHY node or Variant and its references in one immutable project result. T-15. */
export function deleteNodeCascade(p: ProjectFile, nodeId: string): ProjectFile {
const o = structuredClone(p);
const target = findTarget(o.phy.root, nodeId);
if (!target) throw new Error(`Unknown node: ${nodeId}`);
const deletedIds = new Set(descendants(target.kind === "node" ? target.node : target.variant));
removeTarget(o.phy.root, target);
o.idx.order = o.idx.order.filter((id) => !deletedIds.has(id));
o.elc.keys = o.elc.keys.filter((w) => !deletedIds.has(w.key));
if (o.elc.ledChains)
o.elc.ledChains = o.elc.ledChains.map((chain) => ({
...chain,
order: chain.order.filter((id) => !deletedIds.has(id)),
}));
o.map.layers = o.map.layers.map((layer) => ({
...layer,
keymap: layer.keymap.filter((assignment) => !deletedIds.has(assignment.keyId)),
}));
if (target.kind === "variant") {
if (target.group.default === nodeId) target.group.default = target.group.children[0]!.id;
if (o.output.variantSelection[target.group.id] === nodeId)
o.output.variantSelection[target.group.id] = target.group.default;
}
pruneVariantSelection(o);
return o;
}
function findTarget(nodes: Node[], id: string): DeleteTarget | undefined {
for (const node of nodes) {
if (node.id === id) return { kind: "node", node };
if (node.type === "cluster") {
const found = findTarget(node.children, id);
if (found) return found;
}
if (node.type === "variantGroup")
for (const variant of node.children) {
if (variant.id === id) return { kind: "variant", variant, group: node };
const found = findTarget(variant.children, id);
if (found) return found;
}
}
return undefined;
}
function removeTarget(nodes: Node[], target: DeleteTarget): void {
const id = target.kind === "node" ? target.node.id : target.variant.id;
if (target.kind === "variant") {
const { group, variant } = target;
if (group.children.length <= 2)
throw new Error("Cannot delete a variant leaving fewer than two variants");
group.children.splice(group.children.indexOf(variant), 1);
if (group.default === id) group.default = group.children[0]!.id;
return;
}
const index = nodes.findIndex((node) => node.id === id);
if (index >= 0) {
nodes.splice(index, 1);
return;
}
for (const node of nodes) {
if (node.type === "cluster") removeTarget(node.children, target);
if (node.type === "variantGroup")
for (const variant of node.children) removeTarget(variant.children, target);
}
}
function pruneVariantSelection(project: ProjectFile): void {
const valid = new Map<string, { ids: Set<string>; defaultId: string }>();
const walk = (nodes: Node[]) =>
nodes.forEach((node) => {
if (node.type === "cluster") walk(node.children);
if (node.type === "variantGroup") {
valid.set(node.id, {
ids: new Set(node.children.map((variant) => variant.id)),
defaultId: node.default,
});
node.children.forEach((variant) => walk(variant.children));
}
});
walk(project.phy.root);
for (const [groupId, variantId] of Object.entries(project.output.variantSelection)) {
const group = valid.get(groupId);
if (!group) delete project.output.variantSelection[groupId];
else if (!group.ids.has(variantId)) project.output.variantSelection[groupId] = group.defaultId;
}
}
+131
View File
@@ -0,0 +1,131 @@
import type { PhyReference } from "./idx";
export type PinRef = string;
export type McuProvider = { id: string; kind: "mcu"; part: string; [key: string]: unknown };
export type AnalogMuxProvider = {
id: string;
kind: "analog-mux";
channels: number;
select: PinRef[];
common: PinRef;
enable?: PinRef;
[key: string]: unknown;
};
export type ContactWiring = {
key: string;
detect: "contact";
terminals: { a: PinRef; b: PinRef };
diode?: "a-to-b" | "b-to-a";
[key: string]: unknown;
};
export type AnalogWiring = {
key: string;
detect: "analog";
terminals: { out: PinRef; vcc: PinRef; gnd: PinRef };
[key: string]: unknown;
};
export type KeyWiring = ContactWiring | AnalogWiring;
export type LedChain = { id: string; data: PinRef; order: string[]; [key: string]: unknown };
export type ElcFile = {
version: string;
phyReference: PhyReference;
providers: (McuProvider | AnalogMuxProvider)[];
keys: KeyWiring[];
ledChains?: LedChain[];
[key: string]: unknown;
};
/** Returns RP2040 GPIO pin suggestions, or no fixed list for unknown parts. T-26. */
export function mcuPinCandidates(provider: McuProvider): string[] {
return provider.part.toUpperCase() === "RP2040" ? Array.from({ length: 30 }, (_, i) => `GP${i}`) : [];
}
/** Creates an empty ELC file. */
export function createElc(ref: PhyReference): ElcFile {
return { version: "0.1.0", phyReference: structuredClone(ref), providers: [], keys: [], ledChains: [] };
}
/** Adds or replaces provider settings, rejecting IDs that conflict with PinRef syntax. T-26. */
export function setProvider(elc: ElcFile, p: McuProvider | AnalogMuxProvider): ElcFile {
if (!p.id || p.id.includes(".")) throw new Error("Provider ID must be nonempty and cannot contain '.'");
const o = structuredClone(elc);
const i = o.providers.findIndex((x) => x.id === p.id);
if (i < 0) o.providers.push(structuredClone(p));
else {
const old = o.providers[i]!;
const known = new Set(["id", "kind", "part", "channels", "select", "common", "enable"]);
const extensions = Object.fromEntries(Object.entries(old).filter(([key]) => !known.has(key)));
o.providers[i] = { ...extensions, ...(old.kind === p.kind ? old : {}), ...p } as
McuProvider | AnalogMuxProvider;
}
return o;
}
/** Removes a provider and its dependent references. */
export function removeProvider(elc: ElcFile, id: string): ElcFile {
const o = structuredClone(elc),
removed = new Set([id]);
let changed = true;
while (changed) {
changed = false;
for (const p of o.providers)
if (p.kind === "analog-mux" && !removed.has(p.id)) {
const refs = [...p.select, p.common, ...(p.enable ? [p.enable] : [])];
if (refs.some((ref) => [...removed].some((owner) => ref.startsWith(owner + ".")))) {
removed.add(p.id);
changed = true;
}
}
}
const pin = (s: string) => [...removed].some((owner) => s.startsWith(owner + "."));
o.providers = o.providers.filter((p) => !removed.has(p.id));
o.keys = o.keys.filter((k) => !Object.values(k.terminals).some((v) => pin(v)));
if (o.ledChains) o.ledChains = o.ledChains.filter((c) => !pin(c.data));
return o;
}
/** Adds or edits terminal and diode configuration for a key. */
export function setKeyWiring(elc: ElcFile, w: KeyWiring): ElcFile {
const o = structuredClone(elc);
const i = o.keys.findIndex((x) => x.key === w.key);
if (i < 0) o.keys.push(structuredClone(w));
else {
const old = o.keys[i]!;
const known = new Set(["key", "detect", "terminals", "diode"]);
const extensions = Object.fromEntries(Object.entries(old).filter(([key]) => !known.has(key)));
const knownTerminals = old.detect === "contact" ? new Set(["a", "b"]) : new Set(["out", "vcc", "gnd"]);
const terminalExtensions = Object.fromEntries(
Object.entries(old.terminals).filter(([key]) => !knownTerminals.has(key)),
);
const updated = {
...extensions,
...(old.detect === w.detect ? old : {}),
...w,
terminals: { ...terminalExtensions, ...(old.detect === w.detect ? old.terminals : {}), ...w.terminals },
} as KeyWiring;
if (w.detect === "contact" && w.diode === undefined) delete (updated as ContactWiring).diode;
o.keys[i] = updated;
}
return o;
}
/** Removes wiring for a key. */
export function removeKeyWiring(elc: ElcFile, keyId: string): ElcFile {
const o = structuredClone(elc);
o.keys = o.keys.filter((x) => x.key !== keyId);
return o;
}
/** Returns keys without any ELC entry. */
export function unwiredKeys(keyIds: string[], elc: ElcFile): string[] {
const wired = new Set(elc.keys.map((k) => k.key));
return keyIds.filter((k) => !wired.has(k));
}
/** Creates an LED chain with an independent ordered node list. T-11. */
export function createLedChain(elc: ElcFile, id: string, data: PinRef): ElcFile {
const o = structuredClone(elc);
o.ledChains ??= [];
if (o.ledChains.some((c) => c.id === id)) throw new Error("Duplicate LED chain ID");
o.ledChains.push({ id, data, order: [] });
return o;
}
/** Replaces LED order while preserving the chain's other fields. */
export function setLedOrder(elc: ElcFile, id: string, order: string[]): ElcFile {
const o = structuredClone(elc);
const c = o.ledChains?.find((x) => x.id === id);
if (!c) throw new Error("Unknown LED chain");
c.order = [...order];
return o;
}
+26
View File
@@ -0,0 +1,26 @@
import { setKeyWiring, type ContactWiring, type ElcFile } from "./elc";
/** Assigns a row-major matrix to the supplied keys. The input order is preserved. */
export function wireMatrixKeys(
elc: ElcFile,
keyIds: string[],
rowPins: string[],
colPins: string[],
diode?: "a-to-b" | "b-to-a",
): ElcFile {
if (!rowPins.length || !colPins.length) throw new Error("rowPins and colPins are required");
if (keyIds.length > rowPins.length * colPins.length)
throw new Error("Matrix does not have enough row/column positions");
let next = elc;
keyIds.forEach((key, i) => {
const wiring: ContactWiring = {
key,
detect: "contact",
terminals: { a: rowPins[Math.floor(i / colPins.length)]!, b: colPins[i % colPins.length]! },
...(diode ? { diode } : {}),
};
// Keep unknown extension fields on an existing ELC entry, just like normal edits.
next = setKeyWiring(next, wiring);
});
return next;
}
+102
View File
@@ -0,0 +1,102 @@
import type { ProjectFile } from "./project";
import { flattenKeys } from "./geometry";
import { validateProject, type ValidationIssue } from "./validate";
export type MockTarget = {
format: "mock-target";
version: "0.1.0";
layout: Array<{ keyId: string; x: number; y: number; rotation: number; w: number; h: number }>;
idx: string[];
matrix: { rowPins: string[]; colPins: string[]; rows: number[]; cols: number[] };
keymaps: Array<{ name: string; keycodes: string[] }>;
mapping: Array<{ number: number; keyId: string; row: number; col: number; label: string }>;
};
export type GeneratorResult = { target?: MockTarget; errors: ValidationIssue[] };
/** Generates preview output after validation, without modifying the project. T-2/T-19. */
export function generateMockTarget(p: ProjectFile): GeneratorResult {
const errors = validateProject(p).filter(
(x) => x.severity === "blocksOutput" || x.severity === "blocksSave",
);
if (!p.output.matrix)
errors.push({
severity: "blocksOutput",
code: "matrix-settings-missing",
message: "Matrix rowPins and colPins must be configured",
});
if (p.output.matrix?.kind && p.output.matrix.kind !== "matrix")
errors.push({
severity: "blocksOutput",
code: "unsupported-scan-mode",
message: `${p.output.matrix.kind} scan mode is not supported`,
});
if (errors.length) return { errors };
const selected = flattenKeys(p.phy, p.output.variantSelection),
active = new Set(selected.map((x) => x.key.id)),
order = p.idx.order.filter((id) => active.has(id)),
matrix = p.output.matrix!;
const rowIndex = new Map(matrix.rowPins.map((x, i) => [x, i])),
colIndex = new Map(matrix.colPins.map((x, i) => [x, i]));
const wiring = new Map(p.elc.keys.map((x) => [x.key, x]));
const row: number[] = [],
col: number[] = [];
for (const id of order) {
const w = wiring.get(id);
if (!w || w.detect !== "contact")
return {
errors: [
{
severity: "blocksOutput",
code: "unsupported-detection",
message: "Only contact matrix wiring is supported",
ids: [id],
},
],
};
const a = w.terminals.a,
b = w.terminals.b;
let r: number | undefined, c: number | undefined;
if (rowIndex.has(a) && colIndex.has(b)) {
r = rowIndex.get(a);
c = colIndex.get(b);
} else if (rowIndex.has(b) && colIndex.has(a)) {
r = rowIndex.get(b);
c = colIndex.get(a);
}
if (r === undefined || c === undefined)
return {
errors: [
{
severity: "blocksOutput",
code: "unsupported-matrix-wiring",
message: "Wiring is not a supported row/column matrix",
ids: [id],
},
],
};
row.push(r);
col.push(c);
}
const poses = new Map(selected.map((x) => [x.key.id, x]));
const keymaps = p.map.layers.map((l) => ({
name: l.name,
keycodes: order.map((id) => l.keymap.find((a) => a.keyId === id)!.keycode),
}));
const target: MockTarget = {
format: "mock-target",
version: "0.1.0",
layout: order.map((id) => {
const x = poses.get(id)!;
return { keyId: id, x: x.x, y: x.y, rotation: x.rotation, w: x.key.size.w, h: x.key.size.h };
}),
idx: order,
matrix: { rowPins: [...matrix.rowPins], colPins: [...matrix.colPins], rows: row, cols: col },
keymaps,
mapping: order.map((id, i) => ({
number: i,
keyId: id,
row: row[i]!,
col: col[i]!,
label: poses.get(id)!.key.labels?.find(Boolean) ?? "",
})),
};
return { target, errors: [] };
}
+71
View File
@@ -0,0 +1,71 @@
import type { FlatKey, Node, PhyFile, Transform } from "./phy";
/** Translate·Rotate のアフィン行列を返す。 */
export function matrix(t: Transform): Affine {
const a = (t.rotation * Math.PI) / 180,
c = Math.cos(a),
s = Math.sin(a);
return { a: c, b: s, c: -s, d: c, e: t.x, f: t.y };
}
export type Affine = { a: number; b: number; c: number; d: number; e: number; f: number };
/** 左から右の順でなく、通常の行列積 left·right を返す。 */
export function compose(left: Affine, right: Affine): Affine {
return {
a: left.a * right.a + left.c * right.b,
b: left.b * right.a + left.d * right.b,
c: left.a * right.c + left.c * right.d,
d: left.b * right.c + left.d * right.d,
e: left.a * right.e + left.c * right.f + left.e,
f: left.b * right.e + left.d * right.f + left.f,
};
}
/** 剛体変換の逆行列を返す。 */
export function inverse(m: Affine): Affine {
return { a: m.a, b: m.c, c: m.b, d: m.d, e: -(m.a * m.e + m.b * m.f), f: -(m.c * m.e + m.d * m.f) };
}
/** 点に行列を適用する。 */
export function apply(m: Affine, p: { x: number; y: number }): { x: number; y: number } {
return { x: m.a * p.x + m.c * p.y + m.e, y: m.b * p.x + m.d * p.y + m.f };
}
/** PHY ノードのワールド行列を計算する。 */
export function worldTransform(file: PhyFile, id: string): Affine {
const walk = (nodes: Node[], parent: Affine): Affine | undefined => {
for (const n of nodes) {
const own = compose(parent, matrix(n.transform));
if (n.id === id) return own;
if (n.type === "cluster") {
const r = walk(n.children, own);
if (r) return r;
}
if (n.type === "variantGroup")
for (const v of n.children) {
const r = walk(v.children, own);
if (r) return r;
}
}
return undefined;
};
const found = walk(file.root, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 });
if (!found) throw new Error(`Unknown node: ${id}`);
return found;
}
/** 選択したVariantに従いキーを平坦化する。省略時は各Groupのdefaultを使う。 */
export function flattenKeys(file: PhyFile, selection: Record<string, string> = {}): FlatKey[] {
const out: FlatKey[] = [];
const visit = (nodes: Node[], parent: Affine, parentRotation: number, path: string[]) => {
for (const n of nodes) {
const wm = compose(parent, matrix(n.transform)),
p = [...path, n.id],
absoluteRotation = parentRotation + n.transform.rotation;
if (n.type === "key") out.push({ key: n, x: wm.e, y: wm.f, rotation: absoluteRotation, path: p });
else if (n.type === "cluster") visit(n.children, wm, absoluteRotation, p);
else if (n.type === "variantGroup") {
const v = n.children.find((x) => x.id === (selection[n.id] ?? n.default));
if (!v) throw new Error(`Unknown variant for ${n.id}`);
visit(v.children, wm, absoluteRotation, [...p, v.id]);
}
}
};
visit(file.root, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, 0, []);
return out;
}
+76
View File
@@ -0,0 +1,76 @@
import type { ProjectFile } from "./project";
/** Snapshot history for whole-project undo/redo. T-17. */
export class ProjectHistory {
private items: ProjectFile[];
private cursor = 0;
private pending = false;
constructor(
initial: ProjectFile,
private readonly limit = 100,
) {
this.items = [structuredClone(initial)];
}
/** Adds one undoable snapshot unless a grouped edit is active. */
push(value: ProjectFile): void {
if (this.pending) {
this.replaceTop(value);
return;
}
this.add(value);
}
/** Starts grouping subsequent pushes into a single operation. T-17. */
begin(): void {
if (this.pending) throw new Error("History group already active");
this.items = this.items.slice(0, this.cursor + 1);
this.items.push(structuredClone(this.items[this.cursor]!));
this.cursor = this.items.length - 1;
if (this.items.length > this.limit) {
this.items.shift();
this.cursor--;
}
this.pending = true;
}
/** Commits the final grouped snapshot as one undo step. T-17. */
commit(value?: ProjectFile): void {
if (!this.pending) throw new Error("No history group active");
if (value) this.items[this.cursor] = structuredClone(value);
if (JSON.stringify(this.items[this.cursor]) === JSON.stringify(this.items[this.cursor - 1])) {
this.items.pop();
this.cursor--;
}
this.pending = false;
}
/** Replaces the current snapshot during a grouped drag. T-17. */
replaceTop(value: ProjectFile): void {
this.items[this.cursor] = structuredClone(value);
}
/** Moves to the previous snapshot. */
undo(): ProjectFile {
if (!this.canUndo) throw new Error("Nothing to undo");
return structuredClone(this.items[--this.cursor]!);
}
/** Moves to the next snapshot. */
redo(): ProjectFile {
if (!this.canRedo) throw new Error("Nothing to redo");
return structuredClone(this.items[++this.cursor]!);
}
get canUndo(): boolean {
return this.cursor > 0;
}
get canRedo(): boolean {
return this.cursor < this.items.length - 1;
}
/** Returns a copy of the current snapshot. */
current(): ProjectFile {
return structuredClone(this.items[this.cursor]!);
}
private add(value: ProjectFile) {
this.items = this.items.slice(0, this.cursor + 1);
this.items.push(structuredClone(value));
this.cursor = this.items.length - 1;
if (this.items.length > this.limit) {
this.items.shift();
this.cursor--;
}
}
}
+14
View File
@@ -0,0 +1,14 @@
/** T-11: UUID先頭12文字を種別接頭辞に付ける。生成器を注入できる。 */
export function createId(prefix: string, uuid: () => string = () => globalThis.crypto.randomUUID()): string {
return `${prefix}${uuid().replaceAll("-", "").slice(0, 12)}`;
}
/** T-11: ノード種別ごとのIDを生成する。 */
export function createNodeId(
kind: "key" | "cluster" | "component" | "variantGroup" | "variant",
uuid?: () => string,
): string {
return createId(
{ key: "key-", cluster: "cl-", component: "cmp-", variantGroup: "vg-", variant: "v-" }[kind],
uuid,
);
}
+57
View File
@@ -0,0 +1,57 @@
import type { PhyFile, Node, KeyNode } from "./phy";
export type PhyReference = { source: string; checksum: string; [key: string]: unknown };
export type IdxFile = {
version: string;
phyReference: PhyReference;
order: string[];
[key: string]: unknown;
};
/** Collects keys in document order, including ghost keys and excluding decals. T-18. */
export function eligibleKeyIds(phy: PhyFile): string[] {
const ids: string[] = [];
const visit = (nodes: Node[]) =>
nodes.forEach((n) => {
if (n.type === "key") {
if (!n.decal) ids.push(n.id);
} else if (n.type === "cluster") visit(n.children);
else if (n.type === "variantGroup") n.children.forEach((v) => visit(v.children));
});
visit(phy.root);
return ids;
}
/** Makes initial index order from KLE occurrence order. T-18. */
export function createIdx(phy: PhyFile, keyOrder: string[], phyReference: PhyReference): IdxFile {
const valid = new Set(eligibleKeyIds(phy));
return {
version: "0.1.0",
phyReference: structuredClone(phyReference),
order: keyOrder.filter((id) => valid.has(id)),
};
}
/** Lists eligible PHY keys omitted from the saved order. T-18. */
export function unregisteredKeys(phy: PhyFile, idx: IdxFile): string[] {
const registered = new Set(idx.order);
return eligibleKeyIds(phy).filter((id) => !registered.has(id));
}
/** Registers new keys at the end without rebuilding existing order. T-18. */
export function appendKeys(idx: IdxFile, ids: string[]): IdxFile {
const out = structuredClone(idx);
for (const id of ids) if (!out.order.includes(id)) out.order.push(id);
return out;
}
/** Removes one key from index order. */
export function excludeKey(idx: IdxFile, keyId: string): IdxFile {
const o = structuredClone(idx);
o.order = o.order.filter((x) => x !== keyId);
return o;
}
/** Reorders the saved key list. */
export function reorderKeys(idx: IdxFile, order: string[]): IdxFile {
if (
order.length !== idx.order.length ||
new Set(order).size !== order.length ||
order.some((x) => !idx.order.includes(x))
)
throw new Error("Order must contain every registered key exactly once");
return { ...structuredClone(idx), order: [...order] };
}
+76
View File
@@ -0,0 +1,76 @@
import { qmkBasicKeycodes } from "./qmkBasicKeycodes";
/** Small US keyboard display map for common QMK basic keys. T-23. */
export const keycodeLabels: Record<string, string> = {
KC_A: "A",
KC_B: "B",
KC_C: "C",
KC_D: "D",
KC_E: "E",
KC_F: "F",
KC_G: "G",
KC_H: "H",
KC_I: "I",
KC_J: "J",
KC_K: "K",
KC_L: "L",
KC_M: "M",
KC_N: "N",
KC_O: "O",
KC_P: "P",
KC_Q: "Q",
KC_R: "R",
KC_S: "S",
KC_T: "T",
KC_U: "U",
KC_V: "V",
KC_W: "W",
KC_X: "X",
KC_Y: "Y",
KC_Z: "Z",
KC_1: "1",
KC_2: "2",
KC_3: "3",
KC_4: "4",
KC_5: "5",
KC_6: "6",
KC_7: "7",
KC_8: "8",
KC_9: "9",
KC_0: "0",
KC_SPC: "Space",
KC_ENTER: "Enter",
KC_ESC: "Escape",
KC_TAB: "Tab",
KC_MINUS: "-",
KC_EQUAL: "=",
KC_LBRC: "[",
KC_RBRC: "]",
KC_BSLS: "\\",
KC_SCLN: ";",
KC_QUOT: "'",
KC_GRV: "`",
KC_COMM: ",",
KC_DOT: ".",
KC_SLSH: "/",
};
const qmkCodeByName = new Map(
qmkBasicKeycodes.flatMap((entry) =>
[entry.key, ...entry.aliases].map((name) => [name, entry.key] as const),
),
);
/** Looks up the existing US display label, resolving a QMK alias to its formal name. */
export function getKeycodeLabel(code: string): string | undefined {
const formalName = qmkCodeByName.get(code);
if (!formalName) return keycodeLabels[code];
return (
keycodeLabels[code] ??
keycodeLabels[formalName] ??
qmkBasicKeycodes
.find((entry) => entry.key === formalName)
?.aliases.map((alias) => keycodeLabels[alias])
.find((label): label is string => label !== undefined)
);
}
+149
View File
@@ -0,0 +1,149 @@
import type { PhyReference } from "./idx";
import { qmkBasicKeycodes } from "./qmkBasicKeycodes";
export type KeyAssignment = { keyId: string; keycode: string; [key: string]: unknown };
export type MapLayer = { name: string; keymap: KeyAssignment[]; [key: string]: unknown };
export type MapFile = {
version: string;
phyReference: PhyReference;
layers: MapLayer[];
[key: string]: unknown;
};
export type ParsedKeycode = {
kind: "basic" | "modifier" | "none" | "transparent" | "layer" | "modified" | "unknown";
code: string;
layer?: string;
inner?: ParsedKeycode;
};
const codesFor = (groups: QmkKeyGroup[]) =>
new Set(
qmkBasicKeycodes
.filter((keycode) => groups.includes(keycode.group))
.flatMap(({ key, aliases }) => [key, ...aliases]),
);
type QmkKeyGroup = (typeof qmkBasicKeycodes)[number]["group"];
const codesForKey = (key: string) => {
const entry = qmkBasicKeycodes.find((keycode) => keycode.key === key);
return new Set(entry ? [entry.key, ...entry.aliases] : []);
};
const basic = codesFor(["basic"]);
const modifiers = codesFor(["modifiers"]);
const noneCodes = codesForKey("KC_NO");
const transparentCodes = codesForKey("KC_TRANSPARENT");
/** Parses supported QMK keycode syntax without evaluation. T-22. */
export function parseKeycode(s: string): ParsedKeycode {
const m = s.match(/^(MO|TG|TO|TT|DF|OSL)\(([^()]+)\)$/);
if (m) return { kind: "layer", code: m[1]!, layer: m[2]! };
const lt = s.match(/^LT\(([^(),]+),\s*([A-Za-z0-9_]+)\)$/);
if (lt) return { kind: "layer", code: "LT", layer: lt[1]!, inner: parseKeycode(lt[2]!) };
const mod = s.match(/^(LCTL|LSFT|LALT|LGUI|RCTL|RSFT|RALT|RGUI)\(([A-Za-z0-9_]+)\)$/);
if (mod) return { kind: "modified", code: mod[1]!, inner: parseKeycode(mod[2]!) };
if (noneCodes.has(s)) return { kind: "none", code: s };
if (transparentCodes.has(s)) return { kind: "transparent", code: s };
if (basic.has(s)) return { kind: "basic", code: s };
if (modifiers.has(s)) return { kind: "modifier", code: s };
return { kind: "unknown", code: s };
}
function simpleEditableKeycode(parsed: ParsedKeycode): boolean {
return (
parsed.kind === "basic" ||
parsed.kind === "modifier" ||
parsed.kind === "none" ||
parsed.kind === "transparent"
);
}
/** Returns whether a keycode uses syntax editable under T-22. */
export function isEditableKeycode(keycode: string): boolean {
const parsed = parseKeycode(keycode);
if (parsed.kind === "unknown") return false;
if (parsed.kind === "modified") return !!parsed.inner && simpleEditableKeycode(parsed.inner);
if (parsed.kind === "layer")
return parsed.code !== "LT" || (!!parsed.inner && simpleEditableKeycode(parsed.inner));
return true;
}
/** Creates a MAP with one empty Base layer. T-22. */
export function createMap(ref: PhyReference): MapFile {
return { version: "0.1.0", phyReference: structuredClone(ref), layers: [{ name: "Base", keymap: [] }] };
}
/** Adds a uniquely named layer. T-22. */
export function addLayer(map: MapFile, name: string): MapFile {
if (map.layers.some((l) => l.name === name)) throw new Error("Layer name already exists");
const o = structuredClone(map);
o.layers.push({ name, keymap: [] });
return o;
}
/** Renames a layer and rewrites encoded layer references. T-22. */
export function renameLayer(map: MapFile, oldName: string, newName: string): MapFile {
if (map.layers.some((l) => l.name === newName)) throw new Error("Layer name already exists");
const o = structuredClone(map);
const l = o.layers.find((x) => x.name === oldName);
if (!l) throw new Error("Unknown layer");
l.name = newName;
for (const x of o.layers)
for (const a of x.keymap) a.keycode = rewriteLayerRef(a.keycode, oldName, newName);
return o;
}
function rewriteLayerRef(s: string, oldName: string, newName: string): string {
const p = parseKeycode(s);
if (p.kind === "layer" && p.layer === oldName) {
if (p.code === "LT") return `LT(${newName}, ${p.inner?.code ?? ""})`;
return `${p.code}(${newName})`;
}
return s;
}
/** Moves a layer to a new array position. */
export function reorderLayer(map: MapFile, name: string, index: number): MapFile {
const o = structuredClone(map),
i = o.layers.findIndex((x) => x.name === name);
if (i < 0) throw new Error("Unknown layer");
const [l] = o.layers.splice(i, 1);
o.layers.splice(Math.max(0, Math.min(index, o.layers.length)), 0, l!);
return o;
}
/** Deletes an unreferenced layer; references block deletion. T-22. */
export function deleteLayer(map: MapFile, name: string): MapFile {
if (map.layers.length < 2) throw new Error("At least one layer is required");
if (
map.layers.some((l) =>
l.keymap.some((a) => {
const p = parseKeycode(a.keycode);
return p.kind === "layer" && p.layer === name;
}),
)
)
throw new Error("Layer is referenced");
const o = structuredClone(map);
o.layers = o.layers.filter((l) => l.name !== name);
return o;
}
/** Assigns a keycode to a key, replacing an existing assignment. T-22. */
export function assignKey(map: MapFile, layerName: string, keyId: string, keycode: string): MapFile {
if (!isEditableKeycode(keycode)) throw new Error(`Unsupported editable keycode: ${keycode}`);
const parsed = parseKeycode(keycode);
if (parsed.kind === "layer" && !map.layers.some((layer) => layer.name === parsed.layer))
throw new Error(`Unknown MAP layer reference: ${parsed.layer}`);
const o = structuredClone(map),
l = o.layers.find((x) => x.name === layerName);
if (!l) throw new Error("Unknown layer");
const i = l.keymap.findIndex((a) => a.keyId === keyId),
old = i < 0 ? undefined : l.keymap[i],
a = { ...old, keyId, keycode };
if (i < 0) l.keymap.push(a);
else l.keymap[i] = a;
return o;
}
/** Removes a key assignment. */
export function unassignKey(map: MapFile, layerName: string, keyId: string): MapFile {
const o = structuredClone(map),
l = o.layers.find((x) => x.name === layerName);
if (!l) throw new Error("Unknown layer");
l.keymap = l.keymap.filter((a) => a.keyId !== keyId);
return o;
}
/** Lists keys not assigned in a layer. T-22. */
export function unassignedKeys(map: MapFile, layerName: string, keyIds: string[]): string[] {
const l = map.layers.find((x) => x.name === layerName);
const assigned = new Set(l?.keymap.map((x) => x.keyId));
return keyIds.filter((x) => !assigned?.has(x));
}
+53
View File
@@ -0,0 +1,53 @@
/** PHY JSON Schema §1 の型。D-1: Schema の必須 type/name/compatibility を採用。 */
export type Point = { x: number; y: number };
export type Transform = { x: number; y: number; rotation: number };
export type Size = { w: number; h: number; x2?: number; y2?: number; w2?: number; h2?: number };
export type Profile = {
id: string;
name: string;
unitSize: Point;
visualOffset?: { top: number; right: number; bottom: number; left: number; radius: number };
};
export type KleCompatibility = Record<string, unknown>;
export type KeyNode = {
type: "key";
id: string;
profile: string;
transform: Transform;
size: Size;
switchOffset?: Point;
partGroup?: string;
orientation?: 0 | 90 | 180 | 270;
perKeyLED?: boolean;
decal?: boolean;
ghost?: boolean;
labels?: string[];
kleCompatibility?: KleCompatibility;
};
export type ComponentNode = { type: "component"; id: string; transform: Transform; partGroup?: string };
export type ClusterNode = {
type: "cluster";
id: string;
name?: string;
transform: Transform;
children: Node[];
};
export type Variant = { type: "variant"; id: string; name: string; children: Node[] };
export type VariantGroupNode = {
type: "variantGroup";
id: string;
name: string;
transform: Transform;
default: string;
children: Variant[];
};
export type Node = KeyNode | ComponentNode | ClusterNode | VariantGroupNode;
export type PhyFile = {
version: string;
metadata: { name?: string; author?: string; description?: string; created?: string; modified?: string };
profiles: Profile[];
root: Node[];
kleCompatibility?: KleCompatibility;
};
export type FlatKey = { key: KeyNode; x: number; y: number; rotation: number; path: string[] };
+426
View File
@@ -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;
}
+11
View File
@@ -0,0 +1,11 @@
import type { PhyFile } from "./phy";
import type { PhyReference } from "./idx";
import { sha256Jcs } from "../io/json";
/** Computes the project-local PHY reference checksum. T-12. */
export async function makePhyReference(phy: PhyFile, source = "project:phy"): Promise<PhyReference> {
return { source, checksum: await sha256Jcs(phy) };
}
/** Checks whether a stored checksum matches PHY. T-12. */
export async function phyReferenceMatches(phy: PhyFile, ref: PhyReference): Promise<boolean> {
return ref.checksum === (await sha256Jcs(phy));
}
+64
View File
@@ -0,0 +1,64 @@
import type { PhyFile, Node } from "./phy";
import { createId } from "./ids";
import { importKle } from "../io/kleImport";
import { sha256Jcs } from "../io/json";
import { createIdx, type IdxFile } from "./idx";
import { createElc, type ElcFile } from "./elc";
import { createMap, type MapFile } from "./map";
export type MatrixOutputSettings = {
rowPins: string[];
colPins: string[];
kind?: "matrix" | "charlieplexing" | "direct";
[key: string]: unknown;
};
export type ProjectFile = {
format: "beyond-kle-mock-project";
version: string;
projectId: string;
phy: PhyFile;
elc: ElcFile;
idx: IdxFile;
map: MapFile;
output: { variantSelection: Record<string, string>; matrix?: MatrixOutputSettings; [key: string]: unknown };
ui?: unknown;
[key: string]: unknown;
};
/** Creates default variant selections from PHY. T-10/T-20. */
export function defaultVariantSelection(phy: PhyFile): Record<string, string> {
const out: Record<string, string> = {};
const walk = (nodes: Node[]) =>
nodes.forEach((n) => {
if (n.type === "cluster") walk(n.children);
if (n.type === "variantGroup") {
out[n.id] = n.default;
const v = n.children.find((x) => x.id === n.default);
if (v) walk(v.children);
}
});
walk(phy.root);
return out;
}
/** Creates an empty project with default reference values. T-10/T-13. */
export async function createProject(phy: PhyFile, gen?: () => string): Promise<ProjectFile> {
const ref = { source: "project:phy", checksum: await sha256Jcs(phy) };
return {
format: "beyond-kle-mock-project",
version: "0.1.0",
projectId: createId("project-", gen),
phy: structuredClone(phy),
elc: createElc(ref),
idx: createIdx(phy, [], ref),
map: createMap(ref),
output: { variantSelection: defaultVariantSelection(phy) },
};
}
/** Imports KLE as a new project and initializes IDX from occurrence order. T-18. */
export async function createProjectFromKle(
input: unknown,
options: { idGenerator?: () => string; name?: string; projectIdGenerator?: () => string } = {},
): Promise<ProjectFile> {
const { phy, keyOrder } = importKle(input, options);
const p = await createProject(phy, options.projectIdGenerator);
p.idx = createIdx(phy, keyOrder, p.idx.phyReference);
return p;
}
File diff suppressed because it is too large Load Diff
+354
View File
@@ -0,0 +1,354 @@
import type { ProjectFile } from "./project";
import { flattenKeys } from "./geometry";
import type { Node } from "./phy";
import { eligibleKeyIds, unregisteredKeys } from "./idx";
import { parseKeycode } from "./map";
import { validateSchema } from "../io/validateSchema";
export type ValidationIssue = {
severity: "blocksSave" | "blocksOutput" | "warning";
code: string;
message: string;
path?: string;
ids?: string[];
};
/** Performs semantic validation of a complete mock project. T-13/T-16/T-21/T-25. */
export function validateProject(p: ProjectFile): ValidationIssue[] {
const out: ValidationIssue[] = [];
for (const layer of ["phy", "idx", "elc", "map"] as const)
for (const e of validateSchema(layer, p[layer]))
out.push({ severity: "blocksSave", code: "schema", message: e.message, path: `${layer}${e.path}` });
if (p.format !== "beyond-kle-mock-project")
out.push({
severity: "blocksSave",
code: "project-format",
message: "Unsupported ProjectFile format",
path: "/format",
});
if (p.version !== "0.1.0")
out.push({
severity: "blocksSave",
code: "version",
message: "Unsupported ProjectFile version",
path: "/version",
});
for (const layer of ["phy", "idx", "elc", "map"] as const)
if (p[layer].version !== "0.1.0")
out.push({
severity: "blocksSave",
code: "version",
message: `Unsupported ${layer} version`,
path: `/${layer}/version`,
});
const ids = new Map<string, string[]>();
const add = (id: string, path: string) => ids.set(id, [...(ids.get(id) ?? []), path]);
const walk = (ns: Node[], path: string) =>
ns.forEach((n, i) => {
const q = `${path}/${i}`;
add(n.id, q);
if (n.type === "cluster") walk(n.children, q + "/children");
if (n.type === "variantGroup") {
if (!n.children.some((v) => v.id === n.default))
out.push({
severity: "blocksSave",
code: "missing-default-variant",
message: "VariantGroup default does not reference a child",
path: q + "/default",
ids: [n.id, n.default],
});
for (const v of n.children) {
add(v.id, q + "/children");
walk(v.children, q + `/children/${v.id}/children`);
}
}
});
walk(p.phy.root, "/phy/root");
for (const [id, paths] of ids)
if (paths.length > 1)
out.push({
severity: "blocksSave",
code: "duplicate-id",
message: "Duplicate PHY node ID",
ids: [id],
path: paths[1],
});
const nodes = new Set(ids.keys()),
keys = new Set(eligibleKeyIds(p.phy));
const ref = (id: string, path: string, valid: Set<string> = nodes) => {
if (!valid.has(id))
out.push({
severity: "blocksSave",
code: "missing-reference",
message: `Unknown reference: ${id}`,
path,
ids: [id],
});
};
p.idx.order.forEach((id, i) => ref(id, `/idx/order/${i}`, keys));
p.elc.keys.forEach((k, i) => ref(k.key, `/elc/keys/${i}/key`, keys));
p.elc.ledChains?.forEach((c, i) => c.order.forEach((id, j) => ref(id, `/elc/ledChains/${i}/order/${j}`)));
p.map.layers.forEach((l, i) =>
l.keymap.forEach((a, j) => {
ref(a.keyId, `/map/layers/${i}/keymap/${j}/keyId`, keys);
const keycode = parseKeycode(a.keycode);
if (keycode.kind === "layer" && !p.map.layers.some((candidate) => candidate.name === keycode.layer))
out.push({
severity: "blocksSave",
code: "missing-map-layer-reference",
message: `Unknown MAP layer reference: ${keycode.layer}`,
path: `/map/layers/${i}/keymap/${j}/keycode`,
ids: [l.name, keycode.layer ?? ""],
});
}),
);
const providerIds = new Map<string, number>();
p.elc.providers.forEach((provider, index) => {
const first = providerIds.get(provider.id);
if (first !== undefined)
out.push({
severity: "blocksSave",
code: "duplicate-provider-id",
message: "Provider IDs must be unique",
path: `/elc/providers/${index}/id`,
ids: [provider.id],
});
else providerIds.set(provider.id, index);
if (!provider.id || provider.id.includes("."))
out.push({
severity: "blocksSave",
code: "invalid-provider-id",
message: "Provider IDs must be nonempty and cannot contain '.'",
path: `/elc/providers/${index}/id`,
ids: [provider.id],
});
});
const chainIds = new Set<string>();
p.elc.ledChains?.forEach((chain, index) => {
if (chainIds.has(chain.id))
out.push({
severity: "blocksSave",
code: "duplicate-led-chain-id",
message: "LED chain IDs must be unique",
path: `/elc/ledChains/${index}/id`,
ids: [chain.id],
});
chainIds.add(chain.id);
});
const wiredKeys = new Set<string>();
p.elc.keys.forEach((wiring, index) => {
if (wiredKeys.has(wiring.key))
out.push({
severity: "blocksSave",
code: "duplicate-key-wiring",
message: "A key may have only one ELC wiring entry",
path: `/elc/keys/${index}/key`,
ids: [wiring.key],
});
wiredKeys.add(wiring.key);
});
const validatePinRef = (pin: string, path: string) => {
if (pin === "VCC" || pin === "GND") return;
const parts = pin.split(".");
const providerId = parts[0];
const pinName = parts[1];
if (parts.length !== 2 || !providerId || !pinName) {
out.push({
severity: "blocksSave",
code: "invalid-pin-reference",
message: `Malformed PinRef: ${pin}`,
path,
ids: [pin],
});
return;
}
const provider = p.elc.providers.find((candidate) => candidate.id === providerId);
if (!provider) {
out.push({
severity: "blocksSave",
code: "missing-pin-provider",
message: `Unknown PinRef provider: ${providerId}`,
path,
ids: [providerId, pin],
});
return;
}
let valid = true;
if (provider.kind === "analog-mux")
valid = /^(0|[1-9]\d*)$/.test(pinName) && Number(pinName) < provider.channels;
else if (provider.part.toUpperCase() === "RP2040") valid = /^GP(?:[0-9]|[12][0-9])$/.test(pinName);
if (!valid)
out.push({
severity: "blocksSave",
code: "unknown-provider-pin",
message: `Unknown pin ${pinName} on provider ${providerId}`,
path,
ids: [providerId, pinName],
});
};
p.elc.providers.forEach((provider, index) => {
if (provider.kind === "analog-mux") {
provider.select.forEach((pin, i) => validatePinRef(pin, `/elc/providers/${index}/select/${i}`));
validatePinRef(provider.common, `/elc/providers/${index}/common`);
if (provider.enable !== undefined) validatePinRef(provider.enable, `/elc/providers/${index}/enable`);
}
});
p.elc.keys.forEach((wiring, index) =>
Object.entries(wiring.terminals).forEach(([terminal, pin]) =>
validatePinRef(pin, `/elc/keys/${index}/terminals/${terminal}`),
),
);
p.elc.ledChains?.forEach((chain, index) => validatePinRef(chain.data, `/elc/ledChains/${index}/data`));
p.output.matrix?.rowPins.forEach((pin, index) => validatePinRef(pin, `/output/matrix/rowPins/${index}`));
p.output.matrix?.colPins.forEach((pin, index) => validatePinRef(pin, `/output/matrix/colPins/${index}`));
const profileIds = new Set(p.phy.profiles.map((x) => x.id));
const checkProfiles = (ns: Node[]) =>
ns.forEach((n, i) => {
if (n.type === "key" && !profileIds.has(n.profile))
out.push({
severity: "blocksSave",
code: "missing-profile",
message: `Unknown profile: ${n.profile}`,
path: `/phy/key/${n.id}/profile`,
ids: [n.id, n.profile],
});
if (n.type === "cluster") checkProfiles(n.children);
if (n.type === "variantGroup") n.children.forEach((v) => checkProfiles(v.children));
});
checkProfiles(p.phy.root);
for (const [i, pr] of p.phy.profiles.entries()) {
if (pr.unitSize.x <= 0 || pr.unitSize.y <= 0)
out.push({
severity: "blocksSave",
code: "positive-unit-size",
message: "unitSize values must be positive",
path: `/phy/profiles/${i}/unitSize`,
});
}
const keyWalk = (ns: Node[]) =>
ns.forEach((n) => {
if (n.type === "key" && (n.size.w <= 0 || n.size.h <= 0))
out.push({
severity: "blocksSave",
code: "positive-key-size",
message: "Key size values must be positive",
path: `/phy/key/${n.id}/size`,
ids: [n.id],
});
if (n.type === "cluster") keyWalk(n.children);
if (n.type === "variantGroup") n.children.forEach((v) => keyWalk(v.children));
});
keyWalk(p.phy.root);
const unreg = unregisteredKeys(p.phy, p.idx);
for (const id of unreg)
out.push({
severity: "blocksOutput",
code: "idx-unregistered",
message: "Eligible key is not in IDX order",
ids: [id],
});
const profileSizes = p.phy.profiles.map((x) => `${x.unitSize.x},${x.unitSize.y}`);
if (new Set(profileSizes).size > 1)
out.push({
severity: "warning",
code: "unit-size-mismatch",
message: "Profile unitSize values differ; geometry editing is restricted",
path: "/phy/profiles",
});
let active: string[] = [];
try {
active = flattenKeys(p.phy, p.output.variantSelection)
.filter((x) => !x.key.decal)
.map((x) => x.key.id);
} catch (e) {
out.push({ severity: "blocksSave", code: "invalid-variant-selection", message: String(e) });
}
for (const id of active) {
if (!p.elc.keys.some((k) => k.key === id))
out.push({
severity: "blocksOutput",
code: "unwired-key",
message: "Selected key has no wiring",
ids: [id],
});
for (const l of p.map.layers)
if (!l.keymap.some((a) => a.keyId === id))
out.push({
severity: "blocksOutput",
code: "unassigned-key",
message: `Selected key is not assigned in layer ${l.name}`,
ids: [id, l.name],
});
}
const branches = new Map<string, Array<[string, string]>>();
const exclusivity = (ns: Node[], branch: Array<[string, string]>) =>
ns.forEach((n) => {
if (n.type === "key") branches.set(n.id, branch);
if (n.type === "cluster") exclusivity(n.children, branch);
if (n.type === "variantGroup")
for (const v of n.children) exclusivity(v.children, [...branch, [n.id, v.id]]);
});
exclusivity(p.phy.root, []);
const mutuallyExclusive = (a: string, b: string) => {
for (const [group, variant] of branches.get(a) ?? [])
if ((branches.get(b) ?? []).some(([g, v]) => g === group && v !== variant)) return true;
return false;
};
const unordered = (a: string, b: string) => (a < b ? `${a}\0${b}` : `${b}\0${a}`);
const contacts = p.elc.keys.filter((w) => w.detect === "contact");
for (let i = 0; i < contacts.length; i++)
for (let j = i + 1; j < contacts.length; j++) {
const a = contacts[i]!,
b = contacts[j]!;
if (a.key === b.key || mutuallyExclusive(a.key, b.key)) continue;
const ap = a.diode
? a.diode === "a-to-b"
? [a.terminals.a, a.terminals.b]
: [a.terminals.b, a.terminals.a]
: null;
const bp = b.diode
? b.diode === "a-to-b"
? [b.terminals.a, b.terminals.b]
: [b.terminals.b, b.terminals.a]
: null;
const same =
ap && bp
? ap[0] === bp[0] && ap[1] === bp[1]
: unordered(a.terminals.a, a.terminals.b) === unordered(b.terminals.a, b.terminals.b);
if (same)
out.push({
severity: "blocksOutput",
code: "wiring-collision",
message: "Non-exclusive keys share the same electrical connection",
ids: [a.key, b.key],
});
}
const layerNames = new Set<string>();
for (const [i, l] of p.map.layers.entries()) {
if (layerNames.has(l.name))
out.push({
severity: "blocksSave",
code: "duplicate-layer-name",
message: "Layer names must be unique",
path: `/map/layers/${i}/name`,
ids: [l.name],
});
layerNames.add(l.name);
}
for (const [li, l] of p.map.layers.entries()) {
const assignments = new Map<string, number>();
for (const [ai, a] of l.keymap.entries()) {
const prev = assignments.get(a.keyId);
if (prev !== undefined)
out.push({
severity: "blocksSave",
code: "duplicate-map-key",
message: "A key may occur only once in a layer keymap",
path: `/map/layers/${li}/keymap/${ai}/keyId`,
ids: [a.keyId, l.name],
});
assignments.set(a.keyId, ai);
}
}
return out;
}