mock: スイッチ・スタビの十字をトグルで出し、スタビ候補を選べるようにする(T-30 単位8)

スタビ候補はキーの長辺の長さで引き、キーごとに複数選べる。選択は ProjectFile.ui.phy.stabilizers に持つ。
プロパティ欄に switchOffset の入力欄を置く。異形キーは候補なし。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yoUu9Xd8dhwyQdCfKfaXG
This commit is contained in:
2026-10-23 22:13:07 +09:00
co-authored by Claude Opus 5.5
parent 2c388e3bdb
commit eacf8a1581
8 changed files with 416 additions and 3 deletions
+34 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { Node } from "../../domain/phy";
import type { Dispatch } from "../types";
@@ -12,7 +12,18 @@ import PhyBadgeLayer from "./PhyBadgeLayer.vue";
import PhyToolbar from "./PhyToolbar.vue";
import PhyProperties from "./PhyProperties.vue";
import PhyHoldOverlay from "./PhyHoldOverlay.vue";
import { normalizeSelection, nextGrid, insertionContainer, structuralNodes, visibleNodeIds } from "./logic";
import PhyMountLayer from "./PhyMountLayer.vue";
import {
normalizeSelection,
nextGrid,
insertionContainer,
structuralNodes,
visibleNodeIds,
stabilizerSelections,
selectedStabilizers,
withStabilizers,
} from "./logic";
import { stabilizerCandidates } from "../../domain/stabilizers";
import { usePhyClipboard } from "./usePhyClipboard";
import { usePhyViewport } from "./usePhyViewport";
import { usePhyCanvasPointer } from "./usePhyCanvasPointer";
@@ -296,6 +307,15 @@ function openBadge(g: Node, e: PointerEvent | MouseEvent) {
const top = r.bottom + 150 < (paneRect?.bottom ?? Infinity) ? r.bottom + 4 : r.top - 150;
popoverPos.value = overlayPoint(left, top, 210, 150);
}
const showMounts = ref(false);
const stabilizerChoice = computed(() => {
const item = prop.value;
if (item?.type !== "key") return null;
return {
candidates: stabilizerCandidates(item.size),
selected: selectedStabilizers(stabilizerSelections(props.project), item),
};
});
function onFocus(_id: string) {
if (
pointer.priorSelection().length === 1 &&
@@ -334,6 +354,8 @@ function onFocus(_id: string) {
@focus="focus = $event"
:hold-mode="holdMode"
@hold-mode="holdMode = holdMode === 'loop' ? 'list' : 'loop'"
:show-mounts="showMounts"
@mounts="showMounts = !showMounts"
/>
<PhyHoldOverlay
v-if="hold.open.value"
@@ -394,6 +416,14 @@ function onFocus(_id: string) {
@down="down"
@focus="onFocus"
/>
<PhyMountLayer
v-if="showMounts"
:nodes="focus ? [node(focus)!] : project.phy.root"
:variants="variants"
:selections="stabilizerSelections(project)"
:zoom="zoom"
:parent="focus ? focusParentMatrix(focus) : undefined"
/>
<PhySelectionLayer
:nodes="focus ? [node(focus)!] : project.phy.root"
:selected="selected"
@@ -484,6 +514,8 @@ function onFocus(_id: string) {
@change-variant="chooseVariant"
@delete-node="(id) => askDelete(id)"
@add-variant="(groupId, name) => addVariantToGroup(groupId, name)"
:stabilizers="stabilizerChoice"
@stabilizers="(keyId, ids) => dispatch('スタビ位置', (p) => withStabilizers(p, keyId, ids))"
/>
</section>
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from "vue";
import type { Node } from "../../domain/phy";
import type { Affine } from "../../domain/geometry";
import { mountMarks, type StabilizerSelections } from "./logic";
const props = withDefaults(
defineProps<{
nodes: Node[];
variants: Record<string, string>;
selections: StabilizerSelections;
zoom: number;
parent?: Affine;
}>(),
{ parent: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }) },
);
const marks = computed(() => mountMarks(props.nodes, props.variants, props.selections, props.parent));
// 十字の大きさは画面上で固定する。
const cross = (p: { x: number; y: number }) => {
const r = 6 / props.zoom;
return `M ${p.x - r} ${p.y} H ${p.x + r} M ${p.x} ${p.y - r} V ${p.y + r}`;
};
const affineText = (m: Affine) => `matrix(${m.a} ${m.b} ${m.c} ${m.d} ${m.e} ${m.f})`;
</script>
<template>
<g class="mount-layer" pointer-events="none">
<g v-for="mark in marks" :key="mark.id" :data-mount-id="mark.id" :transform="affineText(mark.transform)">
<path class="switch" :d="cross(mark.switchAt)" />
<path v-for="(p, i) in mark.stabs" :key="i" class="stab" :d="cross(p)" />
</g>
</g>
</template>
<style scoped>
path {
fill: none;
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
.switch {
stroke: #c2185b;
}
.stab {
stroke: #00897b;
}
</style>
+57
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { Node, Transform } from "../../domain/phy";
import type { StabilizerCandidate } from "../../domain/stabilizers";
const props = defineProps<{
selectedCount: number;
selectedId: string | null;
item: Node | null;
mismatch: boolean;
stabilizers: { candidates: StabilizerCandidate[]; selected: string[] } | null;
}>();
const emit = defineEmits<{
update: [id: string, change: (node: Node) => void, label?: string];
@@ -14,7 +16,24 @@ const emit = defineEmits<{
changeVariant: [group: Node, variantId: string];
deleteNode: [id: string];
addVariant: [groupId: string, name: string];
stabilizers: [keyId: string, ids: string[]];
}>();
function setSwitchOffset(axis: "x" | "y", value: number) {
emit("update", props.item!.id, (n) => {
if (n.type !== "key") return;
const offset = { x: n.switchOffset?.x ?? 0, y: n.switchOffset?.y ?? 0, [axis]: value };
if (offset.x === 0 && offset.y === 0) delete n.switchOffset;
else n.switchOffset = offset;
});
}
function toggleStabilizer(id: string, on: boolean) {
const current = props.stabilizers?.selected ?? [];
emit(
"stabilizers",
props.item!.id,
on ? [...current.filter((x) => x !== id), id] : current.filter((x) => x !== id),
);
}
const group = computed(() => (props.item?.type === "variantGroup" ? props.item : null));
const frameX = ref(0);
const frameY = ref(0);
@@ -94,6 +113,33 @@ const frameR = ref(0);
})
"
/></label>
<label
>switchOffset X
<input
type="number"
step="any"
:value="item.switchOffset?.x ?? 0"
@change="setSwitchOffset('x', Number(($event.target as HTMLInputElement).value))"
/></label>
<label
>switchOffset Y
<input
type="number"
step="any"
:value="item.switchOffset?.y ?? 0"
@change="setSwitchOffset('y', Number(($event.target as HTMLInputElement).value))"
/></label>
<div class="stabilizers">
スタビ
<span v-if="!stabilizers?.candidates.length" class="none">候補なし</span>
<label v-for="c in stabilizers?.candidates ?? []" :key="c.id" class="check"
><input
type="checkbox"
:checked="stabilizers!.selected.includes(c.id)"
@change="toggleStabilizer(c.id, ($event.target as HTMLInputElement).checked)"
/>{{ c.label }}</label
>
</div>
<label
>Label
<input
@@ -174,4 +220,15 @@ const frameR = ref(0);
width: 110px;
margin-left: auto;
}
.properties label.check {
margin: 4px 0 4px 12px;
}
.properties label.check input {
width: auto;
margin-left: 0;
}
.stabilizers .none {
margin-left: 8px;
color: #6b7380;
}
</style>
+15
View File
@@ -7,6 +7,7 @@ defineProps<{
focus: string | null;
crumbs: { id: string | null; label: string }[];
holdMode: "loop" | "list";
showMounts: boolean;
}>();
const emit = defineEmits<{
keyAdd: [];
@@ -25,6 +26,7 @@ const emit = defineEmits<{
help: [];
focus: [id: string | null];
holdMode: [];
mounts: [];
}>();
function addPart(e: Event) {
const select = e.target as HTMLSelectElement;
@@ -103,6 +105,15 @@ function addPart(e: Event) {
>
{{ holdMode === "loop" ? "⟳" : "☰" }}
</button>
<button
title="スイッチ・スタビライザーの位置"
aria-label="スイッチ・スタビライザーの位置"
:aria-pressed="showMounts"
:class="{ on: showMounts }"
@click="emit('mounts')"
>
✛
</button>
<button title="ショートカット一覧" aria-label="ショートカット一覧" @click="emit('help')">?</button>
<span v-if="focus">
<template v-for="(crumb, i) in crumbs" :key="i">
@@ -123,6 +134,10 @@ function addPart(e: Event) {
.tools select {
font-size: 12px;
}
.tools button.on {
background: #dbe8fb;
border-color: #1769d2;
}
/* 候補の名前でセレクトの幅が変わると、ツールバーが折り返してキャンバスの大きさが変わる */
.tools select.move-to {
width: 6em;
+64 -1
View File
@@ -1,4 +1,11 @@
import type { Node, PhyFile, Transform } from "../../domain/phy";
import type { KeyNode, Node, PhyFile, Transform } from "../../domain/phy";
import type { ProjectFile } from "../../domain/project";
import {
defaultStabilizerSelection,
stabilizerCandidates,
stabilizerPoints,
switchPoint,
} from "../../domain/stabilizers";
import { apply, compose, inverse, matrix, type Affine } from "../../domain/geometry";
export function normalizeSelection(file: PhyFile, ids: string[]): string[] {
@@ -324,3 +331,59 @@ export function pasteLocalTransform(sourceWorld: Affine, parentWorld: Affine, st
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 };
}
/** スタビ位置の選択は仕様の層に入れず、ProjectFile.ui.phy.stabilizers にキーIDごとに持つ。T-10/T-30。 */
export type StabilizerSelections = Record<string, string[]>;
export function stabilizerSelections(project: ProjectFile): StabilizerSelections {
const ui = project.ui as { phy?: { stabilizers?: StabilizerSelections } } | undefined;
return ui?.phy?.stabilizers ?? {};
}
/** 記録がなければサイズの既定。サイズが変わって候補にない記録は捨てて読む。 */
export function selectedStabilizers(selections: StabilizerSelections, key: KeyNode): string[] {
const ids = selections[key.id];
if (!ids) return defaultStabilizerSelection(key.size);
const known = new Set(stabilizerCandidates(key.size).map((c) => c.id));
return ids.filter((id) => known.has(id));
}
export function withStabilizers(project: ProjectFile, keyId: string, ids: string[]): ProjectFile {
const ui = (project.ui ?? {}) as Record<string, unknown>,
phy = (ui.phy ?? {}) as Record<string, unknown>;
return {
...project,
ui: { ...ui, phy: { ...phy, stabilizers: { ...stabilizerSelections(project), [keyId]: ids } } },
};
}
export type MountMark = {
id: string;
transform: Affine;
switchAt: { x: number; y: number };
stabs: { x: number; y: number }[];
};
/** 表示中のキーごとに、スイッチ位置とスタビ位置をキーのローカル座標で返す。 */
export function mountMarks(
roots: Node[],
variants: Record<string, string>,
selections: StabilizerSelections,
parent: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
): MountMark[] {
const marks: MountMark[] = [];
const visit = (nodes: Node[], ancestor: Affine) => {
for (const node of nodes) {
const transform = compose(ancestor, matrix(node.transform));
if (node.type === "key") {
if (node.decal) continue;
marks.push({
id: node.id,
transform,
switchAt: switchPoint(node),
stabs: stabilizerPoints(node.size, selectedStabilizers(selections, node)),
});
} else if (node.type === "cluster") visit(node.children, transform);
else if (node.type === "variantGroup") {
const variant = node.children.find((item) => item.id === (variants[node.id] ?? node.default));
if (variant) visit(variant.children, transform);
}
}
};
visit(roots, parent);
return marks;
}