mock: PHY Editorを作り直す(T-29)

provisional/phy-editor-feedback.md の合意事項と phy-editor-keybindings.md の割り当て案に沿って、PHY 画面を一から書き直した。
App.vue は共通のシェル(入出力・履歴・タブ)だけにし、PHY は src/ui/phy/ に、コンポーネント・composable・純粋モジュール(logic.ts, viewport.ts)を分けて置いた。

- 画面確認で見つかった不具合(読み込み時の全体表示、Shift+クリック、削除の確認の大きさ、Alt+↑、フォーカス中の二重描画、長押し中の選択など)を直し、キャンバス上の実際の操作で確かめる e2e を足した
- 階層パネルでクラスターの子を余分な折りたたみで包まず、件数を配下のキー・部品の数にそろえた
- 実装は Codex(Luna)。検証: vue-tsc 0件、Vitest 64件、Playwright 30件、prettier --check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbfgHT848s3JbuFw8MKFF8
This commit is contained in:
2026-10-07 20:54:37 +09:00
co-authored by Claude Opus 5.5
parent beac52c20b
commit 4d1eb23cf8
24 changed files with 3807 additions and 1131 deletions
+236
View File
@@ -0,0 +1,236 @@
import type { Node, PhyFile, Transform } from "../../domain/phy";
import { apply, compose, inverse, matrix, type Affine } from "../../domain/geometry";
export function normalizeSelection(file: PhyFile, ids: string[]): string[] {
const parent = new Map<string, string | null>(),
nodes = new Map<string, Node>();
const walk = (items: Node[], p: string | null) =>
items.forEach((n) => {
parent.set(n.id, p);
nodes.set(n.id, n);
if (n.type === "cluster") walk(n.children, n.id);
if (n.type === "variantGroup")
n.children.forEach((v) => {
parent.set(v.id, n.id);
walk(v.children, v.id);
});
});
walk(file.root, null);
const out: string[] = [];
for (const id of ids) {
if (!nodes.has(id) || out.includes(id)) continue;
const ancestors = new Set<string>();
let p = parent.get(id) ?? null;
while (p) {
ancestors.add(p);
p = parent.get(p) ?? null;
}
const keep = out.filter((x) => !ancestors.has(x) && !isDescendant(parent, x, id));
out.splice(0, out.length, ...keep, id);
}
return out;
}
function isDescendant(parent: Map<string, string | null>, candidate: string, ancestor: string): boolean {
let p = parent.get(candidate) ?? null;
while (p) {
if (p === ancestor) return true;
p = parent.get(p) ?? null;
}
return false;
}
export function longPressColumn(keyId: string, ancestorsNearestFirst: string[], focusId?: string): string[] {
const list = [keyId, ...ancestorsNearestFirst];
if (focusId) {
const i = list.indexOf(focusId);
if (i >= 0) list.splice(i);
}
return list;
}
export function tabOrder<T extends { id: string; x: number; y: number }>(items: T[]): T[] {
const sorted = [...items].sort((a, b) => a.y - b.y),
rows: T[][] = [];
for (const item of sorted) {
const row = rows.at(-1);
if (!row || item.y - row[0]!.y >= 0.5) rows.push([item]);
else row.push(item);
}
return rows.flatMap((row) => row.sort((a, b) => a.x - b.x));
}
/** 1つの走査結果からノードの親と挿入先の器を引けるようにする。 */
export function treeIndex(roots: Node[]) {
const nodes = new Map<string, Node>();
const parents = new Map<string, string | null>();
const containers = new Map<string, string | null>();
const variants = new Map<string, string>();
const visit = (items: Node[], parent: string | null, container: string | null) => {
for (const n of items) {
nodes.set(n.id, n);
parents.set(n.id, parent);
containers.set(n.id, container);
if (n.type === "cluster") visit(n.children, n.id, n.id);
if (n.type === "variantGroup") {
for (const v of n.children) {
variants.set(v.id, n.id);
visit(v.children, n.id, v.id);
}
}
}
};
visit(roots, null, null);
return {
nodes,
parents,
containers,
variants,
selectableParent(id: string) {
return parents.get(id) ?? null;
},
insertionContainer(id: string) {
return containers.get(id) ?? null;
},
groupForVariant(id: string) {
return variants.get(id) ?? null;
},
};
}
export function treeNodes(roots: Node[]): Node[] {
return [...treeIndex(roots).nodes.values()];
}
export function structuralNodes(roots: Node[]): Extract<Node, { type: "cluster" | "variantGroup" }>[] {
return treeNodes(roots).filter(
(node): node is Extract<Node, { type: "cluster" | "variantGroup" }> =>
node.type === "cluster" || node.type === "variantGroup",
);
}
export function removeFocusedSubtree(nodes: Node[], focusedId: string): Node[] {
const result: Node[] = [];
for (const node of nodes) {
if (node.id === focusedId) continue;
if (node.type === "cluster") {
const children = removeFocusedSubtree(node.children, focusedId);
result.push({ ...node, children });
} else if (node.type === "variantGroup") {
result.push({
...node,
children: node.children.map((variant) => ({
...variant,
children: removeFocusedSubtree(variant.children, focusedId),
})),
});
} else {
result.push(node);
}
}
return result;
}
export function visibleNodeIds(root: Node | undefined, shownVariants: Record<string, string>): Set<string> {
const result = new Set<string>();
const visit = (node: Node) => {
result.add(node.id);
if (node.type === "cluster") node.children.forEach(visit);
if (node.type === "variantGroup") {
const variant = node.children.find((item) => item.id === (shownVariants[node.id] ?? node.default));
variant?.children.forEach(visit);
}
};
if (root) visit(root);
return result;
}
export function selectableParent(roots: Node[], id: string): string | null {
return treeIndex(roots).selectableParent(id);
}
export function insertionContainer(
roots: Node[],
parentId: string | null,
shownVariants: Record<string, string> = {},
): string | null {
if (parentId === null) return null;
const index = treeIndex(roots);
const node = index.nodes.get(parentId);
if (node?.type === "cluster") return node.id;
if (node?.type === "variantGroup") return shownVariants[node.id] ?? node.default;
return index.variants.get(parentId) === undefined ? null : parentId;
}
export function snap(value: number, step: number): number {
return Math.round(value / step) * step;
}
export const GRID_STEPS = [1, 0.25, 0.125, 1 / 12] as const;
export function nextGrid(step: number, direction: 1 | -1): number {
const i = GRID_STEPS.findIndex((x) => Math.abs(x - step) < 1e-9);
return GRID_STEPS[(i + direction + GRID_STEPS.length) % GRID_STEPS.length]!;
}
export function resolveKey(
e: Pick<KeyboardEvent, "key" | "code" | "ctrlKey" | "metaKey" | "shiftKey" | "altKey">,
): string | null {
const mod = e.ctrlKey || e.metaKey;
if (e.code === "Digit1" && e.shiftKey) return "fit";
if (e.code === "Digit2" && e.shiftKey) return "selection-fit";
if (mod && e.key.toLowerCase() === "z") return e.shiftKey ? "redo" : "undo";
if (mod && e.key.toLowerCase() === "y") return "redo";
if (mod && e.key.toLowerCase() === "s") return "save";
if (mod && e.key.toLowerCase() === "g") return e.shiftKey ? "ungroup" : "group";
if (mod && ["a", "c", "x", "v", "d"].includes(e.key.toLowerCase())) return e.key.toLowerCase();
return e.key;
}
export type Box = { x: number; y: number; width: number; height: number };
export function obbAabb(points: { x: number; y: number }[]): Box {
if (!points.length) return { x: 0, y: 0, width: 0, height: 0 };
const xs = points.map((p) => p.x),
ys = points.map((p) => p.y),
x = Math.min(...xs),
y = Math.min(...ys);
return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
}
export function pasteParent(selectedParents: (string | null)[], focus: string | null): string | null {
return selectedParents.length ? selectedParents[0]! : focus;
}
/** 子のローカル形状を親座標でまとめる。回転親に追従する選択枠用。 */
export function localBounds(nodes: Node[], variants: Record<string, string> = {}): Box {
const points: { x: number; y: number }[] = [];
const visit = (items: Node[], parent: Affine) =>
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)));
} 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 });
} else if (n.type === "cluster") visit(n.children, w);
else {
const v = n.children.find((x) => x.id === (variants[n.id] ?? n.default));
if (v) visit(v.children, w);
}
});
visit(nodes, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 });
return obbAabb(points);
}
/** ワールド位置を貼り先の親ローカルへ戻し、親軸で1刻みずらす。T-11/T-27。 */
export function pasteLocalTransform(sourceWorld: Affine, parentWorld: Affine, step: number): Transform {
const local = compose(inverse(parentWorld), sourceWorld);
return { x: local.e + step, y: local.f + step, rotation: (Math.atan2(local.b, local.a) * 180) / Math.PI };
}