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(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">
|
||||
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>
|
||||
|
||||
@@ -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">
|
||||
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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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