Files
Beyond-KLE/journal/2026-09-21-kle-output-rowgroup.md
T
ewdernaandClaude Opus 5 ebe1434860 phy: KLE出力の rx, ry をクラスター原点で共有し、座標を丸める
KLEが1行にまとめるのは r, rx, ry が一致し y が厳密に等しいキーだけなので、
同じ絶対角度のキーには共通の軸を与え、絶対座標への変換で入った誤差は丸めて落とす。
ErgoDox・Kinesis Advantage で元のKLE JSONとバイト単位で一致する出力になった。

Journal: journal/2026-09-21-kle-output-rowgroup.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01246a5KFLmwcRXriZvX8fdJ
2026-09-21 19:00:17 +09:00

5.1 KiB

KLE JSON出力での rx, ry の選び方と行のまとまり

PHY P-1「KLE JSONへ書き出した配置が行のまとまりを保てるか」の検証。

結論

保てる。条件は2つ。

  1. 同じ絶対角度を持つキーには共通の rx, ry を与える(クラスターの原点が使える)
  2. 書き出す x, y を丸める

この2つを満たすと、ErgoDox・Kinesis Advantage では元のKLE JSONとバイト単位で一致する出力になった。

KLEが行を切る条件は本家 serial.js の serialize にそのまま書かれている。 (r, rx, ry) のいずれかが変わるか、y が前のキーと厳密に異なると新しい行になる。 キーは書き出し前に 角度 → rx → ry → y → x で並べ替えられる。

案の比較

回転を含む実データをPHY(絶対座標)に変換し、rx, ry の選び方だけを変えて書き出した。 行数が少ないほど、元の行のまとまりに近い。

軸の選び方 回転を含む153枚の行数合計 備考
元のKLE JSON 3465 比較の基準
キーごとに自分の原点を軸にする 6369 回転キーが1つずつ別の回転グループになり、1キー1行に分解される
同じ角度のキーで軸を共有する(クラスター原点) 3526 ほぼ基準どおり

丸めなしだと、軸を共有しても行が余分に割れる。ErgoDox で 26行 → 28行。 serialize の行判定が key.y !== current.y の厳密比較で、絶対座標への変換で入った 1e-16 程度の誤差が残るため。丸めれば 1e-4 でも 1e-9 でも結果は同じだった。

ボード 元の行数 軸共有+丸め 一致
ErgoDox (KLEプリセット) 26 26 完全一致
ErgoDox EZ (VIA) 26 26 完全一致
Kinesis Advantage 17 17 完全一致
Atreus 34 34 数値の誤差のみ(本家は保存時に小数4桁へ丸めるので、その時点で一致)

検証の範囲

本家のWebアプリのUIに読み込ませたわけではなく、本家リポジトリの serial.js をそのままNodeで実行した。 行の再構成と回転グループの判定はこのファイルに閉じているので、UIを経由しても同じ結果になるはずだが、 描画そのものは確認していない。

副作用

キーごとに軸を選ぶ出力は、行が割れるだけでなく、見た目属性の書き戻しも壊しやすい。 KLEは p / sm / sb / st の空文字への書き戻しを無視するため、並びが変わると前のキーの値が漏れる。 軸を共有した出力では1504件中0件、キーごとに軸を選んだ出力では1件発生した。

検証スクリプト

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
{ echo 'global.angular = { toJson: (o) => JSON.stringify(o) };'; cat kle/extensions.js; cat kle/serial.js; } > kleio.js
// 使い方: 下の setup の手順でコーパスを用意してから node rowgroup.js
const fs=require('fs');
const $serial=require('./kleio.js');           // 本家 extensions.js + serial.js を連結したもの
const R=d=>d*Math.PI/180, rnd=v=>Math.round(v*1e6)/1e6;
const rowsOf=s=>s.filter(Array.isArray).length;

// KLEキー -> 絶対配置(PHYのTransformと同じ意味)。rx,ry は元の値をクラスター原点として覚えておく
function toPhy(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;
  return {x:k.rotation_x+dx*c-dy*s, y:k.rotation_y+dx*s+dy*c, rot:k.rotation_angle,
          pivot:[k.rotation_x,k.rotation_y], src:k};
}
// 絶対配置 -> KLEキー。pivot の選び方だけを差し替えて比べる
function toKle(n, pivot){
  const k=JSON.parse(JSON.stringify(n.src));
  const [rx,ry]=pivot, th=R(n.rot), c=Math.cos(th), s=Math.sin(th);
  const dx=n.x-rx, dy=n.y-ry;
  k.x=rnd(rx+dx*c+dy*s); k.y=rnd(ry-dx*s+dy*c);
  k.rotation_angle=n.rot; k.rotation_x=rx; k.rotation_y=ry;
  return k;
}
const load=p=>{const d=JSON.parse(fs.readFileSync(p,'utf8')); return d.layouts?d.layouts.keymap:d;};
const targets=[];
for(const p of JSON.parse(fs.readFileSync('kle/layouts.json','utf8')).presets)
  if(['ErgoDox','Kinesis Advantage','Atreus'].includes(p.name)) targets.push({name:p.name,data:p.data});
targets.push({name:'ergodox_ez(via)',data:load('via/src/ergodox_ez/ergodox_ez.json')});

for(const t of targets){
  const A=$serial.deserialize(t.data).keys;
  const orig=$serial.serialize({meta:{},keys:JSON.parse(JSON.stringify(A))});
  const phy=A.map(toPhy);
  const ser=(pick)=>$serial.serialize({meta:{},keys:phy.map(n=>toKle(n,pick(n)))});
  const perKey=ser(n=>n.rot?[n.x,n.y]:[0,0]); // 回転キーはキーごとに自分の原点を軸にする
  const cluster=ser(n=>n.pivot);           // 同じ角度のキーで軸を共有する
  console.log(t.name.padEnd(20),'元',rowsOf(orig),
    '| キーごと',rowsOf(perKey),
    '| 軸共有',rowsOf(cluster), JSON.stringify(cluster)===JSON.stringify(orig)?'(元と完全一致)':'');
}