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:
@@ -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 ?? [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user