diff --git a/mock/e2e/phy.spec.ts b/mock/e2e/phy.spec.ts
index e935bac..f151337 100644
--- a/mock/e2e/phy.spec.ts
+++ b/mock/e2e/phy.spec.ts
@@ -269,7 +269,7 @@ test("選択枠は画面上で見え、バリアントバッジで表示を切
const group = page.locator('[data-node-type="variantGroup"]').first();
await page.getByLabel("Rotation").fill("35");
await page.getByLabel("Rotation").dispatchEvent("change");
- const badge = group.locator(".badge");
+ const badge = page.locator(`[data-badge-id="${await group.first().getAttribute("data-node-id")}"]`);
await expect(badge).toBeVisible();
await badge.locator("rect").click();
await page
@@ -303,9 +303,20 @@ test("クラスター内のキーから作ったバリアントグループは
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
const group = cluster.locator('[data-node-type="variantGroup"]');
await expect(group).toHaveCount(1);
- const badge = group.locator(".badge");
+ const badge = page.locator(`[data-badge-id="${await group.first().getAttribute("data-node-id")}"]`);
await expect(badge.locator("text")).toHaveText("⇄");
await expect(badge.locator("title")).toHaveText("Variant Group: Option A(既定)");
+ // バッジはキーより後に描き、隣のキーの下に隠れない
+ const onTop = await badge.evaluate((element) => {
+ const keys = [...document.querySelectorAll('[data-node-type="key"]')];
+ const box = element.getBoundingClientRect();
+ const hit = document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2);
+ return [
+ keys.every((key) => key.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING),
+ element.contains(hit),
+ ];
+ });
+ expect(onTop).toEqual([true, true]);
await badge.locator("rect").click();
const popover = page.locator(".popover");
await expect(popover.getByRole("button", { name: /Option A/ }).locator(".default-tag")).toHaveText("既定");
@@ -527,7 +538,7 @@ test("削除のキーボード確認とバリアントポップオーバーを
await page.keyboard.press("Escape");
await keysSelectVariantGroup(page);
- const badge = page.locator('[data-node-type="variantGroup"] .badge').first();
+ const badge = page.locator(".badge-layer:not(.dim) .badge").first();
await badge.locator("rect").click();
const badgeBox = await badge.boundingBox();
const popoverBox = await page.locator(".popover").boundingBox();
diff --git a/mock/src/ui/phy/NodeSvg.vue b/mock/src/ui/phy/NodeSvg.vue
index 5be01e7..0b2fd75 100644
--- a/mock/src/ui/phy/NodeSvg.vue
+++ b/mock/src/ui/phy/NodeSvg.vue
@@ -1,47 +1,17 @@
) {
:variants="variants"
:cut="cut"
:dim="dim"
- :zoom="zoom"
- :angle="angle + n.transform.rotation"
@down="(e, c, el) => emit('down', e, c, el)"
- @badge="(g, e) => emit('badge', g, e)"
@focus="(id) => emit('focus', id)"
/>{{ n.name }}
@@ -127,30 +94,9 @@ function badgeTitle(n: Extract) {
:variants="variants"
:cut="cut"
:dim="dim"
- :zoom="zoom"
- :angle="angle + n.transform.rotation"
@down="(e, c, el) => emit('down', e, c, el)"
- @badge="(g, e) => emit('badge', g, e)"
@focus="(id) => emit('focus', id)"
- />⇄{{ badgeTitle(n) }}
+ />
diff --git a/mock/src/ui/phy/PhyBadgeLayer.vue b/mock/src/ui/phy/PhyBadgeLayer.vue
new file mode 100644
index 0000000..f267ef8
--- /dev/null
+++ b/mock/src/ui/phy/PhyBadgeLayer.vue
@@ -0,0 +1,66 @@
+
+
+
+ ⇄{{ badgeTitle(badge.group) }}
+
+
+
diff --git a/mock/src/ui/phy/PhyEditor.vue b/mock/src/ui/phy/PhyEditor.vue
index 7d1e7de..65ef71a 100644
--- a/mock/src/ui/phy/PhyEditor.vue
+++ b/mock/src/ui/phy/PhyEditor.vue
@@ -8,6 +8,7 @@ import { listDeleteReferences } from "../../domain/deleteCascade";
import PhyTree from "./PhyTree.vue";
import NodeSvg from "./NodeSvg.vue";
import PhySelectionLayer from "./PhySelectionLayer.vue";
+import PhyBadgeLayer from "./PhyBadgeLayer.vue";
import PhyToolbar from "./PhyToolbar.vue";
import PhyProperties from "./PhyProperties.vue";
import { normalizeSelection, nextGrid, insertionContainer, structuralNodes, visibleNodeIds } from "./logic";
@@ -118,7 +119,6 @@ const {
focusGridLines,
moveTargets,
parentMatrix: focusParentMatrix,
- focusAngle,
affineText,
focusCrumbs,
propertyNode: prop,
@@ -360,8 +360,6 @@ function onFocus(_id: string) {
:variants="variants"
:cut="cut"
:dim="true"
- :zoom="zoom"
- :angle="0"
/>
@@ -389,10 +384,7 @@ function onFocus(_id: string) {
:variants="variants"
:cut="cut"
:dim="false"
- :zoom="zoom"
- :angle="0"
@down="down"
- @badge="openBadge"
@focus="onFocus"
/>
+
+
;
+ x: number;
+ y: number;
+ size: number;
+};
+/** 表示中のVariantGroupのバッジを、OBBの右上に正立で置く。大きさは画面上で固定する。 */
+export function variantBadges(
+ roots: Node[],
+ variants: Record,
+ zoom: number,
+ parent: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
+): VariantBadge[] {
+ const badges: VariantBadge[] = [];
+ const size = 20 / zoom;
+ const visit = (nodes: Node[], ancestor: Affine) => {
+ for (const node of nodes) {
+ const transform = compose(ancestor, matrix(node.transform));
+ 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));
+ const box = localBounds(variant?.children ?? [], variants);
+ const corner = apply(transform, { x: box.x + box.width + 4 / zoom, y: box.y - size - 4 / zoom });
+ badges.push({ group: node, x: corner.x, y: corner.y, size });
+ if (variant) visit(variant.children, transform);
+ }
+ }
+ };
+ visit(roots, parent);
+ return badges;
+}
/** ワールド位置を貼り先の親ローカルへ戻し、親軸で1刻みずらす。T-11/T-27。 */
export function pasteLocalTransform(sourceWorld: Affine, parentWorld: Affine, step: number): Transform {
const local = compose(inverse(parentWorld), sourceWorld);
diff --git a/mock/tests/phyLogic.test.ts b/mock/tests/phyLogic.test.ts
index b4c24bb..ca4f140 100644
--- a/mock/tests/phyLogic.test.ts
+++ b/mock/tests/phyLogic.test.ts
@@ -13,6 +13,7 @@ import {
resolveKey,
snap,
selectionFrames,
+ variantBadges,
tabOrder,
treeIndex,
visibleNodeIds,
@@ -69,6 +70,34 @@ describe("PHY pure interaction rules", () => {
const candidateOnly = selectionFrames(roots, [], {}, "target")[0]!;
expect([candidateOnly.selected, candidateOnly.candidate]).toEqual([false, true]);
});
+ it("places variant badges at the rotated group's top-right corner, upright", () => {
+ const key = (id: string): PhyNode => ({
+ type: "key",
+ id,
+ profile: "p",
+ transform: { x: 0, y: 0, rotation: 0 },
+ size: { w: 2, h: 1 },
+ });
+ const roots: PhyNode[] = [
+ {
+ type: "variantGroup",
+ id: "g",
+ name: "G",
+ transform: { x: 1, y: 1, rotation: 90 },
+ default: "a",
+ children: [
+ { type: "variant", id: "a", name: "A", children: [key("ka")] },
+ { type: "variant", id: "b", name: "B", children: [] },
+ ],
+ },
+ ];
+ const [badge] = variantBadges(roots, {}, 10);
+ // ローカル (2 + 0.4, -2 - 0.4) を90度回して (1, 1) だけ動かす
+ expect(badge!.group.id).toBe("g");
+ expect(badge!.x).toBeCloseTo(1 + 2.4);
+ expect(badge!.y).toBeCloseTo(1 + 2.4);
+ expect(badge!.size).toBeCloseTo(2);
+ });
it("removes ancestors and descendants from mixed selections", () => {
expect(normalizeSelection(phy, ["c", "k"])).toEqual(["k"]);
expect(normalizeSelection(phy, ["k", "c"])).toEqual(["c"]);