mock: PHY Editorで異形キーを描き・編集し・追加できるようにする(T-32)

- domain/keyShape.ts に、キーの矩形・外接矩形・和の外形の計算と、異形キーのプリセット表(今はISO Enterだけ)を置いた
- キャンバスと選択枠は、2つ目の矩形を持つキーを和の外形のpathで描く。範囲選択と囲み矩形も両方の矩形で取る
- プロパティ欄に「異形キー」のチェックと、2つ目の矩形の欄(X2・Y2・Width2・Height2)を置いた
- ツールバーに「異形キーを足す」selectを置いた。1Uと同じ規則の位置に、外接矩形の左上を合わせて足す
- 検証: vue-tsc 0件、Vitest 106件(うち新規5件)、Playwright PHY 31件(うち新規1件)、prettier --check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xmqw9x7EoryY6zf116i3yM
This commit is contained in:
2026-10-26 08:55:20 +09:00
co-authored by Claude Opus 5.5
parent 3749f267b5
commit afd9af1914
13 changed files with 314 additions and 68 deletions
+28
View File
@@ -741,3 +741,31 @@ test("スイッチ・スタビの十字はトグルで出し、スタビ候補
await page.getByRole("button", { name: "スイッチ・スタビライザーの位置" }).click(); await page.getByRole("button", { name: "スイッチ・スタビライザーの位置" }).click();
await expect(page.locator(".mount-layer")).toHaveCount(0); await expect(page.locator(".mount-layer")).toHaveCount(0);
}); });
test("ISO Enterを足すと外形で描き、プロパティの異形キーで2つ目の矩形を出し入れできる", async ({ page }) => {
const keys = page.locator('[data-node-type="key"]');
await keys.first().click();
const select = page.getByRole("combobox", { name: "異形キーを足す" });
await select.selectOption("ISO Enter");
await expect(select).toHaveValue("");
await expect(keys).toHaveCount(17);
const added = keys.last();
const addedId = await added.getAttribute("data-node-id");
await expect(added.locator("path.key-face")).toHaveCount(1);
await expect(page.locator(`path[data-sel-id="${addedId}"]`)).toHaveCount(1);
const irregular = page.getByLabel("異形キー", { exact: true });
await expect(irregular).toBeChecked();
await expect(page.getByLabel("X2", { exact: true })).toHaveValue("-0.25");
await expect(page.getByLabel("Width2", { exact: true })).toHaveValue("1.5");
await page.getByLabel("Width2", { exact: true }).fill("2");
await page.getByLabel("Width2", { exact: true }).press("Enter");
await expect(page.getByLabel("Width2", { exact: true })).toHaveValue("2");
await irregular.uncheck();
await expect(added.locator("rect.key-face")).toHaveAttribute("width", "1.25");
await expect(page.getByLabel("X2", { exact: true })).toHaveCount(0);
await irregular.check();
await expect(page.getByLabel("X2", { exact: true })).toHaveValue("0");
await expect(page.getByLabel("Width2", { exact: true })).toHaveValue("1.25");
});
+89
View File
@@ -0,0 +1,89 @@
import type { Point, Size } from "./phy";
export type Rect = { x: number; y: number; w: number; h: number };
/** ISO Enter, BAE and stepped keys carry a second rectangle (KLE's x2/y2/w2/h2). */
export function hasSecondRect(size: Size): boolean {
return [size.x2, size.y2, size.w2, size.h2].some((v) => v !== undefined);
}
/** The key's rectangles in key-local coordinates. Missing second-rectangle fields take KLE's defaults (0, 0, w, h). */
export function keyRects(size: Size): Rect[] {
const main = { x: 0, y: 0, w: size.w, h: size.h };
if (!hasSecondRect(size)) return [main];
return [main, { x: size.x2 ?? 0, y: size.y2 ?? 0, w: size.w2 ?? size.w, h: size.h2 ?? size.h }];
}
/** Corners of every rectangle of the key, for bounding boxes. */
export function keyCorners(size: Size): Point[] {
return keyRects(size).flatMap((r) => [
{ x: r.x, y: r.y },
{ x: r.x + r.w, y: r.y },
{ x: r.x + r.w, y: r.y + r.h },
{ x: r.x, y: r.y + r.h },
]);
}
/** Top-left of the key's local bounding box. */
export function keyOrigin(size: Size): Point {
const corners = keyCorners(size);
return { x: Math.min(...corners.map((p) => p.x)), y: Math.min(...corners.map((p) => p.y)) };
}
/**
* Outline of the union of the key's rectangles, as closed loops running clockwise on screen (y down).
* The plane is cut into cells along every rectangle edge; boundary edges of filled cells are chained into loops.
*/
export function keyOutline(size: Size): Point[][] {
const rects = keyRects(size).filter((r) => r.w > 0 && r.h > 0);
const xs = [...new Set(rects.flatMap((r) => [r.x, r.x + r.w]))].sort((a, b) => a - b);
const ys = [...new Set(rects.flatMap((r) => [r.y, r.y + r.h]))].sort((a, b) => a - b);
const filled = (i: number, j: number) => {
if (i < 0 || j < 0 || i >= xs.length - 1 || j >= ys.length - 1) return false;
const cx = (xs[i]! + xs[i + 1]!) / 2,
cy = (ys[j]! + ys[j + 1]!) / 2;
return rects.some((r) => cx > r.x && cx < r.x + r.w && cy > r.y && cy < r.y + r.h);
};
const key = (p: Point) => `${p.x},${p.y}`;
const edges = new Map<string, Point[]>();
const edge = (from: Point, to: Point) => edges.set(key(from), [...(edges.get(key(from)) ?? []), to]);
for (let i = 0; i < xs.length - 1; i++)
for (let j = 0; j < ys.length - 1; j++) {
if (!filled(i, j)) continue;
const [x0, x1, y0, y1] = [xs[i]!, xs[i + 1]!, ys[j]!, ys[j + 1]!];
if (!filled(i, j - 1)) edge({ x: x0, y: y0 }, { x: x1, y: y0 });
if (!filled(i + 1, j)) edge({ x: x1, y: y0 }, { x: x1, y: y1 });
if (!filled(i, j + 1)) edge({ x: x1, y: y1 }, { x: x0, y: y1 });
if (!filled(i - 1, j)) edge({ x: x0, y: y1 }, { x: x0, y: y0 });
}
const loops: Point[][] = [];
for (const start of edges.keys()) {
while (edges.get(start)?.length) {
const loop: Point[] = [];
let at = start;
for (let next = edges.get(at)?.pop(); next; next = edges.get(at)?.pop()) {
loop.push(next);
at = key(next);
if (at === start) break;
}
loops.push(dropCollinear(loop));
}
}
return loops;
}
function dropCollinear(loop: Point[]): Point[] {
return loop.filter((p, i) => {
const prev = loop[(i + loop.length - 1) % loop.length]!,
next = loop[(i + 1) % loop.length]!;
return !((prev.x === p.x && p.x === next.x) || (prev.y === p.y && p.y === next.y));
});
}
/** A key shape the editor adds in one step. provisional/decisions.md T-32. */
export type KeyPreset = { id: string; label: string; size: Size };
/** Non-rectangular keys offered by「異形キーを足す」. Sizes follow KLE's presets (keys.json). */
export const IRREGULAR_KEY_PRESETS: KeyPreset[] = [
{ id: "iso-enter", label: "ISO Enter", size: { w: 1.25, h: 2, x2: -0.25, w2: 1.5, h2: 1 } },
];
+5 -22
View File
@@ -1,6 +1,7 @@
import type { ClusterNode, KeyNode, Node, PhyFile, Transform, Variant, VariantGroupNode } from "./phy"; import type { ClusterNode, KeyNode, Node, PhyFile, Size, Transform, Variant, VariantGroupNode } from "./phy";
import { apply, compose, inverse, matrix } from "./geometry"; import { apply, compose, inverse, matrix } from "./geometry";
import { createNodeId } from "./ids"; import { createNodeId } from "./ids";
import { keyCorners } from "./keyShape";
const ident = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; const ident = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
type Container = { nodes: Node[]; parentWorld: typeof ident; parentRotation: number }; type Container = { nodes: Node[]; parentWorld: typeof ident; parentRotation: number };
@@ -50,7 +51,7 @@ function nodeId(kind: Parameters<typeof createNodeId>[0], gen?: () => string) {
export function addKey( export function addKey(
f: PhyFile, f: PhyFile,
at: Transform, at: Transform,
size: { w: number; h: number }, size: Size,
profile = f.profiles[0]!.id, profile = f.profiles[0]!.id,
gen?: () => string, gen?: () => string,
): PhyFile { ): PhyFile {
@@ -75,7 +76,7 @@ export function addKeyToParent(
f: PhyFile, f: PhyFile,
parentId: string | null, parentId: string | null,
at: Transform, at: Transform,
size: { w: number; h: number }, size: Size,
profile = f.profiles[0]!.id, profile = f.profiles[0]!.id,
gen?: () => string, gen?: () => string,
): PhyFile { ): PhyFile {
@@ -275,25 +276,7 @@ function containsNode(n: Node, id: string): boolean {
} }
function boundsPoints(n: Node, worldMatrix: ReturnType<typeof matrix>): { x: number; y: number }[] { function boundsPoints(n: Node, worldMatrix: ReturnType<typeof matrix>): { x: number; y: number }[] {
if (n.type === "key") { if (n.type === "key") {
const points = [ return keyCorners(n.size).map((point) => apply(worldMatrix, point));
{ 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 === "component") return [{ x: worldMatrix.e, y: worldMatrix.f }];
if (n.type === "cluster") if (n.type === "cluster")
+3 -5
View File
@@ -1,4 +1,5 @@
import type { Point, Size } from "./phy"; import type { Point, Size } from "./phy";
import { hasSecondRect } from "./keyShape";
/** One candidate stabilizer layout: two mounts at ±offset along the key's long axis, in U. */ /** One candidate stabilizer layout: two mounts at ±offset along the key's long axis, in U. */
export type StabilizerCandidate = { id: string; label: string; offset: number }; export type StabilizerCandidate = { id: string; label: string; offset: number };
@@ -35,12 +36,9 @@ const TABLE: { lengths: number[]; candidates: StabilizerCandidate[] }[] = [
]; ];
const EPS = 1e-9; const EPS = 1e-9;
/** Non-rectangular keys (ISO Enter, BAE, stepped) have no candidates yet. */
function irregular(size: Size): boolean {
return [size.x2, size.y2, size.w2, size.h2].some((v) => v !== undefined);
}
export function stabilizerCandidates(size: Size): StabilizerCandidate[] { export function stabilizerCandidates(size: Size): StabilizerCandidate[] {
if (irregular(size)) return []; // Non-rectangular keys (ISO Enter, BAE, stepped) have no candidates yet.
if (hasSecondRect(size)) return [];
const length = Math.max(size.w, size.h); const length = Math.max(size.w, size.h);
return TABLE.find((row) => row.lengths.some((l) => Math.abs(l - length) < EPS))?.candidates ?? []; return TABLE.find((row) => row.lengths.some((l) => Math.abs(l - length) < EPS))?.candidates ?? [];
} }
+8 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Node } from "../../domain/phy"; import type { Node } from "../../domain/phy";
import { keyPath } from "./logic";
defineOptions({ name: "NodeSvg" }); defineOptions({ name: "NodeSvg" });
defineProps<{ defineProps<{
nodes: Node[]; nodes: Node[];
@@ -27,7 +28,13 @@ const emit = defineEmits<{
v-if="n.type === 'key'" v-if="n.type === 'key'"
class="node" class="node"
@pointerdown.stop="emit('down', $event, n, $event.currentTarget as SVGGElement)" @pointerdown.stop="emit('down', $event, n, $event.currentTarget as SVGGElement)"
><rect ><path
v-if="keyPath(n.size)"
:d="keyPath(n.size)"
class="key-face"
:class="{ chosen: selected.includes(n.id), ghost: n.ghost, decal: n.decal }"
/><rect
v-else
:width="n.size.w" :width="n.size.w"
:height="n.size.h" :height="n.size.h"
rx=".06" rx=".06"
+3 -1
View File
@@ -201,6 +201,7 @@ const {
tabItem, tabItem,
chooseVariant, chooseVariant,
keyAdd, keyAdd,
presetAdd,
partAdd, partAdd,
makeCluster, makeCluster,
ungroup, ungroup,
@@ -337,7 +338,8 @@ function onFocus(_id: string) {
:move-targets="moveTargets" :move-targets="moveTargets"
:focus="focus" :focus="focus"
:crumbs="focus ? focusCrumbs(focus) : []" :crumbs="focus ? focusCrumbs(focus) : []"
@key-add="keyAdd" @key-add="keyAdd()"
@preset-add="presetAdd"
@part-add="partAdd" @part-add="partAdd"
@cluster="makeCluster" @cluster="makeCluster"
@ungroup="ungroup" @ungroup="ungroup"
+56
View File
@@ -2,6 +2,7 @@
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import type { Node, Transform } from "../../domain/phy"; import type { Node, Transform } from "../../domain/phy";
import type { StabilizerCandidate } from "../../domain/stabilizers"; import type { StabilizerCandidate } from "../../domain/stabilizers";
import { hasSecondRect } from "../../domain/keyShape";
const props = defineProps<{ const props = defineProps<{
selectedCount: number; selectedCount: number;
selectedId: string | null; selectedId: string | null;
@@ -26,6 +27,20 @@ function setSwitchOffset(axis: "x" | "y", value: number) {
else n.switchOffset = offset; else n.switchOffset = offset;
}); });
} }
// 異形キーは2つ目の矩形(x2, y2, w2, h2)を持つ。オンにしたときはKLEの既定(0, 0, w, h)から始める。T-32。
const irregular = computed(() => props.item?.type === "key" && hasSecondRect(props.item.size));
function setIrregular(on: boolean) {
emit("update", props.item!.id, (n) => {
if (n.type !== "key") return;
if (on) n.size = { ...n.size, x2: 0, y2: 0, w2: n.size.w, h2: n.size.h };
else n.size = { w: n.size.w, h: n.size.h };
});
}
function setSecondRect(field: "x2" | "y2" | "w2" | "h2", value: number) {
emit("update", props.item!.id, (n) => {
if (n.type === "key") n.size[field] = value;
});
}
function toggleStabilizer(id: string, on: boolean) { function toggleStabilizer(id: string, on: boolean) {
const current = props.stabilizers?.selected ?? []; const current = props.stabilizers?.selected ?? [];
emit( emit(
@@ -113,6 +128,47 @@ const frameR = ref(0);
}) })
" "
/></label> /></label>
<label class="check"
><input
type="checkbox"
:checked="irregular"
@change="setIrregular(($event.target as HTMLInputElement).checked)"
/>異形キー</label
>
<template v-if="irregular && item.type === 'key'">
<label
>X2
<input
type="number"
step="any"
:value="item.size.x2 ?? 0"
@change="setSecondRect('x2', Number(($event.target as HTMLInputElement).value))"
/></label>
<label
>Y2
<input
type="number"
step="any"
:value="item.size.y2 ?? 0"
@change="setSecondRect('y2', Number(($event.target as HTMLInputElement).value))"
/></label>
<label
>Width2
<input
type="number"
step="any"
:value="item.size.w2 ?? item.size.w"
@change="setSecondRect('w2', Number(($event.target as HTMLInputElement).value))"
/></label>
<label
>Height2
<input
type="number"
step="any"
:value="item.size.h2 ?? item.size.h"
@change="setSecondRect('h2', Number(($event.target as HTMLInputElement).value))"
/></label>
</template>
<label <label
>switchOffset X >switchOffset X
<input <input
+19 -11
View File
@@ -23,17 +23,25 @@ const affineText = (m: Affine) => `matrix(${m.a} ${m.b} ${m.c} ${m.d} ${m.e} ${m
</script> </script>
<template> <template>
<g class="selection-layer" pointer-events="none"> <g class="selection-layer" pointer-events="none">
<rect <template v-for="frame in frames" :key="frame.id">
v-for="frame in frames" <path
:key="frame.id" v-if="frame.path"
:data-sel-id="frame.id" :data-sel-id="frame.id"
:class="[frame.type === 'key' ? 'sel' : 'group-sel', { candidate: frame.candidate }]" :class="['sel', { candidate: frame.candidate }]"
:transform="affineText(frame.transform)" :transform="affineText(frame.transform)"
:x="frame.box.x" :d="frame.path"
:y="frame.box.y" />
:width="frame.box.width" <rect
:height="frame.box.height" v-else
/> :data-sel-id="frame.id"
:class="[frame.type === 'key' ? 'sel' : 'group-sel', { candidate: frame.candidate }]"
:transform="affineText(frame.transform)"
:x="frame.box.x"
:y="frame.box.y"
:width="frame.box.width"
:height="frame.box.height"
/>
</template>
<circle <circle
v-for="frame in origins" v-for="frame in origins"
:key="`origin-${frame.id}`" :key="`origin-${frame.id}`"
+13
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { IRREGULAR_KEY_PRESETS } from "../../domain/keyShape";
defineProps<{ defineProps<{
mismatch: boolean; mismatch: boolean;
selectedCount: number; selectedCount: number;
@@ -11,6 +12,7 @@ defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
keyAdd: []; keyAdd: [];
presetAdd: [presetId: string];
partAdd: [group: string]; partAdd: [group: string];
cluster: []; cluster: [];
ungroup: []; ungroup: [];
@@ -28,6 +30,11 @@ const emit = defineEmits<{
holdMode: []; holdMode: [];
mounts: []; mounts: [];
}>(); }>();
function addPreset(e: Event) {
const select = e.target as HTMLSelectElement;
emit("presetAdd", select.value);
select.value = "";
}
function addPart(e: Event) { function addPart(e: Event) {
const select = e.target as HTMLSelectElement; const select = e.target as HTMLSelectElement;
emit("partAdd", select.value); emit("partAdd", select.value);
@@ -40,6 +47,12 @@ function addPart(e: Event) {
<button title="キーを足す" aria-label="キーを足す" :disabled="mismatch" @click="emit('keyAdd')"> <button title="キーを足す" aria-label="キーを足す" :disabled="mismatch" @click="emit('keyAdd')">
+⌨ +⌨
</button> </button>
<select aria-label="異形キーを足す" :disabled="mismatch" @change="addPreset">
<option value="">異形キーを足す</option>
<option v-for="preset in IRREGULAR_KEY_PRESETS" :key="preset.id" :value="preset.id">
{{ preset.label }}
</option>
</select>
<select aria-label="部品を足す" :disabled="mismatch" @change="addPart"> <select aria-label="部品を足す" :disabled="mismatch" @change="addPart">
<option value="">部品を足す</option> <option value="">部品を足す</option>
<option>@led</option> <option>@led</option>
+13 -19
View File
@@ -2,6 +2,7 @@ import type { KeyNode, Node, PhyFile, Transform } from "../../domain/phy";
import type { ProjectFile } from "../../domain/project"; import type { ProjectFile } from "../../domain/project";
import { stabilizerCandidates, stabilizerPoints, switchPoint } from "../../domain/stabilizers"; import { stabilizerCandidates, stabilizerPoints, switchPoint } from "../../domain/stabilizers";
import { apply, compose, inverse, matrix, type Affine } from "../../domain/geometry"; import { apply, compose, inverse, matrix, type Affine } from "../../domain/geometry";
import { hasSecondRect, keyCorners, keyOutline } from "../../domain/keyShape";
export function normalizeSelection(file: PhyFile, ids: string[]): string[] { export function normalizeSelection(file: PhyFile, ids: string[]): string[] {
const parent = new Map<string, string | null>(), const parent = new Map<string, string | null>(),
@@ -200,25 +201,7 @@ export function localBounds(nodes: Node[], variants: Record<string, string> = {}
items.forEach((n) => { items.forEach((n) => {
const w = compose(parent, matrix(n.transform)); const w = compose(parent, matrix(n.transform));
if (n.type === "key") { if (n.type === "key") {
const corners = [ points.push(...keyCorners(n.size).map((p) => apply(w, p)));
{ 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.x2 !== undefined ||
n.size.y2 !== undefined ||
n.size.w2 !== undefined ||
n.size.h2 !== undefined
) {
const x = n.size.x2 ?? 0,
y = n.size.y2 ?? 0,
ww = n.size.w2 ?? n.size.w,
hh = n.size.h2 ?? n.size.h;
corners.push({ x, y }, { x: x + ww, y }, { x: x + ww, y: y + hh }, { x, y: y + hh });
}
points.push(...corners.map((p) => apply(w, p)));
} else if (n.type === "component") { } else if (n.type === "component") {
const c = apply(w, { x: 0, y: 0 }); const c = apply(w, { x: 0, y: 0 });
points.push({ x: c.x - 0.16, y: c.y - 0.16 }, { x: c.x + 0.16, y: c.y + 0.16 }); points.push({ x: c.x - 0.16, y: c.y - 0.16 }, { x: c.x + 0.16, y: c.y + 0.16 });
@@ -232,11 +215,21 @@ export function localBounds(nodes: Node[], variants: Record<string, string> = {}
return obbAabb(points); return obbAabb(points);
} }
/** SVG path of a non-rectangular key's outline; undefined for plain keys, which are drawn as rounded rects. */
export function keyPath(size: KeyNode["size"]): string | undefined {
if (!hasSecondRect(size)) return undefined;
return keyOutline(size)
.map((loop) => `M ${loop.map((p) => `${p.x} ${p.y}`).join(" L ")} Z`)
.join(" ");
}
export type SelectionFrame = { export type SelectionFrame = {
id: string; id: string;
type: Node["type"]; type: Node["type"];
transform: Affine; transform: Affine;
box: Box; box: Box;
/** Outline of a non-rectangular key, drawn instead of `box`. */
path?: string;
selected: boolean; selected: boolean;
candidate: boolean; candidate: boolean;
}; };
@@ -258,6 +251,7 @@ export function selectionFrames(
type: node.type, type: node.type,
transform, transform,
box: { x: 0, y: 0, width: node.size.w, height: node.size.h }, box: { x: 0, y: 0, width: node.size.w, height: node.size.h },
path: keyPath(node.size),
selected: selected.includes(node.id), selected: selected.includes(node.id),
candidate: candidate === node.id, candidate: candidate === node.id,
}); });
+2 -6
View File
@@ -2,6 +2,7 @@ import { nextTick, onUnmounted, ref, type Ref } from "vue";
import type { ProjectFile } from "../../domain/project"; import type { ProjectFile } from "../../domain/project";
import type { KeyNode, Node } from "../../domain/phy"; import type { KeyNode, Node } from "../../domain/phy";
import { apply, worldTransform } from "../../domain/geometry"; import { apply, worldTransform } from "../../domain/geometry";
import { keyCorners } from "../../domain/keyShape";
import { moveNode } from "../../domain/phyCommands"; import { moveNode } from "../../domain/phyCommands";
import { normalizeSelection, snap } from "./logic"; import { normalizeSelection, snap } from "./logic";
import { usePhyLongPress, type HoldMode } from "./usePhyLongPress"; import { usePhyLongPress, type HoldMode } from "./usePhyLongPress";
@@ -303,12 +304,7 @@ export function usePhyCanvasPointer(options: Options) {
for (const item of options.keys()) { for (const item of options.keys()) {
if (!scope.has(item.key.id)) continue; if (!scope.has(item.key.id)) continue;
const transform = worldTransform(options.project().phy, item.key.id); const transform = worldTransform(options.project().phy, item.key.id);
const corners = [ const corners = keyCorners(item.key.size).map((point) => apply(transform, point));
{ x: 0, y: 0 },
{ x: item.key.size.w, y: 0 },
{ x: item.key.size.w, y: item.key.size.h },
{ x: 0, y: item.key.size.h },
].map((point) => apply(transform, point));
const xs = corners.map((point) => point.x); const xs = corners.map((point) => point.x);
const ys = corners.map((point) => point.y); const ys = corners.map((point) => point.y);
if ( if (
+13 -3
View File
@@ -1,6 +1,6 @@
import type { Ref } from "vue"; import type { Ref } from "vue";
import type { ProjectFile } from "../../domain/project"; import type { ProjectFile } from "../../domain/project";
import type { KeyNode, Node, VariantGroupNode } from "../../domain/phy"; import type { KeyNode, Node, Size, VariantGroupNode } from "../../domain/phy";
import { import {
addComponentToParent, addComponentToParent,
addVariant as addVariantCommand, addVariant as addVariantCommand,
@@ -17,6 +17,7 @@ import {
import { deleteNodeCascade, listDeleteReferences } from "../../domain/deleteCascade"; import { deleteNodeCascade, listDeleteReferences } from "../../domain/deleteCascade";
import { apply, inverse, matrix, worldTransform, type Affine } from "../../domain/geometry"; import { apply, inverse, matrix, worldTransform, type Affine } from "../../domain/geometry";
import { newRowPosition, rowEndPosition } from "../../domain/rows"; import { newRowPosition, rowEndPosition } from "../../domain/rows";
import { IRREGULAR_KEY_PRESETS, keyOrigin } from "../../domain/keyShape";
import { pasteLocalTransform, localBounds, treeIndex } from "./logic"; import { pasteLocalTransform, localBounds, treeIndex } from "./logic";
type Options = { type Options = {
@@ -126,7 +127,7 @@ export function usePhyCommands(options: Options) {
return options.allNodes(after.root).find((node) => !existing.has(node.id))?.id || ""; return options.allNodes(after.root).find((node) => !existing.has(node.id))?.id || "";
} }
function keyAdd() { function keyAdd(size: Size = { w: 1, h: 1 }) {
const chosen = options.node(options.selected.value.at(-1) || ""); const chosen = options.node(options.selected.value.at(-1) || "");
// A selected cluster, or the shown variant of a selected group, receives a new row inside it, // A selected cluster, or the shown variant of a selected group, receives a new row inside it,
// like an empty selection inside the focus. // like an empty selection inside the focus.
@@ -149,15 +150,23 @@ export function usePhyCommands(options: Options) {
: chosen?.type === "component" : chosen?.type === "component"
? { x: snap(chosen.transform.x + 1), y: snap(chosen.transform.y) } ? { x: snap(chosen.transform.x + 1), y: snap(chosen.transform.y) }
: (newRowPosition(keys) ?? { x: snap(center.x), y: snap(center.y) }); : (newRowPosition(keys) ?? { x: snap(center.x), y: snap(center.y) });
// The key's bounding box, not its origin, goes to the found position (T-32).
const offset = keyOrigin(size);
let id = ""; let id = "";
options.dispatch("キー追加", (project) => { options.dispatch("キー追加", (project) => {
const phy = addKeyToParent(project.phy, parent, { x, y, rotation: 0 }, { w: 1, h: 1 }); const at = { x: x - offset.x, y: y - offset.y, rotation: 0 };
const phy = addKeyToParent(project.phy, parent, at, size);
id = addedId(project.phy, phy); id = addedId(project.phy, phy);
return { ...project, phy, idx: { ...project.idx, order: [...project.idx.order, id] } }; return { ...project, phy, idx: { ...project.idx, order: [...project.idx.order, id] } };
}); });
if (id) options.selected.value = [id]; if (id) options.selected.value = [id];
} }
function presetAdd(presetId: string) {
const preset = IRREGULAR_KEY_PRESETS.find((item) => item.id === presetId);
if (preset) keyAdd(preset.size);
}
function insertionParent() { function insertionParent() {
const focusId = options.focus.value; const focusId = options.focus.value;
if (!focusId) return null; if (!focusId) return null;
@@ -383,6 +392,7 @@ export function usePhyCommands(options: Options) {
tabItem, tabItem,
chooseVariant, chooseVariant,
keyAdd, keyAdd,
presetAdd,
partAdd, partAdd,
makeCluster, makeCluster,
ungroup, ungroup,
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
IRREGULAR_KEY_PRESETS,
hasSecondRect,
keyOrigin,
keyOutline,
keyRects,
} from "../src/domain/keyShape";
import { keyPath } from "../src/ui/phy/logic";
const isoEnter = IRREGULAR_KEY_PRESETS.find((p) => p.id === "iso-enter")!.size;
const asSet = (loop: { x: number; y: number }[]) => loop.map((p) => `${p.x},${p.y}`).sort();
describe("key shape", () => {
it("fills missing second-rectangle fields with KLE's defaults", () => {
expect(hasSecondRect({ w: 1, h: 1 })).toBe(false);
expect(keyRects({ w: 1, h: 1 })).toEqual([{ x: 0, y: 0, w: 1, h: 1 }]);
expect(keyRects({ w: 1.25, h: 1, w2: 1.75 })).toEqual([
{ x: 0, y: 0, w: 1.25, h: 1 },
{ x: 0, y: 0, w: 1.75, h: 1 },
]);
});
it("traces the ISO Enter as one clockwise loop of six corners", () => {
const loops = keyOutline(isoEnter);
expect(loops).toHaveLength(1);
expect(asSet(loops[0]!)).toEqual(
asSet([
{ x: -0.25, y: 0 },
{ x: 1.25, y: 0 },
{ x: 1.25, y: 2 },
{ x: 0, y: 2 },
{ x: 0, y: 1 },
{ x: -0.25, y: 1 },
]),
);
// Shoelace area is positive for a clockwise loop on a y-down screen.
const loop = loops[0]!;
const area = loop.reduce((sum, p, i) => {
const q = loop[(i + 1) % loop.length]!;
return sum + (p.x * q.y - q.x * p.y) / 2;
}, 0);
expect(area).toBeCloseTo(1.25 * 2 + 0.25);
});
it("traces a rectangle-in-rectangle as the outer rectangle", () => {
expect(keyOutline({ w: 1, h: 1, w2: 2 }).map(asSet)).toEqual([
asSet([
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2, y: 1 },
{ x: 0, y: 1 },
]),
]);
});
it("puts the bounding box's top-left where the key is placed", () => {
expect(keyOrigin(isoEnter)).toEqual({ x: -0.25, y: 0 });
expect(keyOrigin({ w: 1, h: 1 })).toEqual({ x: 0, y: 0 });
});
it("draws plain keys as rects and irregular ones as a path", () => {
expect(keyPath({ w: 1, h: 1 })).toBeUndefined();
expect(keyPath(isoEnter)).toMatch(/^M .* Z$/);
});
});