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:
@@ -706,3 +706,37 @@ test("キャンバスのviewBoxはツールバーを除いたSVGの大きさに
|
|||||||
expect(Math.abs(w! - box.width)).toBeLessThan(1);
|
expect(Math.abs(w! - box.width)).toBeLessThan(1);
|
||||||
expect(Math.abs(h! - box.height)).toBeLessThan(1);
|
expect(Math.abs(h! - box.height)).toBeLessThan(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("スイッチ・スタビの十字はトグルで出し、スタビ候補を複数選べる", async ({ page }) => {
|
||||||
|
const key = page.locator('[data-node-type="key"]').first();
|
||||||
|
const id = await key.getAttribute("data-node-id");
|
||||||
|
await expect(page.locator(".mount-layer")).toHaveCount(0);
|
||||||
|
await page.getByRole("button", { name: "スイッチ・スタビライザーの位置" }).click();
|
||||||
|
const mark = page.locator(`[data-mount-id="${id}"]`);
|
||||||
|
await expect(mark.locator(".switch")).toHaveCount(1);
|
||||||
|
await expect(mark.locator(".stab")).toHaveCount(0);
|
||||||
|
|
||||||
|
await key.click();
|
||||||
|
await expect(page.locator(".stabilizers")).toContainText("候補なし");
|
||||||
|
await page.getByLabel("Width").fill("6.25");
|
||||||
|
await page.getByLabel("Width").blur();
|
||||||
|
for (const label of ["±50mm", "±40mm", "±42.5mm"]) await expect(page.getByLabel(label)).not.toBeChecked();
|
||||||
|
await page.getByLabel("±50mm").check();
|
||||||
|
await page.getByLabel("±40mm").check();
|
||||||
|
await expect(mark.locator(".stab")).toHaveCount(4);
|
||||||
|
|
||||||
|
const switchX = async () => (await mark.locator(".switch").boundingBox())!.x;
|
||||||
|
const before = await switchX();
|
||||||
|
await page.getByLabel("switchOffset X").fill("0.625");
|
||||||
|
await page.getByLabel("switchOffset X").blur();
|
||||||
|
await expect.poll(switchX).toBeGreaterThan(before + 10);
|
||||||
|
await expect(mark.locator(".stab")).toHaveCount(4);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Undo" }).click();
|
||||||
|
await page.getByRole("button", { name: "Undo" }).click();
|
||||||
|
await expect(mark.locator(".stab")).toHaveCount(2);
|
||||||
|
await expect(page.getByLabel("±40mm")).not.toBeChecked();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "スイッチ・スタビライザーの位置" }).click();
|
||||||
|
await expect(page.locator(".mount-layer")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { Point, Size } from "./phy";
|
||||||
|
|
||||||
|
/** 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 };
|
||||||
|
|
||||||
|
const MM = 1 / 19.05;
|
||||||
|
/** Cherry MX spacings by key length. provisional/decisions.md「単位8のスタビ候補」. */
|
||||||
|
const TABLE: { lengths: number[]; candidates: StabilizerCandidate[] }[] = [
|
||||||
|
{
|
||||||
|
lengths: [2, 2.25, 2.5, 2.75],
|
||||||
|
candidates: [{ id: "11.938mm", label: "±11.938mm", offset: 11.938 * MM }],
|
||||||
|
},
|
||||||
|
{ lengths: [3], candidates: [{ id: "1u", label: "±1U", offset: 1 }] },
|
||||||
|
{ lengths: [4], candidates: [{ id: "1.5u", label: "±1.5U", offset: 1.5 }] },
|
||||||
|
{ lengths: [4.5], candidates: [{ id: "34.671mm", label: "±34.671mm", offset: 34.671 * MM }] },
|
||||||
|
{ lengths: [5.5], candidates: [{ id: "2.25u", label: "±2.25U", offset: 2.25 }] },
|
||||||
|
{
|
||||||
|
lengths: [6],
|
||||||
|
candidates: [
|
||||||
|
{ id: "2.5u", label: "±2.5U", offset: 2.5 },
|
||||||
|
{ id: "2u", label: "±2U", offset: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lengths: [6.25],
|
||||||
|
candidates: [
|
||||||
|
{ id: "50mm", label: "±50mm", offset: 50 * MM },
|
||||||
|
{ id: "40mm", label: "±40mm", offset: 40 * MM },
|
||||||
|
{ id: "42.5mm", label: "±42.5mm", offset: 42.5 * MM },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ lengths: [6.5], candidates: [{ id: "2.75u", label: "±2.75U", offset: 2.75 }] },
|
||||||
|
{ lengths: [7], candidates: [{ id: "3u", label: "±3U", offset: 3 }] },
|
||||||
|
{ lengths: [8, 9, 9.75, 10], candidates: [{ id: "3.5u", label: "±3.5U", offset: 3.5 }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 [];
|
||||||
|
const length = Math.max(size.w, size.h);
|
||||||
|
return TABLE.find((row) => row.lengths.some((l) => Math.abs(l - length) < EPS))?.candidates ?? [];
|
||||||
|
}
|
||||||
|
/** A size with a single candidate shows it until chosen otherwise; several start unselected. */
|
||||||
|
export function defaultStabilizerSelection(size: Size): string[] {
|
||||||
|
const candidates = stabilizerCandidates(size);
|
||||||
|
return candidates.length === 1 ? [candidates[0]!.id] : [];
|
||||||
|
}
|
||||||
|
/** Mount points in key-local coordinates, around the (w, h) rectangle's center, not the switch. */
|
||||||
|
export function stabilizerPoints(size: Size, ids: string[]): Point[] {
|
||||||
|
const cx = size.w / 2,
|
||||||
|
cy = size.h / 2,
|
||||||
|
vertical = size.h > size.w;
|
||||||
|
return stabilizerCandidates(size)
|
||||||
|
.filter((c) => ids.includes(c.id))
|
||||||
|
.flatMap((c) =>
|
||||||
|
vertical
|
||||||
|
? [
|
||||||
|
{ x: cx, y: cy - c.offset },
|
||||||
|
{ x: cx, y: cy + c.offset },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ x: cx - c.offset, y: cy },
|
||||||
|
{ x: cx + c.offset, y: cy },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function switchPoint(key: { size: Size; switchOffset?: Point }): Point {
|
||||||
|
return { x: key.size.w / 2 + (key.switchOffset?.x ?? 0), y: key.size.h / 2 + (key.switchOffset?.y ?? 0) };
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { ProjectFile } from "../../domain/project";
|
||||||
import type { Node } from "../../domain/phy";
|
import type { Node } from "../../domain/phy";
|
||||||
import type { Dispatch } from "../types";
|
import type { Dispatch } from "../types";
|
||||||
@@ -12,7 +12,18 @@ import PhyBadgeLayer from "./PhyBadgeLayer.vue";
|
|||||||
import PhyToolbar from "./PhyToolbar.vue";
|
import PhyToolbar from "./PhyToolbar.vue";
|
||||||
import PhyProperties from "./PhyProperties.vue";
|
import PhyProperties from "./PhyProperties.vue";
|
||||||
import PhyHoldOverlay from "./PhyHoldOverlay.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 { usePhyClipboard } from "./usePhyClipboard";
|
||||||
import { usePhyViewport } from "./usePhyViewport";
|
import { usePhyViewport } from "./usePhyViewport";
|
||||||
import { usePhyCanvasPointer } from "./usePhyCanvasPointer";
|
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;
|
const top = r.bottom + 150 < (paneRect?.bottom ?? Infinity) ? r.bottom + 4 : r.top - 150;
|
||||||
popoverPos.value = overlayPoint(left, top, 210, 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) {
|
function onFocus(_id: string) {
|
||||||
if (
|
if (
|
||||||
pointer.priorSelection().length === 1 &&
|
pointer.priorSelection().length === 1 &&
|
||||||
@@ -334,6 +354,8 @@ function onFocus(_id: string) {
|
|||||||
@focus="focus = $event"
|
@focus="focus = $event"
|
||||||
:hold-mode="holdMode"
|
:hold-mode="holdMode"
|
||||||
@hold-mode="holdMode = holdMode === 'loop' ? 'list' : 'loop'"
|
@hold-mode="holdMode = holdMode === 'loop' ? 'list' : 'loop'"
|
||||||
|
:show-mounts="showMounts"
|
||||||
|
@mounts="showMounts = !showMounts"
|
||||||
/>
|
/>
|
||||||
<PhyHoldOverlay
|
<PhyHoldOverlay
|
||||||
v-if="hold.open.value"
|
v-if="hold.open.value"
|
||||||
@@ -394,6 +416,14 @@ function onFocus(_id: string) {
|
|||||||
@down="down"
|
@down="down"
|
||||||
@focus="onFocus"
|
@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
|
<PhySelectionLayer
|
||||||
:nodes="focus ? [node(focus)!] : project.phy.root"
|
:nodes="focus ? [node(focus)!] : project.phy.root"
|
||||||
:selected="selected"
|
:selected="selected"
|
||||||
@@ -484,6 +514,8 @@ function onFocus(_id: string) {
|
|||||||
@change-variant="chooseVariant"
|
@change-variant="chooseVariant"
|
||||||
@delete-node="(id) => askDelete(id)"
|
@delete-node="(id) => askDelete(id)"
|
||||||
@add-variant="(groupId, name) => addVariantToGroup(groupId, name)"
|
@add-variant="(groupId, name) => addVariantToGroup(groupId, name)"
|
||||||
|
:stabilizers="stabilizerChoice"
|
||||||
|
@stabilizers="(keyId, ids) => dispatch('スタビ位置', (p) => withStabilizers(p, keyId, ids))"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import type { Node, Transform } from "../../domain/phy";
|
import type { Node, Transform } from "../../domain/phy";
|
||||||
|
import type { StabilizerCandidate } from "../../domain/stabilizers";
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
item: Node | null;
|
item: Node | null;
|
||||||
mismatch: boolean;
|
mismatch: boolean;
|
||||||
|
stabilizers: { candidates: StabilizerCandidate[]; selected: string[] } | null;
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
update: [id: string, change: (node: Node) => void, label?: string];
|
update: [id: string, change: (node: Node) => void, label?: string];
|
||||||
@@ -14,7 +16,24 @@ const emit = defineEmits<{
|
|||||||
changeVariant: [group: Node, variantId: string];
|
changeVariant: [group: Node, variantId: string];
|
||||||
deleteNode: [id: string];
|
deleteNode: [id: string];
|
||||||
addVariant: [groupId: string, name: 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 group = computed(() => (props.item?.type === "variantGroup" ? props.item : null));
|
||||||
const frameX = ref(0);
|
const frameX = ref(0);
|
||||||
const frameY = ref(0);
|
const frameY = ref(0);
|
||||||
@@ -94,6 +113,33 @@ const frameR = ref(0);
|
|||||||
})
|
})
|
||||||
"
|
"
|
||||||
/></label>
|
/></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
|
||||||
>Label
|
>Label
|
||||||
<input
|
<input
|
||||||
@@ -174,4 +220,15 @@ const frameR = ref(0);
|
|||||||
width: 110px;
|
width: 110px;
|
||||||
margin-left: auto;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ defineProps<{
|
|||||||
focus: string | null;
|
focus: string | null;
|
||||||
crumbs: { id: string | null; label: string }[];
|
crumbs: { id: string | null; label: string }[];
|
||||||
holdMode: "loop" | "list";
|
holdMode: "loop" | "list";
|
||||||
|
showMounts: boolean;
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
keyAdd: [];
|
keyAdd: [];
|
||||||
@@ -25,6 +26,7 @@ const emit = defineEmits<{
|
|||||||
help: [];
|
help: [];
|
||||||
focus: [id: string | null];
|
focus: [id: string | null];
|
||||||
holdMode: [];
|
holdMode: [];
|
||||||
|
mounts: [];
|
||||||
}>();
|
}>();
|
||||||
function addPart(e: Event) {
|
function addPart(e: Event) {
|
||||||
const select = e.target as HTMLSelectElement;
|
const select = e.target as HTMLSelectElement;
|
||||||
@@ -103,6 +105,15 @@ function addPart(e: Event) {
|
|||||||
>
|
>
|
||||||
{{ holdMode === "loop" ? "⟳" : "☰" }}
|
{{ holdMode === "loop" ? "⟳" : "☰" }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
title="スイッチ・スタビライザーの位置"
|
||||||
|
aria-label="スイッチ・スタビライザーの位置"
|
||||||
|
:aria-pressed="showMounts"
|
||||||
|
:class="{ on: showMounts }"
|
||||||
|
@click="emit('mounts')"
|
||||||
|
>
|
||||||
|
✛
|
||||||
|
</button>
|
||||||
<button title="ショートカット一覧" aria-label="ショートカット一覧" @click="emit('help')">?</button>
|
<button title="ショートカット一覧" aria-label="ショートカット一覧" @click="emit('help')">?</button>
|
||||||
<span v-if="focus">
|
<span v-if="focus">
|
||||||
<template v-for="(crumb, i) in crumbs" :key="i">
|
<template v-for="(crumb, i) in crumbs" :key="i">
|
||||||
@@ -123,6 +134,10 @@ function addPart(e: Event) {
|
|||||||
.tools select {
|
.tools select {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
.tools button.on {
|
||||||
|
background: #dbe8fb;
|
||||||
|
border-color: #1769d2;
|
||||||
|
}
|
||||||
/* 候補の名前でセレクトの幅が変わると、ツールバーが折り返してキャンバスの大きさが変わる */
|
/* 候補の名前でセレクトの幅が変わると、ツールバーが折り返してキャンバスの大きさが変わる */
|
||||||
.tools select.move-to {
|
.tools select.move-to {
|
||||||
width: 6em;
|
width: 6em;
|
||||||
|
|||||||
@@ -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";
|
import { apply, compose, inverse, matrix, type Affine } from "../../domain/geometry";
|
||||||
|
|
||||||
export function normalizeSelection(file: PhyFile, ids: string[]): string[] {
|
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);
|
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 };
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
defaultStabilizerSelection,
|
||||||
|
stabilizerCandidates,
|
||||||
|
stabilizerPoints,
|
||||||
|
switchPoint,
|
||||||
|
} from "../src/domain/stabilizers";
|
||||||
|
import { mountMarks, selectedStabilizers, stabilizerSelections, withStabilizers } from "../src/ui/phy/logic";
|
||||||
|
import type { KeyNode } from "../src/domain/phy";
|
||||||
|
import type { ProjectFile } from "../src/domain/project";
|
||||||
|
|
||||||
|
const key = (id: string, w: number, h = 1, extra: Partial<KeyNode> = {}): KeyNode => ({
|
||||||
|
type: "key",
|
||||||
|
id,
|
||||||
|
profile: "p",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
size: { w, h },
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
const ids = (w: number, h = 1) => stabilizerCandidates({ w, h }).map((c) => c.id);
|
||||||
|
|
||||||
|
describe("stabilizer candidates", () => {
|
||||||
|
it("looks up candidates by the long side", () => {
|
||||||
|
expect(ids(1)).toEqual([]);
|
||||||
|
expect(ids(1.75)).toEqual([]);
|
||||||
|
expect(ids(2.25)).toEqual(["11.938mm"]);
|
||||||
|
expect(ids(1, 2)).toEqual(["11.938mm"]);
|
||||||
|
expect(ids(6)).toEqual(["2.5u", "2u"]);
|
||||||
|
expect(ids(6.25)).toEqual(["50mm", "40mm", "42.5mm"]);
|
||||||
|
expect(ids(10)).toEqual(["3.5u"]);
|
||||||
|
expect(ids(6.75)).toEqual([]);
|
||||||
|
});
|
||||||
|
it("gives no candidates to non-rectangular keys", () => {
|
||||||
|
expect(stabilizerCandidates({ w: 1.25, h: 2, w2: 1.5, h2: 1, x2: -0.25 })).toEqual([]);
|
||||||
|
expect(stabilizerCandidates({ w: 2, h: 1, w2: 2.25 })).toEqual([]);
|
||||||
|
});
|
||||||
|
it("selects a lone candidate by default and nothing when there are several", () => {
|
||||||
|
expect(defaultStabilizerSelection({ w: 2, h: 1 })).toEqual(["11.938mm"]);
|
||||||
|
expect(defaultStabilizerSelection({ w: 6.25, h: 1 })).toEqual([]);
|
||||||
|
});
|
||||||
|
it("places mounts around the rectangle center along the long axis", () => {
|
||||||
|
expect(stabilizerPoints({ w: 7, h: 1 }, ["3u"])).toEqual([
|
||||||
|
{ x: 0.5, y: 0.5 },
|
||||||
|
{ x: 6.5, y: 0.5 },
|
||||||
|
]);
|
||||||
|
const [top, bottom] = stabilizerPoints({ w: 1, h: 2 }, ["11.938mm"]);
|
||||||
|
expect(top!.x).toBe(0.5);
|
||||||
|
expect(bottom!.y - top!.y).toBeCloseTo(23.876 / 19.05, 12);
|
||||||
|
expect(stabilizerPoints({ w: 6, h: 1 }, ["2.5u", "2u"])).toHaveLength(4);
|
||||||
|
});
|
||||||
|
it("keeps stabilizers on the key center when the switch is offset", () => {
|
||||||
|
const k = key("k", 6, 1, { switchOffset: { x: 0.5, y: 0 } });
|
||||||
|
expect(switchPoint(k)).toEqual({ x: 3.5, y: 0.5 });
|
||||||
|
expect(stabilizerPoints(k.size, ["2.5u"]).map((p) => p.x)).toEqual([0.5, 5.5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stabilizer selections in ProjectFile.ui", () => {
|
||||||
|
const project = { ui: { other: 1 } } as unknown as ProjectFile;
|
||||||
|
it("falls back to the size default and drops ids the size no longer offers", () => {
|
||||||
|
expect(selectedStabilizers({}, key("a", 2))).toEqual(["11.938mm"]);
|
||||||
|
expect(selectedStabilizers({ a: [] }, key("a", 2))).toEqual([]);
|
||||||
|
expect(selectedStabilizers({ a: ["2.5u", "3u"] }, key("a", 6))).toEqual(["2.5u"]);
|
||||||
|
});
|
||||||
|
it("writes per key without touching other ui state", () => {
|
||||||
|
const next = withStabilizers(withStabilizers(project, "a", ["50mm"]), "b", []);
|
||||||
|
expect(stabilizerSelections(next)).toEqual({ a: ["50mm"], b: [] });
|
||||||
|
expect((next.ui as { other: number }).other).toBe(1);
|
||||||
|
expect(stabilizerSelections(project)).toEqual({});
|
||||||
|
});
|
||||||
|
it("lists marks for visible keys only, skipping decals", () => {
|
||||||
|
const marks = mountMarks(
|
||||||
|
[
|
||||||
|
key("a", 2),
|
||||||
|
key("d", 2, 1, { decal: true }),
|
||||||
|
{
|
||||||
|
type: "variantGroup",
|
||||||
|
id: "g",
|
||||||
|
name: "g",
|
||||||
|
default: "v1",
|
||||||
|
transform: { x: 0, y: 1, rotation: 0 },
|
||||||
|
children: [
|
||||||
|
{ type: "variant", id: "v1", name: "1", children: [key("b", 1)] },
|
||||||
|
{ type: "variant", id: "v2", name: "2", children: [key("c", 1)] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ g: "v2" },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(marks.map((m) => m.id)).toEqual(["a", "c"]);
|
||||||
|
expect(marks[1]!.transform.f).toBe(1);
|
||||||
|
expect(marks[0]!.stabs).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user