意味差分
二つの模型を構成の言葉で比べる。行順・書式・素の wall 宣言と省略 (既定壁) の違いは差分にしない。
import { renderDiff, semanticDiff } from "@kensnzk/koyu";
import type { ModelDiff } from "@kensnzk/koyu";
semanticDiff
function semanticDiff(a: Model, b: Model): ModelDiff
不変量が一つある — toCanonical(a) === toCanonical(b) なら差分は空である。
import { renderDiff, semanticDiff } from "@kensnzk/koyu";
import { parseFile } from "@kensnzk/koyu/node";
const a = parseFile("examples/two-rooms.muro");
const b = parseFile("examples/two-rooms.muro");
b.spaces.get("/L1/b")!.attrs["name"] = "書斎";
console.log(renderDiff(semanticDiff(a, b)));
console.log(renderDiff(semanticDiff(a, a)));
[ '± /L1/b: name 居室B → 書斎' ]
[]
ModelDiff
interface ModelDiff {
version?: { from: string; to: string };
name?: { from?: string; to?: string };
grid: GridChange[];
levels: { added: string[]; removed: string[]; changed: ChangedItem[] };
assets: { added: string[]; removed: string[]; changed: ChangedItem[] };
polygons: { added: string[]; removed: string[]; changed: ChangedItem[] };
zones: { added: string[]; removed: string[]; renamed: RenamedItem[]; changed: ChangedItem[] };
spaces: { added: SpaceItem[]; removed: SpaceItem[]; renamed: RenamedItem[]; changed: ChangedItem[] };
boundaries: { added: BoundaryItem[]; removed: BoundaryItem[]; changed: BoundaryChange[] };
columns: { added: ColumnItem[]; removed: ColumnItem[]; changed: ChangedItem[] };
}
columns を忘れないこと。柱の宣言は順序が意味を持つので、集合だけでなく順位も差分の対象である — 二行を入れ替えると実際に立つ柱が変わる。
構成する型は次の通り。
interface FieldChange {
field: string;
from?: string; // 片方が無ければ、その側に無かった (追加/削除)
to?: string;
}
interface ChangedItem {
path: string; // 新しい側 (b) の名
fields: FieldChange[];
}
interface RenamedItem {
from: string;
to: string;
uid: string;
}
interface GridChange {
axis: "X" | "Y";
name: string;
kind: "added" | "removed" | "moved";
from?: number;
to?: number;
}
interface SpaceItem {
path: string;
type: string;
areaM2?: number;
}
interface BoundaryItem {
between: [string, string];
edge?: Edge;
kind: string;
t?: number;
}
interface BoundaryChange {
between: [string, string];
edge?: Edge;
fields: FieldChange[];
}
interface ColumnItem {
at: number; // 宣言の順位 (1始まり)
label: string;
}
空の差分でも構造は全部出る。キーが欠けることは無いので、d.columns.added.length のような読み方が安全である。
console.log(JSON.stringify(semanticDiff(a, a), null, 1));
{
"grid": [],
"levels": {
"added": [],
"removed": [],
"changed": []
},
"assets": {
"added": [],
"removed": [],
"changed": []
},
"polygons": {
"added": [],
"removed": [],
"changed": []
},
"zones": {
"added": [],
"removed": [],
"renamed": [],
"changed": []
},
"spaces": {
"added": [],
"removed": [],
"renamed": [],
"changed": []
},
"boundaries": {
"added": [],
"removed": [],
"changed": []
},
"columns": {
"added": [],
"removed": [],
"changed": []
}
}
version と name だけは、変化が無ければキーごと出ない。
対応付けの規則
二段構えである。
uidの一致で対にする (両側から消費する)- 残りをパスの一致で対にする
- それでも残ったものが追加・削除
uid が一致してパスが違えば改名である。境界の対応は uid が継ぐので、一つの改名が境界の洪水にならない。
import { parse, renderDiff, semanticDiff } from "@kensnzk/koyu";
const r1 = parse(`grid X 0 3600
grid Y 0 4000
level L1 0 h:2400 slab:150
space /L1/a room X1..X2 Y1..Y2 uid:u-0123456789abcdef`);
const r2 = parse(`grid X 0 3600
grid Y 0 4000
level L1 0 h:2400 slab:150
space /L1/study room X1..X2 Y1..Y2 uid:u-0123456789abcdef`);
console.log(JSON.stringify(semanticDiff(r1, r2).spaces, null, 1));
console.log(renderDiff(semanticDiff(r1, r2)));
{
"added": [],
"removed": [],
"renamed": [
{
"from": "/L1/a",
"to": "/L1/study",
"uid": "u-0123456789abcdef"
}
],
"changed": []
}
[ 'renamed /L1/a → /L1/study (uid:u-0123456789abcdef)' ]
uid が片側で重複している模型 (UID03 のエラーが出る模型) では、その uid はパス照合へ落ちる。検査でエラーの出る模型でも差分は落ちない。
開口と seg の対応
名があれば名が優先である。名の付いた扉を動かせば「同じ扉の at が変わった」であって「消えて生えた」ではない。名が無ければ位置で対応づける他にない — 開口は (kind, edge, at)、seg は (edge, at, w)。
名のある開口と無い開口は別のキー空間に落ちるので、名を後から書き足した編集は追加/削除に見える。名を書く行為そのものが同一性の宣言なので、それでよい。
境界の向き
a の向きが意味を持つのは edge と開口の swing / hinge と seg があるときだけである。それ以外では向きを比べない — 二空間を書く順を入れ替えただけの編集は差分にならない。
derived の印も比較の直列に出ないので、素の wall 宣言と既定壁は同一の直列になる。「明示的に boundary /L1/a /L1/b と書き足した」は差分にならない。
柱
const head = `koyu 1.0
grid X 0 6000 12000
grid Y 0 6000
level L1 0 h:4000 slab:200
space /L1/hall room X1..X3 Y1..Y2
space /out exterior
boundary /L1/hall /out
`;
const c1 = parse(head + `column 800 L1\n`);
const c2 = parse(head + `column 900 L1 x:X1,X2\ncolumn 800 L1\n`);
const d = semanticDiff(c1, c2);
console.log(JSON.stringify(d.columns, null, 1));
console.log(renderDiff(d));
{
"added": [
{
"at": 1,
"label": "900 square L1 x:X1,X2"
}
],
"removed": [],
"changed": [
{
"path": "800 square L1",
"fields": [
{
"field": "rank",
"from": "1",
"to": "2"
}
]
}
]
}
[
'+ column 900 square L1 x:X1,X2',
'± column 800 square L1: rank 1 → 2'
]
宣言そのものは変わっていないのに rank の変化が出ている。先に一行入ったので、その宣言が交点を取る順が変わった — つまり実際に立つ柱が変わりうる。これが「宣言順は意味である」ということである。
敷地形状の比較
多角形は巡回正規化して比べる。回転 (始点の書き替え) と反転 (逆回り) で最小になる直列を取るので、同じ形を違う頂点から書き始めた編集は差分にならない。
renderDiff
function renderDiff(d: ModelDiff): string[]
日本語の差分行を返す。並びは semanticDiff が決めた正準順のままで、空なら空配列である。
記号は三つ。
| 記号 | 意味 |
|---|---|
+ | 追加 |
− | 削除 |
± | 変化 |
改名だけは記号を持たず、renamed <前> → <後> (uid:…) の形になる。
空間のパスはレベル順に並ぶ — 先頭セグメントがレベルなら「残りのパス、レベルの序数」で並べるので、スパン展開で生まれた同名の空間 (/L4/A/ldk … /L10/A/ldk) が隣接して階順に出る。