Compare commits
7
Commits
b314309e8c
...
0838487f4a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0838487f4a | ||
|
|
785daa23c2 | ||
|
|
eacf8a1581 | ||
|
|
2c388e3bdb | ||
|
|
b383fcf844 | ||
|
|
37ed6d5186 | ||
|
|
a4eabe3691 |
@@ -0,0 +1,85 @@
|
|||||||
|
# スタビライザー位置とswitchOffsetの裏取り(T-30 単位8)
|
||||||
|
|
||||||
|
## 結論
|
||||||
|
|
||||||
|
- 6U以上の幅の中には、スタビライザー位置が何通りもあるものがある。6U・6.25U・7Uがそう。
|
||||||
|
6.5U と 8〜10U は、出典にある範囲では1通り。
|
||||||
|
- switchOffset が要るのは2U未満のキーに限らない。6U offset・6.25U(Cherry G80のWindowsキー付き)・BAE(ANSI互換)では
|
||||||
|
スイッチがキーの中心から外れる。逆に、2U未満でよくあるステップドCaps Lockは、KLEの表し方のおかげで offset 0 になる。
|
||||||
|
- ISO Enterは「(w,h)矩形の中心にスイッチ、2U間隔のスタビを縦向きに置く」とすれば、出典にある生成ツールの扱いと合う。
|
||||||
|
実物の寸法の出典はまだ見つけていない。
|
||||||
|
- BAEは、スイッチとスタビの配置が型によって違う。決まった形はない。
|
||||||
|
|
||||||
|
座標はすべてキーのローカル座標で、(w,h)矩形の中心を原点にした値(U、1U = 19.05mm)。
|
||||||
|
PHYの `switchOffset` と同じ基準になる。スタビは中心から±の位置に2つ置く。
|
||||||
|
|
||||||
|
## 候補表(Cherry MX系、横向き。縦長のキーは長辺の向きに読み替える)
|
||||||
|
|
||||||
|
| 幅 | スタビ(中心から) | switchOffset.x | 使っているキーボード・出典 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 2〜2.75U | ±11.938mm(±0.6267U) | 0 | kad・kiswitch・ai03 で一致 |
|
||||||
|
| 3U | ±1U | 0 | DT(Deskthority wiki、以下同じ)、kad、kiswitch |
|
||||||
|
| 4U | ±1.5U | 0 | DT、kad(ai03 は3U以上をすべて±1Uにしている) |
|
||||||
|
| 4.5U | ±34.671mm(±1.82U) | 0 | DT、kad |
|
||||||
|
| 5.5U | ±2.25U | 0 | DT、kad |
|
||||||
|
| 6U offset | ±2.5U(芯々95mm) | +0.5U | DT: Cherry G80-1800/11900/11908/11800。kad、kiswitch `6uOffset` |
|
||||||
|
| 6U 中央A | ±2U | 0 | DT: Razer BlackWidow、TG3 BL-82 |
|
||||||
|
| 6U 中央B | ±2.5U(47.625mm) | 0 | kiswitch の 6U スタビ、kad の6U値。フォーラム: FC660C、Topre |
|
||||||
|
| 6.25U 標準 | ±50mm(±2.6247U) | 0 | DT: Filco、Ducky、Poker、Das 3 ほか。kad・kiswitch・ai03 で一致 |
|
||||||
|
| 6.25U G80 | ±50mm | +0.625U(11.9mm) | DT: Cherry G80-1000/3000(Windowsキー付き)、Das 2。キーキャップに中央のマウントがない |
|
||||||
|
| 6.25U 40mm | ±40mm(±2.1U) | 0 | DT: Leopold FC200、SteelSeries 6GV2/7G、Zowie Celeritas |
|
||||||
|
| 6.25U 42.5mm | ±42.5mm | 0 | DT: Leopold FC500、Keycool 87(2012年より前のモデル) |
|
||||||
|
| 6.5U | ±2.75U(52.38mm) | 0 | DT: Corsair K60〜K95、QuickFire TK、G710+。kad |
|
||||||
|
| 7U | ±3U | 0 | DT: Cherry G80-1000/1800/3000(Windowsキーなし)ほか。kad・kiswitch・ai03 で一致 |
|
||||||
|
| 8U / 9U / 9.75U / 10U | ±3.5U | 0 | DT(9U以上も8Uと同じ間隔)。kiswitch は8U〜10Uに同じフットプリントを使う |
|
||||||
|
|
||||||
|
候補表から外したもの(実物の位置が出典から決まらない):
|
||||||
|
|
||||||
|
- 6U CMStorm QuickFire Ultimate: 「43.5mm, 49mm」「芯々98mm」とだけ書かれていて、軸がどれだけずれているか読み取れない
|
||||||
|
- 7U Compaq MX-11800: 軸から見てスタビが左3U・右2.25U。キーキャップの中でどう対称になっているかは書かれていない。
|
||||||
|
スタビがキーの中心に対して対称なら±2.625U、switchOffset +0.375U になり、6.25U と同じ100mmのワイヤーになるが、これは推測
|
||||||
|
- 7U Propac: 「55mm apart」とあるだけ
|
||||||
|
|
||||||
|
## 異形キー
|
||||||
|
|
||||||
|
KLEのプリセット(`keys.json`)と、PHYの `size` の主矩形(w,h):
|
||||||
|
|
||||||
|
| キー | KLE | スイッチ | スタビ |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ISO Enter | w1.25 h2, w2 1.5 h2 1, x 0.25, x2 -0.25 | 主矩形の中心(0,0) | 縦向きに±0.6267U。kad は h>w なら自動で縦にする。ai03 は長辺を幅として扱うが、向きは利用者が `_rs` で回す |
|
||||||
|
| BAE (a) ANSI互換 | w1.5 h2, w2 2.25 h2 1, x 0.75, x2 -0.75, y2 1 | 下段2.25Uの中心 = (-0.375, +0.5) | (-0.375±0.6267, +0.5) の横向き。GMK CYL BAE: 「ANSIの2.25U Enterのスイッチとスタビの位置、1.5UのPipeキーの位置にマウントがある」 |
|
||||||
|
| BAE (b) 旧Cherry | 同上 | 不明 | 検索の要約には「上段1.5Uを中心にISO風のスタビ、補助のMXスイッチ」とあったが、原文は確認できていない |
|
||||||
|
| ステップドCaps Lock | w1.25 w2 1.75 l | 主矩形 = 高い側の1.25Uの中心。よくある軸の位置(左寄り)と一致し、offset 0 | なし |
|
||||||
|
| ステップドCaps Lock(軸が中央) | 同上 | 1.75Uの中心 = +0.25U | なし。数は少ない(Leopoldのセットにある) |
|
||||||
|
| Center-stepped | w1 w2 1.5, x 0.25, x2 -0.25 l | 主矩形の中心 | なし |
|
||||||
|
|
||||||
|
KLEのステップドキーは、`w` が高い側、`w2` が全幅(kb.js の `stepped` の処理)。
|
||||||
|
kad も主矩形の中心にスイッチを置く(`AltWidth > Width` のときは外形の計算だけを広げる)。
|
||||||
|
|
||||||
|
## switchOffset が0でないケース
|
||||||
|
|
||||||
|
- 2U以上: 6U offset (+0.5U)、6.25U G80 (+0.625U)、BAE(a) (-0.375, +0.5)、おそらく7U Compaq
|
||||||
|
- 2U未満: 軸が中央にあるステップドCaps Lock (+0.25U) だけ。よくある左寄りの軸は0
|
||||||
|
- 「2U未満のみ」という想定は当たらない
|
||||||
|
|
||||||
|
## ツールどうしの食い違い(参考)
|
||||||
|
|
||||||
|
- ai03 は6Uで、スイッチをKLEのキーの中心に置いたまま、スタビを+38.1/-57.15mmと左右非対称に置く。
|
||||||
|
kad はスタビをキーの中心に対して±47.5mmで対称に置き、スイッチを+0.5Uずらす。DTの実測(マウントは左から0.5/3.5/5.5U)と合うのは kad
|
||||||
|
- ai03 は3U〜5.5Uをすべて±19.05mmにしている。DT・kad とは4U以上で合わない
|
||||||
|
- kad は2Uの値を11.9mmに丸めている。kiswitch・ai03 は11.938mm
|
||||||
|
|
||||||
|
## 出典
|
||||||
|
|
||||||
|
- Deskthority wiki "Space bar dimensions"(`?action=raw` で原文を確認): <https://deskthority.net/wiki/Space_bar_dimensions>
|
||||||
|
- swill/kad `key.go` の `GetCherryStabOffset`、`Draw`: <https://github.com/swill/kad>
|
||||||
|
- perigoso/keyswitch-kicad-library `KiSwitch/switch.py` の `StabilizerCherryMX.LU_TABLE`、`KiSwitch/keycap.py`:
|
||||||
|
<https://github.com/perigoso/keyswitch-kicad-library>
|
||||||
|
- ai03-2725/another-keyboard-builder `plategen.py` の `generate_stabs`: <https://github.com/ai03-2725/another-keyboard-builder>
|
||||||
|
- KLE `keys.json`(異形キーのプリセット)、`kb.js` の `stepped`: <https://github.com/ijprest/keyboard-layout-editor>
|
||||||
|
- GMK CYL BAE addon: <https://geon.works/products/gmk-cyl-bae-addon-kits>
|
||||||
|
- ステップドCaps Lockの軸の位置(フォーラム): <https://deskthority.net/viewtopic.php?p=112099>、
|
||||||
|
<https://deskthority.net/keyboards-f2/keycap-cherry-mx-stepped-capslock-t3794.html>
|
||||||
|
- 6Uの中央軸とTopreの値(フォーラム): <https://www.keebtalk.com/t/topre-6u-stabilizer-spacing-spec/17047>
|
||||||
|
|
||||||
|
Alpsのスタビ(kad `GetAlpsStabOffset`: 1.75U 11.938、2〜2.75U 14.096、6.25U 41.859、6.5U 45.3mm。DT: 7U ±55mm)は、モックにスイッチの種別がないので扱っていない。
|
||||||
+84
-2
@@ -201,7 +201,7 @@ test("長押しでクラスターを選び、ダブルクリックでフォー
|
|||||||
const b = await key.boundingBox();
|
const b = await key.boundingBox();
|
||||||
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
|
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await expect(page.locator(".long-label")).toContainText("Cluster", { timeout: 2000 });
|
await expect(page.locator(".hold-row.active")).toContainText("Cluster", { timeout: 2000 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
|
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
|
||||||
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
|
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
|
||||||
@@ -588,7 +588,7 @@ test("長押し候補は離すまで選択を変えず、離すとクラスタ
|
|||||||
const b = await cluster.locator('[data-node-type="key"]').first().boundingBox();
|
const b = await cluster.locator('[data-node-type="key"]').first().boundingBox();
|
||||||
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
|
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await expect(page.locator(".long-label")).toContainText("2/", { timeout: 1800 });
|
await expect(page.locator(".hold-row.active")).toContainText("Cluster", { timeout: 1800 });
|
||||||
await expect(
|
await expect(
|
||||||
page.locator(`[data-sel-id="${await keys.nth(4).getAttribute("data-node-id")}"]`),
|
page.locator(`[data-sel-id="${await keys.nth(4).getAttribute("data-node-id")}"]`),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
@@ -597,6 +597,46 @@ test("長押し候補は離すまで選択を変えず、離すとクラスタ
|
|||||||
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
|
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("リスト方式では、リストに入ると巡回が止まり、指した行で離すと確定する", async ({ page }) => {
|
||||||
|
const cluster = await makeCluster(page);
|
||||||
|
await page.getByRole("button", { name: "長押し: ループ" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "長押し: リスト" })).toBeVisible();
|
||||||
|
const key = cluster.locator('[data-node-type="key"]').first();
|
||||||
|
const keyId = await key.getAttribute("data-node-id");
|
||||||
|
const b = await key.boundingBox();
|
||||||
|
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await expect(page.locator(".hold-row.active")).toContainText("Cluster", { timeout: 2000 });
|
||||||
|
const row = await page.locator(".hold-row", { hasText: "キー" }).boundingBox();
|
||||||
|
await page.mouse.move(row!.x + row!.width / 2, row!.y + row!.height / 2, { steps: 5 });
|
||||||
|
await expect(page.locator(".hold-row.active")).toHaveText("キー");
|
||||||
|
await expect(page.locator(".hold-ring")).toHaveCount(0);
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await expect(page.locator(".hold-row.active")).toHaveText("キー");
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(page.locator(".hold-list")).toHaveCount(0);
|
||||||
|
await expect(page.locator(`[data-sel-id="${keyId}"]`)).toBeVisible();
|
||||||
|
// リストへ動かしてもキーはドラッグされない
|
||||||
|
expect(await key.boundingBox()).toEqual(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ループ方式では、長押しのあと動かすと選んだ先をドラッグする", async ({ page }) => {
|
||||||
|
const cluster = await makeCluster(page);
|
||||||
|
const clusterId = await cluster.getAttribute("data-node-id");
|
||||||
|
const keys = cluster.locator('[data-node-type="key"]');
|
||||||
|
const before = await keys.nth(1).boundingBox();
|
||||||
|
const b = (await keys.first().boundingBox())!;
|
||||||
|
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await expect(page.locator(".hold-ring")).toBeVisible({ timeout: 2000 });
|
||||||
|
await page.mouse.move(b.x + b.width / 2 + 80, b.y + b.height / 2, { steps: 5 });
|
||||||
|
await expect(page.locator(".hold-list")).toHaveCount(0);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
|
||||||
|
// クラスターごと動くので、押していないキーも動く
|
||||||
|
expect((await keys.nth(1).boundingBox())!.x).toBeGreaterThan(before!.x + 40);
|
||||||
|
});
|
||||||
|
|
||||||
test("「その他」の部品はpartGroupを設定せず、選択後は追加メニューを戻す", async ({ page }) => {
|
test("「その他」の部品はpartGroupを設定せず、選択後は追加メニューを戻す", async ({ page }) => {
|
||||||
const select = page.getByRole("combobox", { name: "部品を足す" });
|
const select = page.getByRole("combobox", { name: "部品を足す" });
|
||||||
await select.selectOption("その他");
|
await select.selectOption("その他");
|
||||||
@@ -658,3 +698,45 @@ test("バリアントグループを選んでキーを足すと、表示中の
|
|||||||
expect(Math.abs(box.x - left!.x)).toBeLessThan(1);
|
expect(Math.abs(box.x - left!.x)).toBeLessThan(1);
|
||||||
expect(Math.abs(box.y - (left!.y + unit))).toBeLessThan(1);
|
expect(Math.abs(box.y - (left!.y + unit))).toBeLessThan(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("キャンバスのviewBoxはツールバーを除いたSVGの大きさに合う", async ({ page }) => {
|
||||||
|
const canvas = page.locator("svg.canvas");
|
||||||
|
const box = (await canvas.boundingBox())!;
|
||||||
|
const [, , w, h] = (await canvas.getAttribute("viewBox"))!.split(" ").map(Number);
|
||||||
|
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").dispatchEvent("change");
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ export class ProjectHistory {
|
|||||||
) {
|
) {
|
||||||
this.items = [structuredClone(initial)];
|
this.items = [structuredClone(initial)];
|
||||||
}
|
}
|
||||||
/** Adds one undoable snapshot unless a grouped edit is active. */
|
/** Adds one undoable snapshot unless a grouped edit is active or nothing changed. */
|
||||||
push(value: ProjectFile): void {
|
push(value: ProjectFile): void {
|
||||||
if (this.pending) {
|
if (this.pending) {
|
||||||
this.replaceTop(value);
|
this.replaceTop(value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (JSON.stringify(value) === JSON.stringify(this.items[this.cursor])) return;
|
||||||
this.add(value);
|
this.add(value);
|
||||||
}
|
}
|
||||||
/** Starts grouping subsequent pushes into a single operation. T-17. */
|
/** Starts grouping subsequent pushes into a single operation. T-17. */
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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 ?? [];
|
||||||
|
}
|
||||||
|
/** 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";
|
||||||
@@ -11,10 +11,23 @@ import PhySelectionLayer from "./PhySelectionLayer.vue";
|
|||||||
import PhyBadgeLayer from "./PhyBadgeLayer.vue";
|
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 { normalizeSelection, nextGrid, insertionContainer, structuralNodes, visibleNodeIds } from "./logic";
|
import PhyHoldOverlay from "./PhyHoldOverlay.vue";
|
||||||
|
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";
|
||||||
|
import { HOLD_DELAY, HOLD_INTERVAL, type HoldMode } from "./usePhyLongPress";
|
||||||
import { usePhyKeyboard } from "./usePhyKeyboard";
|
import { usePhyKeyboard } from "./usePhyKeyboard";
|
||||||
import { usePhyCommands } from "./usePhyCommands";
|
import { usePhyCommands } from "./usePhyCommands";
|
||||||
import { usePhyCanvasView } from "./usePhyCanvasView";
|
import { usePhyCanvasView } from "./usePhyCanvasView";
|
||||||
@@ -76,7 +89,6 @@ const {
|
|||||||
} = usePhyTreeModel(() => props.project, focus);
|
} = usePhyTreeModel(() => props.project, focus);
|
||||||
const { fit, fitSelected, localPoint, wheel } = usePhyViewport({
|
const { fit, fitSelected, localPoint, wheel } = usePhyViewport({
|
||||||
project: () => props.project,
|
project: () => props.project,
|
||||||
pane,
|
|
||||||
svg,
|
svg,
|
||||||
size,
|
size,
|
||||||
zoom,
|
zoom,
|
||||||
@@ -218,6 +230,7 @@ function visibleIds(): Set<string> {
|
|||||||
if (!focus.value) return new Set(allNodes().map((n) => n.id));
|
if (!focus.value) return new Set(allNodes().map((n) => n.id));
|
||||||
return visibleNodeIds(node(focus.value), variants.value);
|
return visibleNodeIds(node(focus.value), variants.value);
|
||||||
}
|
}
|
||||||
|
const holdMode = ref<HoldMode>("loop");
|
||||||
const rangeBox = ref<{ x: number; y: number; width: number; height: number } | null>(null);
|
const rangeBox = ref<{ x: number; y: number; width: number; height: number } | null>(null);
|
||||||
const pointer = usePhyCanvasPointer({
|
const pointer = usePhyCanvasPointer({
|
||||||
project: () => props.project,
|
project: () => props.project,
|
||||||
@@ -237,12 +250,13 @@ const pointer = usePhyCanvasPointer({
|
|||||||
isAncestor,
|
isAncestor,
|
||||||
parentLabel,
|
parentLabel,
|
||||||
pressTargets,
|
pressTargets,
|
||||||
|
holdMode,
|
||||||
setSelection: setSel,
|
setSelection: setSel,
|
||||||
beginHistory: props.beginHistory,
|
beginHistory: props.beginHistory,
|
||||||
commitHistory: props.commitHistory,
|
commitHistory: props.commitHistory,
|
||||||
dispatch,
|
dispatch,
|
||||||
});
|
});
|
||||||
const { canvasDown, down, spaceUp, longLabel, longLabelPos, spaceHeld, holdCandidates, holdIndex } = pointer;
|
const { canvasDown, down, spaceUp, hold, spaceHeld } = pointer;
|
||||||
const keyHandler = usePhyKeyboard({
|
const keyHandler = usePhyKeyboard({
|
||||||
project: () => props.project,
|
project: () => props.project,
|
||||||
mismatch: () => props.mismatch,
|
mismatch: () => props.mismatch,
|
||||||
@@ -293,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 &&
|
||||||
@@ -329,14 +352,20 @@ function onFocus(_id: string) {
|
|||||||
@tree="treeOpen = !treeOpen"
|
@tree="treeOpen = !treeOpen"
|
||||||
@help="showHelp = true"
|
@help="showHelp = true"
|
||||||
@focus="focus = $event"
|
@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"
|
||||||
|
:mode="hold.mode.value"
|
||||||
|
:pos="hold.pos.value"
|
||||||
|
:names="hold.names.value"
|
||||||
|
:index="hold.index.value"
|
||||||
|
:step="hold.step.value"
|
||||||
|
:pointing="hold.pointing.value"
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
v-if="longLabel"
|
|
||||||
class="long-label"
|
|
||||||
:style="{ left: longLabelPos.x + 'px', top: longLabelPos.y + 'px' }"
|
|
||||||
>
|
|
||||||
{{ longLabel }}
|
|
||||||
</div>
|
|
||||||
<div v-if="mismatch" class="warning">unitSize が一致しないため編集できません。</div>
|
<div v-if="mismatch" class="warning">unitSize が一致しないため編集できません。</div>
|
||||||
<svg
|
<svg
|
||||||
ref="svg"
|
ref="svg"
|
||||||
@@ -387,12 +416,20 @@ 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"
|
||||||
:variants="variants"
|
:variants="variants"
|
||||||
:zoom="zoom"
|
:zoom="zoom"
|
||||||
:candidate="longLabel ? (holdCandidates[holdIndex] ?? null) : null"
|
:candidate="hold.open.value ? (hold.candidates.value[hold.index.value] ?? null) : null"
|
||||||
:parent="focus ? focusParentMatrix(focus) : undefined"
|
:parent="focus ? focusParentMatrix(focus) : undefined"
|
||||||
/>
|
/>
|
||||||
<PhyBadgeLayer
|
<PhyBadgeLayer
|
||||||
@@ -457,7 +494,12 @@ function onFocus(_id: string) {
|
|||||||
フォーカス · F2 プロパティ
|
フォーカス · F2 プロパティ
|
||||||
</p>
|
</p>
|
||||||
<p>Ctrl+Z/Y/S Undo・Redo・保存 · ? 一覧</p>
|
<p>Ctrl+Z/Y/S Undo・Redo・保存 · ? 一覧</p>
|
||||||
<p>長押し: キー上で500ms待つと選択先候補が450msごとに進み、離すと確定します。</p>
|
<p>
|
||||||
|
長押し: キー上で{{ HOLD_DELAY }}ms待つと選択先候補が{{
|
||||||
|
HOLD_INTERVAL
|
||||||
|
}}msごとに進み、離すと確定します。
|
||||||
|
ループ方式では、そのまま動かすと選んだ先をドラッグします。リスト方式では、リストに入ると巡回が止まり、指している行で離すと確定します。
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -472,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>
|
||||||
@@ -596,16 +640,4 @@ function onFocus(_id: string) {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #6b520f;
|
color: #6b520f;
|
||||||
}
|
}
|
||||||
.long-label {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 3;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
transform: translate(10px, 10px);
|
|
||||||
pointer-events: none;
|
|
||||||
background: #fff3d8;
|
|
||||||
border: 1px solid #c87d13;
|
|
||||||
padding: 3px 7px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { HOLD_INTERVAL, type HoldMode } from "./usePhyLongPress";
|
||||||
|
defineProps<{
|
||||||
|
mode: HoldMode;
|
||||||
|
pos: { x: number; y: number };
|
||||||
|
names: string[];
|
||||||
|
index: number;
|
||||||
|
step: number;
|
||||||
|
pointing: boolean;
|
||||||
|
}>();
|
||||||
|
// 円の周長。インジケータは次の切り替えまでに一周する
|
||||||
|
const ring = 2 * Math.PI * 14;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
v-if="!pointing"
|
||||||
|
:key="step"
|
||||||
|
class="hold-ring"
|
||||||
|
width="36"
|
||||||
|
height="36"
|
||||||
|
:style="{ left: pos.x - 18 + 'px', top: pos.y - 18 + 'px' }"
|
||||||
|
>
|
||||||
|
<circle cx="18" cy="18" r="14" class="track" />
|
||||||
|
<circle
|
||||||
|
cx="18"
|
||||||
|
cy="18"
|
||||||
|
r="14"
|
||||||
|
class="progress"
|
||||||
|
:stroke-dasharray="ring"
|
||||||
|
:style="{ '--ring': ring, animationDuration: HOLD_INTERVAL + 'ms' }"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<ol
|
||||||
|
class="hold-list"
|
||||||
|
:class="mode"
|
||||||
|
:data-hold-mode="mode"
|
||||||
|
:style="{ left: pos.x + 22 + 'px', top: pos.y + 10 + 'px' }"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="(name, i) in names"
|
||||||
|
:key="i"
|
||||||
|
class="hold-row"
|
||||||
|
:class="{ active: i === index }"
|
||||||
|
:data-hold-index="i"
|
||||||
|
>
|
||||||
|
{{ name }}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hold-ring {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.track,
|
||||||
|
.progress {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
.track {
|
||||||
|
stroke: rgb(200 125 19 / 25%);
|
||||||
|
}
|
||||||
|
.progress {
|
||||||
|
stroke: #c87d13;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
transform-origin: 18px 18px;
|
||||||
|
animation: hold-ring linear forwards;
|
||||||
|
}
|
||||||
|
@keyframes hold-ring {
|
||||||
|
from {
|
||||||
|
stroke-dashoffset: var(--ring);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.hold-list {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 3;
|
||||||
|
margin: 0;
|
||||||
|
padding: 3px 0;
|
||||||
|
list-style: none;
|
||||||
|
background: #fff3d8;
|
||||||
|
border: 1px solid #c87d13;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.hold-list.loop {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.hold-row {
|
||||||
|
padding: 3px 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.hold-row.active {
|
||||||
|
background: #c87d13;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ defineProps<{
|
|||||||
moveTargets: { id: string; label: string }[];
|
moveTargets: { id: string; label: string }[];
|
||||||
focus: string | null;
|
focus: string | null;
|
||||||
crumbs: { id: string | null; label: string }[];
|
crumbs: { id: string | null; label: string }[];
|
||||||
|
holdMode: "loop" | "list";
|
||||||
|
showMounts: boolean;
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
keyAdd: [];
|
keyAdd: [];
|
||||||
@@ -23,6 +25,8 @@ const emit = defineEmits<{
|
|||||||
tree: [];
|
tree: [];
|
||||||
help: [];
|
help: [];
|
||||||
focus: [id: string | null];
|
focus: [id: string | null];
|
||||||
|
holdMode: [];
|
||||||
|
mounts: [];
|
||||||
}>();
|
}>();
|
||||||
function addPart(e: Event) {
|
function addPart(e: Event) {
|
||||||
const select = e.target as HTMLSelectElement;
|
const select = e.target as HTMLSelectElement;
|
||||||
@@ -69,6 +73,7 @@ function addPart(e: Event) {
|
|||||||
◇
|
◇
|
||||||
</button>
|
</button>
|
||||||
<select
|
<select
|
||||||
|
class="move-to"
|
||||||
aria-label="移し先…"
|
aria-label="移し先…"
|
||||||
:disabled="mismatch || !selectedCount"
|
:disabled="mismatch || !selectedCount"
|
||||||
@change="emit('moveTo', ($event.target as HTMLSelectElement).value)"
|
@change="emit('moveTo', ($event.target as HTMLSelectElement).value)"
|
||||||
@@ -93,6 +98,22 @@ function addPart(e: Event) {
|
|||||||
<button title="ズームイン" aria-label="ズームイン" @click="emit('zoom', 1)">+</button>
|
<button title="ズームイン" aria-label="ズームイン" @click="emit('zoom', 1)">+</button>
|
||||||
<button title="ズームアウト" aria-label="ズームアウト" @click="emit('zoom', -1)">−</button>
|
<button title="ズームアウト" aria-label="ズームアウト" @click="emit('zoom', -1)">−</button>
|
||||||
<button title="階層" aria-label="階層" @click="emit('tree')">☷</button>
|
<button title="階層" aria-label="階層" @click="emit('tree')">☷</button>
|
||||||
|
<button
|
||||||
|
:title="`長押しの方式: ${holdMode === 'loop' ? 'ループ' : 'リスト'}(押すと切り替え)`"
|
||||||
|
:aria-label="`長押し: ${holdMode === 'loop' ? 'ループ' : 'リスト'}`"
|
||||||
|
@click="emit('holdMode')"
|
||||||
|
>
|
||||||
|
{{ 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>
|
<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">
|
||||||
@@ -113,4 +134,12 @@ 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 {
|
||||||
|
width: 6em;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { Node, PhyFile, Transform } from "../../domain/phy";
|
import type { KeyNode, Node, PhyFile, Transform } from "../../domain/phy";
|
||||||
|
import type { ProjectFile } from "../../domain/project";
|
||||||
|
import { 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 +326,58 @@ 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] ?? [];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { KeyNode, Node } from "../../domain/phy";
|
|||||||
import { apply, worldTransform } from "../../domain/geometry";
|
import { apply, worldTransform } from "../../domain/geometry";
|
||||||
import { moveNode } from "../../domain/phyCommands";
|
import { moveNode } from "../../domain/phyCommands";
|
||||||
import { normalizeSelection, snap } from "./logic";
|
import { normalizeSelection, snap } from "./logic";
|
||||||
import { usePhyLongPress } from "./usePhyLongPress";
|
import { usePhyLongPress, type HoldMode } from "./usePhyLongPress";
|
||||||
import { autoPanVelocity } from "./viewport";
|
import { autoPanVelocity } from "./viewport";
|
||||||
|
|
||||||
type Point = { x: number; y: number };
|
type Point = { x: number; y: number };
|
||||||
@@ -27,6 +27,7 @@ type Options = {
|
|||||||
isAncestor: (candidate: string, id: string) => boolean;
|
isAncestor: (candidate: string, id: string) => boolean;
|
||||||
parentLabel: (id: string | null) => string;
|
parentLabel: (id: string | null) => string;
|
||||||
pressTargets: (id: string) => string[];
|
pressTargets: (id: string) => string[];
|
||||||
|
holdMode: Ref<HoldMode>;
|
||||||
setSelection: (id: string, add?: boolean) => void;
|
setSelection: (id: string, add?: boolean) => void;
|
||||||
beginHistory: () => void;
|
beginHistory: () => void;
|
||||||
commitHistory: () => void;
|
commitHistory: () => void;
|
||||||
@@ -34,7 +35,7 @@ type Options = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function usePhyCanvasPointer(options: Options) {
|
export function usePhyCanvasPointer(options: Options) {
|
||||||
const longPress = usePhyLongPress(options);
|
const longPress = usePhyLongPress({ ...options, mode: () => options.holdMode.value });
|
||||||
const spaceHeld = ref(false);
|
const spaceHeld = ref(false);
|
||||||
let preClickSelection: string[] = [];
|
let preClickSelection: string[] = [];
|
||||||
let preClickTarget: string | null = null;
|
let preClickTarget: string | null = null;
|
||||||
@@ -238,6 +239,10 @@ export function usePhyCanvasPointer(options: Options) {
|
|||||||
}
|
}
|
||||||
if (!dragState) return;
|
if (!dragState) return;
|
||||||
lastPointer = { x: event.clientX, y: event.clientY };
|
lastPointer = { x: event.clientX, y: event.clientY };
|
||||||
|
if (longPress.picking()) {
|
||||||
|
longPress.point(event.clientX, event.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const state = dragState;
|
const state = dragState;
|
||||||
if (Math.hypot(event.clientX - state.start.x, event.clientY - state.start.y) < 4) return;
|
if (Math.hypot(event.clientX - state.start.x, event.clientY - state.start.y) < 4) return;
|
||||||
longPress.cancelPending();
|
longPress.cancelPending();
|
||||||
@@ -373,11 +378,17 @@ export function usePhyCanvasPointer(options: Options) {
|
|||||||
move,
|
move,
|
||||||
up,
|
up,
|
||||||
spaceUp,
|
spaceUp,
|
||||||
longLabel: longPress.label,
|
hold: {
|
||||||
longLabelPos: longPress.labelPos,
|
open: longPress.open,
|
||||||
|
mode: longPress.mode,
|
||||||
|
pos: longPress.pos,
|
||||||
|
candidates: longPress.candidates,
|
||||||
|
names: longPress.names,
|
||||||
|
index: longPress.index,
|
||||||
|
step: longPress.step,
|
||||||
|
pointing: longPress.pointing,
|
||||||
|
},
|
||||||
spaceHeld,
|
spaceHeld,
|
||||||
holdCandidates: longPress.candidates,
|
|
||||||
holdIndex: longPress.index,
|
|
||||||
priorSelection,
|
priorSelection,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,34 @@
|
|||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
export type HoldMode = "loop" | "list";
|
||||||
|
|
||||||
|
// feedback「長押しでの選択の切り替え」で 500ms / 450ms は早いとされたため延ばす。
|
||||||
|
export const HOLD_DELAY = 600;
|
||||||
|
export const HOLD_INTERVAL = 1000;
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
parentLabel: (id: string | null) => string;
|
parentLabel: (id: string | null) => string;
|
||||||
pressTargets: (id: string) => string[];
|
pressTargets: (id: string) => string[];
|
||||||
|
mode: () => HoldMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LongPressPick = { id: string; shift: boolean };
|
export type LongPressPick = { id: string; shift: boolean };
|
||||||
|
|
||||||
// キーの長押しで、キー → 親 → … の順に選択先を巡回する。
|
// キーの長押しで、キー → 親 → … の順に選択先を巡回する。
|
||||||
|
// loop: 巡回だけで選び、長押し後に動かすと選んだ先をドラッグする。
|
||||||
|
// list: 巡回は同じだが、ポインタがリストに入ると巡回をやめ、指している行を離した時点で選ぶ。ドラッグはしない。
|
||||||
// ポインタの扱い(ドラッグ・クリック)は呼び出し側が持ち、ここは候補と表示だけを持つ。
|
// ポインタの扱い(ドラッグ・クリック)は呼び出し側が持ち、ここは候補と表示だけを持つ。
|
||||||
export function usePhyLongPress(options: Options) {
|
export function usePhyLongPress(options: Options) {
|
||||||
const label = ref("");
|
const open = ref(false);
|
||||||
const labelPos = ref({ x: 0, y: 0 });
|
const mode = ref<HoldMode>("loop");
|
||||||
|
const pos = ref({ x: 0, y: 0 });
|
||||||
const candidates = ref<string[]>([]);
|
const candidates = ref<string[]>([]);
|
||||||
|
const names = ref<string[]>([]);
|
||||||
const index = ref(0);
|
const index = ref(0);
|
||||||
|
// 巡回が進むたびに増やし、インジケータのアニメーションをやり直させる
|
||||||
|
const step = ref(0);
|
||||||
|
// list 方式で、ポインタがリストに入ったあと
|
||||||
|
const pointing = ref(false);
|
||||||
let timer: number | undefined;
|
let timer: number | undefined;
|
||||||
let cycle: number | undefined;
|
let cycle: number | undefined;
|
||||||
let shift = false;
|
let shift = false;
|
||||||
@@ -23,31 +38,54 @@ export function usePhyLongPress(options: Options) {
|
|||||||
activated = false;
|
activated = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stopCycle() {
|
||||||
|
if (cycle) window.clearInterval(cycle);
|
||||||
|
cycle = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function start(event: PointerEvent, keyId: string) {
|
function start(event: PointerEvent, keyId: string) {
|
||||||
candidates.value = options.pressTargets(keyId);
|
candidates.value = options.pressTargets(keyId);
|
||||||
index.value = 0;
|
index.value = 0;
|
||||||
shift = event.shiftKey;
|
shift = event.shiftKey;
|
||||||
timer = window.setTimeout(() => {
|
timer = window.setTimeout(() => {
|
||||||
|
timer = undefined;
|
||||||
if (candidates.value.length <= 1) return;
|
if (candidates.value.length <= 1) return;
|
||||||
activated = true;
|
activated = true;
|
||||||
|
mode.value = options.mode();
|
||||||
|
names.value = candidates.value.map((id, i) => (i === 0 ? "キー" : options.parentLabel(id)));
|
||||||
index.value = 1;
|
index.value = 1;
|
||||||
labelPos.value = { x: event.clientX, y: event.clientY };
|
step.value++;
|
||||||
label.value = `${options.parentLabel(candidates.value[1]!)} 2/${candidates.value.length}`;
|
pointing.value = false;
|
||||||
|
pos.value = { x: event.clientX, y: event.clientY };
|
||||||
|
open.value = true;
|
||||||
cycle = window.setInterval(() => {
|
cycle = window.setInterval(() => {
|
||||||
index.value = (index.value + 1) % candidates.value.length;
|
index.value = (index.value + 1) % candidates.value.length;
|
||||||
const id = candidates.value[index.value]!;
|
step.value++;
|
||||||
const name = index.value === 0 ? "キー" : options.parentLabel(id);
|
}, HOLD_INTERVAL);
|
||||||
label.value = `${name} ${index.value + 1}/${candidates.value.length}`;
|
}, HOLD_DELAY);
|
||||||
}, 450);
|
|
||||||
}, 500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cycling() {
|
function cycling() {
|
||||||
return label.value !== "";
|
return open.value && mode.value === "loop";
|
||||||
|
}
|
||||||
|
|
||||||
|
function picking() {
|
||||||
|
return open.value && mode.value === "list";
|
||||||
|
}
|
||||||
|
|
||||||
|
// list 方式: ポインタの下にあるリストの行を指す。リストの外では直前の状態を保つ。
|
||||||
|
function point(clientX: number, clientY: number) {
|
||||||
|
const row = document
|
||||||
|
.elementsFromPoint(clientX, clientY)
|
||||||
|
.find((element) => element instanceof HTMLElement && element.dataset.holdIndex !== undefined);
|
||||||
|
if (!(row instanceof HTMLElement)) return;
|
||||||
|
stopCycle();
|
||||||
|
pointing.value = true;
|
||||||
|
index.value = Number(row.dataset.holdIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelPending() {
|
function cancelPending() {
|
||||||
if (timer && !label.value) {
|
if (timer && !open.value) {
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
timer = undefined;
|
timer = undefined;
|
||||||
}
|
}
|
||||||
@@ -55,16 +93,17 @@ export function usePhyLongPress(options: Options) {
|
|||||||
|
|
||||||
function take(): LongPressPick {
|
function take(): LongPressPick {
|
||||||
const id = candidates.value[index.value]!;
|
const id = candidates.value[index.value]!;
|
||||||
if (cycle) window.clearInterval(cycle);
|
stopCycle();
|
||||||
label.value = "";
|
open.value = false;
|
||||||
return { id, shift };
|
return { id, shift };
|
||||||
}
|
}
|
||||||
|
|
||||||
function finish(): LongPressPick | null {
|
function finish(): LongPressPick | null {
|
||||||
if (timer) window.clearTimeout(timer);
|
if (timer) window.clearTimeout(timer);
|
||||||
if (cycle) window.clearInterval(cycle);
|
timer = undefined;
|
||||||
const id = label.value ? candidates.value[index.value] : undefined;
|
stopCycle();
|
||||||
label.value = "";
|
const id = open.value ? candidates.value[index.value] : undefined;
|
||||||
|
open.value = false;
|
||||||
return id ? { id, shift } : null;
|
return id ? { id, shift } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,13 +112,19 @@ export function usePhyLongPress(options: Options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label,
|
open,
|
||||||
labelPos,
|
mode,
|
||||||
|
pos,
|
||||||
candidates,
|
candidates,
|
||||||
|
names,
|
||||||
index,
|
index,
|
||||||
|
step,
|
||||||
|
pointing,
|
||||||
reset,
|
reset,
|
||||||
start,
|
start,
|
||||||
cycling,
|
cycling,
|
||||||
|
picking,
|
||||||
|
point,
|
||||||
cancelPending,
|
cancelPending,
|
||||||
take,
|
take,
|
||||||
finish,
|
finish,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { localBounds } from "./logic";
|
|||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
project: () => ProjectFile;
|
project: () => ProjectFile;
|
||||||
pane: Ref<HTMLElement | undefined>;
|
|
||||||
svg: Ref<SVGSVGElement | undefined>;
|
svg: Ref<SVGSVGElement | undefined>;
|
||||||
size: Ref<{ w: number; h: number }>;
|
size: Ref<{ w: number; h: number }>;
|
||||||
zoom: Ref<number>;
|
zoom: Ref<number>;
|
||||||
@@ -58,13 +57,14 @@ export function usePhyViewport(options: Options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!options.pane.value) return;
|
if (!options.svg.value) return;
|
||||||
|
// viewBox はSVG自身の大きさに合わせる。ペインにはツールバーも入り、折り返すと高さがずれる
|
||||||
observer = new ResizeObserver((entries) => {
|
observer = new ResizeObserver((entries) => {
|
||||||
const rect = entries[0]!.contentRect;
|
const rect = entries[0]!.contentRect;
|
||||||
options.size.value = { w: rect.width, h: rect.height };
|
options.size.value = { w: rect.width, h: rect.height };
|
||||||
fit();
|
fit();
|
||||||
});
|
});
|
||||||
observer.observe(options.pane.value);
|
observer.observe(options.svg.value);
|
||||||
fit();
|
fit();
|
||||||
});
|
});
|
||||||
onUnmounted(() => observer?.disconnect());
|
onUnmounted(() => observer?.disconnect());
|
||||||
|
|||||||
@@ -149,6 +149,16 @@ describe("domain project flow", () => {
|
|||||||
expect(h.undo().phy.metadata.name).toBe(p.phy.metadata.name);
|
expect(h.undo().phy.metadata.name).toBe(p.phy.metadata.name);
|
||||||
expect(h.redo().phy.metadata.name).toBe("drag");
|
expect(h.redo().phy.metadata.name).toBe("drag");
|
||||||
});
|
});
|
||||||
|
it("does not add a history step for an unchanged snapshot", async () => {
|
||||||
|
const p = await createProjectFromKle(kle, { idGenerator: gen }),
|
||||||
|
h = new ProjectHistory(p);
|
||||||
|
const q = structuredClone(p);
|
||||||
|
q.phy.metadata.name = "edit";
|
||||||
|
h.push(q);
|
||||||
|
h.push(structuredClone(q));
|
||||||
|
expect(h.undo().phy.metadata.name).toBe(p.phy.metadata.name);
|
||||||
|
expect(h.canUndo).toBe(false);
|
||||||
|
});
|
||||||
it("imports the VIA geometry sample", async () => {
|
it("imports the VIA geometry sample", async () => {
|
||||||
const via = JSON.parse(
|
const via = JSON.parse(
|
||||||
readFileSync(new URL("../../samples/via/cr0wn60.json", import.meta.url), "utf8"),
|
readFileSync(new URL("../../samples/via/cr0wn60.json", import.meta.url), "utf8"),
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { 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("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("selects nothing by default and drops ids the size no longer offers", () => {
|
||||||
|
expect(selectedStabilizers({}, key("a", 2))).toEqual([]);
|
||||||
|
expect(selectedStabilizers({ a: ["11.938mm"] }, key("a", 2))).toEqual(["11.938mm"]);
|
||||||
|
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" },
|
||||||
|
{ a: ["11.938mm"] },
|
||||||
|
);
|
||||||
|
expect(marks.map((m) => m.id)).toEqual(["a", "c"]);
|
||||||
|
expect(marks[1]!.transform.f).toBe(1);
|
||||||
|
expect(marks[0]!.stabs).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -86,16 +86,38 @@ T-29 のコードを作り直さず、[phy-editor-feedback.md](phy-editor-feedba
|
|||||||
| 5 | クラスターの原点 | 選択中のクラスターにだけ、原点に丸を出す | 1の層 |
|
| 5 | クラスターの原点 | 選択中のクラスターにだけ、原点に丸を出す | 1の層 |
|
||||||
| 6 | キー追加の位置 | 選択中のキー(複数なら最後に選んだキー)の行の右端に追加する。何も選んでいなければ、同じコンテナの全キーの下に新しい行を作り、その左端に置く。クラスターを選んでいればその中に、バリアントグループを選んでいれば表示中のバリアントの中に、同じように新しい行を作る。行の判定は `domain/` に純粋関数として実装してテストし、のちにIDX Editorでも使う。規則は [idx-editor-feedback.md](idx-editor-feedback.md)「行と列の判定」 | `domain/rows.ts`、`usePhyCommands.ts` |
|
| 6 | キー追加の位置 | 選択中のキー(複数なら最後に選んだキー)の行の右端に追加する。何も選んでいなければ、同じコンテナの全キーの下に新しい行を作り、その左端に置く。クラスターを選んでいればその中に、バリアントグループを選んでいれば表示中のバリアントの中に、同じように新しい行を作る。行の判定は `domain/` に純粋関数として実装してテストし、のちにIDX Editorでも使う。規則は [idx-editor-feedback.md](idx-editor-feedback.md)「行と列の判定」 | `domain/rows.ts`、`usePhyCommands.ts` |
|
||||||
| 7a | 長押しの切り出し | 長押しの処理を composable に切り出す。挙動は変えない | `usePhyCanvasPointer.ts` |
|
| 7a | 長押しの切り出し | 長押しの処理を composable に切り出す。挙動は変えない | `usePhyCanvasPointer.ts` |
|
||||||
| 7b | 長押しの2方式 | ループ方式(間隔を延ばし、円形インジケータと選択先のリストを付ける)と、長押しでリストを出して選ぶ方式を入れ、切り替えて比べる | 7aの composable |
|
| 7b | 長押しの2方式 | ループ方式(間隔を延ばし、円形インジケータと選択先のリストを付ける)と、長押しでリストを出して選ぶ方式を入れ、ツールバーで切り替えて比べる。間隔は、押してから600msで始まり1000msごとに進む。リスト方式は、押した位置から少し離してリストを出し、ポインタがリストに入ったら巡回を止め、離したときに指している行で確定する。リストに入らずに離せば、そのときの巡回先で確定する。ループ方式は長押しのあと動かすと選んだ先をドラッグし、リスト方式はドラッグしない | 7aの composable |
|
||||||
| 8 | スイッチ・スタビライザーの表示 | 既定は非表示にし、トグルで色違いの十字を出す。プロパティ欄に `switchOffset` の入力欄を置く | 1の層、`PhyProperties.vue` |
|
| 8 | スイッチ・スタビライザーの表示 | 既定は非表示にし、トグルで色違いの十字を出す。プロパティ欄に `switchOffset` の入力欄と、スタビ位置の候補の選択欄を置く(下の「単位8のスタビ候補」) | 1の層、`PhyProperties.vue` |
|
||||||
|
|
||||||
順番の理由: 1で作る層を5と8が使う。2〜5は小さく独立しているので先に片付ける。
|
順番の理由: 1で作る層を5と8が使う。2〜5は小さく独立しているので先に片付ける。
|
||||||
7・8は、着手前に下の点を決める必要がある。7は最も大きく、試して比べる部分なので、挙動を変えない切り出しを先に入れる。
|
7は最も大きく、試して比べる部分なので、挙動を変えない切り出しを先に入れる。
|
||||||
|
|
||||||
着手前に決めること:
|
#### 単位8のスタビ候補
|
||||||
|
|
||||||
- 7b: 長押しの間隔の値。リスト方式でどう選ぶか(離した位置で決めるのか、リストを出したあとにクリックするのか)。
|
スタビ位置はキーのサイズで候補を引き、キーごとに候補を複数選べる。何も選ばなくてもよい。
|
||||||
- 8: スタビライザーを付けるキー幅と、幅ごとの位置。これは事実なので、出典で裏取りしてから決める。推測で実装しない。
|
選んだ候補はプロジェクトファイルの `ui` にキーIDごとに持ち、PHYには入れない(いずれPHYへ移す)。
|
||||||
|
どのサイズも、何も選ばない状態から始める。
|
||||||
|
候補を選んでも `switchOffset` は変えない。`switchOffset` は手で入れる。
|
||||||
|
|
||||||
|
候補は、キーの (w,h) 矩形の中心から±の位置に置く2点で表す(1U = 19.05mm)。縦長のキーは長辺の向きに置く。
|
||||||
|
Cherry MX系の値を使う。出典は Deskthority wiki「Space bar dimensions」と、プレート・基板の生成ツール(kad、keyswitch-kicad-library)。
|
||||||
|
|
||||||
|
| 幅 | 候補 |
|
||||||
|
| --- | --- |
|
||||||
|
| 2〜2.75U | ±11.938mm |
|
||||||
|
| 3U | ±1U |
|
||||||
|
| 4U | ±1.5U |
|
||||||
|
| 4.5U | ±34.671mm |
|
||||||
|
| 5.5U | ±2.25U |
|
||||||
|
| 6U | ±2.5U(軸がずれた型。実物は `switchOffset.x` = +0.5U)、±2U |
|
||||||
|
| 6.25U | ±50mm(軸がずれた型もこの位置。実物は `switchOffset.x` = +0.625U)、±40mm、±42.5mm |
|
||||||
|
| 6.5U | ±2.75U |
|
||||||
|
| 7U | ±3U |
|
||||||
|
| 8U・9U・9.75U・10U | ±3.5U |
|
||||||
|
|
||||||
|
6Uの±2.5Uは、軸が中央の型(Topre等)と軸がずれた型で同じ位置になるため、1つの候補にまとめる。
|
||||||
|
ISO Enter・BAE等の異形キー(`w2`・`h2`・`x2`・`y2` のいずれかを持つキー)には、候補を出す仕組みだけを入れ、候補は置かない。
|
||||||
|
上にない幅のキーは候補なし。
|
||||||
|
|
||||||
覆すときに影響する箇所: `mock/src/ui/phy/`、6の行判定の関数とそのテスト。
|
覆すときに影響する箇所: `mock/src/ui/phy/`、6の行判定の関数とそのテスト。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user