From 0ddc0979baf3fce9af7ddac168372fc4e61ae49e Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 5 Aug 2026 09:38:49 +0900 Subject: [PATCH 01/21] =?UTF-8?q?docs:=20conflation=20=E3=82=92=20type=3Dm?= =?UTF-8?q?ultipolygon=20=E3=81=AB=E5=AF=BE=E5=BF=9C=E3=81=95=E3=81=9B?= =?UTF-8?q?=E3=82=8B=E8=A8=AD=E8=A8=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ateau-multipolygon-conflation-design.ja.md | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-05-plateau-multipolygon-conflation-design.ja.md diff --git a/docs/superpowers/specs/2026-08-05-plateau-multipolygon-conflation-design.ja.md b/docs/superpowers/specs/2026-08-05-plateau-multipolygon-conflation-design.ja.md new file mode 100644 index 000000000..fae17fcd4 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-plateau-multipolygon-conflation-design.ja.md @@ -0,0 +1,103 @@ +# conflation を type=multipolygon に対応させる設計 + +- 日付: 2026-08-05 +- 関連: rapid_plateau_api の #39、および取り込みを元データに忠実にする一連の変更 + +## 背景 + +API が、中庭のある建物を `type=multipolygon` の relation として返すようになった。 +`outer` メンバーが外形、`inner` メンバーが穴で、タグは relation にだけ付く。 + +クライアント側の conflation はこの形を知らない。 +`_filterPlateauOverlaps` は Phase 4-A で `type=building` の relation を 1 つの意味単位として扱い、 +outline way の判定結果をメンバー全員に適用している。 +「親の外形が消えて部分だけ宙に浮く」不整合を防ぐためである。 + +`type=multipolygon` はこの仕組みに入らない。 +`PlateauService.js:505` の `e.tags?.type !== 'building'` で除外されるので、 +メンバー way は個別に `_checkWayOverlapsOsmBuildings` にかけられる。 + +## 現状の壊れ方 + +判定はジオメトリだけを見てタグを見ない。 +multipolygon のメンバー way はタグを持たないので、**穴が単独の建物として重なり判定にかけられる**。 + +| 状況 | 結果 | +|---|---| +| `outer` が reject(その建物は既に OSM にある) | relation は残るがメンバー参照が切れる | +| `outer` は残り `inner` が reject | relation が存在しない `inner` を参照する。中庭が失われる | +| `outer` が reject、`inner` は残る | タグの無い環が親を失って単独で浮く | + +既存の 1,489 万行では起きない。すべて `ring_id` が 0 で、multipolygon として出力されないためである。 +最初の 1 都市を再取り込みした時点で出る。 + +## 設計 + +### 1. multipolygon を意味単位として扱う + +`type=building` と同じ扱いにする。 +relation の判定を 1 度だけ計算し、メンバー way 全員がそれに従う。 + +違いは役割名だけである。 +`type=building` は外形を `outline` で持ち、`type=multipolygon` は `outer` で持つ。 +判定の根拠にする「外形のメンバー」を引く箇所で、両方の役割名を受け付ける。 + +`inner` メンバーが個別に判定されることは無くなる。 +タグの無い環を建物として扱う経路が消える。 + +### 2. メンバーが隠れる relation は relation 自身も隠す + +現在の filter は relation を無条件に通す(`if (entity.type === 'relation') return true;`)。 +そのため外形が reject されると、メンバーがすべて消えた relation がグラフに残る。 + +規則は relation 自身の判定に置く。 + +| relation の判定 | 扱い | +|---|---| +| `true`(重なる) | relation も落とす | +| `false` | 残す | +| `null`(判定できない) | 残す | + +`null` は外形のメンバーがグラフに無い、way が閉じていない、座標が足りない場合に返る。 +way 側のフォールバックと同じく、判定できないものは隠さない。 + +これは `type=building` にも同じ 1 つの規則で効く。 +外形が reject されたときに空の relation が残る既存の挙動も解消する。 + +### 3. 生存メンバーを数える方式は採らない + +`getData` は `ds.tree.intersects(extent, ds.graph)` の結果に filter をかける。 +渡ってくるのはタイル単位のバッチではなく、**表示範囲で切り取った空間のスライス**である。 + +したがって、メンバーが一覧に無いことと、そのメンバーが reject されたことは別である。 +範囲外にあるだけのメンバーは単に一覧に含まれない。 + +生存数を数える書き方にすると、パンして relation が範囲の端にかかった時点で +「メンバーが 0 件」と見えて落ちる。 +建物は OSM に無いのに消えるという、逆向きの誤りになる。 + +relation 自身の判定を使えば、一覧に何件メンバーが居るかに依存しない。 +判定は既に `evalRelationOverlap` が計算しており、way 側もそれに従っている。 + +## 対象外 + +**relation のジオメトリで重なりを判定すること。** +外形のメンバー way の形で判定する現在の方式を踏襲する。 +穴を除いた実面積で判定するほうが厳密だが、`type=building` の既存の判定と食い違う形を新たに作ることになる。 +判定の精度そのものは別の論点として切り離す。 + +**`inner` が外形からはみ出している場合の扱い。** +取り込み側が形状の妥当性を検査していないので理論上ありうるが、 +conflation の判定は外形だけを見るので影響しない。 + +**形状置換機能(#5)との関係。** +`feature/plateau-geometry-replacement` は未マージで、この変更とは独立している。 + +## 検証 + +- `type=multipolygon` の `outer` が OSM 建物と重なるとき、`outer` と `inner` と relation がすべて隠れる +- 重ならないとき、3 つとも残る +- `inner` が単独で判定されない(`inner` だけが隠れる状態が作れない) +- 外形のメンバーがグラフに無いとき、relation も way も隠れない +- `type=building` の outline が reject されたとき、relation も隠れる +- `type=building` の既存の挙動(outline の判定にメンバーが従う)が変わらない From 2626007f541cd1fdcf396d8372fd782bae7257cb Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 5 Aug 2026 09:42:33 +0900 Subject: [PATCH 02/21] =?UTF-8?q?docs:=20conflation=20=E3=82=92=20type=3Dm?= =?UTF-8?q?ultipolygon=20=E3=81=AB=E5=AF=BE=E5=BF=9C=E3=81=95=E3=81=9B?= =?UTF-8?q?=E3=82=8B=E5=AE=9F=E8=A3=85=E8=A8=88=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-08-05-plateau-multipolygon-conflation.md | 383 ++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-05-plateau-multipolygon-conflation.md diff --git a/docs/superpowers/plans/2026-08-05-plateau-multipolygon-conflation.md b/docs/superpowers/plans/2026-08-05-plateau-multipolygon-conflation.md new file mode 100644 index 000000000..315e71ba0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-plateau-multipolygon-conflation.md @@ -0,0 +1,383 @@ +# conflation を type=multipolygon に対応させる 実装計画 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `type=multipolygon` の relation を conflation の意味単位として扱い、外形が既存 OSM 建物と重なるときは relation とメンバー way をまとめて隠す。 + +**Architecture:** `_filterPlateauOverlaps` の Phase 4-A の仕組み(外形 way の判定を relation のメンバー全員に適用する)を `type=multipolygon` にも広げる。判定の根拠は relation 自身の決定に置き、一覧に含まれるメンバーの数を数えない。 + +**Tech Stack:** JavaScript (ESM), Karma + Mocha + Chai, Polyclip + +## Global Constraints + +- `type=multipolygon` は外形を役割 `outer` で持ち、`type=building` は `outline` で持つ。両方を外形として受け付ける。 +- `inner` メンバーは単独で重なり判定にかけない。外形の判定に従う。 +- relation を隠すのは、その relation 自身の判定が `true`(重なる)のときだけ。`false` と `null` では隠さない。 +- **一覧に残っているメンバーの数を数えて判定しない。** `getData` は `ds.tree.intersects(extent, ds.graph)` の結果に filter をかけるため、渡ってくるのは表示範囲で切り取った空間のスライスである。メンバーが一覧に無いことと reject されたことは別で、数える方式にするとパンした瞬間に建物が消える。 +- 追跡対象でない relation(`type=route` など)は従来どおり素通しする。そのメンバー way も個別判定のままにする。 +- `_checkWayOverlapsOsmBuildings` の判定ロジックは変えない。判定の精度そのものは本計画の対象外。 +- relation のジオメトリ(穴を除いた実面積)で重なりを判定しない。外形メンバー way の形で判定する現在の方式を踏襲する。 +- `inner` が外形からはみ出している場合の妥当性検査はしない。conflation は外形だけを見る。 +- 形状置換機能(#5、`feature/plateau-geometry-replacement`)には触れない。未マージで独立している。 +- 変更するのは `modules/services/PlateauService.js` と `test/browser/services/PlateauService.test.js` のみ。 +- spec: `docs/superpowers/specs/2026-08-05-plateau-multipolygon-conflation-design.ja.md` + +## テストの実行 + +```bash +npm run test:browser +``` + +Karma がブラウザを起動して全ブラウザテストを走らせる。個別ファイルだけを走らせる仕組みは無いので、 +反復中も全体を回す。所要は 1 分程度。 + +--- + +### Task 1: multipolygon を conflation の意味単位として扱う + +**Files:** +- Modify: `modules/services/PlateauService.js:500-516`(relation マップの構築) +- Test: `test/browser/services/PlateauService.test.js` + +**Interfaces:** +- Produces: `wayToBuildingRelation` が `type=multipolygon` のメンバー way も含む +- Produces: `buildingRelationOutline` が役割 `outline` と `outer` の両方を外形として記録する +- 本タスクでは relation 自身の扱いを変えない。relation は従来どおり素通しする(Task 2 で変える)。 + +- [ ] **Step 1: 失敗するテストを書く** + +`test/browser/services/PlateauService.test.js` の `#_filterPlateauOverlaps` ブロック内、 +`non-building relation members are evaluated per-way` の後ろに追加する。 +ヘルパーは同ブロック内の `makeBuilding` / `makePlateauWay` を使う。 + +```javascript + // type=multipolygon: 中庭のある建物。outer が外形、inner が穴。 + // タグは relation にだけ付き、メンバー way はタグを持たない。 + // ---------------------------------------------------------------------- + + function makeMultipolygon(plateauGraph, relId, outerId, innerIds, outerCoords, innerCoordsArr) { + let g = plateauGraph; + const outRes = makePlateauWay(g, outerId, outerCoords); + g = outRes.graph; + const inners = []; + for (let i = 0; i < innerIds.length; i++) { + const iRes = makePlateauWay(g, innerIds[i], innerCoordsArr[i]); + g = iRes.graph; + inners.push(iRes.way); + } + const members = [{ id: outRes.way.id, type: 'way', role: 'outer' }]; + for (const inner of inners) members.push({ id: inner.id, type: 'way', role: 'inner' }); + const relation = Rapid.osmRelation({ + id: relId, + tags: { type: 'multipolygon', building: 'yes' }, + members: members, + }); + g = g.replace(relation); + return { graph: g, outer: outRes.way, inners, relation }; + } + + it('rejects outer and inner together when the outer overlaps an OSM building', () => { + let osmGraph = new Rapid.Graph(); + const osmRes = makeBuilding(osmGraph, 'osmB1', [[0,0], [1,0], [1,1], [0,1]]); + _service.context.systems.editor._graph = osmRes.graph; + _service.context.systems.editor._entities = [osmRes.way]; + + let plateauGraph = new Rapid.Graph(); + const mp = makeMultipolygon( + plateauGraph, 'r_mp1', 'pOuter1', ['pInner1'], + [[0.5,0.5], [1.5,0.5], [1.5,1.5], [0.5,1.5]], // outer が OSM と重なる + [[[1.2,1.2], [1.4,1.2], [1.4,1.4], [1.2,1.4]]], // inner は OSM の bbox 外 + ); + plateauGraph = mp.graph; + + const entities = [mp.outer, mp.inners[0], mp.relation]; + const result = _service._filterPlateauOverlaps(entities, plateauGraph); + + const wayIds = result.filter(e => e.type === 'way').map(e => e.id); + expect(wayIds).to.have.lengthOf(0, 'outer と inner はまとめて隠れる'); + }); + + it('keeps outer and inner together when the outer does not overlap', () => { + let osmGraph = new Rapid.Graph(); + const osmRes = makeBuilding(osmGraph, 'osmB1', [[0,0], [1,0], [1,1], [0,1]]); + _service.context.systems.editor._graph = osmRes.graph; + _service.context.systems.editor._entities = [osmRes.way]; + + let plateauGraph = new Rapid.Graph(); + const mp = makeMultipolygon( + plateauGraph, 'r_mp2', 'pOuter2', ['pInner2'], + [[10,10], [11,10], [11,11], [10,11]], + [[[10.4,10.4], [10.6,10.4], [10.6,10.6], [10.4,10.6]]], + ); + plateauGraph = mp.graph; + + const entities = [mp.outer, mp.inners[0], mp.relation]; + const result = _service._filterPlateauOverlaps(entities, plateauGraph); + + const wayIds = result.filter(e => e.type === 'way').map(e => e.id); + expect(wayIds).to.include('pOuter2'); + expect(wayIds).to.include('pInner2'); + }); + + it('does not judge an inner ring on its own', () => { + // inner だけを OSM 建物に重ねる。個別判定なら inner が reject される配置。 + // outer は OSM から離れているので、意味単位で扱えば inner も残る。 + // + // ジオメトリとしては inner が outer の外に出るが、conflation は外形だけを + // 見るので判定には影響しない。outer が OSM 建物を含む配置にすると outer 自身も + // 重なり判定に引っかかり、このテストの主張が検証できなくなる。 + let osmGraph = new Rapid.Graph(); + const osmRes = makeBuilding(osmGraph, 'osmB2', [[20.4,20.4], [20.6,20.4], [20.6,20.6], [20.4,20.6]]); + _service.context.systems.editor._graph = osmRes.graph; + _service.context.systems.editor._entities = [osmRes.way]; + + let plateauGraph = new Rapid.Graph(); + const mp = makeMultipolygon( + plateauGraph, 'r_mp3', 'pOuter3', ['pInner3'], + [[30,30], [31,30], [31,31], [30,31]], // outer は OSM から離す + [[[20.4,20.4], [20.6,20.4], [20.6,20.6], [20.4,20.6]]], // inner は OSM に重なる + ); + plateauGraph = mp.graph; + + const entities = [mp.outer, mp.inners[0], mp.relation]; + const result = _service._filterPlateauOverlaps(entities, plateauGraph); + + const wayIds = result.filter(e => e.type === 'way').map(e => e.id); + expect(wayIds).to.include('pInner3', 'inner が単独で判定されている'); + }); +``` + +- [ ] **Step 2: テストが落ちることを確認する** + +Run: `npm run test:browser` +Expected: 3 件のうち `rejects outer and inner together...` と `does not judge an inner ring on its own` が FAIL。 +`keeps outer and inner together...` は PASS(もともと誰も reject しないため)。 + +- [ ] **Step 3: relation マップの構築を広げる** + +`modules/services/PlateauService.js` の `wayToBuildingRelation` を組み立てるループを差し替える。 + +```javascript + // way_id → building relation のマップ + relation_id → 外形 way_id を記録 + // + // 対象は 2 種類ある。 + // type=building は PLATEAU LOD2 の outline + parts で、外形の役割は 'outline'。 + // type=multipolygon は中庭のある建物で、外形の役割は 'outer'、穴が 'inner'。 + // どちらも「1 棟の建物」なので、外形の判定にメンバー全員が従う。 + // + // multipolygon のメンバー way はタグを持たないため、個別に判定すると穴が + // 単独の建物として扱われる。ここでまとめて拾うことでその経路を塞ぐ。 + const wayToBuildingRelation = new Map(); + const buildingRelationOutline = new Map(); + for (const e of entities) { + if (e.type !== 'relation') continue; + const relType = e.tags?.type; + if (relType !== 'building' && relType !== 'multipolygon') continue; + let outlineWayId; + for (const m of e.members ?? []) { + if (m.type !== 'way') continue; + if (!wayToBuildingRelation.has(m.id)) { + wayToBuildingRelation.set(m.id, e); + } + if ((m.role === 'outline' || m.role === 'outer') && outlineWayId === undefined) { + outlineWayId = m.id; + } + } + buildingRelationOutline.set(e.id, outlineWayId); + } +``` + +- [ ] **Step 4: テストが通ることを確認する** + +Run: `npm run test:browser` +Expected: 追加した 3 件が PASS。既存のテストも全件 PASS。 + +- [ ] **Step 5: 意味単位の扱いが本当に効いていることを確かめる** + +`relType !== 'building' && relType !== 'multipolygon'` を一時的に `relType !== 'building'` に戻し、 +`does not judge an inner ring on its own` が落ちることを確認してから戻す。 +戻したあと `git diff` が想定どおりであることを確認する。 + +- [ ] **Step 6: コミット** + +```bash +git add modules/services/PlateauService.js test/browser/services/PlateauService.test.js +git commit -m "fix(plateau): treat type=multipolygon as one conflation unit" +``` + +--- + +### Task 2: メンバーが隠れる relation は relation 自身も隠す + +**Files:** +- Modify: `modules/services/PlateauService.js:540-546`(filter の relation 分岐) +- Test: `test/browser/services/PlateauService.test.js`(既存 1 件の期待値修正を含む) + +**Interfaces:** +- Consumes: `buildingRelationOutline`(relation id → 外形 way id。Task 1 で `outline` と `outer` の両方を拾うようになっている。追跡対象でない relation はキーごと存在しない) +- Consumes: `evalRelationOverlap(relation)`(`true` / `false` / `null` を返す。結果は `relationOverlapDecision` にメモ化される) +- Consumes: テストヘルパー `makeMultipolygon(plateauGraph, relId, outerId, innerIds, outerCoords, innerCoordsArr)`。Task 1 が `test/browser/services/PlateauService.test.js` の `#_filterPlateauOverlaps` ブロックに追加済みで、`{ graph, outer, inners, relation }` を返す。 +- Produces: 追跡対象の relation は、自身の判定が `true` のとき filter から落ちる。`false` と `null` では残る。 +- Produces: 追跡対象でない relation(`type=route` など)は従来どおり素通しする。 + +- [ ] **Step 1: 失敗するテストを書く** + +Task 1 で追加したテストの後ろに追加する。 + +```javascript + it('drops a multipolygon relation when its outer overlaps', () => { + let osmGraph = new Rapid.Graph(); + const osmRes = makeBuilding(osmGraph, 'osmB1', [[0,0], [1,0], [1,1], [0,1]]); + _service.context.systems.editor._graph = osmRes.graph; + _service.context.systems.editor._entities = [osmRes.way]; + + let plateauGraph = new Rapid.Graph(); + const mp = makeMultipolygon( + plateauGraph, 'r_mp4', 'pOuter4', ['pInner4'], + [[0.5,0.5], [1.5,0.5], [1.5,1.5], [0.5,1.5]], + [[[1.2,1.2], [1.4,1.2], [1.4,1.4], [1.2,1.4]]], + ); + plateauGraph = mp.graph; + + const result = _service._filterPlateauOverlaps( + [mp.outer, mp.inners[0], mp.relation], plateauGraph + ); + expect(result.filter(e => e.type === 'relation')).to.have.lengthOf(0); + }); + + it('keeps a multipolygon relation when its outer does not overlap', () => { + let osmGraph = new Rapid.Graph(); + const osmRes = makeBuilding(osmGraph, 'osmB1', [[0,0], [1,0], [1,1], [0,1]]); + _service.context.systems.editor._graph = osmRes.graph; + _service.context.systems.editor._entities = [osmRes.way]; + + let plateauGraph = new Rapid.Graph(); + const mp = makeMultipolygon( + plateauGraph, 'r_mp5', 'pOuter5', ['pInner5'], + [[10,10], [11,10], [11,11], [10,11]], + [[[10.4,10.4], [10.6,10.4], [10.6,10.6], [10.4,10.6]]], + ); + plateauGraph = mp.graph; + + const result = _service._filterPlateauOverlaps( + [mp.outer, mp.inners[0], mp.relation], plateauGraph + ); + expect(result.filter(e => e.type === 'relation')).to.have.lengthOf(1); + }); + + it('keeps a relation whose outer way is not in the graph', () => { + // 判定できない (null) ときは隠さない。way 側のフォールバックと同じ。 + let osmGraph = new Rapid.Graph(); + const osmRes = makeBuilding(osmGraph, 'osmB1', [[0,0], [1,0], [1,1], [0,1]]); + _service.context.systems.editor._graph = osmRes.graph; + _service.context.systems.editor._entities = [osmRes.way]; + + const relation = Rapid.osmRelation({ + id: 'r_mp_missing', + tags: { type: 'multipolygon', building: 'yes' }, + members: [{ id: 'pOuterMissing', type: 'way', role: 'outer' }], + }); + const g = new Rapid.Graph().replace(relation); + + const result = _service._filterPlateauOverlaps([relation], g); + expect(result.filter(e => e.type === 'relation')).to.have.lengthOf(1); + }); + + it('keeps a non-building relation regardless of its members', () => { + let osmGraph = new Rapid.Graph(); + const osmRes = makeBuilding(osmGraph, 'osmB1', [[0,0], [1,0], [1,1], [0,1]]); + _service.context.systems.editor._graph = osmRes.graph; + _service.context.systems.editor._entities = [osmRes.way]; + + let g = new Rapid.Graph(); + const w1 = makePlateauWay(g, 'pRouteWay2', [[0.5,0.5], [1.5,0.5], [1.5,1.5], [0.5,1.5]]); + g = w1.graph; + const routeRel = Rapid.osmRelation({ + id: 'r_route2', + tags: { type: 'route', route: 'bus' }, + members: [{ id: w1.way.id, type: 'way', role: '' }], + }); + g = g.replace(routeRel); + + const result = _service._filterPlateauOverlaps([w1.way, routeRel], g); + expect(result.filter(e => e.type === 'relation')).to.have.lengthOf(1); + expect(result.filter(e => e.type === 'way')).to.have.lengthOf(0); + }); +``` + +- [ ] **Step 2: 既存テストの期待値を直す** + +`rejects all relation members when outline overlaps OSM building` は、 +relation が残ることを固定している。この挙動を変えるので期待値を直す。 + +```javascript + // outline + parts は relation のおかげで一括 reject される + const wayResults = result.filter(e => e.type === 'way'); + expect(wayResults).to.have.lengthOf(0); + // relation 自身も隠す。メンバーが全部消えた relation を残さない。 + const relResults = result.filter(e => e.type === 'relation'); + expect(relResults).to.have.lengthOf(0); +``` + +`falls back to per-way check when relation has no outline member` は、 +外形メンバーが無いので判定が `null` になり relation は残る。期待値の変更は要らない。 + +- [ ] **Step 3: テストが落ちることを確認する** + +Run: `npm run test:browser` +Expected: `drops a multipolygon relation when its outer overlaps` と、 +Step 2 で直した `rejects all relation members...` が FAIL。他の 3 件は PASS。 + +- [ ] **Step 4: filter の relation 分岐を差し替える** + +`modules/services/PlateauService.js` の filter 冒頭を差し替える。 + +```javascript + return entities.filter(entity => { + if (entity.type === 'node') return true; + + if (entity.type === 'relation') { + // 追跡対象でない relation (type=route など) は素通しする。 + if (!buildingRelationOutline.has(entity.id)) return true; + // メンバーが隠れる relation は relation 自身も隠す。 + // 判定できない (null) ときは隠さない。way 側のフォールバックと同じ。 + // + // 一覧に残っているメンバーを数えないこと。getData は表示範囲で切り取った + // スライスに filter をかけるので、範囲外のメンバーは単に一覧に含まれない。 + // 数える方式にすると、パンして relation が範囲の端にかかった時点で + // 「メンバー 0 件」と見えて、OSM に無い建物まで消える。 + return evalRelationOverlap(entity) !== true; + } + + if (entity.type !== 'way') return true; +``` + +- [ ] **Step 5: テストが通ることを確認する** + +Run: `npm run test:browser` +Expected: 追加した 4 件と直した 1 件が PASS。既存のテストも全件 PASS。 + +- [ ] **Step 6: 数える方式にしていないことを確かめる** + +`return evalRelationOverlap(entity) !== true;` を、 +一覧に残るメンバー数を数える書き方に一時的に置き換える。 + +```javascript + const survivors = entities.filter(e => + e.type === 'way' && (entity.members ?? []).some(m => m.id === e.id) + ); + return survivors.length > 0; +``` + +`keeps a relation whose outer way is not in the graph` が落ちることを確認してから戻す。 +この配置では relation だけを渡していてメンバー way が一覧に無いため、数える方式では 0 件と見えて落ちる。 +これが「範囲外のメンバーを reject と取り違える」失敗の最小形である。 + +戻したあと `git diff` が想定どおりであることを確認する。 + +- [ ] **Step 7: コミット** + +```bash +git add modules/services/PlateauService.js test/browser/services/PlateauService.test.js +git commit -m "fix(plateau): hide the relation when its building is already mapped" +``` From c1cd3ee5a2b180144fbf2fa177cc2258c8507cc7 Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 5 Aug 2026 10:53:54 +0900 Subject: [PATCH 03/21] fix(plateau): treat type=multipolygon as one conflation unit --- modules/services/PlateauService.js | 15 ++- test/browser/services/PlateauService.test.js | 97 ++++++++++++++++++++ 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/modules/services/PlateauService.js b/modules/services/PlateauService.js index 60da3e2f2..322170452 100644 --- a/modules/services/PlateauService.js +++ b/modules/services/PlateauService.js @@ -497,19 +497,28 @@ export class PlateauService extends AbstractSystem { if (osmBuildingData.length === 0) return entities; - // way_id → building relation のマップ + relation_id → outline way_id を記録 + // way_id → building relation のマップ + relation_id → 外形 way_id を記録 + // + // 対象は 2 種類ある。 + // type=building は PLATEAU LOD2 の outline + parts で、外形の役割は 'outline'。 + // type=multipolygon は中庭のある建物で、外形の役割は 'outer'、穴が 'inner'。 + // どちらも「1 棟の建物」なので、外形の判定にメンバー全員が従う。 + // + // multipolygon のメンバー way はタグを持たないため、個別に判定すると穴が + // 単独の建物として扱われる。ここでまとめて拾うことでその経路を塞ぐ。 const wayToBuildingRelation = new Map(); const buildingRelationOutline = new Map(); for (const e of entities) { if (e.type !== 'relation') continue; - if (e.tags?.type !== 'building') continue; + const relType = e.tags?.type; + if (relType !== 'building' && relType !== 'multipolygon') continue; let outlineWayId; for (const m of e.members ?? []) { if (m.type !== 'way') continue; if (!wayToBuildingRelation.has(m.id)) { wayToBuildingRelation.set(m.id, e); } - if (m.role === 'outline' && outlineWayId === undefined) { + if ((m.role === 'outline' || m.role === 'outer') && outlineWayId === undefined) { outlineWayId = m.id; } } diff --git a/test/browser/services/PlateauService.test.js b/test/browser/services/PlateauService.test.js index 4f91fcf22..1154f3c29 100644 --- a/test/browser/services/PlateauService.test.js +++ b/test/browser/services/PlateauService.test.js @@ -321,6 +321,103 @@ describe('PlateauService', () => { const wayResults = result.filter(e => e.type === 'way'); expect(wayResults).to.have.lengthOf(0); }); + + + // ---------------------------------------------------------------------- + // type=multipolygon: 中庭のある建物。outer が外形、inner が穴。 + // タグは relation にだけ付き、メンバー way はタグを持たない。 + // ---------------------------------------------------------------------- + + function makeMultipolygon(plateauGraph, relId, outerId, innerIds, outerCoords, innerCoordsArr) { + let g = plateauGraph; + const outRes = makePlateauWay(g, outerId, outerCoords); + g = outRes.graph; + const inners = []; + for (let i = 0; i < innerIds.length; i++) { + const iRes = makePlateauWay(g, innerIds[i], innerCoordsArr[i]); + g = iRes.graph; + inners.push(iRes.way); + } + const members = [{ id: outRes.way.id, type: 'way', role: 'outer' }]; + for (const inner of inners) members.push({ id: inner.id, type: 'way', role: 'inner' }); + const relation = Rapid.osmRelation({ + id: relId, + tags: { type: 'multipolygon', building: 'yes' }, + members: members, + }); + g = g.replace(relation); + return { graph: g, outer: outRes.way, inners, relation }; + } + + it('rejects outer and inner together when the outer overlaps an OSM building', () => { + let osmGraph = new Rapid.Graph(); + const osmRes = makeBuilding(osmGraph, 'osmB1', [[0,0], [1,0], [1,1], [0,1]]); + _service.context.systems.editor._graph = osmRes.graph; + _service.context.systems.editor._entities = [osmRes.way]; + + let plateauGraph = new Rapid.Graph(); + const mp = makeMultipolygon( + plateauGraph, 'r_mp1', 'pOuter1', ['pInner1'], + [[0.5,0.5], [1.5,0.5], [1.5,1.5], [0.5,1.5]], // outer が OSM と重なる + [[[1.2,1.2], [1.4,1.2], [1.4,1.4], [1.2,1.4]]], // inner は OSM の bbox 外 + ); + plateauGraph = mp.graph; + + const entities = [mp.outer, mp.inners[0], mp.relation]; + const result = _service._filterPlateauOverlaps(entities, plateauGraph); + + const wayIds = result.filter(e => e.type === 'way').map(e => e.id); + expect(wayIds).to.have.lengthOf(0, 'outer と inner はまとめて隠れる'); + }); + + it('keeps outer and inner together when the outer does not overlap', () => { + let osmGraph = new Rapid.Graph(); + const osmRes = makeBuilding(osmGraph, 'osmB1', [[0,0], [1,0], [1,1], [0,1]]); + _service.context.systems.editor._graph = osmRes.graph; + _service.context.systems.editor._entities = [osmRes.way]; + + let plateauGraph = new Rapid.Graph(); + const mp = makeMultipolygon( + plateauGraph, 'r_mp2', 'pOuter2', ['pInner2'], + [[10,10], [11,10], [11,11], [10,11]], + [[[10.4,10.4], [10.6,10.4], [10.6,10.6], [10.4,10.6]]], + ); + plateauGraph = mp.graph; + + const entities = [mp.outer, mp.inners[0], mp.relation]; + const result = _service._filterPlateauOverlaps(entities, plateauGraph); + + const wayIds = result.filter(e => e.type === 'way').map(e => e.id); + expect(wayIds).to.include('pOuter2'); + expect(wayIds).to.include('pInner2'); + }); + + it('does not judge an inner ring on its own', () => { + // inner だけを OSM 建物に重ねる。個別判定なら inner が reject される配置。 + // outer は OSM から離れているので、意味単位で扱えば inner も残る。 + // + // ジオメトリとしては inner が outer の外に出るが、conflation は外形だけを + // 見るので判定には影響しない。outer が OSM 建物を含む配置にすると outer 自身も + // 重なり判定に引っかかり、このテストの主張が検証できなくなる。 + let osmGraph = new Rapid.Graph(); + const osmRes = makeBuilding(osmGraph, 'osmB2', [[20.4,20.4], [20.6,20.4], [20.6,20.6], [20.4,20.6]]); + _service.context.systems.editor._graph = osmRes.graph; + _service.context.systems.editor._entities = [osmRes.way]; + + let plateauGraph = new Rapid.Graph(); + const mp = makeMultipolygon( + plateauGraph, 'r_mp3', 'pOuter3', ['pInner3'], + [[30,30], [31,30], [31,31], [30,31]], // outer は OSM から離す + [[[20.4,20.4], [20.6,20.4], [20.6,20.6], [20.4,20.6]]], // inner は OSM に重なる + ); + plateauGraph = mp.graph; + + const entities = [mp.outer, mp.inners[0], mp.relation]; + const result = _service._filterPlateauOverlaps(entities, plateauGraph); + + const wayIds = result.filter(e => e.type === 'way').map(e => e.id); + expect(wayIds).to.include('pInner3', 'inner が単独で判定されている'); + }); }); From 8917d2d28d630644df7ef4f6d91dae3691eb5a1e Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 5 Aug 2026 11:02:36 +0900 Subject: [PATCH 04/21] fix(plateau): hide the relation when its building is already mapped --- modules/services/PlateauService.js | 15 +++- test/browser/services/PlateauService.test.js | 83 +++++++++++++++++++- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/modules/services/PlateauService.js b/modules/services/PlateauService.js index 322170452..48b08c72b 100644 --- a/modules/services/PlateauService.js +++ b/modules/services/PlateauService.js @@ -548,7 +548,20 @@ export class PlateauService extends AbstractSystem { return entities.filter(entity => { if (entity.type === 'node') return true; - if (entity.type === 'relation') return true; + + if (entity.type === 'relation') { + // 追跡対象でない relation (type=route など) は素通しする。 + if (!buildingRelationOutline.has(entity.id)) return true; + // メンバーが隠れる relation は relation 自身も隠す。 + // 判定できない (null) ときは隠さない。way 側のフォールバックと同じ。 + // + // 一覧に残っているメンバーを数えないこと。getData は表示範囲で切り取った + // スライスに filter をかけるので、範囲外のメンバーは単に一覧に含まれない。 + // 数える方式にすると、パンして relation が範囲の端にかかった時点で + // 「メンバー 0 件」と見えて、OSM に無い建物まで消える。 + return evalRelationOverlap(entity) !== true; + } + if (entity.type !== 'way') return true; if (cache.rejected.has(entity.id)) return false; diff --git a/test/browser/services/PlateauService.test.js b/test/browser/services/PlateauService.test.js index 1154f3c29..b10c20501 100644 --- a/test/browser/services/PlateauService.test.js +++ b/test/browser/services/PlateauService.test.js @@ -238,9 +238,9 @@ describe('PlateauService', () => { // outline + parts は relation のおかげで一括 reject される const wayResults = result.filter(e => e.type === 'way'); expect(wayResults).to.have.lengthOf(0); - // relation 自体は filter 対象外なので残る + // relation 自身も隠す。メンバーが全部消えた relation を残さない。 const relResults = result.filter(e => e.type === 'relation'); - expect(relResults).to.have.lengthOf(1); + expect(relResults).to.have.lengthOf(0); }); it('keeps all relation members when outline does NOT overlap OSM building', () => { @@ -418,6 +418,85 @@ describe('PlateauService', () => { const wayIds = result.filter(e => e.type === 'way').map(e => e.id); expect(wayIds).to.include('pInner3', 'inner が単独で判定されている'); }); + + it('drops a multipolygon relation when its outer overlaps', () => { + let osmGraph = new Rapid.Graph(); + const osmRes = makeBuilding(osmGraph, 'osmB1', [[0,0], [1,0], [1,1], [0,1]]); + _service.context.systems.editor._graph = osmRes.graph; + _service.context.systems.editor._entities = [osmRes.way]; + + let plateauGraph = new Rapid.Graph(); + const mp = makeMultipolygon( + plateauGraph, 'r_mp4', 'pOuter4', ['pInner4'], + [[0.5,0.5], [1.5,0.5], [1.5,1.5], [0.5,1.5]], + [[[1.2,1.2], [1.4,1.2], [1.4,1.4], [1.2,1.4]]], + ); + plateauGraph = mp.graph; + + const result = _service._filterPlateauOverlaps( + [mp.outer, mp.inners[0], mp.relation], plateauGraph + ); + expect(result.filter(e => e.type === 'relation')).to.have.lengthOf(0); + }); + + it('keeps a multipolygon relation when its outer does not overlap', () => { + let osmGraph = new Rapid.Graph(); + const osmRes = makeBuilding(osmGraph, 'osmB1', [[0,0], [1,0], [1,1], [0,1]]); + _service.context.systems.editor._graph = osmRes.graph; + _service.context.systems.editor._entities = [osmRes.way]; + + let plateauGraph = new Rapid.Graph(); + const mp = makeMultipolygon( + plateauGraph, 'r_mp5', 'pOuter5', ['pInner5'], + [[10,10], [11,10], [11,11], [10,11]], + [[[10.4,10.4], [10.6,10.4], [10.6,10.6], [10.4,10.6]]], + ); + plateauGraph = mp.graph; + + const result = _service._filterPlateauOverlaps( + [mp.outer, mp.inners[0], mp.relation], plateauGraph + ); + expect(result.filter(e => e.type === 'relation')).to.have.lengthOf(1); + }); + + it('keeps a relation whose outer way is not in the graph', () => { + // 判定できない (null) ときは隠さない。way 側のフォールバックと同じ。 + let osmGraph = new Rapid.Graph(); + const osmRes = makeBuilding(osmGraph, 'osmB1', [[0,0], [1,0], [1,1], [0,1]]); + _service.context.systems.editor._graph = osmRes.graph; + _service.context.systems.editor._entities = [osmRes.way]; + + const relation = Rapid.osmRelation({ + id: 'r_mp_missing', + tags: { type: 'multipolygon', building: 'yes' }, + members: [{ id: 'pOuterMissing', type: 'way', role: 'outer' }], + }); + const g = new Rapid.Graph().replace(relation); + + const result = _service._filterPlateauOverlaps([relation], g); + expect(result.filter(e => e.type === 'relation')).to.have.lengthOf(1); + }); + + it('keeps a non-building relation regardless of its members', () => { + let osmGraph = new Rapid.Graph(); + const osmRes = makeBuilding(osmGraph, 'osmB1', [[0,0], [1,0], [1,1], [0,1]]); + _service.context.systems.editor._graph = osmRes.graph; + _service.context.systems.editor._entities = [osmRes.way]; + + let g = new Rapid.Graph(); + const w1 = makePlateauWay(g, 'pRouteWay2', [[0.5,0.5], [1.5,0.5], [1.5,1.5], [0.5,1.5]]); + g = w1.graph; + const routeRel = Rapid.osmRelation({ + id: 'r_route2', + tags: { type: 'route', route: 'bus' }, + members: [{ id: w1.way.id, type: 'way', role: '' }], + }); + g = g.replace(routeRel); + + const result = _service._filterPlateauOverlaps([w1.way, routeRel], g); + expect(result.filter(e => e.type === 'relation')).to.have.lengthOf(1); + expect(result.filter(e => e.type === 'way')).to.have.lengthOf(0); + }); }); From 65d382c33e97d0548e23814eb702423b390c8579 Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 5 Aug 2026 11:13:35 +0900 Subject: [PATCH 05/21] =?UTF-8?q?docs(plateau):=20=E6=9C=80=E7=B5=82?= =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E3=81=AE=E6=8C=87=E6=91=98?= =?UTF-8?q?=E3=82=92=E5=8F=8D=E6=98=A0=E3=80=81conflation=20=E3=81=AE?= =?UTF-8?q?=E5=A4=96=E3=81=AB=E6=AE=8B=E3=82=8B=202=20=E7=AE=87=E6=89=80?= =?UTF-8?q?=E3=82=92=E8=A8=98=E9=8C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ateau-multipolygon-conflation-design.ja.md | 28 ++++++++++++++++++- modules/services/PlateauService.js | 11 ++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-08-05-plateau-multipolygon-conflation-design.ja.md b/docs/superpowers/specs/2026-08-05-plateau-multipolygon-conflation-design.ja.md index fae17fcd4..346eedad7 100644 --- a/docs/superpowers/specs/2026-08-05-plateau-multipolygon-conflation-design.ja.md +++ b/docs/superpowers/specs/2026-08-05-plateau-multipolygon-conflation-design.ja.md @@ -48,7 +48,11 @@ relation の判定を 1 度だけ計算し、メンバー way 全員がそれに ### 2. メンバーが隠れる relation は relation 自身も隠す 現在の filter は relation を無条件に通す(`if (entity.type === 'relation') return true;`)。 -そのため外形が reject されると、メンバーがすべて消えた relation がグラフに残る。 +そのため外形が reject されると、メンバーがすべて消えた relation が呼び出し側に渡る。 + +**この filter は配列を絞り込むだけで `ds.graph` を変更しない。** +除外した way も relation もグラフには残り続ける。 +影響するのは `getData` の戻り値を受け取る側だけである。 規則は relation 自身の判定に置く。 @@ -93,6 +97,28 @@ conflation の判定は外形だけを見るので影響しない。 **形状置換機能(#5)との関係。** `feature/plateau-geometry-replacement` は未マージで、この変更とは独立している。 +## conflation だけでは足りない(実装後の最終レビューで判明) + +`type=multipolygon` を認識していない箇所が、conflation の他に 2 つある。 +どちらも本設計の対象外だが、**再取り込みの前に片付ける必要がある**。 + +**accept の cascade。** `modules/actions/rapid_accept_feature.js:260` は +`parent.tags.type === 'building'` の relation にしか cascade しない。 +multipolygon の外形をユーザが accept すると `acceptWay` に落ちる。 +タグは relation にしか無いので、**OSM に上がるのはタグの無い閉じた way になる**。 +中庭も `building` タグも失われる。 +conflation が隠しそこねるのは機会損失だが、こちらは壊れたデータの upload であり、より有害である。 + +**hover / select の兄弟 highlight。** `modules/util/building_relation.js:24` も +`type=building` しか返さない。外形をホバーしても穴が光らず、1 棟であることが UI から伝わらない。 + +**描画。** `modules/pixi/PixiLayerRapid.js:347` は way だけを拾って個別のポリゴンとして積む。 +穴が穴として描かれず、外形の上に小さな図形が重なって見える。 +conflation が穴を隠さなくなった分、再取り込み後に必ず表に出る。 + +なお同じ箇所の絞り込みにより、relation を返り値から外す変更は現時点で観測可能な効果を持たない。 +将来 relation を読む consumer が現れたときのための備えである。 + ## 検証 - `type=multipolygon` の `outer` が OSM 建物と重なるとき、`outer` と `inner` と relation がすべて隠れる diff --git a/modules/services/PlateauService.js b/modules/services/PlateauService.js index 48b08c72b..745c98252 100644 --- a/modules/services/PlateauService.js +++ b/modules/services/PlateauService.js @@ -451,6 +451,17 @@ export class PlateauService extends AbstractSystem { * outline と各 parts が個別に reject される結果として「親 outline が消えて parts だけ宙に浮く」 * 等のジオメトリ不整合を防ぐ。 * + * 中庭のある建物は type=multipolygon で届く。外形が role='outer'、穴が role='inner' で、 + * タグは relation にだけ付く。判定はジオメトリだけを見てタグを見ないので、個別に評価すると + * 穴が単独の建物として扱われる。type=building と同じ semantic 単位として扱ってこれを塞ぐ。 + * 外形の役割名だけが違う (outline と outer)。 + * + * 判定が true になった relation は、メンバー way だけでなく relation 自身も返り値から外す。 + * メンバーが全部隠れた relation を呼び出し側に渡さないためである。 + * + * この関数は配列を絞り込むだけで、`ds.graph` は変更しない。 + * 除外した way も relation もグラフには残り続ける。 + * * @param {Array} entities * @param {Graph} plateauGraph * @return {Array} Filtered entities From 36f4f0fe4ca93704a0888903a0794be71cc0b22a Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 5 Aug 2026 11:57:00 +0900 Subject: [PATCH 06/21] =?UTF-8?q?docs:=20multipolygon=20=E3=81=AE=20accept?= =?UTF-8?q?=E3=83=BBUI=E3=83=BB=E6=8F=8F=E7=94=BB=E3=82=92=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=E3=81=95=E3=81=9B=E3=82=8B=E8=A8=AD=E8=A8=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...au-multipolygon-accept-render-design.ja.md | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-05-plateau-multipolygon-accept-render-design.ja.md diff --git a/docs/superpowers/specs/2026-08-05-plateau-multipolygon-accept-render-design.ja.md b/docs/superpowers/specs/2026-08-05-plateau-multipolygon-accept-render-design.ja.md new file mode 100644 index 000000000..56fdde1d7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-plateau-multipolygon-accept-render-design.ja.md @@ -0,0 +1,137 @@ +# multipolygon の accept・UI・描画を対応させる設計 + +- 日付: 2026-08-05 +- 関連: `feature/plateau-multipolygon-conflation`(conflation 側、先行して実装済み)、rapid_plateau_api の #39 + +## 背景 + +API が、中庭のある建物を `type=multipolygon` の relation として返すようになった。 +`outer` が外形、`inner` が穴で、**タグは relation にだけ付き、メンバー way はタグを持たない**。 + +クライアント側にはこの形を前提にしていない箇所が 3 つある。 +conflation は先行して対応したが、それは 3 分の 1 にすぎなかった。 + +残る 2 つのうち、accept の経路は**壊れたデータを OSM に upload する**。 +描画は、ユーザが何を accept するのか判断できない状態を作る。 + +## 現状 + +| 箇所 | 現状 | 結果 | +|---|---|---| +| `modules/actions/rapid_accept_feature.js:260` | `parent.tags.type === 'building'` の relation にしか cascade しない | multipolygon の外形を accept すると `acceptWay` に落ちる。タグは relation にしか無いので、**OSM に上がるのはタグの無い閉じた way**。中庭も `building` タグも失われる | +| `modules/util/building_relation.js:24` | 同じく `type=building` のみ | インスペクタの文言が切り替わらず、hover / select の兄弟 highlight も効かない。外形をホバーしても穴が光らないので、1 棟であることが UI から伝わらない | +| `modules/pixi/PixiLayerRapid.js:347` | `entity.type === 'way'` で絞ってから個別のポリゴンとして積む | 穴が穴として描かれない。外形の上に小さな図形が重なり、**「建物の中に建物」に見える**。api#39 で消したはずの見た目が描画側で再現する | + +隠しそこねは機会損失だが、タグの無い way の upload は公開データベースを汚す。 +影響の重さが違う。 + +## 描画の機構は既にある + +`osmRelation.geometry()` は `isMultipolygon()`(`tags.type === 'multipolygon'`)のとき `'area'` を返す。 +`asGeoJSON()` は `MultiPolygon` を返し、`PixiFeaturePolygon` は `rings`(外側に続けて穴)を既に描ける。 +`PixiLayerRapid` も同じ `PixiFeaturePolygon` を使い、`geometry() === 'area'` で `data.polygons` に積んでいる。 + +塞いでいるのは `:347` の way 限定だけである。 +新しい描画機構は要らない。 + +## 設計 + +### 1. accept の cascade を multipolygon にも効かせる + +`rapid_accept_feature.js:260` の型判定に `multipolygon` を足す。 + +`acceptRelation` は relation の型を見ない汎用の処理で、relation を複製し、 +メンバーを再帰的に accept し、置き換えた member id で更新する。 +cascade に入りさえすれば、relation とメンバー way が揃って追加される。 + +### 2. 「この feature だけ追加」を multipolygon では出さない + +`type=building` ではメンバー way が自分のタグを持つ(outline は `building=yes`、part は `building:part=yes`)。 +だから 1 本だけ追加しても妥当な OSM になる。 + +`type=multipolygon` ではメンバー way がタグを持たない。 +outer だけ追加すればタグの無い way、inner だけ追加すればタグの無い環になる。 +**`skipCascade` を出すと、この設計で直そうとしている不具合をボタンの裏に残すことになる。** + +relation のタグを way にコピーする案は採らない。 +中庭を塗りつぶした建物を黙って作ることになり、サーバ側で直したばかりの誤りと同じ型である。 + +multipolygon のときは「建物全体を追加」だけを出す。 + +### 3. 兄弟 highlight とインスペクタ + +`utilBuildingRelationInfo` を `type=multipolygon` にも広げる。 + +役割名が違うので、カウントは両方の語彙を数える。 + +| relation | 外形の役割 | 内訳の役割 | +|---|---|---| +| `type=building` | `outline` | `part` | +| `type=multipolygon` | `outer` | `inner` | + +返り値に relation の種別を足し、呼び出し側が文言を選べるようにする。 + +文言も分ける。 +現在の "multi-section building (outline + parts)" は「区画に分かれた建物」の意味で、 +中庭のある建物には当てはまらない。穴の本数を伝える文言を別に用意する。 + +### 4. 描画 + +`PixiLayerRapid.js:347` の way 限定を外し、`type=multipolygon` の relation を `data.polygons` に流す。 + +**そのメンバー way は二重に描かない。** +relation が外形と穴をまとめて描くので、メンバー way を個別に積むと外形が二重になり、 +穴の上にも塗りが乗る。描画対象にした relation のメンバー way は除外する。 + +conflation で relation を返り値から外した変更は、ここで初めて観測可能になる。 +それまでは relation が描画に届いていなかった。 + +## 対象外(今回やらないこと) + +**`type=building` の relation の描画。** +outline と parts を個別のポリゴンとして描く現在の形を変えない。 +穴とは別の構造で、同時に触ると変更が大きくなる。 + +**`skipCascade` を multipolygon で安全に提供する方法。** +今回は出さないことで回避する。 +「穴を保ったまま 1 棟だけ追加する」を実現するには、relation ごと複製する別の操作が要る。 + +**conflation の判定精度。** +外形メンバー way の形で重なりを判定する現在の方式を踏襲する。 +穴を除いた実面積で判定するほうが厳密だが、`type=building` の既存の判定と食い違う形を新たに作ることになる。 + +## 次に実施しないといけないこと + +本設計の完了後も、再取り込みまでに片付ける必要があるものが残る。 + +**サーバ側の本番投入手順。** +`ring_id` の `ALTER TABLE` を API のデプロイより前に流す。 +API は `n.ring_id` を無条件に SELECT するため、順序を誤ると全 bbox クエリが 500 になる。 + +**API のデプロイをまたいだセッションのリロード周知。** +合成 OSM id の採番方式が変わったため、デプロイをまたいで開いたままのセッションでは +同じ建物が旧 id と新 id の両方で届き、二重に描かれうる。新規セッションでは起きない。 + +**捨てる way のタグが救えるかの実測。** +取り込み側は建物 ID を持たない建物 way を捨てる。10 メッシュ 386 本のうち 1 本は +`name=市立秋月小学校` を持っていた。融合でタグが移ったのなら、捨てた時点でその名前は DB に入らない。 +形状は正しくなるが、実在の学校が無名の建物として配信されうる。再取り込み前に一度測る。 + +**三角形の面積判定(api#44)の修正。** +面積の検算が度の二乗で行われ、閾値が緯度 34 度で約 10,190 m² に相当する。 +1 万 m² 未満の三角形の建物がすべて落ちる。 + +**`fix/api-drop-far-parts` の PR 作成と、本番サーバへの `git pull`。** + +## 検証 + +- multipolygon の外形を accept すると、relation とメンバー way が揃って追加される +- 追加された relation が `type=multipolygon` と `building` のタグを持つ +- 追加されたメンバー way がタグを持たない(タグは relation にある) +- multipolygon のメンバーを選択したとき「この feature だけ追加」が出ない +- `type=building` のメンバーを選択したときは従来どおり「この feature だけ追加」が出る +- `utilBuildingRelationInfo` が multipolygon で `outer` / `inner` を正しく数える +- multipolygon の外形をホバーすると穴も一緒に highlight される +- 穴のある建物が 1 つのポリゴンとして描かれ、穴の部分に塗りが乗らない +- その relation のメンバー way が個別のポリゴンとして重複して描かれない +- `type=building` の accept・UI・描画の挙動が変わらない From 9ec0c28760b6c7624f499adedd264fce815e82a0 Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 5 Aug 2026 14:05:52 +0900 Subject: [PATCH 07/21] =?UTF-8?q?docs:=20multipolygon=20=E3=81=AE=20accept?= =?UTF-8?q?=E3=83=BBUI=E3=83=BB=E6=8F=8F=E7=94=BB=E3=81=AE=E5=AE=9F?= =?UTF-8?q?=E8=A3=85=E8=A8=88=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...8-05-plateau-multipolygon-accept-render.md | 664 ++++++++++++++++++ 1 file changed, 664 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-05-plateau-multipolygon-accept-render.md diff --git a/docs/superpowers/plans/2026-08-05-plateau-multipolygon-accept-render.md b/docs/superpowers/plans/2026-08-05-plateau-multipolygon-accept-render.md new file mode 100644 index 000000000..c657862ba --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-plateau-multipolygon-accept-render.md @@ -0,0 +1,664 @@ +# multipolygon の accept・UI・描画 実装計画 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `type=multipolygon` の建物を accept したときに relation ごと追加され、UI が 1 棟であることを示し、穴が穴として描かれるようにする。 + +**Architecture:** 3 箇所の `type=building` 前提を広げる。accept の cascade 判定、`utilBuildingRelationInfo`、`PixiLayerRapid` の描画対象。描画機構は既にあるので、way 限定を外して relation を流し、メンバー way の二重描画を止める。 + +**Tech Stack:** JavaScript (ESM), node:test(unit), Karma + Mocha + Chai(browser), Pixi.js + +## Global Constraints + +- `type=multipolygon` は外形が役割 `outer`、穴が `inner`。`type=building` は `outline` と `part`。両方の語彙を扱う。 +- **`type=multipolygon` のメンバー way はタグを持たない。**タグは relation にだけ付く。 +- multipolygon では「この feature だけ追加」(`skipCascade`)を提示しない。メンバー way 単独では必ずタグ無しになる。 +- relation のタグをメンバー way にコピーしない。中庭を塗りつぶした建物を黙って作ることになる。 +- 描画対象にした relation のメンバー way は個別のポリゴンとして積まない。二重描画になる。 +- `type=building` の accept・UI・描画の挙動を変えない。 +- `type=building` の relation の描画方式(outline と parts を個別に描く)は変えない。 +- conflation の判定ロジック(`_filterPlateauOverlaps` / `_checkWayOverlapsOsmBuildings`)には触れない。 +- spec: `docs/superpowers/specs/2026-08-05-plateau-multipolygon-accept-render-design.ja.md` + +## テストの実行 + +```bash +npm run test:unit # node:test。速い。ビルド不要 +npm run test:browser # Karma。dist/rapid.js を読むので事前ビルドが必要 +npm run build:bundle:modern:dev # browser テストの前に必ず実行する +``` + +**Karma はプリビルドの `dist/rapid.js` を読み、`modules/` を直接見ない。** +ソースを変えたらビルドを挟まないとテスト結果が変わらない。 +ビルド成果物はコミットしない。 + +--- + +### Task 1: accept の cascade を multipolygon にも効かせる + +**Files:** +- Modify: `modules/actions/rapid_accept_feature.js:256-266`(cascade 検出) +- Test: `test/unit/actions/rapid_accept_feature.test.js` + +**Interfaces:** +- Produces: `type=multipolygon` の relation を親に持つ way を accept すると、`acceptRelation` に入り relation とメンバーが揃って追加される。 +- `acceptRelation` 自体は変更しない。relation の型を見ない汎用処理である。 + +- [ ] **Step 1: 失敗するテストを書く** + +`test/unit/actions/rapid_accept_feature.test.js` の末尾、`describe('actionRapidAcceptFeature', ...)` の中に追加する。 + +```javascript + describe('type=multipolygon (courtyard building)', () => { + // 中庭のある建物。outer が外形、inner が穴。 + // タグは relation にだけ付き、メンバー way はタグを持たない。 + function makeCourtyardGraph() { + const n1 = Rapid.osmNode({ id: 'n1', loc: [0, 0] }); + const n2 = Rapid.osmNode({ id: 'n2', loc: [1, 0] }); + const n3 = Rapid.osmNode({ id: 'n3', loc: [1, 1] }); + const n4 = Rapid.osmNode({ id: 'n4', loc: [0, 1] }); + const n5 = Rapid.osmNode({ id: 'n5', loc: [0.4, 0.4] }); + const n6 = Rapid.osmNode({ id: 'n6', loc: [0.6, 0.4] }); + const n7 = Rapid.osmNode({ id: 'n7', loc: [0.6, 0.6] }); + const n8 = Rapid.osmNode({ id: 'n8', loc: [0.4, 0.6] }); + const outer = Rapid.osmWay({ id: 'w_outer', nodes: ['n1','n2','n3','n4','n1'] }); + const inner = Rapid.osmWay({ id: 'w_inner', nodes: ['n5','n6','n7','n8','n5'] }); + const relation = Rapid.osmRelation({ + id: 'r_mp', + tags: { type: 'multipolygon', building: 'yes', height: '12' }, + members: [ + { id: outer.id, type: 'way', role: 'outer' }, + { id: inner.id, type: 'way', role: 'inner' } + ] + }); + const extGraph = new Rapid.Graph([n1,n2,n3,n4,n5,n6,n7,n8, outer, inner, relation]); + return { extGraph, outer, inner, relation }; + } + + it('accepts the whole relation when the outer way is clicked', () => { + const { extGraph, outer } = makeCourtyardGraph(); + const graph = Rapid.actionRapidAcceptFeature(outer.id, extGraph)(new Rapid.Graph()); + + assert.ok(graph.hasEntity('r_mp'), 'relation not in graph'); + assert.ok(graph.hasEntity('w_outer'), 'outer not in graph'); + assert.ok(graph.hasEntity('w_inner'), 'inner not in graph'); + }); + + it('accepts the whole relation when the inner way is clicked', () => { + const { extGraph, inner } = makeCourtyardGraph(); + const graph = Rapid.actionRapidAcceptFeature(inner.id, extGraph)(new Rapid.Graph()); + + assert.ok(graph.hasEntity('r_mp')); + assert.ok(graph.hasEntity('w_outer')); + assert.ok(graph.hasEntity('w_inner')); + }); + + it('keeps the tags on the relation and none on the member ways', () => { + const { extGraph, outer } = makeCourtyardGraph(); + const graph = Rapid.actionRapidAcceptFeature(outer.id, extGraph)(new Rapid.Graph()); + + const rel = graph.entity('r_mp'); + assert.equal(rel.tags.type, 'multipolygon'); + assert.equal(rel.tags.building, 'yes'); + assert.equal(rel.tags.height, '12'); + + // メンバー way にタグは足さない。relation にあるものをコピーしない。 + assert.equal(graph.entity('w_outer').tags.building, undefined); + assert.equal(graph.entity('w_inner').tags.building, undefined); + }); + + it('keeps the member roles', () => { + const { extGraph, outer } = makeCourtyardGraph(); + const graph = Rapid.actionRapidAcceptFeature(outer.id, extGraph)(new Rapid.Graph()); + + const roles = {}; + for (const m of graph.entity('r_mp').members) roles[m.role] = m.id; + assert.equal(roles.outer, 'w_outer'); + assert.equal(roles.inner, 'w_inner'); + }); + + it('adds only the clicked way when skipCascade is set', () => { + // action 自体は skipCascade を尊重する。UI 側が multipolygon で + // この選択肢を出さないことは Task 2 で担保する。 + const { extGraph, outer } = makeCourtyardGraph(); + const graph = Rapid.actionRapidAcceptFeature( + outer.id, extGraph, { skipCascade: true } + )(new Rapid.Graph()); + + assert.ok(graph.hasEntity('w_outer')); + assert.equal(graph.hasEntity('r_mp'), undefined); + }); + }); +``` + +- [ ] **Step 2: テストが落ちることを確認する** + +Run: `npm run test:unit` +Expected: `accepts the whole relation when the outer way is clicked` と +`accepts the whole relation when the inner way is clicked` と `keeps the member roles` が FAIL +(relation が graph に入らない)。 +`keeps the tags on the relation and none on the member ways` も relation が無いので FAIL。 +`adds only the clicked way when skipCascade is set` は PASS。 + +- [ ] **Step 3: cascade の型判定を広げる** + +`modules/actions/rapid_accept_feature.js` の cascade 検出を差し替える。 + +```javascript + if (!skipOuterCascade) { + var parents = extGraph.parentRelations(extWay); + for (var i = 0; i < parents.length; i++) { + var parent = parents[i]; + // type=building は PLATEAU LOD2 の outline + parts。 + // type=multipolygon は中庭のある建物で、外形が outer、穴が inner。 + // multipolygon のメンバー way はタグを持たず、タグは relation にしか無い。 + // cascade しないと acceptWay に落ちてタグの無い way が OSM に上がる。 + var parentType = parent.tags && parent.tags.type; + if ((parentType === 'building' || parentType === 'multipolygon') + && !seenRelations[parent.id] + && !inProgressRelations[parent.id]) { + return acceptRelation(parent); + } + } + } +``` + +- [ ] **Step 4: テストが通ることを確認する** + +Run: `npm run test:unit` +Expected: 追加した 5 件が PASS。既存のテストも全件 PASS。 + +- [ ] **Step 5: cascade が本当に効いていることを確かめる** + +`parentType === 'building' || parentType === 'multipolygon'` を一時的に +`parentType === 'building'` に戻し、`accepts the whole relation when the outer way is clicked` +が落ちることを確認してから戻す。 +戻したあと `git diff` が想定どおりであることを確認する。 + +- [ ] **Step 6: コミット** + +```bash +git add modules/actions/rapid_accept_feature.js test/unit/actions/rapid_accept_feature.test.js +git commit -m "fix(plateau): cascade accept through type=multipolygon relations" +``` + +--- + +### Task 2: 兄弟 highlight とインスペクタを multipolygon に対応させる + +**Files:** +- Modify: `modules/util/building_relation.js`(全面。34 行の小さなファイル) +- Modify: `modules/ui/UiRapidInspector.js:440-470`(選択肢の組み立て) +- Modify: `modules/ui/UiRapidInspector.js:505-515`(情報行の文言) +- Modify: `data/core.yaml:1139-1141`(文言の追加) +- Test: `test/unit/util/building_relation.test.js` + +**Interfaces:** +- Consumes: なし(Task 1 とは独立) +- Produces: `utilBuildingRelationInfo` の返り値が `{ relation, outlineCount, partCount, relationType }`。 + `relationType` は `'building'` または `'multipolygon'`。 + multipolygon では `outer` を `outlineCount`、`inner` を `partCount` に数える。 +- Produces: multipolygon のメンバーを選択したとき「この feature だけ追加」が選択肢に出ない。 + +**hover / select の兄弟 highlight は自動で直る。** +`modules/services/PlateauService.js:143` と `:198` が `utilBuildingRelationInfo` を直接呼び、 +返ってきた relation のメンバーに highlight を付けている。 +この関数を広げれば両方に効くので、**`PlateauService.js` は変更しない。** + +**UI の選択肢そのものにはテストが無い。** +`UiRapidInspector` は d3 の DOM 構築に密結合していて、既存のテストも無い。 +`utilBuildingRelationInfo` が `relationType` を返すところまでを unit テストで固定し、 +それを使う分岐は目視で確認する。テストで固定できないことを承知のうえで進める。 + +- [ ] **Step 1: 失敗するテストを書く** + +`test/unit/util/building_relation.test.js` の末尾に追加する。 + +```javascript + it('recognizes a type=multipolygon relation and counts outer/inner', () => { + const outer = Rapid.osmWay({ id: 'w_outer', nodes: [] }); + const inner1 = Rapid.osmWay({ id: 'w_inner1', nodes: [] }); + const inner2 = Rapid.osmWay({ id: 'w_inner2', nodes: [] }); + const relation = Rapid.osmRelation({ + id: 'r_mp', + tags: { type: 'multipolygon', building: 'yes' }, + members: [ + { id: 'w_outer', type: 'way', role: 'outer' }, + { id: 'w_inner1', type: 'way', role: 'inner' }, + { id: 'w_inner2', type: 'way', role: 'inner' } + ] + }); + const graph = new Rapid.Graph([outer, inner1, inner2, relation]); + + const info = Rapid.utilBuildingRelationInfo(outer, graph); + assert.ok(info, 'multipolygon が認識されていない'); + assert.equal(info.relationType, 'multipolygon'); + assert.equal(info.outlineCount, 1); + assert.equal(info.partCount, 2); + }); + + it('reports relationType for a type=building relation', () => { + const outline = Rapid.osmWay({ id: 'w_outline', nodes: [] }); + const part = Rapid.osmWay({ id: 'w_part', nodes: [] }); + const relation = Rapid.osmRelation({ + id: 'r_b', + tags: { type: 'building', building: 'yes' }, + members: [ + { id: 'w_outline', type: 'way', role: 'outline' }, + { id: 'w_part', type: 'way', role: 'part' } + ] + }); + const graph = new Rapid.Graph([outline, part, relation]); + + const info = Rapid.utilBuildingRelationInfo(outline, graph); + assert.equal(info.relationType, 'building'); + assert.equal(info.outlineCount, 1); + assert.equal(info.partCount, 1); + }); + + it('returns null for a multipolygon without a building tag', () => { + // 建物でない multipolygon (森林など) は対象外。 + const outer = Rapid.osmWay({ id: 'w_outer', nodes: [] }); + const relation = Rapid.osmRelation({ + id: 'r_forest', + tags: { type: 'multipolygon', landuse: 'forest' }, + members: [{ id: 'w_outer', type: 'way', role: 'outer' }] + }); + const graph = new Rapid.Graph([outer, relation]); + + assert.equal(Rapid.utilBuildingRelationInfo(outer, graph), null); + }); +``` + +- [ ] **Step 2: テストが落ちることを確認する** + +Run: `npm run test:unit` +Expected: `recognizes a type=multipolygon relation and counts outer/inner` が FAIL(null が返る)。 +`reports relationType for a type=building relation` も FAIL(`relationType` が undefined)。 +`returns null for a multipolygon without a building tag` は PASS(今は multipolygon 自体を見ていないため)。 + +- [ ] **Step 3: `utilBuildingRelationInfo` を広げる** + +`modules/util/building_relation.js` を差し替える。 + +```javascript +/** + * utilBuildingRelationInfo + * + * 指定 entity が「1 棟の建物」を表す relation のメンバー way である場合に、その relation + * 情報を返す。それ以外は null。 + * + * 対象は 2 種類ある。 + * type=building は Simple 3D Buildings / PLATEAU LOD2 の構造で、外形が役割 outline、 + * 内訳が part。メンバー way はそれぞれ自分のタグを持つ。 + * type=multipolygon は中庭のある建物で、外形が outer、穴が inner。 + * **タグは relation にだけ付き、メンバー way はタグを持たない。** + * + * UiRapidInspector や conflation ロジックから、UI 表示 / 動作判定の両方で使用。 + * + * @param {Object|null} entity - osmEntity (typically a way) + * @param {Graph|null} graph - 該当 entity の含まれる Graph (parentRelations を提供) + * @return {{relation: osmRelation, outlineCount: number, partCount: number, + * relationType: string} | null} + */ +export function utilBuildingRelationInfo(entity, graph) { + if (!entity || entity.type !== 'way') return null; + if (!graph || typeof graph.parentRelations !== 'function') return null; + + let parents; + try { + parents = graph.parentRelations(entity); + } catch (e) { + return null; + } + + const relation = parents.find(r => { + if (!r.tags) return false; + if (r.tags.type === 'building') return true; + // 建物でない multipolygon (森林など) は対象外。 + return r.tags.type === 'multipolygon' && !!r.tags.building; + }); + if (!relation) return null; + + const relationType = relation.tags.type; + // 役割名は relation の種別で変わる。外形と内訳を同じ 2 つの数に集約する。 + const outlineRole = (relationType === 'multipolygon') ? 'outer' : 'outline'; + const partRole = (relationType === 'multipolygon') ? 'inner' : 'part'; + + let outlineCount = 0; + let partCount = 0; + for (const m of relation.members || []) { + if (m.role === outlineRole) outlineCount++; + else if (m.role === partRole) partCount++; + } + return { relation, outlineCount, partCount, relationType }; +} +``` + +- [ ] **Step 4: テストが通ることを確認する** + +Run: `npm run test:unit` +Expected: 追加した 3 件が PASS。既存のテストも全件 PASS。 + +- [ ] **Step 5: 文言を足す** + +`data/core.yaml` の `multi_section_building_info` の直後に追加する。 +インデントは前後の行に合わせる(` ` 4 スペース)。 + +```yaml + courtyard_building_info: + one: This way is part of a building with {n} courtyard. + other: This way is part of a building with {n} courtyards. +``` + +`option_accept_entire_building` の下に、multipolygon 用の説明を足す。 + +```yaml + option_accept_entire_courtyard_building: + label: Add Entire Building + description: This way is part of a building with a courtyard. The tags live on the relation, so the outline, every courtyard ring, and the relation are added together. Adding a single ring on its own would produce an untagged way. + tooltip: Add the whole building, including its courtyards, to the map. +``` + +- [ ] **Step 6: インスペクタの選択肢を切り替える** + +`modules/ui/UiRapidInspector.js` の `renderChoices` で、 +`acceptLabelStringID` / `acceptReferenceStringID` を決めている箇所を差し替える。 + +```javascript + const buildingInfo = this._getBuildingRelationInfo(); + const isCourtyard = buildingInfo?.relationType === 'multipolygon'; + let acceptLabelStringID = 'rapid_inspector.option_accept.label'; + let acceptReferenceStringID = 'rapid_inspector.option_accept.description'; + if (isCourtyard) { + acceptLabelStringID = 'rapid_inspector.option_accept_entire_courtyard_building.label'; + acceptReferenceStringID = 'rapid_inspector.option_accept_entire_courtyard_building.description'; + } else if (buildingInfo) { + acceptLabelStringID = 'rapid_inspector.option_accept_entire_building.label'; + acceptReferenceStringID = 'rapid_inspector.option_accept_entire_building.description'; + } +``` + +「この feature だけ追加」を出す条件を差し替える。 + +```javascript + // Phase 4-C: relation member 時は「この feature だけ追加」 (cascade なし) を追加。 + // ただし multipolygon では出さない。メンバー way はタグを持たないので、 + // 1 本だけ追加すると必ずタグの無い way になる。 + if (buildingInfo && !isCourtyard) { +``` + +情報行の文言を差し替える。 + +```javascript + const $multiInfo = $choices.selectAll('.rapid-inspector-multi-section-building-info'); + if (buildingInfo) { + const partCount = buildingInfo.partCount; + const infoStringID = isCourtyard + ? 'rapid_inspector.courtyard_building_info' + : 'rapid_inspector.multi_section_building_info'; + $multiInfo + .style('display', null) + .text(l10n.t(infoStringID, { n: partCount })); + } else { +``` + +- [ ] **Step 7: 全テストを実行する** + +Run: `npm run test:unit` +Expected: 失敗なし + +Run: `npm run build:bundle:modern:dev && npm run test:browser` +Expected: 失敗なし + +- [ ] **Step 8: コミット** + +```bash +git add modules/util/building_relation.js modules/ui/UiRapidInspector.js data/core.yaml test/unit/util/building_relation.test.js +git commit -m "feat(plateau): treat courtyard buildings as one unit in the inspector" +``` + +--- + +### Task 3: 穴のある建物を 1 つのポリゴンとして描く + +**Files:** +- Modify: `modules/pixi/PixiLayerRapid.js:345-351`(Plateau の描画対象の絞り込み) +- Modify: `modules/index.js:25-26` の並びに 1 行追加(テストから参照するため) +- Test: `test/browser/pixi/PixiLayerRapid.test.js`(新規) + +**テストから参照できるようにする。** +`PixiLayerRapid` はどこからも export されていない。 +このフォークは Pixi レイヤをテストするために `modules/index.js` で個別に export しており +(`PixiLayerPlateauCoverage` と `PixiLayerHeightTransfer` の 2 行がある)、同じ形で 1 行足す。 + +```javascript +export { PixiLayerRapid } from './pixi/PixiLayerRapid.js'; +``` + +**Interfaces:** +- Consumes: `utilBuildingRelationInfo` は使わない。描画は relation のタグを直接見る。 +- Produces: `type=multipolygon` かつ `building` タグを持つ relation が `data.polygons` に入る。 +- Produces: その relation のメンバー way は `data.polygons` に入らない。 + +`osmRelation.geometry()` は `isMultipolygon()`(`tags.type === 'multipolygon'`)のとき `'area'` を返し、 +`PixiFeaturePolygon` は `rings`(外側に続けて穴)を既に描ける。 +新しい描画機構は要らない。 + +- [ ] **Step 1: 失敗するテストを書く** + +`test/browser/pixi/PixiLayerRapid.test.js` を新規作成する。 + +このタスクの検証は描画対象の選別なので、`renderPolygons` の描画結果ではなく +**`data.polygons` に何が入るか**を確かめる。そのため絞り込みロジックを +`PixiLayerRapid._plateauRenderables` に切り出し、テストからそれを呼ぶ。 + +`scene` のモックは `test/browser/pixi/PixiLayerPlateauCoverage.test.js` の `makeScene` と同じ形で足りる。 +`_plateauRenderables` は `scene` を使わないので、コンストラクタを通せればよい。 + +```javascript +describe('PixiLayerRapid', () => { + function makeScene() { + const gfx = { + scene: null, + deferredRedraw() {}, + immediateRedraw() {} + }; + const context = { services: {}, systems: { gfx: gfx } }; + const scene = { gfx: gfx, context: context, groups: new Map([['basemap', null]]) }; + gfx.scene = scene; + return scene; + } + + describe('#_plateauRenderables', () => { + function makeWay(graph, id, coords, tags) { + let g = graph; + const nodeIds = []; + for (let i = 0; i < coords.length; i++) { + const nodeId = id + '-n' + i; + nodeIds.push(nodeId); + g = g.replace(Rapid.osmNode({ id: nodeId, loc: coords[i] })); + } + nodeIds.push(nodeIds[0]); + const way = Rapid.osmWay({ id, nodes: nodeIds, tags: tags || {} }); + g = g.replace(way); + return { graph: g, way }; + } + + function makeCourtyard(graph) { + let g = graph; + const o = makeWay(g, 'w_outer', [[0,0], [1,0], [1,1], [0,1]]); + g = o.graph; + const i = makeWay(g, 'w_inner', [[0.4,0.4], [0.6,0.4], [0.6,0.6], [0.4,0.6]]); + g = i.graph; + const relation = Rapid.osmRelation({ + id: 'r_mp', + tags: { type: 'multipolygon', building: 'yes' }, + members: [ + { id: 'w_outer', type: 'way', role: 'outer' }, + { id: 'w_inner', type: 'way', role: 'inner' } + ] + }); + g = g.replace(relation); + return { graph: g, outer: o.way, inner: i.way, relation }; + } + + it('renders a courtyard relation as one polygon', () => { + const mp = makeCourtyard(new Rapid.Graph()); + const layer = new Rapid.PixiLayerRapid(makeScene(), 'rapid'); + const out = layer._plateauRenderables( + [mp.outer, mp.inner, mp.relation], mp.graph + ); + const ids = out.polygons.map(e => e.id); + expect(ids).to.include('r_mp'); + }); + + it('does not also render the member ways of a courtyard relation', () => { + const mp = makeCourtyard(new Rapid.Graph()); + const layer = new Rapid.PixiLayerRapid(makeScene(), 'rapid'); + const out = layer._plateauRenderables( + [mp.outer, mp.inner, mp.relation], mp.graph + ); + const ids = out.polygons.map(e => e.id); + expect(ids).to.not.include('w_outer', 'outer が二重に描かれる'); + expect(ids).to.not.include('w_inner', 'inner が単独で描かれる'); + }); + + it('still renders a plain building way', () => { + let g = new Rapid.Graph(); + const b = makeWay(g, 'w_plain', [[10,10], [11,10], [11,11], [10,11]], { building: 'yes' }); + g = b.graph; + const layer = new Rapid.PixiLayerRapid(makeScene(), 'rapid'); + const out = layer._plateauRenderables([b.way], g); + expect(out.polygons.map(e => e.id)).to.include('w_plain'); + }); + + it('does not render a type=building relation, only its member ways', () => { + // type=building は outline と parts を個別に描く現在の方式を変えない。 + let g = new Rapid.Graph(); + const o = makeWay(g, 'w_outline', [[20,20], [21,20], [21,21], [20,21]], { building: 'yes' }); + g = o.graph; + const p = makeWay(g, 'w_part', [[20.2,20.2], [20.8,20.2], [20.8,20.8], [20.2,20.8]], { 'building:part': 'yes' }); + g = p.graph; + const rel = Rapid.osmRelation({ + id: 'r_b', + tags: { type: 'building', building: 'yes' }, + members: [ + { id: 'w_outline', type: 'way', role: 'outline' }, + { id: 'w_part', type: 'way', role: 'part' } + ] + }); + g = g.replace(rel); + const layer = new Rapid.PixiLayerRapid(makeScene(), 'rapid'); + const out = layer._plateauRenderables([o.way, p.way, rel], g); + const ids = out.polygons.map(e => e.id); + expect(ids).to.include('w_outline'); + expect(ids).to.include('w_part'); + expect(ids).to.not.include('r_b'); + }); + }); +}); +``` + +- [ ] **Step 2: テストが落ちることを確認する** + +Run: `npm run build:bundle:modern:dev && npm run test:browser` +Expected: `_plateauRenderables` が存在しないので 4 件すべて FAIL。 + +- [ ] **Step 3: 絞り込みを私有メソッドに切り出す** + +`modules/pixi/PixiLayerRapid.js` の Plateau 分岐にある次の 2 行を、 + +```javascript + const entities = service.getData(datasetID) + .filter(entity => entity.type === 'way' && !isAcceptedOrIgnored(entity)); + + data.polygons = entities.filter(d => d.geometry(dsGraph) === 'area'); +``` + +次に差し替える。 + +```javascript + const renderables = this._plateauRenderables( + service.getData(datasetID), dsGraph, isAcceptedOrIgnored + ); + data.polygons = renderables.polygons; +``` + +`isAcceptedOrIgnored` は同じスコープの局所関数なので、第 3 引数で渡す。 + +- [ ] **Step 4: 絞り込みを実装する** + +`PixiLayerRapid` にメソッドを足す。`renderPolygons` の直前に置く。 + +```javascript + /** + * _plateauRenderables + * Plateau の entity 群から、描画するポリゴンを選ぶ。 + * + * 中庭のある建物は `type=multipolygon` の relation で届く。外形が role='outer'、 + * 穴が role='inner' で、タグは relation にだけ付く。`osmRelation.geometry()` は + * multipolygon に対して 'area' を返し、`PixiFeaturePolygon` は外側に続く穴を + * 既に描けるので、relation をそのまま積めば穴が穴として描かれる。 + * + * そのメンバー way は積まない。積むと外形が二重に描かれ、穴の上にも塗りが乗る。 + * + * `type=building` は従来どおり outline と parts を個別に積む。穴とは別の構造なので、 + * 描画方式は変えない。 + * + * @param {Array} entities service.getData() の戻り値 + * @param {Graph} dsGraph データセットのグラフ + * @return {{polygons: Array}} + */ + _plateauRenderables(entities, dsGraph, isAcceptedOrIgnored) { + const skip = isAcceptedOrIgnored || (() => false); + + // 先に「relation として描く」対象を決め、そのメンバー way を除外集合に入れる。 + const memberWayIDs = new Set(); + const relations = []; + for (const entity of entities) { + if (entity.type !== 'relation') continue; + if (entity.tags?.type !== 'multipolygon' || !entity.tags?.building) continue; + if (skip(entity)) continue; + relations.push(entity); + for (const m of entity.members ?? []) { + if (m.type === 'way') memberWayIDs.add(m.id); + } + } + + const polygons = []; + for (const relation of relations) { + if (relation.geometry(dsGraph) === 'area') polygons.push(relation); + } + for (const entity of entities) { + if (entity.type !== 'way') continue; + if (memberWayIDs.has(entity.id)) continue; + if (skip(entity)) continue; + if (entity.geometry(dsGraph) === 'area') polygons.push(entity); + } + return { polygons }; + } +``` + +- [ ] **Step 5: テストが通ることを確認する** + +Run: `npm run build:bundle:modern:dev && npm run test:browser` +Expected: 追加した 4 件が PASS。既存のテストも全件 PASS。 + +- [ ] **Step 6: 二重描画の除外が効いていることを確かめる** + +`if (memberWayIDs.has(entity.id)) continue;` を一時的に消し、 +`does not also render the member ways of a courtyard relation` が落ちることを確認してから戻す。 +戻したあと `git diff` が想定どおりであることを確認する。 + +- [ ] **Step 7: unit テストも回す** + +Run: `npm run test:unit` +Expected: 失敗なし + +- [ ] **Step 8: コミット** + +```bash +git add modules/pixi/PixiLayerRapid.js test/browser/pixi/PixiLayerRapid.test.js +git commit -m "fix(plateau): draw courtyard buildings with their holes" +``` From 5f72b519eb5fd29448c262e9976e471e4273be3a Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 5 Aug 2026 14:09:12 +0900 Subject: [PATCH 08/21] fix(plateau): cascade accept through type=multipolygon relations --- modules/actions/rapid_accept_feature.js | 7 +- .../unit/actions/rapid_accept_feature.test.js | 82 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/modules/actions/rapid_accept_feature.js b/modules/actions/rapid_accept_feature.js index 3bfaf97e2..1949be187 100644 --- a/modules/actions/rapid_accept_feature.js +++ b/modules/actions/rapid_accept_feature.js @@ -257,7 +257,12 @@ export function actionRapidAcceptFeature(entityID, extGraph, options) { var parents = extGraph.parentRelations(extWay); for (var i = 0; i < parents.length; i++) { var parent = parents[i]; - if (parent.tags && parent.tags.type === 'building' + // type=building は PLATEAU LOD2 の outline + parts。 + // type=multipolygon は中庭のある建物で、外形が outer、穴が inner。 + // multipolygon のメンバー way はタグを持たず、タグは relation にしか無い。 + // cascade しないと acceptWay に落ちてタグの無い way が OSM に上がる。 + var parentType = parent.tags && parent.tags.type; + if ((parentType === 'building' || parentType === 'multipolygon') && !seenRelations[parent.id] && !inProgressRelations[parent.id]) { return acceptRelation(parent); diff --git a/test/unit/actions/rapid_accept_feature.test.js b/test/unit/actions/rapid_accept_feature.test.js index c30919e9a..045932860 100644 --- a/test/unit/actions/rapid_accept_feature.test.js +++ b/test/unit/actions/rapid_accept_feature.test.js @@ -482,4 +482,86 @@ describe('actionRapidAcceptFeature', () => { 'highway tag should not be overwritten when it is already specific'); }); }); + + + describe('type=multipolygon (courtyard building)', () => { + // 中庭のある建物。outer が外形、inner が穴。 + // タグは relation にだけ付き、メンバー way はタグを持たない。 + function makeCourtyardGraph() { + const n1 = Rapid.osmNode({ id: 'n1', loc: [0, 0] }); + const n2 = Rapid.osmNode({ id: 'n2', loc: [1, 0] }); + const n3 = Rapid.osmNode({ id: 'n3', loc: [1, 1] }); + const n4 = Rapid.osmNode({ id: 'n4', loc: [0, 1] }); + const n5 = Rapid.osmNode({ id: 'n5', loc: [0.4, 0.4] }); + const n6 = Rapid.osmNode({ id: 'n6', loc: [0.6, 0.4] }); + const n7 = Rapid.osmNode({ id: 'n7', loc: [0.6, 0.6] }); + const n8 = Rapid.osmNode({ id: 'n8', loc: [0.4, 0.6] }); + const outer = Rapid.osmWay({ id: 'w_outer', nodes: ['n1','n2','n3','n4','n1'] }); + const inner = Rapid.osmWay({ id: 'w_inner', nodes: ['n5','n6','n7','n8','n5'] }); + const relation = Rapid.osmRelation({ + id: 'r_mp', + tags: { type: 'multipolygon', building: 'yes', height: '12' }, + members: [ + { id: outer.id, type: 'way', role: 'outer' }, + { id: inner.id, type: 'way', role: 'inner' } + ] + }); + const extGraph = new Rapid.Graph([n1,n2,n3,n4,n5,n6,n7,n8, outer, inner, relation]); + return { extGraph, outer, inner, relation }; + } + + it('accepts the whole relation when the outer way is clicked', () => { + const { extGraph, outer } = makeCourtyardGraph(); + const graph = Rapid.actionRapidAcceptFeature(outer.id, extGraph)(new Rapid.Graph()); + + assert.ok(graph.hasEntity('r_mp'), 'relation not in graph'); + assert.ok(graph.hasEntity('w_outer'), 'outer not in graph'); + assert.ok(graph.hasEntity('w_inner'), 'inner not in graph'); + }); + + it('accepts the whole relation when the inner way is clicked', () => { + const { extGraph, inner } = makeCourtyardGraph(); + const graph = Rapid.actionRapidAcceptFeature(inner.id, extGraph)(new Rapid.Graph()); + + assert.ok(graph.hasEntity('r_mp')); + assert.ok(graph.hasEntity('w_outer')); + assert.ok(graph.hasEntity('w_inner')); + }); + + it('keeps the tags on the relation and none on the member ways', () => { + const { extGraph, outer } = makeCourtyardGraph(); + const graph = Rapid.actionRapidAcceptFeature(outer.id, extGraph)(new Rapid.Graph()); + + const rel = graph.entity('r_mp'); + assert.equal(rel.tags.type, 'multipolygon'); + assert.equal(rel.tags.building, 'yes'); + assert.equal(rel.tags.height, '12'); + + // メンバー way にタグは足さない。relation にあるものをコピーしない。 + assert.equal(graph.entity('w_outer').tags.building, undefined); + assert.equal(graph.entity('w_inner').tags.building, undefined); + }); + + it('keeps the member roles', () => { + const { extGraph, outer } = makeCourtyardGraph(); + const graph = Rapid.actionRapidAcceptFeature(outer.id, extGraph)(new Rapid.Graph()); + + const roles = {}; + for (const m of graph.entity('r_mp').members) roles[m.role] = m.id; + assert.equal(roles.outer, 'w_outer'); + assert.equal(roles.inner, 'w_inner'); + }); + + it('adds only the clicked way when skipCascade is set', () => { + // action 自体は skipCascade を尊重する。UI 側が multipolygon で + // この選択肢を出さないことは Task 2 で担保する。 + const { extGraph, outer } = makeCourtyardGraph(); + const graph = Rapid.actionRapidAcceptFeature( + outer.id, extGraph, { skipCascade: true } + )(new Rapid.Graph()); + + assert.ok(graph.hasEntity('w_outer')); + assert.equal(graph.hasEntity('r_mp'), undefined); + }); + }); }); From cc21c3b82a1861a561f167e27ff3260649221a91 Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 5 Aug 2026 14:15:09 +0900 Subject: [PATCH 09/21] =?UTF-8?q?docs(plateau):=20cascade=20=E3=81=AE?= =?UTF-8?q?=E9=81=A9=E7=94=A8=E7=AF=84=E5=9B=B2=E3=82=92=E6=AD=A3=E3=81=97?= =?UTF-8?q?=E3=81=8F=E8=AA=AC=E6=98=8E=E3=81=97=E3=80=81=E3=82=BF=E3=82=B0?= =?UTF-8?q?=E9=9D=9E=E3=82=B3=E3=83=94=E3=83=BC=E3=81=AE=E6=A4=9C=E8=A8=BC?= =?UTF-8?q?=E3=82=92=E5=BC=B7=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/actions/rapid_accept_feature.js | 13 ++++++++++--- test/unit/actions/rapid_accept_feature.test.js | 7 ++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/modules/actions/rapid_accept_feature.js b/modules/actions/rapid_accept_feature.js index 1949be187..31b1d4699 100644 --- a/modules/actions/rapid_accept_feature.js +++ b/modules/actions/rapid_accept_feature.js @@ -257,10 +257,17 @@ export function actionRapidAcceptFeature(entityID, extGraph, options) { var parents = extGraph.parentRelations(extWay); for (var i = 0; i < parents.length; i++) { var parent = parents[i]; + // メンバー way だけを accept すると壊れる 2 つの構造を cascade する。 + // // type=building は PLATEAU LOD2 の outline + parts。 - // type=multipolygon は中庭のある建物で、外形が outer、穴が inner。 - // multipolygon のメンバー way はタグを持たず、タグは relation にしか無い。 - // cascade しないと acceptWay に落ちてタグの無い way が OSM に上がる。 + // メンバー way は自分のタグを持つが、relation を落とすと構造が失われる。 + // + // type=multipolygon は複数リングのポリゴンで、外形が outer、穴が inner。 + // **メンバー way はタグを持たず、タグは relation にしか無い。** + // cascade しないと acceptWay に落ちて、タグの無い way が OSM に上がる。 + // 中庭のある PLATEAU の建物がこの形で届くが、Esri も複数リングの + // ポリゴンを同じ形で作る (EsriService.js の multiple rings 分岐)。 + // 建物に限らないので、building タグでは絞らない。 var parentType = parent.tags && parent.tags.type; if ((parentType === 'building' || parentType === 'multipolygon') && !seenRelations[parent.id] diff --git a/test/unit/actions/rapid_accept_feature.test.js b/test/unit/actions/rapid_accept_feature.test.js index 045932860..579f08b9d 100644 --- a/test/unit/actions/rapid_accept_feature.test.js +++ b/test/unit/actions/rapid_accept_feature.test.js @@ -537,9 +537,10 @@ describe('actionRapidAcceptFeature', () => { assert.equal(rel.tags.building, 'yes'); assert.equal(rel.tags.height, '12'); - // メンバー way にタグは足さない。relation にあるものをコピーしない。 - assert.equal(graph.entity('w_outer').tags.building, undefined); - assert.equal(graph.entity('w_inner').tags.building, undefined); + // メンバー way にタグは足さない。relation にあるものを 1 つもコピーしない。 + // building だけを見ると height など他のタグの混入を見逃すので、空であることを見る。 + assert.deepEqual(graph.entity('w_outer').tags, {}); + assert.deepEqual(graph.entity('w_inner').tags, {}); }); it('keeps the member roles', () => { From 6a2f55a5a85fcd346e7375b0e30dc4ab3dd026b0 Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 5 Aug 2026 14:29:54 +0900 Subject: [PATCH 10/21] feat(plateau): treat courtyard buildings as one unit in the inspector --- data/core.yaml | 7 +++ modules/ui/UiRapidInspector.js | 27 ++++++++---- modules/util/building_relation.js | 31 +++++++++++--- test/unit/util/building_relation.test.js | 54 ++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 16 deletions(-) diff --git a/data/core.yaml b/data/core.yaml index 42cd6eb77..b0138d568 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -1131,6 +1131,10 @@ en: label: Add Entire Feature description: This way is part of a multi-section building (outline + parts). Selecting this will add the outline, every part, and the relation together so the OSM structure stays consistent. tooltip: Add the entire feature group (outline and all parts) to the map. + option_accept_entire_courtyard_building: + label: Add Entire Building + description: This way is part of a building with a courtyard. The tags live on the relation, so the outline, every courtyard ring, and the relation are added together. Adding a single ring on its own would produce an untagged way. + tooltip: Add the whole building, including its courtyards, to the map. option_accept_only_this: label: Add Only This Feature description: This way is part of a multi-section building, but you can also add just this one way without the rest of the structure. The outline and other parts will stay as suggestions. @@ -1139,6 +1143,9 @@ en: multi_section_building_info: one: This way is part of a multi-section building ({n} part). other: This way is part of a multi-section building ({n} parts). + courtyard_building_info: + one: This way is part of a building with {n} courtyard. + other: This way is part of a building with {n} courtyards. option_ignore: label: Ignore This Feature description: Ignoring this feature helps us improve our machine learning. If you are unsure whether or not this is an accurate feature, feel free to leave it as is, nothing will be saved or lost. 👍 diff --git a/modules/ui/UiRapidInspector.js b/modules/ui/UiRapidInspector.js index 429d0b2bd..25a6e894b 100644 --- a/modules/ui/UiRapidInspector.js +++ b/modules/ui/UiRapidInspector.js @@ -438,12 +438,16 @@ export class UiRapidInspector { // Phase 4-B-1: building relation member の場合は label / description を切り替え // Phase 4-C: building relation member の場合は「この feature のみ追加」の opt-out を追加 const buildingInfo = this._getBuildingRelationInfo(); - const acceptLabelStringID = buildingInfo - ? 'rapid_inspector.option_accept_entire_building.label' - : 'rapid_inspector.option_accept.label'; - const acceptReferenceStringID = buildingInfo - ? 'rapid_inspector.option_accept_entire_building.description' - : 'rapid_inspector.option_accept.description'; + const isCourtyard = buildingInfo?.relationType === 'multipolygon'; + let acceptLabelStringID = 'rapid_inspector.option_accept.label'; + let acceptReferenceStringID = 'rapid_inspector.option_accept.description'; + if (isCourtyard) { + acceptLabelStringID = 'rapid_inspector.option_accept_entire_courtyard_building.label'; + acceptReferenceStringID = 'rapid_inspector.option_accept_entire_courtyard_building.description'; + } else if (buildingInfo) { + acceptLabelStringID = 'rapid_inspector.option_accept_entire_building.label'; + acceptReferenceStringID = 'rapid_inspector.option_accept_entire_building.description'; + } const choiceData = [ { @@ -456,8 +460,10 @@ export class UiRapidInspector { } ]; - // Phase 4-C: relation member 時は「この feature だけ追加」 (cascade なし) を追加 - if (buildingInfo) { + // Phase 4-C: relation member 時は「この feature だけ追加」 (cascade なし) を追加。 + // ただし multipolygon では出さない。メンバー way はタグを持たないので、 + // 1 本だけ追加すると必ずタグの無い way になる。 + if (buildingInfo && !isCourtyard) { choiceData.push({ key: 'accept_only_this', iconName: '#rapid-icon-rapid-plus-circle', @@ -506,9 +512,12 @@ export class UiRapidInspector { const $multiInfo = $choices.selectAll('.rapid-inspector-multi-section-building-info'); if (buildingInfo) { const partCount = buildingInfo.partCount; + const infoStringID = isCourtyard + ? 'rapid_inspector.courtyard_building_info' + : 'rapid_inspector.multi_section_building_info'; $multiInfo .style('display', null) - .text(l10n.t('rapid_inspector.multi_section_building_info', { n: partCount })); + .text(l10n.t(infoStringID, { n: partCount })); } else { $multiInfo .style('display', 'none') diff --git a/modules/util/building_relation.js b/modules/util/building_relation.js index ab66d8ae1..3e7c0d6db 100644 --- a/modules/util/building_relation.js +++ b/modules/util/building_relation.js @@ -1,14 +1,21 @@ /** * utilBuildingRelationInfo * - * 指定 entity が `type=building` relation (Simple 3D Buildings / PLATEAU LOD2 の構造) の - * メンバー way である場合に、その relation 情報を返す。それ以外は null。 + * 指定 entity が「1 棟の建物」を表す relation のメンバー way である場合に、その relation + * 情報を返す。それ以外は null。 + * + * 対象は 2 種類ある。 + * type=building は Simple 3D Buildings / PLATEAU LOD2 の構造で、外形が役割 outline、 + * 内訳が part。メンバー way はそれぞれ自分のタグを持つ。 + * type=multipolygon は中庭のある建物で、外形が outer、穴が inner。 + * **タグは relation にだけ付き、メンバー way はタグを持たない。** * * UiRapidInspector や conflation ロジックから、UI 表示 / 動作判定の両方で使用。 * * @param {Object|null} entity - osmEntity (typically a way) * @param {Graph|null} graph - 該当 entity の含まれる Graph (parentRelations を提供) - * @return {{relation: osmRelation, outlineCount: number, partCount: number} | null} + * @return {{relation: osmRelation, outlineCount: number, partCount: number, + * relationType: string} | null} */ export function utilBuildingRelationInfo(entity, graph) { if (!entity || entity.type !== 'way') return null; @@ -21,14 +28,24 @@ export function utilBuildingRelationInfo(entity, graph) { return null; } - const relation = parents.find(r => r.tags && r.tags.type === 'building'); + const relation = parents.find(r => { + if (!r.tags) return false; + if (r.tags.type === 'building') return true; + // 建物でない multipolygon (森林など) は対象外。 + return r.tags.type === 'multipolygon' && !!r.tags.building; + }); if (!relation) return null; + const relationType = relation.tags.type; + // 役割名は relation の種別で変わる。外形と内訳を同じ 2 つの数に集約する。 + const outlineRole = (relationType === 'multipolygon') ? 'outer' : 'outline'; + const partRole = (relationType === 'multipolygon') ? 'inner' : 'part'; + let outlineCount = 0; let partCount = 0; for (const m of relation.members || []) { - if (m.role === 'outline') outlineCount++; - else if (m.role === 'part') partCount++; + if (m.role === outlineRole) outlineCount++; + else if (m.role === partRole) partCount++; } - return { relation, outlineCount, partCount }; + return { relation, outlineCount, partCount, relationType }; } diff --git a/test/unit/util/building_relation.test.js b/test/unit/util/building_relation.test.js index a55f8a604..7f7e32f0e 100644 --- a/test/unit/util/building_relation.test.js +++ b/test/unit/util/building_relation.test.js @@ -92,4 +92,58 @@ describe('utilBuildingRelationInfo', () => { }; assert.equal(Rapid.utilBuildingRelationInfo(way, brokenGraph), null); }); + + it('recognizes a type=multipolygon relation and counts outer/inner', () => { + const outer = Rapid.osmWay({ id: 'w_outer', nodes: [] }); + const inner1 = Rapid.osmWay({ id: 'w_inner1', nodes: [] }); + const inner2 = Rapid.osmWay({ id: 'w_inner2', nodes: [] }); + const relation = Rapid.osmRelation({ + id: 'r_mp', + tags: { type: 'multipolygon', building: 'yes' }, + members: [ + { id: 'w_outer', type: 'way', role: 'outer' }, + { id: 'w_inner1', type: 'way', role: 'inner' }, + { id: 'w_inner2', type: 'way', role: 'inner' } + ] + }); + const graph = new Rapid.Graph([outer, inner1, inner2, relation]); + + const info = Rapid.utilBuildingRelationInfo(outer, graph); + assert.ok(info, 'multipolygon が認識されていない'); + assert.equal(info.relationType, 'multipolygon'); + assert.equal(info.outlineCount, 1); + assert.equal(info.partCount, 2); + }); + + it('reports relationType for a type=building relation', () => { + const outline = Rapid.osmWay({ id: 'w_outline', nodes: [] }); + const part = Rapid.osmWay({ id: 'w_part', nodes: [] }); + const relation = Rapid.osmRelation({ + id: 'r_b', + tags: { type: 'building', building: 'yes' }, + members: [ + { id: 'w_outline', type: 'way', role: 'outline' }, + { id: 'w_part', type: 'way', role: 'part' } + ] + }); + const graph = new Rapid.Graph([outline, part, relation]); + + const info = Rapid.utilBuildingRelationInfo(outline, graph); + assert.equal(info.relationType, 'building'); + assert.equal(info.outlineCount, 1); + assert.equal(info.partCount, 1); + }); + + it('returns null for a multipolygon without a building tag', () => { + // 建物でない multipolygon (森林など) は対象外。 + const outer = Rapid.osmWay({ id: 'w_outer', nodes: [] }); + const relation = Rapid.osmRelation({ + id: 'r_forest', + tags: { type: 'multipolygon', landuse: 'forest' }, + members: [{ id: 'w_outer', type: 'way', role: 'outer' }] + }); + const graph = new Rapid.Graph([outer, relation]); + + assert.equal(Rapid.utilBuildingRelationInfo(outer, graph), null); + }); }); From 32c70bf789bac202d29fb4a932f4cdeec9e1a9e6 Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 5 Aug 2026 14:36:22 +0900 Subject: [PATCH 11/21] fix(ui): hide courtyard building info line when partCount is 0 utilBuildingRelationInfo does not guarantee a multipolygon+building relation has an inner member. If one arrived with zero, the info line would read "a building with 0 courtyards". Hide the line in that case instead of printing the nonsense count. isCourtyard still only depends on relationType, not partCount, so the "Add Only This Feature" suppression (the safety-critical part, since multipolygon member ways carry no tags) is unaffected. --- modules/ui/UiRapidInspector.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/modules/ui/UiRapidInspector.js b/modules/ui/UiRapidInspector.js index 25a6e894b..3a4f6860b 100644 --- a/modules/ui/UiRapidInspector.js +++ b/modules/ui/UiRapidInspector.js @@ -509,9 +509,19 @@ export class UiRapidInspector { .text(l10n.t('rapid_inspector.prompt')); // multi-section building 情報行: 該当時のみ表示 + // + // type=multipolygon + building relation は inner (courtyard) 0 件でも仕様上あり得る。 + // 現状の 2 producer はどちらも実際には 0 件を送ってこない: + // - PLATEAU API は ring が 1 つ以下なら relation でなく単一の tagged way を出す + // (XML 生成側の `if len(rings) <= 1`)。 + // - EsriService も ways.length === 1 なら単一 tagged way を返し、relation を組むのは + // 複数 ring のときだけで、そのとき role 割り当てにより inner が最低 1 つ入る。 + // それでも「0 courtyards」という文言はナンセンスなので、この行だけは防御的に非表示にする。 + // isCourtyard 自体は partCount に依存させない (acceptOnlyThis の抑制に使われるため)。 const $multiInfo = $choices.selectAll('.rapid-inspector-multi-section-building-info'); - if (buildingInfo) { - const partCount = buildingInfo.partCount; + const partCount = buildingInfo?.partCount; + const hideAsEmptyCourtyard = isCourtyard && partCount === 0; + if (buildingInfo && !hideAsEmptyCourtyard) { const infoStringID = isCourtyard ? 'rapid_inspector.courtyard_building_info' : 'rapid_inspector.multi_section_building_info'; From 90ebf0f296e306530e8531db8941a45911b2b8b4 Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 5 Aug 2026 14:49:14 +0900 Subject: [PATCH 12/21] fix(plateau): draw courtyard buildings with their holes --- modules/index.js | 1 + modules/pixi/PixiLayerRapid.js | 58 ++++++++++- test/browser/pixi/PixiLayerRapid.test.js | 117 +++++++++++++++++++++++ 3 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 test/browser/pixi/PixiLayerRapid.test.js diff --git a/modules/index.js b/modules/index.js index afadc1f37..fe4aafc0d 100644 --- a/modules/index.js +++ b/modules/index.js @@ -24,3 +24,4 @@ export { Context } from './Context.js'; // mock scenes (avoids booting a full Rapid context). export { PixiLayerPlateauCoverage } from './pixi/PixiLayerPlateauCoverage.js'; export { PixiLayerHeightTransfer } from './pixi/PixiLayerHeightTransfer.js'; +export { PixiLayerRapid } from './pixi/PixiLayerRapid.js'; diff --git a/modules/pixi/PixiLayerRapid.js b/modules/pixi/PixiLayerRapid.js index aa6eb39aa..334b4994d 100644 --- a/modules/pixi/PixiLayerRapid.js +++ b/modules/pixi/PixiLayerRapid.js @@ -343,11 +343,11 @@ export class PixiLayerRapid extends AbstractLayer { // PlateauService.getData applies its own conflation; here we only need // to filter out features the user already accepted or ignored and keep - // the polygonal members (outline + parts). - const entities = service.getData(datasetID) - .filter(entity => entity.type === 'way' && !isAcceptedOrIgnored(entity)); - - data.polygons = entities.filter(d => d.geometry(dsGraph) === 'area'); + // the polygonal members (outline + parts, or a courtyard relation). + const renderables = this._plateauRenderables( + service.getData(datasetID), dsGraph, isAcceptedOrIgnored + ); + data.polygons = renderables.polygons; } const pointsContainer = this.scene.groups.get('points'); @@ -377,6 +377,54 @@ export class PixiLayerRapid extends AbstractLayer { } + /** + * _plateauRenderables + * Plateau の entity 群から、描画するポリゴンを選ぶ。 + * + * 中庭のある建物は `type=multipolygon` の relation で届く。外形が role='outer'、 + * 穴が role='inner' で、タグは relation にだけ付く。`osmRelation.geometry()` は + * multipolygon に対して 'area' を返し、`PixiFeaturePolygon` は外側に続く穴を + * 既に描けるので、relation をそのまま積めば穴が穴として描かれる。 + * + * そのメンバー way は積まない。積むと外形が二重に描かれ、穴の上にも塗りが乗る。 + * + * `type=building` は従来どおり outline と parts を個別に積む。穴とは別の構造なので、 + * 描画方式は変えない。 + * + * @param {Array} entities service.getData() の戻り値 + * @param {Graph} dsGraph データセットのグラフ + * @return {{polygons: Array}} + */ + _plateauRenderables(entities, dsGraph, isAcceptedOrIgnored) { + const skip = isAcceptedOrIgnored || (() => false); + + // 先に「relation として描く」対象を決め、そのメンバー way を除外集合に入れる。 + const memberWayIDs = new Set(); + const relations = []; + for (const entity of entities) { + if (entity.type !== 'relation') continue; + if (entity.tags?.type !== 'multipolygon' || !entity.tags?.building) continue; + if (skip(entity)) continue; + relations.push(entity); + for (const m of entity.members ?? []) { + if (m.type === 'way') memberWayIDs.add(m.id); + } + } + + const polygons = []; + for (const relation of relations) { + if (relation.geometry(dsGraph) === 'area') polygons.push(relation); + } + for (const entity of entities) { + if (entity.type !== 'way') continue; + if (memberWayIDs.has(entity.id)) continue; + if (skip(entity)) continue; + if (entity.geometry(dsGraph) === 'area') polygons.push(entity); + } + return { polygons }; + } + + /** * renderPolygons */ diff --git a/test/browser/pixi/PixiLayerRapid.test.js b/test/browser/pixi/PixiLayerRapid.test.js new file mode 100644 index 000000000..abd4a7aea --- /dev/null +++ b/test/browser/pixi/PixiLayerRapid.test.js @@ -0,0 +1,117 @@ +describe('PixiLayerRapid', () => { + // `osmWay#geometry()` calls `isArea()`, which consults `osmAreaKeys`. + // That map is normally populated by `PresetSystem.init()` at app boot; + // these mock-based tests never boot a real context, so we seed it here. + // Same pattern as `test/unit/osm/way.test.js` and + // `test/browser/validations/mismatched_geometry.js`. + let _savedAreaKeys; + + before(() => { + _savedAreaKeys = Rapid.osmAreaKeys; + Rapid.osmSetAreaKeys({ building: {}, 'building:part': {} }); + }); + + after(() => { + Rapid.osmSetAreaKeys(_savedAreaKeys); + }); + + function makeScene() { + const gfx = { + scene: null, + deferredRedraw() {}, + immediateRedraw() {} + }; + const context = { services: {}, systems: { gfx: gfx } }; + const scene = { gfx: gfx, context: context, groups: new Map([['basemap', null]]) }; + gfx.scene = scene; + return scene; + } + + describe('#_plateauRenderables', () => { + function makeWay(graph, id, coords, tags) { + let g = graph; + const nodeIds = []; + for (let i = 0; i < coords.length; i++) { + const nodeId = id + '-n' + i; + nodeIds.push(nodeId); + g = g.replace(Rapid.osmNode({ id: nodeId, loc: coords[i] })); + } + nodeIds.push(nodeIds[0]); + const way = Rapid.osmWay({ id, nodes: nodeIds, tags: tags || {} }); + g = g.replace(way); + return { graph: g, way }; + } + + function makeCourtyard(graph) { + let g = graph; + const o = makeWay(g, 'w_outer', [[0,0], [1,0], [1,1], [0,1]]); + g = o.graph; + const i = makeWay(g, 'w_inner', [[0.4,0.4], [0.6,0.4], [0.6,0.6], [0.4,0.6]]); + g = i.graph; + const relation = Rapid.osmRelation({ + id: 'r_mp', + tags: { type: 'multipolygon', building: 'yes' }, + members: [ + { id: 'w_outer', type: 'way', role: 'outer' }, + { id: 'w_inner', type: 'way', role: 'inner' } + ] + }); + g = g.replace(relation); + return { graph: g, outer: o.way, inner: i.way, relation }; + } + + it('renders a courtyard relation as one polygon', () => { + const mp = makeCourtyard(new Rapid.Graph()); + const layer = new Rapid.PixiLayerRapid(makeScene(), 'rapid'); + const out = layer._plateauRenderables( + [mp.outer, mp.inner, mp.relation], mp.graph + ); + const ids = out.polygons.map(e => e.id); + expect(ids).to.include('r_mp'); + }); + + it('does not also render the member ways of a courtyard relation', () => { + const mp = makeCourtyard(new Rapid.Graph()); + const layer = new Rapid.PixiLayerRapid(makeScene(), 'rapid'); + const out = layer._plateauRenderables( + [mp.outer, mp.inner, mp.relation], mp.graph + ); + const ids = out.polygons.map(e => e.id); + expect(ids).to.not.include('w_outer', 'outer が二重に描かれる'); + expect(ids).to.not.include('w_inner', 'inner が単独で描かれる'); + }); + + it('still renders a plain building way', () => { + let g = new Rapid.Graph(); + const b = makeWay(g, 'w_plain', [[10,10], [11,10], [11,11], [10,11]], { building: 'yes' }); + g = b.graph; + const layer = new Rapid.PixiLayerRapid(makeScene(), 'rapid'); + const out = layer._plateauRenderables([b.way], g); + expect(out.polygons.map(e => e.id)).to.include('w_plain'); + }); + + it('does not render a type=building relation, only its member ways', () => { + // type=building は outline と parts を個別に描く現在の方式を変えない。 + let g = new Rapid.Graph(); + const o = makeWay(g, 'w_outline', [[20,20], [21,20], [21,21], [20,21]], { building: 'yes' }); + g = o.graph; + const p = makeWay(g, 'w_part', [[20.2,20.2], [20.8,20.2], [20.8,20.8], [20.2,20.8]], { 'building:part': 'yes' }); + g = p.graph; + const rel = Rapid.osmRelation({ + id: 'r_b', + tags: { type: 'building', building: 'yes' }, + members: [ + { id: 'w_outline', type: 'way', role: 'outline' }, + { id: 'w_part', type: 'way', role: 'part' } + ] + }); + g = g.replace(rel); + const layer = new Rapid.PixiLayerRapid(makeScene(), 'rapid'); + const out = layer._plateauRenderables([o.way, p.way, rel], g); + const ids = out.polygons.map(e => e.id); + expect(ids).to.include('w_outline'); + expect(ids).to.include('w_part'); + expect(ids).to.not.include('r_b'); + }); + }); +}); From f24095d8d2bff96a65a6b94eab7ae70101f97e67 Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 5 Aug 2026 14:56:53 +0900 Subject: [PATCH 13/21] test(plateau): make courtyard member-way exclusion test exercise the guard The test gave member ways no tags, matching real data, but that also made osmWay#isArea() reject them as non-area regardless of the memberWayIDs exclusion in _plateauRenderables. The assertion passed even with the guard deleted. Tag the member ways in this one fixture so the guard is genuinely under test; every other fixture in the file stays untagged. Also correct the spec's description of the pre-fix symptom: reading the pre-change PixiLayerRapid.js shows the Plateau branch only ever assigned data.polygons, and untagged outer/inner ways never reached it (geometry() returns 'line', not 'area'), so a courtyard building was not drawn at all rather than appearing as "a building inside a building". Also note in the spec that the member-way exclusion is kept as an explicit invariant, not because member ways happen to be untagged today. --- ...au-multipolygon-accept-render-design.ja.md | 9 ++++++++- test/browser/pixi/PixiLayerRapid.test.js | 19 +++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-08-05-plateau-multipolygon-accept-render-design.ja.md b/docs/superpowers/specs/2026-08-05-plateau-multipolygon-accept-render-design.ja.md index 56fdde1d7..242ee9708 100644 --- a/docs/superpowers/specs/2026-08-05-plateau-multipolygon-accept-render-design.ja.md +++ b/docs/superpowers/specs/2026-08-05-plateau-multipolygon-accept-render-design.ja.md @@ -20,7 +20,7 @@ conflation は先行して対応したが、それは 3 分の 1 にすぎなか |---|---|---| | `modules/actions/rapid_accept_feature.js:260` | `parent.tags.type === 'building'` の relation にしか cascade しない | multipolygon の外形を accept すると `acceptWay` に落ちる。タグは relation にしか無いので、**OSM に上がるのはタグの無い閉じた way**。中庭も `building` タグも失われる | | `modules/util/building_relation.js:24` | 同じく `type=building` のみ | インスペクタの文言が切り替わらず、hover / select の兄弟 highlight も効かない。外形をホバーしても穴が光らないので、1 棟であることが UI から伝わらない | -| `modules/pixi/PixiLayerRapid.js:347` | `entity.type === 'way'` で絞ってから個別のポリゴンとして積む | 穴が穴として描かれない。外形の上に小さな図形が重なり、**「建物の中に建物」に見える**。api#39 で消したはずの見た目が描画側で再現する | +| `modules/pixi/PixiLayerRapid.js:347` | `entity.type === 'way'` で絞ってから個別のポリゴンとして積む | outer・inner ともタグを持たないので `geometry()` が `'area'` を返さず、`data.polygons` に一本も乗らない。`data.lines` への振り分けも無い。**中庭のある建物が丸ごと描かれない** | 隠しそこねは機会損失だが、タグの無い way の upload は公開データベースを汚す。 影響の重さが違う。 @@ -83,6 +83,13 @@ multipolygon のときは「建物全体を追加」だけを出す。 relation が外形と穴をまとめて描くので、メンバー way を個別に積むと外形が二重になり、 穴の上にも塗りが乗る。描画対象にした relation のメンバー way は除外する。 +この除外は、メンバー way がタグを持たないことに頼らない明示の invariant として持つ。 +現状はメンバー way が無タグなので `geometry()` が `'area'` を返さず、除外がなくても +`data.polygons` には乗らない。 +だが除外を「たまたま無タグだから効いている」状態にはしない。 +メンバー way にタグが付く形に API が変わっても、relation を描画対象にした時点でそのメンバー way は +明示的に除外されるようにする。 + conflation で relation を返り値から外した変更は、ここで初めて観測可能になる。 それまでは relation が描画に届いていなかった。 diff --git a/test/browser/pixi/PixiLayerRapid.test.js b/test/browser/pixi/PixiLayerRapid.test.js index abd4a7aea..fe1a6b0ba 100644 --- a/test/browser/pixi/PixiLayerRapid.test.js +++ b/test/browser/pixi/PixiLayerRapid.test.js @@ -42,11 +42,13 @@ describe('PixiLayerRapid', () => { return { graph: g, way }; } - function makeCourtyard(graph) { + // memberTags defaults to untagged, matching real data: importer-produced + // member ways of a courtyard relation carry no tags of their own. + function makeCourtyard(graph, memberTags) { let g = graph; - const o = makeWay(g, 'w_outer', [[0,0], [1,0], [1,1], [0,1]]); + const o = makeWay(g, 'w_outer', [[0,0], [1,0], [1,1], [0,1]], memberTags); g = o.graph; - const i = makeWay(g, 'w_inner', [[0.4,0.4], [0.6,0.4], [0.6,0.6], [0.4,0.6]]); + const i = makeWay(g, 'w_inner', [[0.4,0.4], [0.6,0.4], [0.6,0.6], [0.4,0.6]], memberTags); g = i.graph; const relation = Rapid.osmRelation({ id: 'r_mp', @@ -71,7 +73,16 @@ describe('PixiLayerRapid', () => { }); it('does not also render the member ways of a courtyard relation', () => { - const mp = makeCourtyard(new Rapid.Graph()); + // Member ways here must carry an area-suggesting tag (unlike every other + // fixture in this file). If they were left untagged, `osmWay#geometry()` + // would already return 'line' for them (isArea() needs + // tagSuggestingArea() !== null — see modules/osm/way.js), so + // `_plateauRenderables` would skip them for being non-area regardless of + // whether the member-way exclusion guard exists. That would make this + // assertion pass even with the guard deleted, proving nothing. Tagging + // the member ways as `building: 'yes'` makes them area-geometry ways + // that only the guard keeps out, so the test actually exercises it. + const mp = makeCourtyard(new Rapid.Graph(), { building: 'yes' }); const layer = new Rapid.PixiLayerRapid(makeScene(), 'rapid'); const out = layer._plateauRenderables( [mp.outer, mp.inner, mp.relation], mp.graph From 250924a09831ec929764de82abd69969451a229b Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 5 Aug 2026 20:31:46 +0900 Subject: [PATCH 14/21] =?UTF-8?q?docs:=20=E4=B8=AD=E5=BA=AD=E5=BB=BA?= =?UTF-8?q?=E7=89=A9=E3=81=AE=E3=82=A4=E3=83=B3=E3=82=B9=E3=83=9A=E3=82=AF?= =?UTF-8?q?=E3=82=BF=E3=82=92=20relation=20=E3=81=AE=20datum=20=E3=81=AB?= =?UTF-8?q?=E5=AF=BE=E5=BF=9C=E3=81=95=E3=81=9B=E3=82=8B=E8=A8=AD=E8=A8=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...yard-inspector-relation-datum-design.ja.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-05-courtyard-inspector-relation-datum-design.ja.md diff --git a/docs/superpowers/specs/2026-08-05-courtyard-inspector-relation-datum-design.ja.md b/docs/superpowers/specs/2026-08-05-courtyard-inspector-relation-datum-design.ja.md new file mode 100644 index 000000000..d2e1c2c52 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-courtyard-inspector-relation-datum-design.ja.md @@ -0,0 +1,79 @@ +# 中庭建物のインスペクタを relation の datum に対応させる設計 + +- 日付: 2026-08-05 +- 関連: `feature/plateau-multipolygon-conflation`(conflation・accept・描画をこのブランチで実装済み) + +## 背景 + +中庭のある建物は `type=multipolygon` の relation で届く。 +このブランチで accept・インスペクタ・描画を対応させたが、**インスペクタの分岐だけが表示されない**。 + +描画を relation 単位に変えたため、メンバー way は描画対象から外れた。 +描かれていない way は hover も select もできないので、インスペクタが受け取る datum は常に relation になる。 + +`utilBuildingRelationInfo` は先頭で `entity.type !== 'way'` なら null を返す。 +そのため `isCourtyard` が常に false になり、用意した文言が一度も出ない。 + +動作は壊れていない。 +relation を選んで「Add This Feature」を押せば `acceptRelation` が正しく cascade する。 +足りないのは、ユーザに中庭があると伝えることだけである。 + +## 確認した事実 + +**relation の datum はインスペクタに届く。** +`modules/ui/UiSidebar.js:241` は `__fbid__` を要求するが、`PlateauService._parseEntity` は +relation を含む全 entity に `__fbid__` を付けている。 + +**API は member の role を必ず明示する。** +`osmfj_plateau_api.py:716` が `m.set('role', 'outer' if ring_no == 0 else 'inner')` を書く。 +role が空になる経路は無い。 + +**中庭建物に兄弟 highlight は意味を持たない。** +描かれているのは relation 1 つだけで、光らせる相手が居ない。 +`type=building` は datum が way のままなので、そちらの highlight は従来どおり動く。 + +## 設計 + +### 1. `utilBuildingRelationInfo` が relation も受け付ける + +way が渡されたら、その way が属する建物 relation を返す(現状)。 +relation が渡されたら、その relation 自身について同じ形の情報を返す。 + +判定条件は変えない。 +`type=building`、または `building` タグを持つ `type=multipolygon` だけを建物として扱う。 + +返り値の形も変えない。 +`{ relation, outlineCount, partCount, relationType }` のまま。 + +### 2. 情報行の文言を relation 用に分ける + +way を選んでいるときは「この way は〜の一部です」でよい。 +relation を選んでいるときは建物そのものを選んでいるので、「一部」は当たらない。 + +relation を選んでいるときの文言を別に用意し、中庭の本数を伝える。 + +### 3. 「Add Only This」の抑制が意図した理由で効く + +現状は `buildingInfo` が null であることによって、たまたま出ていない。 +本設計のあと `isCourtyard` が true になるので、`!isCourtyard` のガードが実際に働く。 + +relation だけを追加してメンバー way を置き去りにする操作は壊れている。 +その抑制が偶然ではなく意図で成り立つようになる。 + +## 対象外 + +**兄弟 highlight。** 中庭建物では光らせる相手が居ない。 +`PlateauService` は変更しない。 + +**`type=building` の挙動。** datum は way のままで、文言も選択肢もカウントも変えない。 + +**描画。** relation 単位で描く現在の形を変えない。メンバー way を描画対象に戻さない。 + +## 検証 + +- relation の datum で `utilBuildingRelationInfo` が情報を返す +- way の datum での返り値が変わらない +- 建物でない `type=multipolygon` の relation では null が返る +- relation を選んだとき「Add Entire Building」が出て、「Add Only This」が出ない +- relation を選んだときの情報行が「一部」ではなく建物そのものを指す文言になる +- `type=building` の way を選んだときの文言と選択肢が変わらない From c37145b241778f23af10ea6e9808674e25d65e96 Mon Sep 17 00:00:00 2001 From: nyampire Date: Wed, 5 Aug 2026 20:33:40 +0900 Subject: [PATCH 15/21] =?UTF-8?q?docs:=20=E4=B8=AD=E5=BA=AD=E5=BB=BA?= =?UTF-8?q?=E7=89=A9=E3=81=AE=E3=82=A4=E3=83=B3=E3=82=B9=E3=83=9A=E3=82=AF?= =?UTF-8?q?=E3=82=BF=E3=82=92=20relation=20=E3=81=AE=20datum=20=E3=81=AB?= =?UTF-8?q?=E5=AF=BE=E5=BF=9C=E3=81=95=E3=81=9B=E3=82=8B=E5=AE=9F=E8=A3=85?= =?UTF-8?q?=E8=A8=88=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...8-05-courtyard-inspector-relation-datum.md | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-05-courtyard-inspector-relation-datum.md diff --git a/docs/superpowers/plans/2026-08-05-courtyard-inspector-relation-datum.md b/docs/superpowers/plans/2026-08-05-courtyard-inspector-relation-datum.md new file mode 100644 index 000000000..fea9ed35e --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-courtyard-inspector-relation-datum.md @@ -0,0 +1,322 @@ +# 中庭建物のインスペクタを relation の datum に対応させる 実装計画 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** relation を選んでいるときも中庭建物だと分かるようにし、用意済みのインスペクタ分岐が実際に働くようにする。 + +**Architecture:** `utilBuildingRelationInfo` が relation の entity も受け付けるようにする。way なら「属する建物 relation」、relation なら「その relation 自身」を返す。あわせて、relation を選んでいるときの情報行の文言を分ける。 + +**Tech Stack:** JavaScript (ESM), node:test(unit), Karma(browser) + +## Global Constraints + +- 判定条件は変えない。`type=building`、または `building` タグを持つ `type=multipolygon` だけを建物として扱う。 +- 返り値の形は変えない。`{ relation, outlineCount, partCount, relationType }`。 +- way を渡したときの返り値を変えない。 +- `type=building` の文言・選択肢・カウントを変えない。 +- `modules/services/PlateauService.js` を変更しない。兄弟 highlight は中庭建物では光らせる相手が居ないので対象外。 +- 描画を変えない。メンバー way を描画対象に戻さない。 +- `isCourtyard` を `partCount` に依存させない。「Add Only This」の抑制という安全側の判定を担うため。 +- spec: `docs/superpowers/specs/2026-08-05-courtyard-inspector-relation-datum-design.ja.md` + +## テストの実行 + +```bash +node --test-reporter dot --test "test/unit/**/*.test.js" # npm run test:unit は c8 が Node v26 で壊れて起動しない +npm run build:bundle:modern:dev && npm run test:browser # Karma は dist/rapid.js を読む +``` + +`detect.test.js` に既存の失敗が 3 件ある。今回の変更とは無関係。 +browser の baseline は 745 completed / 5 skipped / 0 failed。 +ビルド成果物はコミットしない。 + +--- + +### Task 1: `utilBuildingRelationInfo` が relation も受け付ける + +**Files:** +- Modify: `modules/util/building_relation.js`(全面。52 行の小さなファイル) +- Test: `test/unit/util/building_relation.test.js` + +**Interfaces:** +- Produces: entity が relation のとき、その relation 自身の情報を返す。判定条件と返り値の形は way のときと同じ。 +- Produces: way のときの挙動は変わらない。 + +- [ ] **Step 1: 失敗するテストを書く** + +`test/unit/util/building_relation.test.js` の末尾に追加する。 + +```javascript + it('accepts a multipolygon relation itself, not only its member ways', () => { + // 中庭建物は relation 単位で描画されるので、インスペクタが受け取る datum は + // relation になる。way が来ないので、relation を直接渡せる必要がある。 + const outer = Rapid.osmWay({ id: 'w_outer', nodes: [] }); + const inner = Rapid.osmWay({ id: 'w_inner', nodes: [] }); + const relation = Rapid.osmRelation({ + id: 'r_mp', + tags: { type: 'multipolygon', building: 'yes' }, + members: [ + { id: 'w_outer', type: 'way', role: 'outer' }, + { id: 'w_inner', type: 'way', role: 'inner' } + ] + }); + const graph = new Rapid.Graph([outer, inner, relation]); + + const info = Rapid.utilBuildingRelationInfo(relation, graph); + assert.ok(info, 'relation 自身が受け付けられていない'); + assert.equal(info.relation.id, 'r_mp'); + assert.equal(info.relationType, 'multipolygon'); + assert.equal(info.outlineCount, 1); + assert.equal(info.partCount, 1); + }); + + it('accepts a type=building relation itself', () => { + const outline = Rapid.osmWay({ id: 'w_outline', nodes: [] }); + const part = Rapid.osmWay({ id: 'w_part', nodes: [] }); + const relation = Rapid.osmRelation({ + id: 'r_b', + tags: { type: 'building', building: 'yes' }, + members: [ + { id: 'w_outline', type: 'way', role: 'outline' }, + { id: 'w_part', type: 'way', role: 'part' } + ] + }); + const graph = new Rapid.Graph([outline, part, relation]); + + const info = Rapid.utilBuildingRelationInfo(relation, graph); + assert.ok(info); + assert.equal(info.relationType, 'building'); + assert.equal(info.outlineCount, 1); + assert.equal(info.partCount, 1); + }); + + it('returns null for a relation that is not a building', () => { + const outer = Rapid.osmWay({ id: 'w_outer', nodes: [] }); + const relation = Rapid.osmRelation({ + id: 'r_forest', + tags: { type: 'multipolygon', landuse: 'forest' }, + members: [{ id: 'w_outer', type: 'way', role: 'outer' }] + }); + const graph = new Rapid.Graph([outer, relation]); + + assert.equal(Rapid.utilBuildingRelationInfo(relation, graph), null); + }); + + it('returns null for a route relation', () => { + const way = Rapid.osmWay({ id: 'w1', nodes: [] }); + const relation = Rapid.osmRelation({ + id: 'r_route', + tags: { type: 'route', route: 'bus' }, + members: [{ id: 'w1', type: 'way', role: '' }] + }); + const graph = new Rapid.Graph([way, relation]); + + assert.equal(Rapid.utilBuildingRelationInfo(relation, graph), null); + }); + + it('does not need parentRelations when a relation is passed', () => { + // relation 自身を渡すときは親をたどらないので、graph が + // parentRelations を持たなくても答えられる。 + const relation = Rapid.osmRelation({ + id: 'r_mp2', + tags: { type: 'multipolygon', building: 'yes' }, + members: [{ id: 'w_outer', type: 'way', role: 'outer' }] + }); + + const info = Rapid.utilBuildingRelationInfo(relation, {}); + assert.ok(info, 'graph に依存しない経路になっていない'); + assert.equal(info.outlineCount, 1); + assert.equal(info.partCount, 0); + }); +``` + +- [ ] **Step 2: テストが落ちることを確認する** + +Run: `node --test-reporter dot --test test/unit/util/building_relation.test.js` +Expected: `accepts a multipolygon relation itself...`、`accepts a type=building relation itself`、 +`does not need parentRelations when a relation is passed` が FAIL(先頭の `entity.type !== 'way'` で null)。 +`returns null for a relation that is not a building` と `returns null for a route relation` は PASS。 + +- [ ] **Step 3: relation を受け付ける** + +`modules/util/building_relation.js` を差し替える。 + +```javascript +/** + * utilBuildingRelationInfo + * + * 指定 entity が「1 棟の建物」に属するとき、その relation 情報を返す。それ以外は null。 + * + * way を渡すと、その way が属する建物 relation を返す。 + * relation を渡すと、その relation 自身について同じ形の情報を返す。 + * 中庭建物は relation 単位で描画されるため、インスペクタが受け取る datum は relation になる。 + * メンバー way は描かれず hover も select もできないので、way からの経路だけでは届かない。 + * + * 対象は 2 種類ある。 + * type=building は Simple 3D Buildings / PLATEAU LOD2 の構造で、外形が役割 outline、 + * 内訳が part。メンバー way はそれぞれ自分のタグを持つ。 + * type=multipolygon は中庭のある建物で、外形が outer、穴が inner。 + * **タグは relation にだけ付き、メンバー way はタグを持たない。** + * + * UiRapidInspector や conflation ロジックから、UI 表示 / 動作判定の両方で使用。 + * + * @param {Object|null} entity - osmEntity (way または relation) + * @param {Graph|null} graph - 該当 entity の含まれる Graph (parentRelations を提供)。 + * relation を渡す場合は参照しない。 + * @return {{relation: osmRelation, outlineCount: number, partCount: number, + * relationType: string} | null} + */ +export function utilBuildingRelationInfo(entity, graph) { + if (!entity) return null; + + const relation = (entity.type === 'relation') + ? (isBuildingRelation(entity) ? entity : null) + : findParentBuildingRelation(entity, graph); + if (!relation) return null; + + const relationType = relation.tags.type; + // 役割名は relation の種別で変わる。外形と内訳を同じ 2 つの数に集約する。 + const outlineRole = (relationType === 'multipolygon') ? 'outer' : 'outline'; + const partRole = (relationType === 'multipolygon') ? 'inner' : 'part'; + + let outlineCount = 0; + let partCount = 0; + for (const m of relation.members || []) { + if (m.role === outlineRole) outlineCount++; + else if (m.role === partRole) partCount++; + } + return { relation, outlineCount, partCount, relationType }; +} + + +/** + * 「1 棟の建物」を表す relation かどうか。 + * 建物でない multipolygon (森林など) は対象外にする。 + */ +function isBuildingRelation(r) { + if (!r || !r.tags) return false; + if (r.tags.type === 'building') return true; + return r.tags.type === 'multipolygon' && !!r.tags.building; +} + + +/** + * way が属する建物 relation を探す。 + */ +function findParentBuildingRelation(entity, graph) { + if (entity.type !== 'way') return null; + if (!graph || typeof graph.parentRelations !== 'function') return null; + + let parents; + try { + parents = graph.parentRelations(entity); + } catch (e) { + return null; + } + return parents.find(isBuildingRelation) || null; +} +``` + +- [ ] **Step 4: テストが通ることを確認する** + +Run: `node --test-reporter dot --test test/unit/util/building_relation.test.js` +Expected: 既存のテストを含めて全件 PASS + +- [ ] **Step 5: relation の経路が本当に効いていることを確かめる** + +`entity.type === 'relation'` の分岐を一時的に `false` に置き換え、 +`accepts a multipolygon relation itself, not only its member ways` が落ちることを確認してから戻す。 +戻したあと `git diff` が想定どおりであることを確認する。 + +- [ ] **Step 6: 全テストを実行する** + +Run: `node --test-reporter dot --test "test/unit/**/*.test.js"` +Expected: `detect.test.js` の既存 3 件以外に失敗が無い + +Run: `npm run build:bundle:modern:dev && npm run test:browser` +Expected: 745 completed / 5 skipped / 0 failed + +- [ ] **Step 7: コミット** + +```bash +git add modules/util/building_relation.js test/unit/util/building_relation.test.js +git commit -m "feat(plateau): let building relation info accept the relation itself" +``` + +--- + +### Task 2: relation を選んでいるときの情報行を分ける + +**Files:** +- Modify: `data/core.yaml`(`courtyard_building_info` の直後に 1 キー追加) +- Modify: `modules/ui/UiRapidInspector.js:516-536`(情報行の文言選択) +- Test: なし(`UiRapidInspector` にテストは無い。理由は下記) + +**Interfaces:** +- Consumes: Task 1 が relation の datum で `buildingInfo` を返すようになったこと +- Produces: datum が relation のとき、情報行が「一部です」ではなく建物そのものを指す文言になる + +**このタスクにテストは無い。** +`UiRapidInspector` は d3 の DOM 構築に密結合していて、リポジトリ全体でテストが 1 件も無い。 +Task 1 で `relationType` と datum 種別の判定を unit テストに固定してあるので、 +ここで残るのは文言の選択だけである。目視で確認する。 + +- [ ] **Step 1: 文言を足す** + +`data/core.yaml` の `courtyard_building_info` の直後に追加する。 +インデントは前後の行に合わせる(キーは 4 スペース、その下は 6 スペース)。 + +```yaml + courtyard_building_selected_info: + one: This building has {n} courtyard. + other: This building has {n} courtyards. +``` + +- [ ] **Step 2: 情報行の文言選択を差し替える** + +`modules/ui/UiRapidInspector.js` の `$multiInfo` を組み立てる箇所で、 +`infoStringID` を決めている部分を差し替える。 + +```javascript + // datum が relation なら建物そのものを選んでいる。way なら建物の一部を選んでいる。 + // 中庭建物は relation 単位で描画されるので、実際に来るのは relation のほうである。 + const isRelationDatum = this.datum?.type === 'relation'; + let infoStringID = 'rapid_inspector.multi_section_building_info'; + if (isCourtyard) { + infoStringID = isRelationDatum + ? 'rapid_inspector.courtyard_building_selected_info' + : 'rapid_inspector.courtyard_building_info'; + } +``` + +`$multiInfo.text(l10n.t(infoStringID, { n: partCount }))` はそのまま使う。 + +- [ ] **Step 3: 全テストを実行する** + +Run: `node --test-reporter dot --test "test/unit/**/*.test.js"` +Expected: `detect.test.js` の既存 3 件以外に失敗が無い + +Run: `npm run build:bundle:modern:dev && npm run test:browser` +Expected: 745 completed / 5 skipped / 0 failed + +- [ ] **Step 4: 文言の選択を目視で確認する** + +次の 3 つの経路について、`renderChoices` を読んで確認する。 +確認した内容を報告に書く。 + +| datum | `isCourtyard` | 出る文言 | +|---|---|---| +| `type=multipolygon` の relation | true | `courtyard_building_selected_info`(「この建物には中庭が N 個あります」) | +| `type=building` の way | false | `multi_section_building_info`(従来どおり) | +| 建物 relation に属さない way | — | `buildingInfo` が null なので情報行を隠す(従来どおり) | + +あわせて、`type=multipolygon` の relation で +「Add Entire Building」が出て「Add Only This」が出ないことを、 +`acceptLabelStringID` の分岐と `if (buildingInfo && !isCourtyard)` のガードから確認する。 + +- [ ] **Step 5: コミット** + +```bash +git add data/core.yaml modules/ui/UiRapidInspector.js +git commit -m "feat(plateau): word the courtyard info line for a selected relation" +``` From 444e66b4c549eee04c3c5e106af7a02bacd6b801 Mon Sep 17 00:00:00 2001 From: nyampire Date: Thu, 6 Aug 2026 07:18:38 +0900 Subject: [PATCH 16/21] feat(plateau): let building relation info accept the relation itself --- modules/util/building_relation.js | 60 +++++++++++------ test/unit/util/building_relation.test.js | 82 ++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 19 deletions(-) diff --git a/modules/util/building_relation.js b/modules/util/building_relation.js index 3e7c0d6db..ca8823ce9 100644 --- a/modules/util/building_relation.js +++ b/modules/util/building_relation.js @@ -1,8 +1,12 @@ /** * utilBuildingRelationInfo * - * 指定 entity が「1 棟の建物」を表す relation のメンバー way である場合に、その relation - * 情報を返す。それ以外は null。 + * 指定 entity が「1 棟の建物」に属するとき、その relation 情報を返す。それ以外は null。 + * + * way を渡すと、その way が属する建物 relation を返す。 + * relation を渡すと、その relation 自身について同じ形の情報を返す。 + * 中庭建物は relation 単位で描画されるため、インスペクタが受け取る datum は relation になる。 + * メンバー way は描かれず hover も select もできないので、way からの経路だけでは届かない。 * * 対象は 2 種類ある。 * type=building は Simple 3D Buildings / PLATEAU LOD2 の構造で、外形が役割 outline、 @@ -12,28 +16,18 @@ * * UiRapidInspector や conflation ロジックから、UI 表示 / 動作判定の両方で使用。 * - * @param {Object|null} entity - osmEntity (typically a way) - * @param {Graph|null} graph - 該当 entity の含まれる Graph (parentRelations を提供) + * @param {Object|null} entity - osmEntity (way または relation) + * @param {Graph|null} graph - 該当 entity の含まれる Graph (parentRelations を提供)。 + * relation を渡す場合は参照しない。 * @return {{relation: osmRelation, outlineCount: number, partCount: number, * relationType: string} | null} */ export function utilBuildingRelationInfo(entity, graph) { - if (!entity || entity.type !== 'way') return null; - if (!graph || typeof graph.parentRelations !== 'function') return null; - - let parents; - try { - parents = graph.parentRelations(entity); - } catch (e) { - return null; - } + if (!entity) return null; - const relation = parents.find(r => { - if (!r.tags) return false; - if (r.tags.type === 'building') return true; - // 建物でない multipolygon (森林など) は対象外。 - return r.tags.type === 'multipolygon' && !!r.tags.building; - }); + const relation = (entity.type === 'relation') + ? (isBuildingRelation(entity) ? entity : null) + : findParentBuildingRelation(entity, graph); if (!relation) return null; const relationType = relation.tags.type; @@ -49,3 +43,31 @@ export function utilBuildingRelationInfo(entity, graph) { } return { relation, outlineCount, partCount, relationType }; } + + +/** + * 「1 棟の建物」を表す relation かどうか。 + * 建物でない multipolygon (森林など) は対象外にする。 + */ +function isBuildingRelation(r) { + if (!r || !r.tags) return false; + if (r.tags.type === 'building') return true; + return r.tags.type === 'multipolygon' && !!r.tags.building; +} + + +/** + * way が属する建物 relation を探す。 + */ +function findParentBuildingRelation(entity, graph) { + if (entity.type !== 'way') return null; + if (!graph || typeof graph.parentRelations !== 'function') return null; + + let parents; + try { + parents = graph.parentRelations(entity); + } catch (e) { + return null; + } + return parents.find(isBuildingRelation) || null; +} diff --git a/test/unit/util/building_relation.test.js b/test/unit/util/building_relation.test.js index 7f7e32f0e..7761685ef 100644 --- a/test/unit/util/building_relation.test.js +++ b/test/unit/util/building_relation.test.js @@ -146,4 +146,86 @@ describe('utilBuildingRelationInfo', () => { assert.equal(Rapid.utilBuildingRelationInfo(outer, graph), null); }); + + it('accepts a multipolygon relation itself, not only its member ways', () => { + // 中庭建物は relation 単位で描画されるので、インスペクタが受け取る datum は + // relation になる。way が来ないので、relation を直接渡せる必要がある。 + const outer = Rapid.osmWay({ id: 'w_outer', nodes: [] }); + const inner = Rapid.osmWay({ id: 'w_inner', nodes: [] }); + const relation = Rapid.osmRelation({ + id: 'r_mp', + tags: { type: 'multipolygon', building: 'yes' }, + members: [ + { id: 'w_outer', type: 'way', role: 'outer' }, + { id: 'w_inner', type: 'way', role: 'inner' } + ] + }); + const graph = new Rapid.Graph([outer, inner, relation]); + + const info = Rapid.utilBuildingRelationInfo(relation, graph); + assert.ok(info, 'relation 自身が受け付けられていない'); + assert.equal(info.relation.id, 'r_mp'); + assert.equal(info.relationType, 'multipolygon'); + assert.equal(info.outlineCount, 1); + assert.equal(info.partCount, 1); + }); + + it('accepts a type=building relation itself', () => { + const outline = Rapid.osmWay({ id: 'w_outline', nodes: [] }); + const part = Rapid.osmWay({ id: 'w_part', nodes: [] }); + const relation = Rapid.osmRelation({ + id: 'r_b', + tags: { type: 'building', building: 'yes' }, + members: [ + { id: 'w_outline', type: 'way', role: 'outline' }, + { id: 'w_part', type: 'way', role: 'part' } + ] + }); + const graph = new Rapid.Graph([outline, part, relation]); + + const info = Rapid.utilBuildingRelationInfo(relation, graph); + assert.ok(info); + assert.equal(info.relationType, 'building'); + assert.equal(info.outlineCount, 1); + assert.equal(info.partCount, 1); + }); + + it('returns null for a relation that is not a building', () => { + const outer = Rapid.osmWay({ id: 'w_outer', nodes: [] }); + const relation = Rapid.osmRelation({ + id: 'r_forest', + tags: { type: 'multipolygon', landuse: 'forest' }, + members: [{ id: 'w_outer', type: 'way', role: 'outer' }] + }); + const graph = new Rapid.Graph([outer, relation]); + + assert.equal(Rapid.utilBuildingRelationInfo(relation, graph), null); + }); + + it('returns null for a route relation', () => { + const way = Rapid.osmWay({ id: 'w1', nodes: [] }); + const relation = Rapid.osmRelation({ + id: 'r_route', + tags: { type: 'route', route: 'bus' }, + members: [{ id: 'w1', type: 'way', role: '' }] + }); + const graph = new Rapid.Graph([way, relation]); + + assert.equal(Rapid.utilBuildingRelationInfo(relation, graph), null); + }); + + it('does not need parentRelations when a relation is passed', () => { + // relation 自身を渡すときは親をたどらないので、graph が + // parentRelations を持たなくても答えられる。 + const relation = Rapid.osmRelation({ + id: 'r_mp2', + tags: { type: 'multipolygon', building: 'yes' }, + members: [{ id: 'w_outer', type: 'way', role: 'outer' }] + }); + + const info = Rapid.utilBuildingRelationInfo(relation, {}); + assert.ok(info, 'graph に依存しない経路になっていない'); + assert.equal(info.outlineCount, 1); + assert.equal(info.partCount, 0); + }); }); From 8f3d92f6b6a4c40bc9e51bf6a0644f6463c2a3f3 Mon Sep 17 00:00:00 2001 From: nyampire Date: Thu, 6 Aug 2026 07:24:26 +0900 Subject: [PATCH 17/21] feat(plateau): word the courtyard info line for a selected relation --- data/core.yaml | 3 +++ modules/ui/UiRapidInspector.js | 12 +++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/data/core.yaml b/data/core.yaml index b0138d568..9d0a141fa 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -1146,6 +1146,9 @@ en: courtyard_building_info: one: This way is part of a building with {n} courtyard. other: This way is part of a building with {n} courtyards. + courtyard_building_selected_info: + one: This building has {n} courtyard. + other: This building has {n} courtyards. option_ignore: label: Ignore This Feature description: Ignoring this feature helps us improve our machine learning. If you are unsure whether or not this is an accurate feature, feel free to leave it as is, nothing will be saved or lost. 👍 diff --git a/modules/ui/UiRapidInspector.js b/modules/ui/UiRapidInspector.js index 3a4f6860b..2e86a4350 100644 --- a/modules/ui/UiRapidInspector.js +++ b/modules/ui/UiRapidInspector.js @@ -522,9 +522,15 @@ export class UiRapidInspector { const partCount = buildingInfo?.partCount; const hideAsEmptyCourtyard = isCourtyard && partCount === 0; if (buildingInfo && !hideAsEmptyCourtyard) { - const infoStringID = isCourtyard - ? 'rapid_inspector.courtyard_building_info' - : 'rapid_inspector.multi_section_building_info'; + // datum が relation なら建物そのものを選んでいる。way なら建物の一部を選んでいる。 + // 中庭建物は relation 単位で描画されるので、実際に来るのは relation のほうである。 + const isRelationDatum = this.datum?.type === 'relation'; + let infoStringID = 'rapid_inspector.multi_section_building_info'; + if (isCourtyard) { + infoStringID = isRelationDatum + ? 'rapid_inspector.courtyard_building_selected_info' + : 'rapid_inspector.courtyard_building_info'; + } $multiInfo .style('display', null) .text(l10n.t(infoStringID, { n: partCount })); From 5a74a19e8eebc152d76be63ac9f7ba448de37811 Mon Sep 17 00:00:00 2001 From: nyampire Date: Thu, 6 Aug 2026 07:34:13 +0900 Subject: [PATCH 18/21] =?UTF-8?q?docs(plateau):=20relation=20=E3=81=AE=20p?= =?UTF-8?q?arse=20=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E3=82=92=20multipol?= =?UTF-8?q?ygon=20=E3=81=AE=E5=AE=9F=E6=85=8B=E3=81=AB=E5=90=88=E3=82=8F?= =?UTF-8?q?=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/services/PlateauService.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/modules/services/PlateauService.js b/modules/services/PlateauService.js index 745c98252..24f2afe33 100644 --- a/modules/services/PlateauService.js +++ b/modules/services/PlateauService.js @@ -897,9 +897,15 @@ export class PlateauService extends AbstractSystem { } else if (type === 'way') { entity = this._parseWay(element, entityID); } else if (type === 'relation') { - // Phase 3: PLATEAU LOD2 type=building relation (outline + parts) のような - // 構造をクライアント graph に取り込む。relation 単独では geometry を持たず - // メンバー way がレンダリングを担うため、parse + graph 追加だけで十分。 + // PLATEAU の relation をクライアント graph に取り込む。2 種類ある。 + // + // type=building は LOD2 の outline + parts で、メンバー way がレンダリングを担う。 + // relation 自身は geometry を持たないので、parse して graph に入れるだけでよい。 + // + // type=building タグ付きの type=multipolygon は中庭のある建物で、こちらは違う。 + // relation 自身が geometry を持ち (osmRelation.geometry() が 'area' を返す)、 + // PixiLayerRapid が relation を 1 つのポリゴンとして描き、メンバー way は描かない。 + // そのためユーザが hover / select できるのは relation のほうになる。 entity = this._parseRelation(element, entityID); } else { return null; From b3713aa67fbcbaceb37ada4504ef3c9328c0cf69 Mon Sep 17 00:00:00 2001 From: nyampire Date: Thu, 6 Aug 2026 12:51:32 +0900 Subject: [PATCH 19/21] =?UTF-8?q?docs:=20=E4=B8=AD=E5=BA=AD=E3=81=AE?= =?UTF-8?q?=E3=81=82=E3=82=8B=E5=BB=BA=E7=89=A9=E3=82=92=E9=AB=98=E3=81=95?= =?UTF-8?q?=E8=BB=A2=E8=A8=98=E3=81=AE=E5=AF=BE=E8=B1=A1=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B=E8=A8=AD=E8=A8=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-06-courtyard-height-transfer-design.ja.md | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-06-courtyard-height-transfer-design.ja.md diff --git a/docs/superpowers/specs/2026-08-06-courtyard-height-transfer-design.ja.md b/docs/superpowers/specs/2026-08-06-courtyard-height-transfer-design.ja.md new file mode 100644 index 000000000..2573f98b5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-courtyard-height-transfer-design.ja.md @@ -0,0 +1,119 @@ +# 中庭のある建物を高さ転記の対象にする設計 + +- 日付: 2026-08-06 +- 関連: `feature/plateau-multipolygon-conflation`(conflation・accept・描画・インスペクタを実装済み)、`feature/plateau-geometry-replacement`(未マージ) + +## 背景 + +中庭のある建物は `type=multipolygon` の relation で届き、タグは relation にだけ付く。 +このブランチで表示・選択・説明・追加は 1 棟として扱えるようになった。 + +高さ転記だけが残っている。 +`HeightTransferMatcher.findCandidates` が候補を `f.type === 'way'` で絞るため、 +relation は落ち、タグを持たないメンバー way も落ちる。 +**中庭のある建物は高さ転記の対象に一度も現れない。** + +インスペクタが中庭建物でまともに動くようになった分、ユーザが触れる回数は増える。 +高さ転記だけ黙って対象外である状態は、これまでより目につきやすくなる。 + +## 調査で確認したこと + +高さ転記の経路を端から端まで読んだ。relation を通すのに必要な変更は 2 箇所だけである。 + +| 段 | relation で動くか | +|---|---| +| 候補の絞り込み | **動かない。`f.type === 'way'` で落ちる** | +| `asGeoJSON` と面積 | **要検討。MultiPolygon になり、turf の `area` は穴を差し引く** | +| `analyzeTagStates` | 動く。`plateauFeature.tags` を読むだけで、タグは relation にある | +| `booleanPointInPolygon` | 動く。OSM 側のポリゴンに対してのみ使う | +| `PixiLayerHeightTransfer` | 動く。`representativePoint` と `id` しか使わない | +| 転記後の `transferredIDs` | 動く。`plateauFeature.id` を記録するだけ | +| タグ転記の action | 動く。entity の型に依存しない | + +`type=building` の LOD2 は影響を受けない。 +外形 way が `building` タグを持つので、いまも候補に入っている。 + +## 設計 + +### 1. 候補の絞り込みを relation にも広げる + +`type=multipolygon` かつ `building` タグを持つ relation を候補にする。 +判定は意味の問いなので `building` を要求する。森林などの multipolygon は対象外。 + +`!f.tags['building:part']` の条件はそのままでよい。relation はこのタグを持たない。 + +### 2. 面積比は外側リングで測る + +turf の `area` は MultiPolygon の穴を差し引く。 +一方 OSM 側は単純な way なので総面積である。 +そのまま比べると**正味面積と総面積の比較**になり、中庭が広い建物ほど比が小さく出る。 + +`AREA_RATIO_MIN` のコメントはこの閾値の目的をこう書いている。 + +> A Plateau outline far SMALLER than the OSM building it sits in is an ancillary structure +> Plateau models as its own `building` — a rooftop stair enclosure, a shed. + +正味面積で比べると、**中庭のある建物が小屋と同じ理由で落ちる**。 +閾値が防ごうとしているものと正反対になる。 + +そこで PLATEAU 側の面積は**外側リングだけ**で測る。 +OSM 側が総面積なので、同種どうしの比較になる。 + +単純な way はリングが 1 本なので、外側リングの面積は全体の面積と等しい。 +**本番データの大半を占めるこの経路の値は変わらない。** + +outer が複数ある multipolygon は、黙って 1 本目だけを測らない。 +現行の producer は outer を 1 本しか出さない(importer は `len(outer) == 1` を要求し、 +Esri は `ways[0]` にだけ outer を振る)が、来たときの扱いを決めておく。 + +### 3. 形状置換ブランチを安全にする + +`feature/plateau-geometry-replacement` の `isReplaceable` は OSM 側しか見ておらず、既定が `true` である。 +本設計で relation が候補になると、そのブランチでは `replaceable: true` が付き、 +`actionReplaceBuildingGeometry` が `plateauWay.nodes` を回そうとして落ちる。 + +形状置換は「OSM の way の形状を差し替えて id を保つ」操作である。 +中庭のある形は 1 本の way で表せないので、置換するなら既存の way を multipolygon relation に +作り変えることになる。これは形状の差し替えではなく OSM オブジェクトの種類を変える操作で、別の設計が要る。 + +そこで、そのブランチに**防御コミットを 1 本**入れる。 +`isReplaceable` に PLATEAU 側の条件を足し、`outline.type !== 'way'` なら `false` を返す。 + +これは機能追加ではなく、そのブランチ単体を安全にする変更である。 +形状置換のレビューやデプロイ判断とは切り離せる。マージのタイミングも自由なままになる。 + +提示してから失敗するより、提示しないほうが素直なので、`isReplaceable` に置く。 +`actionReplaceBuildingGeometry` 側では拒否しない。 + +## 対象外 + +**OSM 側が multipolygon の建物。** +`f.type === 'way'` で絞っているので、すでに中庭付きで描かれている OSM の建物は転記先にならない。 +PLATEAU 側の形とは独立した既存の穴で、本設計では扱わない。 + +**形状置換を中庭建物に対応させること。** +上記のとおり別の操作になる。今回は候補から外すだけにする。 + +**面積比の閾値そのもの。** `0.5` と `2.0` は変えない。 + +## 記録しておくこと + +比率が画面の見た目と一致しなくなる。 +描画は穴を抜いた形を見せるのに、判定は穴を含む面積で行う。 +不一致を調べる人が迷わないよう、コードにその理由を残す。 + +`osmRelation.multipolygon()` は `'outer' === (m.role || 'outer')` で role 空を outer とみなす。 +本設計を含め他の判定は文字列一致を要求するのでずれる。 +API は必ず role を書くので到達しない。 + +## 検証 + +- `type=multipolygon` + `building` の relation が候補になる +- 建物でない multipolygon の relation は候補にならない +- `type=building` の LOD2 外形 way が従来どおり候補になる +- **単純な way の面積比が変わらない**(外側リング=全体) +- 中庭のある建物の面積比が、穴を差し引かない値で計算される +- 中庭が広い建物が `AREA_RATIO_MIN` で落ちない +- outer が複数ある multipolygon で、1 本目だけを黙って測らない +- 転記後に relation の id が `transferredIDs` に入り、再び候補にならない +- 形状置換ブランチで、中庭建物の候補が `replaceable: false` になる From 5b2cf23139bc66697d9197ea7d94c79ddd6ea3c3 Mon Sep 17 00:00:00 2001 From: nyampire Date: Thu, 6 Aug 2026 12:56:57 +0900 Subject: [PATCH 20/21] =?UTF-8?q?docs:=20=E4=B8=AD=E5=BA=AD=E3=81=AE?= =?UTF-8?q?=E3=81=82=E3=82=8B=E5=BB=BA=E7=89=A9=E3=82=92=E9=AB=98=E3=81=95?= =?UTF-8?q?=E8=BB=A2=E8=A8=98=E3=81=AE=E5=AF=BE=E8=B1=A1=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B=E5=AE=9F=E8=A3=85=E8=A8=88=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-08-06-courtyard-height-transfer.md | 418 ++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-06-courtyard-height-transfer.md diff --git a/docs/superpowers/plans/2026-08-06-courtyard-height-transfer.md b/docs/superpowers/plans/2026-08-06-courtyard-height-transfer.md new file mode 100644 index 000000000..f6b09c699 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-courtyard-height-transfer.md @@ -0,0 +1,418 @@ +# 中庭のある建物を高さ転記の対象にする 実装計画 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `type=multipolygon` の中庭建物を高さ転記の候補にし、面積比を外側リングで測る。あわせて未マージの形状置換ブランチを、中庭建物の候補が来ても壊れない状態にする。 + +**Architecture:** `HeightTransferMatcher.findCandidates` の絞り込みを relation にも広げ、PLATEAU 側の面積を外側リングだけで測る補助関数を足す。形状置換ブランチには `isReplaceable` に PLATEAU 側の条件を足す防御コミットを 1 本入れる。 + +**Tech Stack:** JavaScript (ESM), Karma + Mocha + Chai(browser), node:test(unit), @turf/area + +## Global Constraints + +- 候補にするのは `type=multipolygon` かつ `building` タグを持つ relation。建物でない multipolygon は対象外。 +- `!f.tags['building:part']` の条件は残す。 +- **PLATEAU 側の面積は外側リングだけで測る。**OSM 側は単純な way で総面積なので、同種どうしの比較にする。 +- **単純な way の面積比を変えない。**リング 1 本なので外側リングの面積は全体の面積と等しい。本番データの大半がこの経路である。 +- outer が複数ある multipolygon で、黙って 1 本目だけを測らない。 +- 面積比の閾値 `AREA_RATIO_MIN = 0.5` と `AREA_RATIO_MAX = 2.0` は変えない。 +- `analyzeTagStates`、`booleanPointInPolygon` の使い方、`PixiLayerHeightTransfer`、`transferredIDs` の記録は変えない。調査でいずれも relation でそのまま動くことを確認済み。 +- OSM 側の `f.type === 'way'` は変えない。OSM が multipolygon で描かれた建物を転記先にするのは対象外。 +- `type=building` の LOD2 外形 way の挙動を変えない。 +- spec: `docs/superpowers/specs/2026-08-06-courtyard-height-transfer-design.ja.md` + +## テストの実行 + +```bash +npm run build:bundle:modern:dev && npm run test:browser # Karma は dist/rapid.js を読む +node --test-reporter dot --test "test/unit/**/*.test.js" # npm run test:unit は c8 が Node v26 で起動しない +``` + +browser の baseline は 745 completed / 5 skipped / 0 failed。 +unit は `detect.test.js` に既存の失敗がある。今回の変更とは無関係。 +ビルド成果物はコミットしない。 + +## GeoJSON の形が 2 通りある + +`osmWay.asGeoJSON` は素のジオメトリ `{ type: 'Polygon', coordinates }` を返す。 +`osmRelation.asGeoJSON` も素の `{ type: 'MultiPolygon', coordinates }` を返す。 + +一方、`test/browser/core/lib/HeightTransferMatcher.test.js` の既存モックは +`{ type: 'Feature', geometry: {...}, properties: {} }` を返す。 + +turf はどちらも受け付けるが、**自分で `coordinates` を読む補助関数は両方に対応する必要がある。** + +--- + +### Task 1: 中庭建物を候補にし、面積を外側リングで測る + +**Files:** +- Modify: `modules/core/lib/HeightTransferMatcher.js`(絞り込みと面積計算) +- Test: `test/browser/core/lib/HeightTransferMatcher.test.js` + +**Interfaces:** +- Produces: `outerRingArea(geo)` — PLATEAU 側の面積を外側リングだけで測る内部関数。エクスポートしない。 +- Produces: `findCandidates` が `type=multipolygon` + `building` の relation も候補にする。 +- 単純な way に対する結果は変わらない。 + +- [ ] **Step 1: 失敗するテストを書く** + +`test/browser/core/lib/HeightTransferMatcher.test.js` の `describe('findCandidates', ...)` の中、 +既存のヘルパー定義の後ろに追加する。 + +```javascript + // 中庭のある建物。asGeoJSON は MultiPolygon を返し、タグは relation にだけ付く。 + // outerCoords が外側リング、holeCoords が穴。 + function courtyard(id, outerCoords, holeCoords, tags, rp) { + return { id, type: 'relation', + tags: { type: 'multipolygon', building: 'yes', ...tags }, + representativePoint: rp, + asGeoJSON: () => ({ type: 'MultiPolygon', + coordinates: [[outerCoords, holeCoords]] }) }; + } + + // SQR の内側に収まる、一辺がおよそ 8 割の穴。面積比でおよそ 0.36 になる。 + const HOLE = [[139.7551, 35.6791], [139.75590, 35.6791], + [139.75590, 35.67990], [139.7551, 35.67990], [139.7551, 35.6791]]; + + it('accepts a type=multipolygon building relation as a candidate', () => { + const p = courtyard('r1', SQR, HOLE, { height: '12' }, SQR_CENTER); + const o = osmBuilding('o1', SQR); + const out = Rapid.findCandidates({ + plateauEntities: [p], osmEntities: [o], + transferredIDs: new Set(), acceptIDs: new Set(), ignoreIDs: new Set() + }); + expect(out).to.have.lengthOf(1, '中庭建物が候補になっていない'); + expect(out[0].state).to.equal('CANDIDATE'); + expect(out[0].missingTags).to.eql(['height']); + }); + + it('measures the courtyard building by its outer ring, not its net area', () => { + // 穴を差し引くと比は 0.5 を割り、AREA_RATIO_MIN で落ちる。 + // 外側リングで測れば OSM と同じ形なので比は 1 になる。 + const p = courtyard('r2', SQR, HOLE, { height: '12' }, SQR_CENTER); + const o = osmBuilding('o2', SQR); + const out = Rapid.findCandidates({ + plateauEntities: [p], osmEntities: [o], + transferredIDs: new Set(), acceptIDs: new Set(), ignoreIDs: new Set() + }); + expect(out).to.have.lengthOf(1, '正味面積で測って落ちている'); + expect(out[0].ratio).to.be.closeTo(1, 0.05); + }); + + it('ignores a multipolygon that is not a building', () => { + const forest = { id: 'r3', type: 'relation', + tags: { type: 'multipolygon', landuse: 'forest', height: '12' }, + representativePoint: SQR_CENTER, + asGeoJSON: () => ({ type: 'MultiPolygon', + coordinates: [[SQR, HOLE]] }) }; + const o = osmBuilding('o3', SQR); + const out = Rapid.findCandidates({ + plateauEntities: [forest], osmEntities: [o], + transferredIDs: new Set(), acceptIDs: new Set(), ignoreIDs: new Set() + }); + expect(out).to.have.lengthOf(0); + }); + + it('keeps the area of a plain way unchanged', () => { + // 本番データの大半はこの経路。リングが 1 本なので外側リング = 全体。 + const p = outline('p1', SQR, { height: '12' }, SQR_CENTER); + const o = osmBuilding('o1', SQR); + const out = Rapid.findCandidates({ + plateauEntities: [p], osmEntities: [o], + transferredIDs: new Set(), acceptIDs: new Set(), ignoreIDs: new Set() + }); + expect(out).to.have.lengthOf(1); + expect(out[0].ratio).to.be.closeTo(1, 0.001); + }); + + it('sums every outer ring when a multipolygon has more than one', () => { + // 1 本目だけを黙って測らないことを固定する。 + // SQR と、その東隣に同じ大きさの正方形をもう 1 つ置く。 + const EAST = SQR.map(([lon, lat]) => [lon + 0.001, lat]); + const twoOuters = { id: 'r4', type: 'relation', + tags: { type: 'multipolygon', building: 'yes', height: '12' }, + representativePoint: SQR_CENTER, + asGeoJSON: () => ({ type: 'MultiPolygon', + coordinates: [[SQR], [EAST]] }) }; + const o = osmBuilding('o4', SQR); + const out = Rapid.findCandidates({ + plateauEntities: [twoOuters], osmEntities: [o], + transferredIDs: new Set(), acceptIDs: new Set(), ignoreIDs: new Set() + }); + // 2 つ分の面積なので比はおよそ 2 になる。1 本目だけを測っていれば 1 になる。 + // このテストが見るのは比の値だけで、state は見ない。比が 2.0 の境界に乗るため + // CANDIDATE と AREA_MISMATCH のどちらになるかは投影の誤差で変わりうる。 + expect(out).to.have.lengthOf(1); + expect(out[0].ratio).to.be.closeTo(2, 0.1); + }); + + it('does not offer a courtyard building again after it was transferred', () => { + // 転記後は relation の id が transferredIDs に入る。絞り込みが id で見ているので + // way と同じ扱いになるが、relation でも効くことを固定しておく。 + const p = courtyard('r5', SQR, HOLE, { height: '12' }, SQR_CENTER); + const o = osmBuilding('o5', SQR); + const out = Rapid.findCandidates({ + plateauEntities: [p], osmEntities: [o], + transferredIDs: new Set(['r5']), acceptIDs: new Set(), ignoreIDs: new Set() + }); + expect(out).to.have.lengthOf(0); + }); +``` + +- [ ] **Step 2: テストが落ちることを確認する** + +Run: `npm run build:bundle:modern:dev && npm run test:browser` +Expected: `accepts a type=multipolygon building relation as a candidate`、 +`measures the courtyard building by its outer ring, not its net area`、 +`sums every outer ring when a multipolygon has more than one` が FAIL(relation が絞り込みで落ちる)。 +`ignores a multipolygon that is not a building`、`keeps the area of a plain way unchanged`、 +`does not offer a courtyard building again after it was transferred` は PASS。 + +- [ ] **Step 3: 外側リングだけで面積を測る補助関数を足す** + +`modules/core/lib/HeightTransferMatcher.js` の `AREA_RATIO_MAX` の定義の後ろに追加する。 + +```javascript +/** + * PLATEAU 側の面積を外側リングだけで測る。 + * + * turf の `area` は MultiPolygon の穴を差し引く。OSM 側は単純な way で総面積なので、 + * そのまま比べると正味面積と総面積の比較になり、中庭が広い建物ほど比が小さく出る。 + * AREA_RATIO_MIN は「OSM の建物よりずっと小さい PLATEAU の外形は塔屋や物置である」 + * という判定なので、中庭のある建物がそれと同じ理由で落ちてしまう。 + * + * 外側リングだけで測れば、OSM 側と同種どうしの比較になる。 + * 単純な way はリングが 1 本なので、値は全体の面積と等しく、結果は変わらない。 + * + * 描画は穴を抜いた形を見せるので、この面積は画面の見た目と一致しない。 + * + * `osmWay.asGeoJSON` / `osmRelation.asGeoJSON` は素のジオメトリを返すが、 + * テストのモックは Feature を返すので、どちらの形も受ける。 + */ +function outerRingArea(geo) { + const g = geo?.geometry ?? geo; + if (!g?.type) return 0; + + if (g.type === 'Polygon') { + return area({ type: 'Polygon', coordinates: [g.coordinates[0]] }); + } + if (g.type === 'MultiPolygon') { + // outer が複数あるときは全部の外側リングを合算する。1 本目だけを測らない。 + return area({ + type: 'MultiPolygon', + coordinates: g.coordinates.map(poly => [poly[0]]) + }); + } + return area(geo); +} +``` + +- [ ] **Step 4: 絞り込みを relation にも広げる** + +`findCandidates` の `outlines` を差し替える。 + +```javascript + const outlines = plateauEntities.filter(f => { + // 転記元になるのは 1 棟の建物である。way は自分のタグを持ち、 + // 中庭のある建物は type=multipolygon の relation で届いてタグは relation にだけ付く。 + // 建物でない multipolygon (森林など) は対象外なので building タグを要求する。 + const isWay = f.type === 'way'; + const isCourtyard = f.type === 'relation' && f.tags?.type === 'multipolygon'; + if (!isWay && !isCourtyard) return false; + + return f.tags?.building && + !f.tags['building:part'] && + !transferredIDs.has(f.id) && + !acceptIDs.has(f.id) && + !ignoreIDs.has(f.id) && + f.representativePoint; + }); +``` + +- [ ] **Step 5: 面積の計算を差し替える** + +`findCandidates` の面積を求めている行を差し替える。OSM 側は変えない。 + +```javascript + const outlineArea = outerRingArea(outlineGeo); + const osmArea = area(osmGeo); +``` + +- [ ] **Step 6: テストが通ることを確認する** + +Run: `npm run build:bundle:modern:dev && npm run test:browser` +Expected: 追加した 6 件が PASS。既存の `findCandidates` テストも全件 PASS。 + +- [ ] **Step 7: 外側リングで測っていることを確かめる** + +`outerRingArea` の `MultiPolygon` 分岐を一時的に `return area(geo);` に置き換え、 +`measures the courtyard building by its outer ring, not its net area` が落ちることを確認してから戻す。 +これで「穴を差し引くと落ちる」という前提が実際に成り立っていることが分かる。 + +戻したあと `git diff` が想定どおりであることを確認する。 + +- [ ] **Step 8: 全テストを実行する** + +Run: `node --test-reporter dot --test "test/unit/**/*.test.js"` +Expected: `detect.test.js` の既存の失敗以外に失敗が無い + +- [ ] **Step 9: コミット** + +```bash +git add modules/core/lib/HeightTransferMatcher.js test/browser/core/lib/HeightTransferMatcher.test.js +git commit -m "feat(plateau): offer courtyard buildings for height transfer" +``` + +--- + +### Task 2: 形状置換ブランチを中庭建物に対して安全にする + +**Files:** +- Modify: `modules/core/lib/HeightTransferMatcher.js`(**`feature/plateau-geometry-replacement` ブランチ上**) + +**Interfaces:** +- Consumes: Task 1 で relation が候補になること。ただしコードとしては独立していて、Task 1 が無くても意味を持つ。 +- Produces: PLATEAU 側が way でない候補は `replaceable: false` になる。 + +**このタスクは別のブランチで作業する。** +`isReplaceable` は `feature/plateau-geometry-replacement` にしか存在しない。 +そのブランチは WIP で、まだデプロイもレビューも通していない。 +本タスクは機能追加ではなく、そのブランチ単体を安全にする防御コミット 1 本である。 + +なぜ要るか。 +`isReplaceable` は OSM 側しか見ておらず既定が `true` なので、中庭建物が候補になると +`replaceable: true` が付く。`actionReplaceBuildingGeometry` は `plateauWay.nodes` を回すが +relation に `.nodes` は無いので落ちる。 + +形状置換は「OSM の way の形状を差し替えて id を保つ」操作で、中庭のある形は 1 本の way では +表せない。置換するなら既存の way を multipolygon relation に作り変えることになり、別の設計が要る。 +今回は候補から外すだけにする。 + +- [ ] **Step 1: ブランチを切り替える** + +作業前に現ブランチがきれいであることを確認する。 + +```bash +git status --short # 空であること +git branch --show-current # feature/plateau-multipolygon-conflation +git checkout feature/plateau-geometry-replacement +git log --oneline -1 # 29c18e2ba feat(plateau): auto-preview replace + compare view + shortcuts (#5) +``` + +- [ ] **Step 2: 失敗するテストを書く** + +`test/browser/core/lib/HeightTransferMatcher.test.js` の `describe('findCandidates', ...)` の中に追加する。 +**このブランチのテストファイルには Task 1 のテストは無い。**ヘルパー `outline` / `osmBuilding` / +`SQR` / `SQR_CENTER` はこのブランチにも同じ形で存在するので、それを使う。 + +```javascript + it('never offers a non-way plateau source for geometry replacement', () => { + // 中庭のある建物は type=multipolygon の relation で届く。 + // 形状置換は OSM の way の形状を差し替える操作なので、1 本の way で表せない + // 形は元にできない。候補として提示しない。 + const courtyard = { id: 'r1', type: 'relation', + tags: { type: 'multipolygon', building: 'yes', height: '12' }, + representativePoint: SQR_CENTER, + asGeoJSON: () => ({ type: 'MultiPolygon', coordinates: [[SQR]] }) }; + const o = osmBuilding('o1', SQR); + const out = Rapid.findCandidates({ + plateauEntities: [courtyard], osmEntities: [o], + transferredIDs: new Set(), acceptIDs: new Set(), ignoreIDs: new Set() + }); + + // このブランチの絞り込みはまだ way しか通さないので候補は 0 件になる。 + // それでも、通るようになったときに replaceable が false であることを固定しておく。 + for (const c of out) { + expect(c.replaceable).to.be.false; + } + }); + + it('still offers a plain way for geometry replacement', () => { + const p = outline('p1', SQR, { height: '12' }, SQR_CENTER); + const o = osmBuilding('o1', SQR); + const out = Rapid.findCandidates({ + plateauEntities: [p], osmEntities: [o], + transferredIDs: new Set(), acceptIDs: new Set(), ignoreIDs: new Set() + }); + expect(out).to.have.lengthOf(1); + expect(out[0].replaceable).to.be.true; + }); +``` + +1 件目はこのブランチ単体では候補が 0 件なので空ループになる。 +**それでよい。**このブランチに Task 1 がマージされた瞬間に意味を持つ形で置いておく。 +2 件目が既存の挙動を守る。 + +- [ ] **Step 3: テストを実行する** + +Run: `npm run build:bundle:modern:dev && npm run test:browser` +Expected: 2 件とも PASS(1 件目は空ループ、2 件目は既存の挙動)。 + +- [ ] **Step 4: PLATEAU 側の条件を足す** + +`modules/core/lib/HeightTransferMatcher.js` の `isReplaceable` を差し替える。 + +```javascript +function isReplaceable(osmWay, osmGraph, state, plateauFeature) { + if (state === 'AREA_MISMATCH') return false; + + // 中庭のある建物は type=multipolygon の relation で届く。 + // この操作は OSM の way の形状を差し替えて id を保つものなので、1 本の way で + // 表せない形は元にできない。既定が true なので、ここで明示的に落とす。 + if (plateauFeature?.type !== 'way') return false; + + if (!osmGraph?.hasEntity || !osmGraph.parentWays) return true; // mock/no-graph fallback + for (const nid of osmWay.nodes ?? []) { + const node = osmGraph.hasEntity(nid); + if (!node) continue; + for (const parent of osmGraph.parentWays(node)) { + if (parent.id !== osmWay.id && (parent.tags?.building || parent.tags?.['building:part'])) { + return false; + } + } + } + return true; +} +``` + +呼び出し側に `outline` を渡す。 + +```javascript + replaceable: isReplaceable(osm, osmGraph, state, outline), +``` + +- [ ] **Step 5: テストが通ることを確認する** + +Run: `npm run build:bundle:modern:dev && npm run test:browser` +Expected: 追加した 2 件を含めて全件 PASS。 + +- [ ] **Step 6: 守りが効いていることを確かめる** + +このブランチ単体では中庭建物が候補にならないので、guard が効くことを直接は見られない。 +絞り込みを一時的に `f.type === 'way' || +(f.type === 'relation' && f.tags?.type === 'multipolygon')` に広げ、 +`never offers a non-way plateau source for geometry replacement` が +空ループでなく実際に 1 件を検査して PASS することを確認する。 + +そのうえで `if (plateauFeature?.type !== 'way') return false;` を消し、 +同じテストが FAIL することを確認する。 + +両方戻したあと `git diff` に guard の追加だけが残っていることを確認する。 + +- [ ] **Step 7: コミット** + +```bash +git add modules/core/lib/HeightTransferMatcher.js test/browser/core/lib/HeightTransferMatcher.test.js +git commit -m "fix(plateau): never offer a relation source for geometry replacement" +``` + +- [ ] **Step 8: 元のブランチに戻る** + +```bash +git checkout feature/plateau-multipolygon-conflation +git log --oneline -1 # Task 1 のコミットであること +git status --short # 空であること +``` + +戻ったことを報告に明記する。 From d71c3d81309ac4a0ed8c9ad4eb7929d59210f320 Mon Sep 17 00:00:00 2001 From: nyampire Date: Thu, 6 Aug 2026 13:04:30 +0900 Subject: [PATCH 21/21] feat(plateau): offer courtyard buildings for height transfer --- modules/core/lib/HeightTransferMatcher.js | 59 +++++++++-- .../core/lib/HeightTransferMatcher.test.js | 98 +++++++++++++++++++ 2 files changed, 147 insertions(+), 10 deletions(-) diff --git a/modules/core/lib/HeightTransferMatcher.js b/modules/core/lib/HeightTransferMatcher.js index 1a2a34d6e..5a79c76ac 100644 --- a/modules/core/lib/HeightTransferMatcher.js +++ b/modules/core/lib/HeightTransferMatcher.js @@ -6,6 +6,39 @@ export const TARGET_TAG_KEYS = ['height', 'ele', 'building:levels']; const AREA_RATIO_MIN = 0.5; const AREA_RATIO_MAX = 2.0; +/** + * PLATEAU 側の面積を外側リングだけで測る。 + * + * turf の `area` は MultiPolygon の穴を差し引く。OSM 側は単純な way で総面積なので、 + * そのまま比べると正味面積と総面積の比較になり、中庭が広い建物ほど比が小さく出る。 + * AREA_RATIO_MIN は「OSM の建物よりずっと小さい PLATEAU の外形は塔屋や物置である」 + * という判定なので、中庭のある建物がそれと同じ理由で落ちてしまう。 + * + * 外側リングだけで測れば、OSM 側と同種どうしの比較になる。 + * 単純な way はリングが 1 本なので、値は全体の面積と等しく、結果は変わらない。 + * + * 描画は穴を抜いた形を見せるので、この面積は画面の見た目と一致しない。 + * + * `osmWay.asGeoJSON` / `osmRelation.asGeoJSON` は素のジオメトリを返すが、 + * テストのモックは Feature を返すので、どちらの形も受ける。 + */ +function outerRingArea(geo) { + const g = geo?.geometry ?? geo; + if (!g?.type) return 0; + + if (g.type === 'Polygon') { + return area({ type: 'Polygon', coordinates: [g.coordinates[0]] }); + } + if (g.type === 'MultiPolygon') { + // outer が複数あるときは全部の外側リングを合算する。1 本目だけを測らない。 + return area({ + type: 'MultiPolygon', + coordinates: g.coordinates.map(poly => [poly[0]]) + }); + } + return area(geo); +} + export function analyzeTagStates(osmFeature, plateauFeature) { const missing = []; @@ -36,15 +69,21 @@ export function findCandidates({ plateauGraph, osmGraph, transferredIDs, acceptIDs, ignoreIDs }) { - const outlines = plateauEntities.filter(f => - f.type === 'way' && - f.tags?.building && - !f.tags['building:part'] && - !transferredIDs.has(f.id) && - !acceptIDs.has(f.id) && - !ignoreIDs.has(f.id) && - f.representativePoint - ); + const outlines = plateauEntities.filter(f => { + // 転記元になるのは 1 棟の建物である。way は自分のタグを持ち、 + // 中庭のある建物は type=multipolygon の relation で届いてタグは relation にだけ付く。 + // 建物でない multipolygon (森林など) は対象外なので building タグを要求する。 + const isWay = f.type === 'way'; + const isCourtyard = f.type === 'relation' && f.tags?.type === 'multipolygon'; + if (!isWay && !isCourtyard) return false; + + return f.tags?.building && + !f.tags['building:part'] && + !transferredIDs.has(f.id) && + !acceptIDs.has(f.id) && + !ignoreIDs.has(f.id) && + f.representativePoint; + }); const osmBuildings = osmEntities.filter(f => f.type === 'way' && f.tags?.building @@ -68,7 +107,7 @@ export function findCandidates({ const { osm, osmGeo } = matched[0]; - const outlineArea = area(outlineGeo); + const outlineArea = outerRingArea(outlineGeo); const osmArea = area(osmGeo); if (osmArea === 0) continue; const ratio = outlineArea / osmArea; diff --git a/test/browser/core/lib/HeightTransferMatcher.test.js b/test/browser/core/lib/HeightTransferMatcher.test.js index fad27f470..704c55edd 100644 --- a/test/browser/core/lib/HeightTransferMatcher.test.js +++ b/test/browser/core/lib/HeightTransferMatcher.test.js @@ -59,6 +59,104 @@ describe('HeightTransferMatcher', () => { [139.756, 35.680], [139.755, 35.680], [139.755, 35.679]]; const SQR_CENTER = [139.7555, 35.6795]; + // 中庭のある建物。asGeoJSON は MultiPolygon を返し、タグは relation にだけ付く。 + // outerCoords が外側リング、holeCoords が穴。 + function courtyard(id, outerCoords, holeCoords, tags, rp) { + return { id, type: 'relation', + tags: { type: 'multipolygon', building: 'yes', ...tags }, + representativePoint: rp, + asGeoJSON: () => ({ type: 'MultiPolygon', + coordinates: [[outerCoords, holeCoords]] }) }; + } + + // SQR の内側に収まる、一辺がおよそ 8 割の穴。面積比でおよそ 0.36 になる。 + const HOLE = [[139.7551, 35.6791], [139.75590, 35.6791], + [139.75590, 35.67990], [139.7551, 35.67990], [139.7551, 35.6791]]; + + it('accepts a type=multipolygon building relation as a candidate', () => { + const p = courtyard('r1', SQR, HOLE, { height: '12' }, SQR_CENTER); + const o = osmBuilding('o1', SQR); + const out = Rapid.findCandidates({ + plateauEntities: [p], osmEntities: [o], + transferredIDs: new Set(), acceptIDs: new Set(), ignoreIDs: new Set() + }); + expect(out).to.have.lengthOf(1, '中庭建物が候補になっていない'); + expect(out[0].state).to.equal('CANDIDATE'); + expect(out[0].missingTags).to.eql(['height']); + }); + + it('measures the courtyard building by its outer ring, not its net area', () => { + // 穴を差し引くと比は 0.5 を割り、AREA_RATIO_MIN で落ちる。 + // 外側リングで測れば OSM と同じ形なので比は 1 になる。 + const p = courtyard('r2', SQR, HOLE, { height: '12' }, SQR_CENTER); + const o = osmBuilding('o2', SQR); + const out = Rapid.findCandidates({ + plateauEntities: [p], osmEntities: [o], + transferredIDs: new Set(), acceptIDs: new Set(), ignoreIDs: new Set() + }); + expect(out).to.have.lengthOf(1, '正味面積で測って落ちている'); + expect(out[0].ratio).to.be.closeTo(1, 0.05); + }); + + it('ignores a multipolygon that is not a building', () => { + const forest = { id: 'r3', type: 'relation', + tags: { type: 'multipolygon', landuse: 'forest', height: '12' }, + representativePoint: SQR_CENTER, + asGeoJSON: () => ({ type: 'MultiPolygon', + coordinates: [[SQR, HOLE]] }) }; + const o = osmBuilding('o3', SQR); + const out = Rapid.findCandidates({ + plateauEntities: [forest], osmEntities: [o], + transferredIDs: new Set(), acceptIDs: new Set(), ignoreIDs: new Set() + }); + expect(out).to.have.lengthOf(0); + }); + + it('keeps the area of a plain way unchanged', () => { + // 本番データの大半はこの経路。リングが 1 本なので外側リング = 全体。 + const p = outline('p1', SQR, { height: '12' }, SQR_CENTER); + const o = osmBuilding('o1', SQR); + const out = Rapid.findCandidates({ + plateauEntities: [p], osmEntities: [o], + transferredIDs: new Set(), acceptIDs: new Set(), ignoreIDs: new Set() + }); + expect(out).to.have.lengthOf(1); + expect(out[0].ratio).to.be.closeTo(1, 0.001); + }); + + it('sums every outer ring when a multipolygon has more than one', () => { + // 1 本目だけを黙って測らないことを固定する。 + // SQR と、その東隣に同じ大きさの正方形をもう 1 つ置く。 + const EAST = SQR.map(([lon, lat]) => [lon + 0.001, lat]); + const twoOuters = { id: 'r4', type: 'relation', + tags: { type: 'multipolygon', building: 'yes', height: '12' }, + representativePoint: SQR_CENTER, + asGeoJSON: () => ({ type: 'MultiPolygon', + coordinates: [[SQR], [EAST]] }) }; + const o = osmBuilding('o4', SQR); + const out = Rapid.findCandidates({ + plateauEntities: [twoOuters], osmEntities: [o], + transferredIDs: new Set(), acceptIDs: new Set(), ignoreIDs: new Set() + }); + // 2 つ分の面積なので比はおよそ 2 になる。1 本目だけを測っていれば 1 になる。 + // このテストが見るのは比の値だけで、state は見ない。比が 2.0 の境界に乗るため + // CANDIDATE と AREA_MISMATCH のどちらになるかは投影の誤差で変わりうる。 + expect(out).to.have.lengthOf(1); + expect(out[0].ratio).to.be.closeTo(2, 0.1); + }); + + it('does not offer a courtyard building again after it was transferred', () => { + // 転記後は relation の id が transferredIDs に入る。絞り込みが id で見ているので + // way と同じ扱いになるが、relation でも効くことを固定しておく。 + const p = courtyard('r5', SQR, HOLE, { height: '12' }, SQR_CENTER); + const o = osmBuilding('o5', SQR); + const out = Rapid.findCandidates({ + plateauEntities: [p], osmEntities: [o], + transferredIDs: new Set(['r5']), acceptIDs: new Set(), ignoreIDs: new Set() + }); + expect(out).to.have.lengthOf(0); + }); + it('returns CANDIDATE when Plateau has tags and OSM is missing them', () => { const p = outline('p1', SQR, { height: '12' }, SQR_CENTER); const o = osmBuilding('o1', SQR);