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:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 } },
|
||||
];
|
||||
@@ -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")
|
||||
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`"
|
||||
|
||||
@@ -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
@@ -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 +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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user