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 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 { createNodeId } from "./ids";
import { keyCorners } from "./keyShape";
const ident = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
type Container = { nodes: Node[]; parentWorld: typeof ident; parentRotation: number };
@@ -50,7 +51,7 @@ function nodeId(kind: Parameters<typeof createNodeId>[0], gen?: () => string) {
export function addKey(
f: PhyFile,
at: Transform,
size: { w: number; h: number },
size: Size,
profile = f.profiles[0]!.id,
gen?: () => string,
): PhyFile {
@@ -75,7 +76,7 @@ export function addKeyToParent(
f: PhyFile,
parentId: string | null,
at: Transform,
size: { w: number; h: number },
size: Size,
profile = f.profiles[0]!.id,
gen?: () => string,
): PhyFile {
@@ -275,25 +276,7 @@ function containsNode(n: Node, id: string): boolean {
}
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));
return keyCorners(n.size).map((point) => apply(worldMatrix, point));
}
if (n.type === "component") return [{ x: worldMatrix.e, y: worldMatrix.f }];
if (n.type === "cluster")
+3 -5
View File
@@ -1,4 +1,5 @@
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. */
export type StabilizerCandidate = { id: string; label: string; offset: number };
@@ -35,12 +36,9 @@ const TABLE: { lengths: number[]; candidates: StabilizerCandidate[] }[] = [
];
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[] {
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);
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">
import type { Node } from "../../domain/phy";
import { keyPath } from "./logic";
defineOptions({ name: "NodeSvg" });
defineProps<{
nodes: Node[];
@@ -27,7 +28,13 @@ const emit = defineEmits<{
v-if="n.type === 'key'"
class="node"
@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"
:height="n.size.h"
rx=".06"
+3 -1
View File
@@ -201,6 +201,7 @@ const {
tabItem,
chooseVariant,
keyAdd,
presetAdd,
partAdd,
makeCluster,
ungroup,
@@ -337,7 +338,8 @@ function onFocus(_id: string) {
:move-targets="moveTargets"
:focus="focus"
:crumbs="focus ? focusCrumbs(focus) : []"
@key-add="keyAdd"
@key-add="keyAdd()"
@preset-add="presetAdd"
@part-add="partAdd"
@cluster="makeCluster"
@ungroup="ungroup"
+56
View File
@@ -2,6 +2,7 @@
import { computed, ref } from "vue";
import type { Node, Transform } from "../../domain/phy";
import type { StabilizerCandidate } from "../../domain/stabilizers";
import { hasSecondRect } from "../../domain/keyShape";
const props = defineProps<{
selectedCount: number;
selectedId: string | null;
@@ -26,6 +27,20 @@ function setSwitchOffset(axis: "x" | "y", value: number) {
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) {
const current = props.stabilizers?.selected ?? [];
emit(
@@ -113,6 +128,47 @@ const frameR = ref(0);
})
"
/></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
>switchOffset X
<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>
<template>
<g class="selection-layer" pointer-events="none">
<rect
v-for="frame in frames"
:key="frame.id"
: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 v-for="frame in frames" :key="frame.id">
<path
v-if="frame.path"
:data-sel-id="frame.id"
:class="['sel', { candidate: frame.candidate }]"
:transform="affineText(frame.transform)"
:d="frame.path"
/>
<rect
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
v-for="frame in origins"
:key="`origin-${frame.id}`"
+13
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { IRREGULAR_KEY_PRESETS } from "../../domain/keyShape";
defineProps<{
mismatch: boolean;
selectedCount: number;
@@ -11,6 +12,7 @@ defineProps<{
}>();
const emit = defineEmits<{
keyAdd: [];
presetAdd: [presetId: string];
partAdd: [group: string];
cluster: [];
ungroup: [];
@@ -28,6 +30,11 @@ const emit = defineEmits<{
holdMode: [];
mounts: [];
}>();
function addPreset(e: Event) {
const select = e.target as HTMLSelectElement;
emit("presetAdd", select.value);
select.value = "";
}
function addPart(e: Event) {
const select = e.target as HTMLSelectElement;
emit("partAdd", select.value);
@@ -40,6 +47,12 @@ function addPart(e: Event) {
<button title="キーを足す" aria-label="キーを足す" :disabled="mismatch" @click="emit('keyAdd')">
+⌨
</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">
<option value="">部品を足す</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 { stabilizerCandidates, stabilizerPoints, switchPoint } from "../../domain/stabilizers";
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[] {
const parent = new Map<string, string | null>(),
@@ -200,25 +201,7 @@ export function localBounds(nodes: Node[], variants: Record<string, string> = {}
items.forEach((n) => {
const w = compose(parent, matrix(n.transform));
if (n.type === "key") {
const corners = [
{ 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)));
points.push(...keyCorners(n.size).map((p) => apply(w, p)));
} else if (n.type === "component") {
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 });
@@ -232,11 +215,21 @@ export function localBounds(nodes: Node[], variants: Record<string, string> = {}
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 = {
id: string;
type: Node["type"];
transform: Affine;
box: Box;
/** Outline of a non-rectangular key, drawn instead of `box`. */
path?: string;
selected: boolean;
candidate: boolean;
};
@@ -258,6 +251,7 @@ export function selectionFrames(
type: node.type,
transform,
box: { x: 0, y: 0, width: node.size.w, height: node.size.h },
path: keyPath(node.size),
selected: selected.includes(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 { KeyNode, Node } from "../../domain/phy";
import { apply, worldTransform } from "../../domain/geometry";
import { keyCorners } from "../../domain/keyShape";
import { moveNode } from "../../domain/phyCommands";
import { normalizeSelection, snap } from "./logic";
import { usePhyLongPress, type HoldMode } from "./usePhyLongPress";
@@ -303,12 +304,7 @@ export function usePhyCanvasPointer(options: Options) {
for (const item of options.keys()) {
if (!scope.has(item.key.id)) continue;
const transform = worldTransform(options.project().phy, item.key.id);
const corners = [
{ 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 corners = keyCorners(item.key.size).map((point) => apply(transform, point));
const xs = corners.map((point) => point.x);
const ys = corners.map((point) => point.y);
if (
+13 -3
View File
@@ -1,6 +1,6 @@
import type { Ref } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { KeyNode, Node, VariantGroupNode } from "../../domain/phy";
import type { KeyNode, Node, Size, VariantGroupNode } from "../../domain/phy";
import {
addComponentToParent,
addVariant as addVariantCommand,
@@ -17,6 +17,7 @@ import {
import { deleteNodeCascade, listDeleteReferences } from "../../domain/deleteCascade";
import { apply, inverse, matrix, worldTransform, type Affine } from "../../domain/geometry";
import { newRowPosition, rowEndPosition } from "../../domain/rows";
import { IRREGULAR_KEY_PRESETS, keyOrigin } from "../../domain/keyShape";
import { pasteLocalTransform, localBounds, treeIndex } from "./logic";
type Options = {
@@ -126,7 +127,7 @@ export function usePhyCommands(options: Options) {
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) || "");
// A selected cluster, or the shown variant of a selected group, receives a new row inside it,
// like an empty selection inside the focus.
@@ -149,15 +150,23 @@ export function usePhyCommands(options: Options) {
: chosen?.type === "component"
? { x: snap(chosen.transform.x + 1), y: snap(chosen.transform.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 = "";
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);
return { ...project, phy, idx: { ...project.idx, order: [...project.idx.order, 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() {
const focusId = options.focus.value;
if (!focusId) return null;
@@ -383,6 +392,7 @@ export function usePhyCommands(options: Options) {
tabItem,
chooseVariant,
keyAdd,
presetAdd,
partAdd,
makeCluster,
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$/);
});
});