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