From 35c9aa9bdf37bf61a3fbcb587e48bac82ac52c90 Mon Sep 17 00:00:00 2001 From: Roman Deev Date: Tue, 6 May 2025 16:54:28 +0300 Subject: [PATCH 01/13] Split data downloading and building creating (#118) --- src/building.js | 7 +++---- src/index.js | 10 +++++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/building.js b/src/building.js index 1b00a43..5df6ce1 100644 --- a/src/building.js +++ b/src/building.js @@ -30,9 +30,9 @@ class Building { options; /** - * Create new building + * Download data for new building */ - static async create(type, id) { + static async downloadDataAroundBuilding(type, id) { var data; if (type === 'way') { data = await Building.getWayData(id); @@ -42,8 +42,7 @@ class Building { let xmlData = new window.DOMParser().parseFromString(data, 'text/xml'); const nodelist = Building.buildNodeList(xmlData); const extents = Building.getExtents(id, xmlData, nodelist); - const innerData = await Building.getInnerData(...extents); - return new Building(id, innerData); + return await Building.getInnerData(...extents); } /** diff --git a/src/index.js b/src/index.js index bd27fc9..356b1f5 100644 --- a/src/index.js +++ b/src/index.js @@ -53,13 +53,13 @@ function init() { if (params.has('errorBox')) { errorBox = true; } - Building.create(type, id).then(function(myObj){ - mainBuilding = myObj; - const helperSize = myObj.outerElement.getWidth(); + Building.downloadDataAroundBuilding(type, id).then(function(innerData){ + mainBuilding = new Building(id, innerData); + const helperSize = mainBuilding.outerElement.getWidth(); const helper = new GridHelper(helperSize / 0.9, helperSize / 9); scene.add(helper); - const mesh = myObj.render(); + const mesh = mainBuilding.render(); for (let i = 0; i < mesh.length; i++) { if (mesh[i] && mesh[i].isObject3D) { scene.add(mesh[i]); @@ -69,7 +69,7 @@ function init() { } if (displayInfo) { gui = new GUI(); - const info = myObj.getInfo(); + const info = mainBuilding.getInfo(); const folder = gui.addFolder(info.type + ' - ' + info.id); createFolders(folder, info.options); for (let i = 0; i < info.parts.length; i++) { From 0caff1afeb546f815e2b0fb1edfa8d340ec61a83 Mon Sep 17 00:00:00 2001 From: Kevin Nowaczyk Date: Tue, 6 May 2025 12:46:08 -0400 Subject: [PATCH 02/13] Outer building visibility (#119) --- src/building.js | 20 ++++++++++++++++---- test/building.test.js | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/building.js b/src/building.js index 5df6ce1..e66fab0 100644 --- a/src/building.js +++ b/src/building.js @@ -8,13 +8,22 @@ import {MultiBuildingPart} from './multibuildingpart.js'; * XML data from the API. */ class Building { - // Latitude and longitude that transitioned to (0, 0) + /** + * Latitude and longitude that transitions to (0, 0) + * @type {number[2]} + */ home = []; - // the parts + /** + * The parts. + * @type {BuildingPart[]} + */ parts = []; - // the BuildingPart of the outer building parimeter + /** + * The building part of the outer parimeter. + * @type {BuildingPart} + */ outerElement; // DOM Tree of all elements to render @@ -139,7 +148,10 @@ class Building { const mesh = []; if (this.parts.length > 0) { this.outerElement.options.building.visible = false; - mesh.push(...this.outerElement.render()); + const outerMeshes = this.outerElement.render(); + outerMeshes[0].visible = false; + outerMeshes[1].visible = false; + mesh.push(...outerMeshes); for (let i = 0; i < this.parts.length; i++) { mesh.push(...this.parts[i].render()); } diff --git a/test/building.test.js b/test/building.test.js index 744821b..76d2938 100644 --- a/test/building.test.js +++ b/test/building.test.js @@ -77,6 +77,24 @@ test('Create Nodelist', () => { expect(errors.length).toBe(0); }); + +test('Invisible Outer Building', () => { + const bldg = new Building('31361386', data); + bldg.parts = [bldg.outerElement]; + const mesh = bldg.render(); + //expect outer building and roof to not be visible + expect(mesh[0].visible).toBe(false); + expect(mesh[1].visible).toBe(false); +}); + +test('Visible Outer Building', () => { + const bldg = new Building('31361386', data); + const mesh = bldg.render(); + //expect outer building and roof to be visible + expect(mesh[0].visible).toBe(true); + expect(mesh[1].visible).toBe(true); +}); + window.printError = printError; var errors = []; From a3657a16164f7b91504ddb2b62e7eb87069678ef Mon Sep 17 00:00:00 2001 From: Kevin Nowaczyk Date: Tue, 6 May 2025 13:51:58 -0400 Subject: [PATCH 03/13] Visibility (#120) --- src/building.js | 2 ++ src/buildingpart.js | 1 - test/building.test.js | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/building.js b/src/building.js index e66fab0..fb8dd62 100644 --- a/src/building.js +++ b/src/building.js @@ -150,7 +150,9 @@ class Building { this.outerElement.options.building.visible = false; const outerMeshes = this.outerElement.render(); outerMeshes[0].visible = false; + this.outerElement.options.roof.visible = false; outerMeshes[1].visible = false; + this.outerElement.options.building.visible = false; mesh.push(...outerMeshes); for (let i = 0; i < this.parts.length; i++) { mesh.push(...this.parts[i].render()); diff --git a/src/buildingpart.js b/src/buildingpart.js index f342614..97fe38a 100644 --- a/src/buildingpart.js +++ b/src/buildingpart.js @@ -193,7 +193,6 @@ class BuildingPart { this.createRoof(); this.parts.push(this.roof); const mesh = this.createBuilding(); - this.options.building.visible = true; if (this.getAttribute('building:part') === 'roof') { mesh.visible = false; this.options.building.visible = false; diff --git a/test/building.test.js b/test/building.test.js index 76d2938..73ec7ca 100644 --- a/test/building.test.js +++ b/test/building.test.js @@ -84,6 +84,7 @@ test('Invisible Outer Building', () => { const mesh = bldg.render(); //expect outer building and roof to not be visible expect(mesh[0].visible).toBe(false); + expect(bldg.outerElement.options.building.visible).toBe(false); expect(mesh[1].visible).toBe(false); }); From 64335b0f2297eba61960400ee6af13fc1d93974a Mon Sep 17 00:00:00 2001 From: Kevin Nowaczyk Date: Tue, 6 May 2025 13:58:48 -0400 Subject: [PATCH 04/13] Update building.js (#121) --- src/building.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/building.js b/src/building.js index fb8dd62..7f26e89 100644 --- a/src/building.js +++ b/src/building.js @@ -26,7 +26,10 @@ class Building { */ outerElement; - // DOM Tree of all elements to render + /** + * DOM Tree of all elements to render + * @type {DOM.Element} + */ fullXmlData; id = '0'; From 0c52fe6ae280c0f5f70dbf84d0ec0206d2ace1b4 Mon Sep 17 00:00:00 2001 From: Roman Deev Date: Tue, 6 May 2025 22:04:29 +0300 Subject: [PATCH 05/13] Tests for API (#122) * check HTTP code status and show alert with error * tests for API errors --- index.html | 2 +- src/apis.js | 2 +- src/building.js | 32 ++++++++++++++++++++++++-------- src/index.js | 3 +++ test/building.test.js | 33 +++++++++++++++++---------------- 5 files changed, 46 insertions(+), 26 deletions(-) diff --git a/index.html b/index.html index 454213e..e453328 100644 --- a/index.html +++ b/index.html @@ -22,7 +22,7 @@ } } - +
diff --git a/src/apis.js b/src/apis.js index dc54ff1..d173b3d 100644 --- a/src/apis.js +++ b/src/apis.js @@ -1,5 +1,5 @@ const osmApiUrl = new URLSearchParams(location.search).get('osmApiUrl') || 'https://api.openstreetmap.org/api/0.6'; -const apis = { +export const apis = { bounding: { api: osmApiUrl + '/map?bbox=', url: (left, bottom, right, top) => { diff --git a/src/building.js b/src/building.js index 7f26e89..7c3806b 100644 --- a/src/building.js +++ b/src/building.js @@ -1,3 +1,4 @@ +import {apis} from './apis.js'; import {BuildingShapeUtils} from './extras/BuildingShapeUtils.js'; import {BuildingPart} from './buildingpart.js'; import {MultiBuildingPart} from './multibuildingpart.js'; @@ -209,24 +210,39 @@ class Building { static async getWayData(id) { let restPath = apis.getWay.url(id); let response = await fetch(restPath); - let text = await response.text(); - return text; + if (response.status === 404) { + throw `The way ${id} was not found on the server.\nURL: ${restPath}`; + } else if (response.status === 410) { + throw `The way ${id} was deleted.\nURL: ${restPath}`; + } else if (response.status !== 200) { + throw `HTTP ${response.status}.\nURL: ${restPath}`; + } + return await response.text(); } static async getRelationData(id) { let restPath = apis.getRelation.url(id); let response = await fetch(restPath); - let text = await response.text(); - return text; + if (response.status === 404) { + throw `The relation ${id} was not found on the server.\nURL: ${restPath}`; + } else if (response.status === 410) { + throw `The relation ${id} was deleted.\nURL: ${restPath}`; + } else if (response.status !== 200) { + throw `HTTP ${response.status}.\nURL: ${restPath}`; + } + return await response.text(); } /** - * Fetch way data from OSM + * Fetch map data data from OSM */ static async getInnerData(left, bottom, right, top) { - let response = await fetch(apis.bounding.url(left, bottom, right, top)); - let res = await response.text(); - return res; + let url = apis.bounding.url(left, bottom, right, top); + let response = await fetch(url); + if (response.status !== 200) { + throw `HTTP ${response.status}.\nURL: ${url}`; + } + return await response.text(); } /** diff --git a/src/index.js b/src/index.js index 356b1f5..e4c7f76 100644 --- a/src/index.js +++ b/src/index.js @@ -79,6 +79,9 @@ function init() { createFolders(folder, part.options); } } + }).catch(err => { + window.printError(err); + alert(err); }); camera = new PerspectiveCamera( 50, diff --git a/test/building.test.js b/test/building.test.js index 73ec7ca..542f0e0 100644 --- a/test/building.test.js +++ b/test/building.test.js @@ -6,23 +6,10 @@ expect.extend({toBeDeepCloseTo}); import { Shape, Mesh } from 'three'; import { TextEncoder } from 'node:util'; +import {expect, test, beforeEach, describe} from '@jest/globals'; global.TextEncoder = TextEncoder; -let apis = { - bounding: { - api:'https://api.openstreetmap.org/api/0.6/map?bbox=', - url: (left, bottom, right, top) => { - return apis.bounding.api + left + ',' + bottom + ',' + right + ',' + top; - }, - }, - getRelation: { - api:'https://api.openstreetmap.org/api/0.6/relation/', - parameters:'/full', - url: (relationId) => { - return apis.getRelation.api + relationId + apis.getRelation.parameters; - }, - }, -}; +import {apis} from '../src/apis.js'; global.apis = apis; import { Building } from '../src/building.js'; @@ -55,10 +42,24 @@ const data = ` `; beforeEach(() => { - fetch.resetMocks(); + fetchMock.resetMocks(); errors = []; }); +describe.each([ + [['way', -1], ['', { status: 404 }], /^The way -1 was not found on the server.\nURL: /], + [['way', -1], ['', { status: 410 }], /^The way -1 was deleted.\nURL: /], + [['way', -1], ['', { status: 509 }], /^HTTP 509.\nURL: /], + [['relation', -1], ['', { status: 404 }], /^The relation -1 was not found on the server.\nURL: /], + [['relation', -1], ['', { status: 410 }], /^The relation -1 was deleted.\nURL: /], + [['relation', -1], ['', { status: 509 }], /^HTTP 509.\nURL: /], +])('Test API error handling', (args, response, matcher) => { + test(`Test API error for ${args[0]} ${args[1]} with HTTP ${response[1].status}`, async() => { + fetch.mockResponses(response); + await expect(Building.downloadDataAroundBuilding(...args)).rejects.toMatch(matcher); + }); +}); + test('Test Constructor', async() => { const bldg = new Building('31361386', data); expect(bldg.home).toBeDeepCloseTo([11.015512, 49.5833659], 10); From e1105a8c1e228515c6bb05fa34493cee2aa2a30f Mon Sep 17 00:00:00 2001 From: Roman Deev Date: Wed, 7 May 2025 14:52:47 +0300 Subject: [PATCH 06/13] Show validation errors (#123) * show validation errors + tests * fix typo --- src/building.js | 54 +++++++++++++++++++++---------------------- test/building.test.js | 34 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 28 deletions(-) diff --git a/src/building.js b/src/building.js index 7c3806b..7f0374c 100644 --- a/src/building.js +++ b/src/building.js @@ -75,29 +75,30 @@ class Building { } else { this.type = 'relation'; } - if (this.isValidData(outerElementXml)) { - this.nodelist = Building.buildNodeList(this.fullXmlData); - this.setHome(); - this.repositionNodes(); - if (this.type === 'way') { + try { + this.validateData(outerElementXml); + } catch (e) { + throw new Error(`Rendering of ${outerElementXml.tagName.toLowerCase()} ${id} is not possible. ${e}`); + } + + this.nodelist = Building.buildNodeList(this.fullXmlData); + this.setHome(); + this.repositionNodes(); + if (this.type === 'way') { + this.outerElement = new BuildingPart(id, this.fullXmlData, this.nodelist); + } else if (this.type === 'multipolygon') { + this.outerElement = new MultiBuildingPart(id, this.fullXmlData, this.nodelist); + } else { + const outlineRef = outerElementXml.querySelector('member[role="outline"]').getAttribute('ref'); + const outline = this.fullXmlData.getElementById(outlineRef); + const outlineType = outline.tagName.toLowerCase(); + if (outlineType === 'way') { this.outerElement = new BuildingPart(id, this.fullXmlData, this.nodelist); - } else if (this.type === 'multipolygon') { - this.outerElement = new MultiBuildingPart(id, this.fullXmlData, this.nodelist); } else { - const outlineRef = outerElementXml.querySelector('member[role="outline"]').getAttribute('ref'); - const outline = this.fullXmlData.getElementById(outlineRef); - const outlineType = outline.tagName.toLowerCase(); - if (outlineType === 'way') { - this.outerElement = new BuildingPart(id, this.fullXmlData, this.nodelist); - } else { - this.outerElement = new MultiBuildingPart(outlineRef, this.fullXmlData, this.nodelist); - } + this.outerElement = new MultiBuildingPart(outlineRef, this.fullXmlData, this.nodelist); } - this.addParts(); - } else { - window.printError('XML Not Valid'); - throw new Error('invalid XML'); } + this.addParts(); } /** @@ -248,7 +249,7 @@ class Building { /** * validate that we have the ID of a building way. */ - isValidData(xmlData) { + validateData(xmlData) { // Check that it is a building ( exists) const buildingType = xmlData.querySelector('[k="building"]'); const ways = []; @@ -256,7 +257,7 @@ class Building { // get all building relation parts // todo: multipolygon inner and outer roles. let parts = xmlData.querySelectorAll('member[role="part"]'); - var ref = 0; + let ref = 0; for (let i = 0; i < parts.length; i++) { ref = parts[i].getAttribute('ref'); const part = this.fullXmlData.getElementById(ref); @@ -268,8 +269,7 @@ class Building { } } else { if (!buildingType) { - window.printError('Outer way is not a building'); - return false; + throw new Error('Outer way is not a building'); } ways.push(xmlData); } @@ -282,16 +282,14 @@ class Building { const firstRef = nodes[0].getAttribute('ref'); const lastRef = nodes[nodes.length - 1].getAttribute('ref'); if (firstRef !== lastRef) { - window.printError('Way ' + way.getAttribute('id') + ' is not a closed way. ' + firstRef + ' !== ' + lastRef + '.'); - return false; + throw new Error('Way ' + way.getAttribute('id') + ' is not a closed way. ' + firstRef + ' !== ' + lastRef + '.'); } } else { - window.printError('Way ' + way.getAttribute('id') + ' has no nodes.'); - return false; + throw new Error('Way ' + way.getAttribute('id') + ' has no nodes.'); } } else { let parts = way.querySelectorAll('member[role="part"]'); - var ref = 0; + let ref = 0; for (let i = 0; i < parts.length; i++) { ref = parts[i].getAttribute('ref'); const part = this.fullXmlData.getElementById(ref); diff --git a/test/building.test.js b/test/building.test.js index 542f0e0..b076afe 100644 --- a/test/building.test.js +++ b/test/building.test.js @@ -60,6 +60,40 @@ describe.each([ }); }); +test('Test data validation open outline', () => { + const data = ` + + + + + + `; + expect(() => new Building('1', data)) + .toThrow(new Error('Rendering of way 1 is not possible. Error: Way 1 is not a closed way. 2 !== 4.')); +}); + +test('Test data validation with not building', () => { + const data = ` + + + + + + + `; + expect(() => new Building('1', data)) + .toThrow(new Error('Rendering of way 1 is not possible. Error: Outer way is not a building')); +}); + +test('Test data validation with empty way', () => { + const data = ` + + + `; + expect(() => new Building('1', data)) + .toThrow(new Error('Rendering of way 1 is not possible. Error: Way 1 has no nodes.')); +}); + test('Test Constructor', async() => { const bldg = new Building('31361386', data); expect(bldg.home).toBeDeepCloseTo([11.015512, 49.5833659], 10); From 874b3fe8b12dda2c030983db5fa69039206aafc9 Mon Sep 17 00:00:00 2001 From: Roman Deev Date: Wed, 7 May 2025 15:17:32 +0300 Subject: [PATCH 07/13] Skip incompleted ways, skip non-way members, prevent global modification of way object (#100) * skip non-way members, skip incompleted ways, prevent global modification of Document with way * add test --- src/building.js | 6 ++- src/multibuildingpart.js | 9 +++-- test/building.test.js | 44 +++++++++++++++++++++ test/multipolygon.test.js | 2 +- test/split_way_multipolygon.test.js | 6 +-- test/split_way_multipolygon_reverse.test.js | 6 +-- 6 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/building.js b/src/building.js index 7f0374c..0b707e4 100644 --- a/src/building.js +++ b/src/building.js @@ -199,7 +199,11 @@ class Building { for (let i = 0; i < parts.length; i++) { if (parts[i].querySelector('[k="building:part"]')) { const id = parts[i].getAttribute('id'); - this.parts.push(new MultiBuildingPart(id, this.fullXmlData, this.nodelist, this.outerElement.options)); + try { + this.parts.push(new MultiBuildingPart(id, this.fullXmlData, this.nodelist, this.outerElement.options)); + } catch (e) { + window.printError(e); + } } } } diff --git a/src/multibuildingpart.js b/src/multibuildingpart.js index d8c885d..8e50cf4 100644 --- a/src/multibuildingpart.js +++ b/src/multibuildingpart.js @@ -14,8 +14,8 @@ class MultiBuildingPart extends BuildingPart { */ buildShape() { this.type = 'multipolygon'; - const innerMembers = this.way.querySelectorAll('member[role="inner"]'); - const outerMembers = this.way.querySelectorAll('member[role="outer"]'); + const innerMembers = this.way.querySelectorAll('member[role="inner"][type="way"]'); + const outerMembers = this.way.querySelectorAll('member[role="outer"][type="way"]'); const innerShapes = []; var shapes = []; for (let i = 0; i < innerMembers.length; i++) { @@ -25,7 +25,10 @@ class MultiBuildingPart extends BuildingPart { const ways = []; for (let j = 0; j < outerMembers.length; j++) { const way = this.fullXmlData.getElementById(outerMembers[j].getAttribute('ref')); - ways.push(way); + if (way === null) { + throw `Incompleted way ${outerMembers[j].getAttribute('ref')}`; + } + ways.push(way.cloneNode(true)); } const closedWays = BuildingShapeUtils.combineWays(ways); for (let k = 0; k < closedWays.length; k++) { diff --git a/test/building.test.js b/test/building.test.js index b076afe..409f21f 100644 --- a/test/building.test.js +++ b/test/building.test.js @@ -131,6 +131,50 @@ test('Visible Outer Building', () => { expect(mesh[1].visible).toBe(true); }); + +test('Test with neighboring incomplete building:part relation', () => { + const data = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + expect(new Building('42', data).id).toBe('42'); +}); + + window.printError = printError; var errors = []; diff --git a/test/multipolygon.test.js b/test/multipolygon.test.js index 1125ce4..2fac8e8 100644 --- a/test/multipolygon.test.js +++ b/test/multipolygon.test.js @@ -16,7 +16,7 @@ const data = ` - + diff --git a/test/split_way_multipolygon.test.js b/test/split_way_multipolygon.test.js index b133f72..ffa0681 100644 --- a/test/split_way_multipolygon.test.js +++ b/test/split_way_multipolygon.test.js @@ -19,9 +19,9 @@ const data = ` - - - + + + diff --git a/test/split_way_multipolygon_reverse.test.js b/test/split_way_multipolygon_reverse.test.js index 4350ebe..d1bc3c2 100644 --- a/test/split_way_multipolygon_reverse.test.js +++ b/test/split_way_multipolygon_reverse.test.js @@ -19,9 +19,9 @@ const data = ` - - - + + + From 2433a7ad786bf438fc0108cee94ba39813241463 Mon Sep 17 00:00:00 2001 From: Roman Deev Date: Thu, 8 May 2025 20:02:32 +0300 Subject: [PATCH 08/13] better colors for MeshPhysicalMaterial (#126) --- src/buildingpart.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/buildingpart.js b/src/buildingpart.js index 97fe38a..17b9e61 100644 --- a/src/buildingpart.js +++ b/src/buildingpart.js @@ -458,7 +458,13 @@ class BuildingPart { } const material = BuildingPart.getBaseMaterial(materialName); if (color !== '') { - material.color = new Color(color); + if (material instanceof MeshPhysicalMaterial) { + material.emissive = new Color(color); + material.emissiveIntensity = 0.5; + material.roughness = 0.5; + } else { + material.color = new Color(color); + } } else if (materialName === ''){ material.color = new Color('white'); } @@ -488,7 +494,11 @@ class BuildingPart { material = BuildingPart.getBaseMaterial(materialName); } if (color !== '') { - material.color = new Color(color); + if (material instanceof MeshPhysicalMaterial) { + material.emissive = new Color(color); + } else { + material.color = new Color(color); + } } return material; } From 3a31a0e708810937578f13b5cab5e23e14aa7f0a Mon Sep 17 00:00:00 2001 From: Roman Deev Date: Thu, 8 May 2025 20:08:44 +0300 Subject: [PATCH 09/13] Fix crash when processing type=building with outline being a multipolygon (#124) * #88 initial support type=building with multipolygon outline * support multiple ways in inner rings * add test --- src/building.js | 22 ++++++- src/multibuildingpart.js | 39 +++++------ test/building.test.js | 136 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 20 deletions(-) diff --git a/src/building.js b/src/building.js index 0b707e4..cbac318 100644 --- a/src/building.js +++ b/src/building.js @@ -42,15 +42,33 @@ class Building { type; options; + static async getRelationDataWithChildRelations(id) { + const xmlData = new window.DOMParser().parseFromString(await Building.getRelationData(id), 'text/xml'); + await Promise.all(Array.from(xmlData.querySelectorAll('member[type=relation]')).map(async r => { + const childId = r.getAttribute('ref'); + if (r.getAttribute('id') === childId) { + return; + } + const childData = new window.DOMParser().parseFromString(await Building.getRelationData(childId), 'text/xml'); + childData.querySelectorAll('node, way, relation').forEach(i => { + if (xmlData.querySelector(`${i.tagName}[id="${i.getAttribute('id')}"]`)) { + return; + } + xmlData.querySelector('osm').appendChild(i); + }); + })); + return new XMLSerializer().serializeToString(xmlData); + } + /** * Download data for new building */ static async downloadDataAroundBuilding(type, id) { - var data; + let data; if (type === 'way') { data = await Building.getWayData(id); } else { - data = await Building.getRelationData(id); + data = await Building.getRelationDataWithChildRelations(id); } let xmlData = new window.DOMParser().parseFromString(data, 'text/xml'); const nodelist = Building.buildNodeList(xmlData); diff --git a/src/multibuildingpart.js b/src/multibuildingpart.js index 8e50cf4..08176d0 100644 --- a/src/multibuildingpart.js +++ b/src/multibuildingpart.js @@ -7,6 +7,21 @@ import {BuildingPart} from './buildingpart.js'; */ class MultiBuildingPart extends BuildingPart { + makeRings(members) { + const ways = []; + for (let j = 0; j < members.length; j++) { + const wayID = members[j].getAttribute('ref'); + const way = this.fullXmlData.getElementById(wayID); + if (way) { + ways.push(way.cloneNode(true)); + } else { + window.printError(`Missing way ${wayID} for relation ${this.id}`); + ways.push(this.augmentedWays[wayID].cloneNode(true)); + } + } + return BuildingShapeUtils.combineWays(ways); + } + /** * Create the shape of the outer relation. * @@ -16,27 +31,15 @@ class MultiBuildingPart extends BuildingPart { this.type = 'multipolygon'; const innerMembers = this.way.querySelectorAll('member[role="inner"][type="way"]'); const outerMembers = this.way.querySelectorAll('member[role="outer"][type="way"]'); - const innerShapes = []; - var shapes = []; - for (let i = 0; i < innerMembers.length; i++) { - const way = this.fullXmlData.getElementById(innerMembers[i].getAttribute('ref')); - innerShapes.push(BuildingShapeUtils.createShape(way, this.nodelist)); - } - const ways = []; - for (let j = 0; j < outerMembers.length; j++) { - const way = this.fullXmlData.getElementById(outerMembers[j].getAttribute('ref')); - if (way === null) { - throw `Incompleted way ${outerMembers[j].getAttribute('ref')}`; - } - ways.push(way.cloneNode(true)); - } - const closedWays = BuildingShapeUtils.combineWays(ways); - for (let k = 0; k < closedWays.length; k++) { - const shape = BuildingShapeUtils.createShape(closedWays[k], this.nodelist); + const shapes = []; + const innerShapes = this.makeRings(innerMembers).map(ring => BuildingShapeUtils.createShape(ring, this.nodelist, this.augmentedNodelist)); + const closedOuterWays = this.makeRings(outerMembers); + for (let k = 0; k < closedOuterWays.length; k++) { + const shape = BuildingShapeUtils.createShape(closedOuterWays[k], this.nodelist, this.augmentedNodelist); shape.holes.push(...innerShapes); shapes.push(shape); } - if (closedWays.length === 1) { + if (closedOuterWays.length === 1) { return shapes[0]; } // Multiple outer members diff --git a/test/building.test.js b/test/building.test.js index 409f21f..1bc9167 100644 --- a/test/building.test.js +++ b/test/building.test.js @@ -174,6 +174,142 @@ test('Test with neighboring incomplete building:part relation', () => { expect(new Building('42', data).id).toBe('42'); }); +const typeBuildingWithMultipolygonOutline = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; +const typeBuildingRelationFullResponse = ` + + + + + + + + + + + + + + + + + + +`; +const outlineRelationFullResponse = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +test('Test downloading type=building with multipolygon outline and multiple inner ways', async() => { + fetch.mockResponses( + [typeBuildingRelationFullResponse], // /relation/42/full + [outlineRelationFullResponse], // /relation/40/full + [typeBuildingWithMultipolygonOutline], // /map call + ); + const innerData = await Building.downloadDataAroundBuilding('relation', '42'); + const building = new Building('42', innerData); + expect(building.id).toBe('42'); + expect(building.outerElement.shape.holes.length).toBe(1); +}); window.printError = printError; From 74154fb56ec7b62b729035ab9bb52ccc5d18ddda Mon Sep 17 00:00:00 2001 From: Kevin Nowaczyk Date: Tue, 3 Jun 2025 10:10:45 -0400 Subject: [PATCH 10/13] Hipped roof (#128) --- .github/workflows/main.yml | 4 ++++ index.html | 4 +++- package.json | 4 +++- src/buildingpart.js | 10 ++++++++++ test/buildingpart.test.js | 11 +---------- test/utils.test.js | 10 +++++++++- 6 files changed, 30 insertions(+), 13 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 69ad97e..2a0f05e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,15 +10,19 @@ jobs: mkdir ../pyramid mkdir ../ramp mkdir ../wedge + mkdir ../hipped wget -P ../pyramid https://beakerboy.github.io/Threejs-Geometries/src/PyramidGeometry.js wget -P ../ramp https://beakerboy.github.io/Threejs-Geometries/src/RampGeometry.js wget -P ../wedge https://beakerboy.github.io/Threejs-Geometries/src/WedgeGeometry.js + wget -P ../hipped https://beakerboy.github.io/Threejs-Geometries/src/HippedGeometry.js cd ../pyramid echo '{"name":"pyramid","type":"module","private":true,"scripts":{"test":"npx jest"}}' > "./package.json" && npm init -y cd ../ramp echo '{"name":"ramp","type":"module","private":true,"scripts":{"test":"npx jest"}}' > "./package.json" && npm init -y cd ../wedge echo '{"name":"wedge","type":"module","private":true,"scripts":{"test":"npx jest"}}' > "./package.json" && npm init -y + cd ../hipped + echo '{"name":"hipped","type":"module","private":true,"scripts":{"test":"npx jest"}}' > "./package.json" && npm init -y cd ../OSMBuilding yarn --prod=false - name: Lint diff --git a/index.html b/index.html index e453328..26b69e9 100644 --- a/index.html +++ b/index.html @@ -16,9 +16,11 @@ { "imports": { "three": "https://unpkg.com/three/build/three.module.js", + "straight-skeleton": "https://cdn.skypack.dev/straight-skeleton@1.1.0", "pyramid": "https://beakerboy.github.io/Threejs-Geometries/src/PyramidGeometry.js", "ramp": "https://beakerboy.github.io/Threejs-Geometries/src/RampGeometry.js", - "wedge": "https://beakerboy.github.io/Threejs-Geometries/src/WedgeGeometry.js" + "wedge": "https://beakerboy.github.io/Threejs-Geometries/src/WedgeGeometry.js", + "hipped": "https://beakerboy.github.io/Threejs-Geometries/src/HippedGeometry.js" } } diff --git a/package.json b/package.json index 58d3b38..07ff0d6 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,11 @@ "jest-fetch-mock": "*", "jest-matcher-deep-close-to": "*", "jsdom": "*", + "hipped": "file:../hipped", "pyramid": "file:../pyramid", "ramp": "file:../ramp", - "wedge": "file:../wedge" + "wedge": "file:../wedge", + "straight-skeleton": "1.1.0" }, "scripts": { "test": "c8 jest" diff --git a/src/buildingpart.js b/src/buildingpart.js index 17b9e61..84160dc 100644 --- a/src/buildingpart.js +++ b/src/buildingpart.js @@ -11,6 +11,7 @@ import { import {PyramidGeometry} from 'pyramid'; import {RampGeometry} from 'ramp'; import {WedgeGeometry} from 'wedge'; +import {HippedGeometry} from 'hipped'; import {BuildingShapeUtils} from './extras/BuildingShapeUtils.js'; /** * An OSM Building Part @@ -285,6 +286,15 @@ class BuildingPart { }; const geometry = new PyramidGeometry(this.shape, options); + material = BuildingPart.getRoofMaterial(this.way); + roof = new Mesh( geometry, material ); + roof.rotation.x = -Math.PI / 2; + roof.position.set( 0, this.options.building.height - this.options.roof.height, 0); + } else if (this.options.roof.shape === 'hipped') { + const options = { + depth: this.options.roof.height, + }; + const geometry = new HippedGeometry(this.shape, options); material = BuildingPart.getRoofMaterial(this.way); roof = new Mesh( geometry, material ); roof.rotation.x = -Math.PI / 2; diff --git a/test/buildingpart.test.js b/test/buildingpart.test.js index b967a66..f368452 100644 --- a/test/buildingpart.test.js +++ b/test/buildingpart.test.js @@ -60,16 +60,7 @@ test('Constructor', () => { // Gabled with unspecified orientation shal be 'along' expect(part.options.roof.orientation).toBe('along'); - // Troubleshoot Bug - const shape = part.shape.extractPoints().shape; - let value = [shape[0].x, shape[0].y]; - expect(value).toStrictEqual([-4.332738077015795, -5.882209888874915]); - value = [shape[1].x, shape[1].y]; - expect(value).toStrictEqual([-4.332738077015795, 5.88221335051411]); - value = [shape[2].x, shape[2].y]; - expect(value).toStrictEqual([4.332747472106493, 5.88221335051411]); - expect(BuildingShapeUtils.edgeDirection(part.shape)).toStrictEqual([Math.PI / 2, 0, -Math.PI / 2, Math.PI]); - expect(BuildingShapeUtils.longestSideAngle(part.shape)).toBe(Math.PI / 2); + // toDo: Mock BuildingShapeUtils and test options expect(part.options.roof.direction).toBe(90); expect(errors.length).toBe(0); }); diff --git a/test/utils.test.js b/test/utils.test.js index 3eac0b2..3c646cc 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -124,6 +124,13 @@ rightTriangle2.lineTo(-1, 1); rightTriangle2.lineTo(1, -1); rightTriangle2.lineTo(1, 1); +const rectangle = new Shape(); +rectangle.moveTo(-4.332738077015795, -5.882209888874915); +rectangle.lineTo(-4.332738077015795, 5.88221335051411); +rectangle.lineTo(4.332747472106493, 5.88221335051411); +rectangle.lineTo(4.332747472106493, -5.882209888874915); +rectangle.lineTo(-4.332738077015795, -5.882209888874915); + test('Extents no Rotation', () => { expect(BuildingShapeUtils.extents(rightTriangle)).toStrictEqual([-1, -1, 1, 1]); }); @@ -152,9 +159,10 @@ test('Vertex Angles counterclockwise', () => { describe.each([ [rightTriangle, [-Math.PI / 2, 3 * Math.PI / 4, 0], 'CW'], [rightTriangle2, [Math.PI, -Math.PI / 4, Math.PI / 2], 'CCW'], + [rectangle, [Math.PI / 2, 0, -Math.PI / 2, Math.PI], 'Rect'], ])('Edge Direction', (shape, expected, description) =>{ test(`${description}`, () => { - expect(BuildingShapeUtils.edgeDirection(shape)).toBeDeepCloseTo(expected); + expect(BuildingShapeUtils.edgeDirection(shape)).toStrictEqual(expected); }); }); From 3c771d774ce57459692231b05381594e5ce73ddc Mon Sep 17 00:00:00 2001 From: Kevin Nowaczyk Date: Tue, 3 Jun 2025 10:36:16 -0400 Subject: [PATCH 11/13] Update BuildingShapeUtils.js (#129) --- src/extras/BuildingShapeUtils.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/extras/BuildingShapeUtils.js b/src/extras/BuildingShapeUtils.js index cb4a3d1..fa0c215 100644 --- a/src/extras/BuildingShapeUtils.js +++ b/src/extras/BuildingShapeUtils.js @@ -17,16 +17,24 @@ class BuildingShapeUtils extends ShapeUtils { // Initialize objects const shape = new Shape(); var ref; - var node = []; + const nodes = []; // Get all the nodes in the way of interest + /** {HTMLCollection} */ const elements = way.getElementsByTagName('nd'); // Get the coordinates of all the nodes and add them to the shape outline. - let first = true; + for (const element of elements) { ref = element.getAttribute('ref'); - node = nodelist[ref]; + nodes.push(nodelist[ref]); + } + // If the first and last point are identical, remove the last copy. + if (nodes.length > 1 && nodes[0][0] === nodes[elements.length - 1][0] && nodes[0][1] === nodes[elements.length - 1][1]) { + nodes.pop(); + } + let first = true; + for (const node of nodes) { // The first node requires a different function call. if (first) { first = false; @@ -37,7 +45,6 @@ class BuildingShapeUtils extends ShapeUtils { } return shape; } - /** * Check if a way is a closed shape. * From 0aa70797ddcc500c66894bf382bad909bd0fb4e1 Mon Sep 17 00:00:00 2001 From: Kevin Nowaczyk Date: Tue, 3 Jun 2025 10:37:46 -0400 Subject: [PATCH 12/13] Update BuildingShapeUtils.js (#130) --- src/extras/BuildingShapeUtils.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extras/BuildingShapeUtils.js b/src/extras/BuildingShapeUtils.js index fa0c215..6dedcf8 100644 --- a/src/extras/BuildingShapeUtils.js +++ b/src/extras/BuildingShapeUtils.js @@ -17,14 +17,14 @@ class BuildingShapeUtils extends ShapeUtils { // Initialize objects const shape = new Shape(); var ref; - const nodes = []; + const nodes = []; // Get all the nodes in the way of interest /** {HTMLCollection} */ const elements = way.getElementsByTagName('nd'); // Get the coordinates of all the nodes and add them to the shape outline. - + for (const element of elements) { ref = element.getAttribute('ref'); nodes.push(nodelist[ref]); From 872168a6ed9fa37b4404b351d8733ea34d4d250c Mon Sep 17 00:00:00 2001 From: Kevin Nowaczyk Date: Tue, 3 Jun 2025 10:51:42 -0400 Subject: [PATCH 13/13] Update utils.test.js (#132) --- test/utils.test.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/utils.test.js b/test/utils.test.js index 3c646cc..b673c85 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -25,12 +25,11 @@ test('', () => { 3: [-1, 1], }; const shape = BuildingShapeUtils.createShape(xmlData, nodelist); - expect(shape.extractPoints().shape.length).toBe(4); + expect(shape.extractPoints().shape.length).toBe(3); const points = shape.extractPoints().shape; expect([points[0].x, points[0].y]).toStrictEqual(nodelist[1]); expect([points[1].x, points[1].y]).toStrictEqual(nodelist[2]); expect([points[2].x, points[2].y]).toStrictEqual(nodelist[3]); - expect([points[0].x, points[0].y]).toStrictEqual(nodelist[1]); }); /** Test isClosed */