diff --git a/data/core.yaml b/data/core.yaml index 42cd6eb77..9d0a141fa 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,12 @@ 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. + 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/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" +``` 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" +``` 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" +``` 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 # 空であるこず +``` + +戻ったこずを報告に明蚘する。 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 を遞んだずきの文蚀ず遞択肢が倉わらない 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..242ee9708 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-plateau-multipolygon-accept-render-design.ja.md @@ -0,0 +1,144 @@ +# 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'` で絞っおから個別のポリゎンずしお積む | outer・inner ずもタグを持たないので `geometry()` が `'area'` を返さず、`data.polygons` に䞀本も乗らない。`data.lines` ぞの振り分けも無い。**䞭庭のある建物が䞞ごず描かれない** | + +隠しそこねは機䌚損倱だが、タグの無い 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 は陀倖する。 + +この陀倖は、メンバヌ way がタグを持たないこずに頌らない明瀺の invariant ずしお持぀。 +珟状はメンバヌ way が無タグなので `geometry()` が `'area'` を返さず、陀倖がなくおも +`data.polygons` には乗らない。 +だが陀倖を「たたたた無タグだから効いおいる」状態にはしない。 +メンバヌ way にタグが付く圢に API が倉わっおも、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・描画の挙動が倉わらない 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..346eedad7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-plateau-multipolygon-conflation-design.ja.md @@ -0,0 +1,129 @@ +# 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 が呌び出し偎に枡る。 + +**この filter は配列を絞り蟌むだけで `ds.graph` を倉曎しない。** +陀倖した way も relation もグラフには残り続ける。 +圱響するのは `getData` の戻り倀を受け取る偎だけである。 + +芏則は 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` は未マヌゞで、この倉曎ずは独立しおいる。 + +## 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 がすべお隠れる +- 重ならないずき、3 ぀ずも残る +- `inner` が単独で刀定されない`inner` だけが隠れる状態が䜜れない +- 倖圢のメンバヌがグラフに無いずき、relation も way も隠れない +- `type=building` の outline が reject されたずき、relation も隠れる +- `type=building` の既存の挙動outline の刀定にメンバヌが埓うが倉わらない 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` になる diff --git a/modules/actions/rapid_accept_feature.js b/modules/actions/rapid_accept_feature.js index 3bfaf97e2..31b1d4699 100644 --- a/modules/actions/rapid_accept_feature.js +++ b/modules/actions/rapid_accept_feature.js @@ -257,7 +257,19 @@ 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' + // メンバヌ way だけを accept するず壊れる 2 ぀の構造を cascade する。 + // + // type=building は PLATEAU LOD2 の outline + parts。 + // メンバヌ 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] && !inProgressRelations[parent.id]) { return acceptRelation(parent); 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/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/modules/services/PlateauService.js b/modules/services/PlateauService.js index 60da3e2f2..24f2afe33 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 @@ -497,19 +508,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; } } @@ -539,7 +559,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; @@ -864,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; diff --git a/modules/ui/UiRapidInspector.js b/modules/ui/UiRapidInspector.js index 429d0b2bd..2e86a4350 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', @@ -503,12 +509,31 @@ 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) { + // 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('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..ca8823ce9 100644 --- a/modules/util/building_relation.js +++ b/modules/util/building_relation.js @@ -1,17 +1,66 @@ /** * utilBuildingRelationInfo * - * 指定 entity が `type=building` relation (Simple 3D Buildings / PLATEAU LOD2 の構造) の - * メンバヌ 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、 + * 内蚳が 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} + * @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 (!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; @@ -20,15 +69,5 @@ export function utilBuildingRelationInfo(entity, graph) { } catch (e) { return null; } - - const relation = parents.find(r => r.tags && r.tags.type === 'building'); - if (!relation) return null; - - let outlineCount = 0; - let partCount = 0; - for (const m of relation.members || []) { - if (m.role === 'outline') outlineCount++; - else if (m.role === 'part') partCount++; - } - return { relation, outlineCount, partCount }; + return parents.find(isBuildingRelation) || null; } 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); diff --git a/test/browser/pixi/PixiLayerRapid.test.js b/test/browser/pixi/PixiLayerRapid.test.js new file mode 100644 index 000000000..fe1a6b0ba --- /dev/null +++ b/test/browser/pixi/PixiLayerRapid.test.js @@ -0,0 +1,128 @@ +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 }; + } + + // 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]], 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]], memberTags); + 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', () => { + // 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 + ); + 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'); + }); + }); +}); diff --git a/test/browser/services/PlateauService.test.js b/test/browser/services/PlateauService.test.js index 4f91fcf22..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', () => { @@ -321,6 +321,182 @@ 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 が単独で刀定されおいる'); + }); + + 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); + }); }); diff --git a/test/unit/actions/rapid_accept_feature.test.js b/test/unit/actions/rapid_accept_feature.test.js index c30919e9a..579f08b9d 100644 --- a/test/unit/actions/rapid_accept_feature.test.js +++ b/test/unit/actions/rapid_accept_feature.test.js @@ -482,4 +482,87 @@ 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 にあるものを 1 ぀もコピヌしない。 + // building だけを芋るず height など他のタグの混入を芋逃すので、空であるこずを芋る。 + assert.deepEqual(graph.entity('w_outer').tags, {}); + assert.deepEqual(graph.entity('w_inner').tags, {}); + }); + + 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); + }); + }); }); diff --git a/test/unit/util/building_relation.test.js b/test/unit/util/building_relation.test.js index a55f8a604..7761685ef 100644 --- a/test/unit/util/building_relation.test.js +++ b/test/unit/util/building_relation.test.js @@ -92,4 +92,140 @@ 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); + }); + + 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); + }); });