From 12a47fe822baa2fb90c9fb39558d422a85879abb Mon Sep 17 00:00:00 2001 From: EwdErna Date: Mon, 21 Sep 2026 19:00:32 +0900 Subject: [PATCH] =?UTF-8?q?phy:=20=E5=85=AC=E9=96=8BKLE=20JSON=201505?= =?UTF-8?q?=E4=BB=B6=E3=81=A7=E5=A4=89=E6=8F=9B=E5=8F=AF=E5=90=A6=E3=82=92?= =?UTF-8?q?=E7=A2=BA=E3=81=8B=E3=82=81=E3=80=81=E6=9B=B8=E3=81=8D=E6=88=BB?= =?UTF-8?q?=E3=81=97=E3=81=AE=E5=88=B6=E7=B4=84=E3=82=92=E5=8A=A0=E3=81=88?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VIAの定義とKLE本家のプリセット・サンプルを集め、本家の serial.js を通して往復させた。 幾何は全件無損失で、PHYで表せないKLEの幾何情報は無かった。 KLEは p, sm, sb, st を空文字に戻す指定を無視するため、混在する配置は書き戻せない。 本家が読めないJSONも実在するので、取り込みの寛容さを問いとして残す。 Journal: journal/2026-09-21-kle-corpus.md Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01246a5KFLmwcRXriZvX8fdJ --- PHYレイヤー.md | 11 ++ journal/2026-09-21-kle-corpus.md | 215 +++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 journal/2026-09-21-kle-corpus.md diff --git a/PHYレイヤー.md b/PHYレイヤー.md index a2b63ee..a834a5c 100644 --- a/PHYレイヤー.md +++ b/PHYレイヤー.md @@ -328,6 +328,8 @@ const kleData = (ConfigObject | KeyLabel)[][] - ユニットサイズ(mm)、ビジュアルオフセット、クラスター構造、部品ノード、per-key LEDはKLE JSONに出力されない - バリアント構造はKLE JSONに出力しない。VariantGroupごとにバリアントを1つ選び、その配下だけを畳んで出力する - 部品グループ・スイッチの向きはKLE JSONに出力しない。sm, sb, st には取り込み時に保持した値をそのまま書き戻す +- p, sm, sb, st は空文字に戻す指定がKLE側で無視される。値を持つキーと持たないキーが混在する配置は、 + KLEで開いた時点で後続のキーに前の値が残る - KLEの `p` はキーキャップのプロファイル名(SA、DCSなど)であり、PHYのプロファイルとは対応させない ### KLE JSONの取り込み @@ -340,6 +342,8 @@ const kleData = (ConfigObject | KeyLabel)[][] - 構造の復元は保証しない。KLE側での編集が大きいほど、クラスター構造は失われる - 配列の順番は照合に使わない。KLEは保存時にキーを「角度 → rx → ry → y → x」の順に並べ替えるため、順番は形状から決まる以上の情報を持たない +r, rx, ry を行の先頭以外に置いたKLE JSONが公開されている。KLE本家はこれを読み込まずエラーにする。 + KLEの rx, ry, r はキーの絶対配置 (x, y, rotation) に変換して取り込む。元の rx, ry は保持しない。 見た目の属性(c, t, p, a, f, f2, fa, l, n)は、KLE JSONへ書き戻せるように保持する。どの層で持つかは決まっていない。 @@ -363,6 +367,13 @@ Beyond KLEはスイッチの品種を持たないため、対応づける先が - 現状の認識: 形状の許容誤差、ラベルの扱い、複数候補がある場合の決め方が未定 - 格上げ条件: 照合規則を決めたうえで、KLE本家でキーの移動・追加・削除をしたJSONを取り込み、意図どおりに対応づくことを確認する +### P-16 KLE本家が拒否するJSONを取り込むか + +- 位置づけ: 未解決の問い。本文「KLE JSONの取り込み」が扱えていない +- 現状の認識: r, rx, ry を行の先頭以外に置いたJSONが公開されている(1505件中1件)。KLE本家はエラーにするが、 + VIAのパーサーは読める。本家に合わせて拒否するか、読めるものは読むかが未定 +- 格上げ条件: 取り込みの寛容さの方針をユーザーが決める + ### P-6 キーキャップの見た目情報を持つ層 - 位置づけ: 本文「見た目の属性は保持する。どの層で持つかは決まっていない」の未解決部分 diff --git a/journal/2026-09-21-kle-corpus.md b/journal/2026-09-21-kle-corpus.md new file mode 100644 index 0000000..e725128 --- /dev/null +++ b/journal/2026-09-21-kle-corpus.md @@ -0,0 +1,215 @@ +# 公開KLE JSONの収集とPHYへの変換可否 + +## 結論 + +公開されているKLE JSON 1505件(キー111,619個)を集め、PHYへ変換して書き戻す往復を通した。 + +- 幾何(位置・サイズ・回転・2つ目の矩形・decal・ghost)は全件で無損失。PHYのデータモデルで表せないKLEの + 幾何情報は無かった +- 置き場がないのは見た目の属性だけ(c, t, p, a, f, f2, fa, n, l と sm, sb, st、ファイル単位の + backcolor, radii, background, css)。検証では退避用のフィールドに逃がした。PHY P-6 の対象そのもの +- KLE本家のデシリアライザが読めないJSONが実在する(1505件中1件) +- `l`(stepped)が付いたキーが16個あった。KLEはstepped Caps Lockに「段差がある」ことしか持たず、 + スイッチ位置のオフセットは持たない。PHYの `switchOffset` は取り込みでは決まらない + +## コーパス + +| 出所 | 件数 | 備考 | +| --- | --- | --- | +| KLE本家のプリセット (`layouts.json`) | 12 | ANSI 104, ISO 105, ErgoDox, Kinesis Advantage, Atreus など | +| KLE本家のサンプル (`samples/`) | 9 | Symbolics Space Cadet, Apple Wireless, Commodore VIC-20 など | +| VIA キーボード定義 (the-via/keyboards の `layouts.keymap`) | 1484 | 市販・自作基板の実データ | + +Geekhackやgistは当たっていない。VIAの定義はKLE JSONそのもの(`layouts.keymap`)で、 +実際に流通している基板をほぼ網羅しているため、これで足りると判断した。 + +## 検証方法 + +KLE本家のリポジトリ (ijprest/keyboard-layout-editor) から `serial.js` と `extensions.js` を +そのままNodeで読み込み、本家の `deserialize` / `serialize` を使った。自前の再実装ではない。 + +1. 元データ → `deserialize` → キー配列A +2. A → PHY(root直下に平坦なKey木、絶対座標を `Translate·Rotate` に分解) +3. PHY → KLEのキー配列 → 本家 `serialize` → `deserialize` → キー配列B +4. AとBを、絶対位置・角度・サイズ・ラベル・見た目属性のフィンガープリントの多重集合で比較 + +比較対象は「Aをそのまま本家で往復させた結果」とした(本家自身の往復の癖を分離するため)。 + +## 結果 + +- 1504/1504 で配置が一致(1件は手順1で本家が例外を投げるため対象外) +- 直列化結果そのものが本家の出力と完全一致: 1408件。小数4桁に丸めると一致(誤差のみ): 20件。 + 残り76件は行の切り方が違うだけで、読み直した配置は一致する + +出現頻度(キー単位、111,619個中): + +| 属性 | 件数 | +| --- | --- | +| c (色) | 35,888 | +| 0.25U刻みでない座標・サイズ | 6,824 | +| 回転あり | 4,583 (うち15度の倍数でない 4,155) | +| rx/ry あり | 2,567 | +| f/f2/fa | 893 | +| d (decal) | 830 | +| 2つ目の矩形 (x2,y2,w2,h2) | 570 | +| sm/sb/st | 142 | +| t (文字色) | 61 | +| n (ホームポジションの突起) | 52 | +| l (stepped) | 16 | +| g (ghost) | 8 | + +ファイル単位のメタデータは name 21件、backcolor 7件、author 2件、radii 2件、 +background/switchMount/switchBrand/switchType 各1件。 + +座標・サイズ・回転は正規化されていない値が普通に出てくる。PHYが「スナップするが正規化しない」と +決めているのと整合する。 + +## 本家が読めないJSON + +`owlab/spring/spring.json` の `layouts.keymap` は `{"r":12,"y":-0.8,"x":0.1}` を行の先頭以外に置いており、 +本家の `deserialize` は `'r' can only be used on the first key in a row` で例外を投げる。 +VIA側のパーサーは通してしまうため、このまま公開されている。 + +取り込みでこれをどう扱うか(本家と同じく拒否するか、行の途中の r を受け入れるか)は未定。 +PHY の未確認事項に置いた。 + +## 見た目属性の扱い + +退避して書き戻す方針は、次の制約とセットでないと成立しない。 + +本家の `deserialize` は `p` / `sm` / `sb` / `st` を `if(key.p)` の形で読む。空文字への書き戻しが無視されるため、 +一度値を持ったキーの後ろに値を持たないキーが来る並びでは、前の値が後続に残る。 + +```txt +入力: [[{p:'SA',sm:'cherry'},'A',{p:'',sm:''},'B']] +結果: A → p=SA sm=cherry / B → p=SA sm=cherry (Bの空文字指定は無視される) +``` + +実コーパスでは、クラスターの原点を rx,ry に選ぶ出力では1件も発生しなかった。 +キーごとに rx,ry を選ぶ出力では1件発生した(floookay/adelheid、`p:"SPACE"` が11キーに漏れる)。 +並び順に依存する事故なので、出力側で回避しきれない。 + +## 検証スクリプト + +コーパスと本家のコードを用意する。 + +```sh +git clone --depth 1 https://github.com/ijprest/keyboard-layout-editor.git kle +git clone --depth 1 https://github.com/the-via/keyboards.git via +# 本家の serial.js を Node から使えるようにする(angular は toJson しか使われない) +{ echo 'global.angular = { toJson: (o) => JSON.stringify(o) };'; cat kle/extensions.js; cat kle/serial.js; } > kleio.js +``` + +`convert.js` — KLEキーとPHYノードの相互変換と比較用フィンガープリント。 + +```js +// KLE JSON <-> Beyond KLE PHY の変換(検証用) +const $serial = require('./kleio.js'); +const R = (d) => d * Math.PI / 180; + +// KLE key -> PHY Key (root直下の平坦な木) +function kleKeyToPhy(k, i) { + const th = R(k.rotation_angle), c = Math.cos(th), s = Math.sin(th); + const dx = k.x - k.rotation_x, dy = k.y - k.rotation_y; + const node = { + id: 'k' + i, + transform: { + x: k.rotation_x + dx * c - dy * s, + y: k.rotation_y + dx * s + dy * c, + rotation: k.rotation_angle, + }, + size: { w: k.width, h: k.height }, + profile: 'default', + }; + if (k.width2 !== k.width || k.height2 !== k.height || k.x2 !== 0 || k.y2 !== 0) { + Object.assign(node.size, { x2: k.x2, y2: k.y2, w2: k.width2, h2: k.height2 }); + } + if (k.decal) node.decal = true; + if (k.ghost) node.ghost = true; + const labels = k.labels.map(x => x === undefined ? '' : x); + if (labels.some(x => x !== '')) node.labels = labels; + // PHYに置き場のない属性(P-6)。検証用に退避 + node._kle = { c: k.color, t: k.textColor, p: k.profile, n: k.nub, l: k.stepped, + sm: k.sm, sb: k.sb, st: k.st, ts: k.textSize, dflt: k.default }; + return node; +} + +// PHY Key -> KLE key (rx,ry の選び方は出力側の裁量) +function phyToKleKey(n, opts = {}) { + const k = $serial.defaultKeyProps(); + const rot = n.transform.rotation; + const [rx, ry] = opts.pivot ? opts.pivot(n) : (rot === 0 ? [0, 0] : [n.transform.x, n.transform.y]); + const th = R(rot), c = Math.cos(th), s = Math.sin(th); + // 絶対座標 -> KLE の (x,y): 逆回転 + const dx = n.transform.x - rx, dy = n.transform.y - ry; + k.x = rx + dx * c + dy * s; + k.y = ry + -dx * s + dy * c; + k.rotation_angle = rot; k.rotation_x = rx; k.rotation_y = ry; + k.width = n.size.w; k.height = n.size.h; + k.width2 = n.size.w2 !== undefined ? n.size.w2 : n.size.w; + k.height2 = n.size.h2 !== undefined ? n.size.h2 : n.size.h; + k.x2 = n.size.x2 || 0; k.y2 = n.size.y2 || 0; + k.decal = !!n.decal; k.ghost = !!n.ghost; + k.labels = (n.labels || []).slice(); + const e = n._kle || {}; + k.color = e.c !== undefined ? e.c : k.color; + k.textColor = e.t || []; k.profile = e.p || ''; k.nub = !!e.n; k.stepped = !!e.l; + k.sm = e.sm || ''; k.sb = e.sb || ''; k.st = e.st || ''; + k.textSize = e.ts || []; if (e.dflt) k.default = e.dflt; + return k; +} + +// 比較用: キーの見た目を決める絶対量だけを取り出す +function fingerprint(k) { + const th = R(k.rotation_angle), c = Math.cos(th), s = Math.sin(th); + const dx = k.x - k.rotation_x, dy = k.y - k.rotation_y; + const ax = k.rotation_x + dx * c - dy * s, ay = k.rotation_y + dx * s + dy * c; + const r6 = (v) => Math.round(v * 1e6) / 1e6; + const ang = ((k.rotation_angle % 360) + 360) % 360; + return JSON.stringify([r6(ax), r6(ay), r6(ang), k.width, k.height, k.width2, k.height2, k.x2, k.y2, + k.decal, k.ghost, k.stepped, k.nub, (k.labels || []).map(x => x || '').join('\u0001'), + k.color, k.profile, k.sm, k.sb, k.st, + (k.textColor || []).map(x => x || '').join('\u0001'), + (k.textSize || []).map(x => x || 0).join('\u0001'), k.default && k.default.textSize]); +} + +module.exports = { $serial, kleKeyToPhy, phyToKleKey, fingerprint }; +``` + +`run2.js` — コーパス全件の往復。 + +```js +const fs=require('fs'), path=require('path'); +const { $serial, kleKeyToPhy, phyToKleKey, fingerprint } = require('./convert.js'); +const rnd=v=>Math.round(v*1e6)/1e6; +function walk(d,out=[]){for(const e of fs.readdirSync(d,{withFileTypes:true})){const p=path.join(d,e.name); e.isDirectory()?walk(p,out):e.name.endsWith('.json')&&out.push(p);}return out;} +const corpus=[]; +for(const p of JSON.parse(fs.readFileSync('kle/layouts.json','utf8')).presets) if(p.data.length) corpus.push({name:'preset:'+p.name,data:p.data}); +for(const f of fs.readdirSync('kle/samples')) { try{ corpus.push({name:'sample:'+f,data:JSON.parse(fs.readFileSync('kle/samples/'+f,'utf8'))});}catch(e){} } +for(const p of walk('via/src')){ try{ const d=JSON.parse(fs.readFileSync(p,'utf8')); if(d.layouts&&Array.isArray(d.layouts.keymap)&&d.layouts.keymap.length) corpus.push({name:'via:'+path.relative('via/src',p),data:d.layouts.keymap}); }catch(e){} } + +let ok=0, sameSer=0, n=0, parseFail=0; +let stickyA=0, stickyB=0; const stickyBoards=[]; +const RESET=/"(p|sm|sb|st|t)":""/; +const rowsOf=s=>s.filter(Array.isArray).length; +let rowsOrig=0,rowsA=0,rowsB=0, rotBoards=0; +for(const b of corpus){ + let A; try{ A=$serial.deserialize(b.data).keys; }catch(e){ parseFail++; continue; } + n++; + const orig=$serial.serialize({meta:{},keys:JSON.parse(JSON.stringify(A))}); + const phy=A.map((k,i)=>{const nd=kleKeyToPhy(k,i); nd._pivot=[k.rotation_x,k.rotation_y]; return nd;}); + const mk=(opts)=>$serial.serialize({meta:{},keys:phy.map(nd=>{const k=phyToKleKey(nd,opts); k.x=rnd(k.x); k.y=rnd(k.y); return k;})}); + const sA=mk(), sB=mk({pivot:nd=>nd._pivot}); + const g=s=>$serial.deserialize(s).keys.map(fingerprint).sort().join('|'); + if(g(sB)===g(orig)) ok++; + if(JSON.stringify(sB)===JSON.stringify(orig)) sameSer++; + if(RESET.test(JSON.stringify(sA))) stickyA++; + if(RESET.test(JSON.stringify(sB))) { stickyB++; stickyBoards.push(b.name); } + if(A.some(k=>k.rotation_angle)){ rotBoards++; rowsOrig+=rowsOf(orig); rowsA+=rowsOf(sA); rowsB+=rowsOf(sB); } +} +console.log(`boards=${n} parseFail=${parseFail}`); +console.log(`PHY経由(クラスター原点を軸+丸め)で元と同じ配置: ${ok}/${n}`); +console.log(`直列化結果が元のKLE出力と完全一致: ${sameSer}/${n}`); +console.log(`回転を含む板: ${rotBoards} 行数合計 orig=${rowsOrig} A(キーごと軸)=${rowsA} B(クラスター軸)=${rowsB}`); +console.log(`sticky属性の""リセットを含む出力: A=${stickyA} B=${stickyB}`, JSON.stringify(stickyBoards.slice(0,6))); +```