From afd9af1914da79a91c89830e72cabee65306a027 Mon Sep 17 00:00:00 2001 From: EwdErna Date: Mon, 26 Oct 2026 08:55:20 +0900 Subject: [PATCH] =?UTF-8?q?mock:=20PHY=20Editor=E3=81=A7=E7=95=B0=E5=BD=A2?= =?UTF-8?q?=E3=82=AD=E3=83=BC=E3=82=92=E6=8F=8F=E3=81=8D=E3=83=BB=E7=B7=A8?= =?UTF-8?q?=E9=9B=86=E3=81=97=E3=83=BB=E8=BF=BD=E5=8A=A0=E3=81=A7=E3=81=8D?= =?UTF-8?q?=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B(T-32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Xmqw9x7EoryY6zf116i3yM --- mock/e2e/phy.spec.ts | 28 ++++++++ mock/src/domain/keyShape.ts | 89 ++++++++++++++++++++++++++ mock/src/domain/phyCommands.ts | 27 ++------ mock/src/domain/stabilizers.ts | 8 +-- mock/src/ui/phy/NodeSvg.vue | 9 ++- mock/src/ui/phy/PhyEditor.vue | 4 +- mock/src/ui/phy/PhyProperties.vue | 56 ++++++++++++++++ mock/src/ui/phy/PhySelectionLayer.vue | 30 +++++---- mock/src/ui/phy/PhyToolbar.vue | 13 ++++ mock/src/ui/phy/logic.ts | 32 ++++----- mock/src/ui/phy/usePhyCanvasPointer.ts | 8 +-- mock/src/ui/phy/usePhyCommands.ts | 16 ++++- mock/tests/keyShape.test.ts | 62 ++++++++++++++++++ 13 files changed, 314 insertions(+), 68 deletions(-) create mode 100644 mock/src/domain/keyShape.ts create mode 100644 mock/tests/keyShape.test.ts diff --git a/mock/e2e/phy.spec.ts b/mock/e2e/phy.spec.ts index ec5c53d..536d797 100644 --- a/mock/e2e/phy.spec.ts +++ b/mock/e2e/phy.spec.ts @@ -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"); +}); diff --git a/mock/src/domain/keyShape.ts b/mock/src/domain/keyShape.ts new file mode 100644 index 0000000..e70c035 --- /dev/null +++ b/mock/src/domain/keyShape.ts @@ -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(); + 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 } }, +]; diff --git a/mock/src/domain/phyCommands.ts b/mock/src/domain/phyCommands.ts index f0073a4..2228c2d 100644 --- a/mock/src/domain/phyCommands.ts +++ b/mock/src/domain/phyCommands.ts @@ -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[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): { 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") diff --git a/mock/src/domain/stabilizers.ts b/mock/src/domain/stabilizers.ts index 8b136f1..f5d724f 100644 --- a/mock/src/domain/stabilizers.ts +++ b/mock/src/domain/stabilizers.ts @@ -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 ?? []; } diff --git a/mock/src/ui/phy/NodeSvg.vue b/mock/src/ui/phy/NodeSvg.vue index 0b2fd75..ac9906b 100644 --- a/mock/src/ui/phy/NodeSvg.vue +++ b/mock/src/ui/phy/NodeSvg.vue @@ -1,5 +1,6 @@