├── test ├── data │ ├── way │ │ ├── 1.xml │ │ ├── 2.xml │ │ ├── 3.xml │ │ ├── 5.xml │ │ ├── 6.xml │ │ └── 915563505.xml │ ├── map │ │ ├── 0,0,0.001,0.001.xml │ │ ├── 2,2,2.001,2.001.xml │ │ ├── 3,3,3.001,3.001.xml │ │ ├── 5,5,5.001,5.001.xml │ │ ├── 6,6,6.001,6.001.xml │ │ ├── 4,4,4.001,4.001.xml │ │ ├── 7,7,7.001,7.001.xml │ │ └── -84.6401403,38.2796482,-84.639963,38.2797774.xml │ └── relation │ │ ├── 4.xml │ │ └── 5.xml ├── src │ └── apis.js ├── README.md ├── index.html ├── multipolygon.test.js ├── split_way_multipolygon.test.js ├── split_way_multipolygon_reverse.test.js ├── buildingpart.test.js ├── combine_ways.test.js ├── utils.test.js └── building.test.js ├── style.css ├── src ├── apis.js ├── multibuildingpart.js ├── index.js ├── building.js ├── extras │ └── BuildingShapeUtils.js └── buildingpart.js ├── package.json ├── LICENSE.md ├── index.html ├── .eslintrc.json ├── .github └── workflows │ └── main.yml ├── README.md └── CONTRIBUTING.md /test/data/way/1.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/data/map/0,0,0.001,0.001.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/data/way/2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /test/data/way/3.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /test/data/way/5.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /test/data/way/6.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /test/data/map/2,2,2.001,2.001.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /test/data/map/3,3,3.001,3.001.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /test/data/map/5,5,5.001,5.001.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /test/data/map/6,6,6.001,6.001.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /test/src/apis.js: -------------------------------------------------------------------------------- 1 | let apis = { 2 | bounding: { 3 | api:'https://beakerboy.github.io/OSMBuilding/test/data/map/', 4 | url: (left, bottom, right, top) => { 5 | return apis.bounding.api + left + ',' + bottom + ',' + right + ',' + top; 6 | }, 7 | }, 8 | getRelation: { 9 | api:'https://beakerboy.github.io/OSMBuilding/test/data/relation/', 10 | url: (relationId) => { 11 | return apis.getRelation.api + relationId; 12 | }, 13 | }, 14 | getWay: { 15 | api:'https://beakerboy.github.io/OSMBuilding/test/data/way/', 16 | url: (wayId) => { 17 | return apis.getWay.api + wayId; 18 | }, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | .collapsible { 2 | background-color: #eee; 3 | color: #444; 4 | cursor: pointer; 5 | padding: 18px; 6 | width: 50%; 7 | border: none; 8 | text-align: left; 9 | outline: none; 10 | font-size: 15px; 11 | } 12 | 13 | body { 14 | margin: 0; 15 | background-color: #222; 16 | } 17 | 18 | /* Add a background color to the button if it is clicked on (add the .active class with JS), and when you move the mouse over it (hover) */ 19 | .active, .collapsible:hover { 20 | background-color: #ccc; 21 | } 22 | 23 | /* Style the collapsible content. Note: hidden by default */ 24 | .content { 25 | padding: 0 18px; 26 | display: none; 27 | overflow: hidden; 28 | background-color: #f1f1f1; 29 | } 30 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | OSM Building Viewer 2 | ===================== 3 | 4 | ### Visualize an OSM Building in 3D 5 | 6 | Tests 7 | * [Minimal Building](https://beakerboy.github.io/OSMBuilding/test/index.html?id=1) 8 | * [Minimal Building with skillion roof defined with height](https://beakerboy.github.io/OSMBuilding/test/index.html?id=2) 9 | * [Minimal Building with skillion roof defined with angle](https://beakerboy.github.io/OSMBuilding/test/index.html?id=3) 10 | * [Minimal Building with skillion roof defined with angle, 90º direction](https://beakerboy.github.io/OSMBuilding/test/index.html?id=5) 11 | * [Multipolygon with skillion roof defined with angle](https://beakerboy.github.io/OSMBuilding/test/index.html?type=relation&id=4) 12 | -------------------------------------------------------------------------------- /src/apis.js: -------------------------------------------------------------------------------- 1 | const osmApiUrl = new URLSearchParams(location.search).get('osmApiUrl') || 'https://api.openstreetmap.org/api/0.6'; 2 | export const apis = { 3 | bounding: { 4 | api: osmApiUrl + '/map?bbox=', 5 | url: (left, bottom, right, top) => { 6 | return apis.bounding.api + left + ',' + bottom + ',' + right + ',' + top; 7 | }, 8 | }, 9 | getRelation: { 10 | api: osmApiUrl + '/relation/', 11 | parameters: '/full', 12 | url: (relationId) => { 13 | return apis.getRelation.api + relationId + apis.getRelation.parameters; 14 | }, 15 | }, 16 | getWay: { 17 | api: osmApiUrl + '/way/', 18 | parameters: '/full', 19 | url: (wayId) => { 20 | return apis.getWay.api + wayId + apis.getWay.parameters; 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "OSMBuilding", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "main": "src/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/Beakerboy/OSMBuilding.git" 9 | }, 10 | "dependencies": { 11 | "three": "^0.138.3" 12 | }, 13 | "devDependencies" : { 14 | "coveralls": "*", 15 | "c8": "*", 16 | "eslint": "^8.13", 17 | "jest": "*", 18 | "jest-environment-jsdom": "*", 19 | "jest-fetch-mock": "*", 20 | "jest-matcher-deep-close-to": "*", 21 | "jsdom": "*", 22 | "hipped": "file:../hipped", 23 | "pyramid": "file:../pyramid", 24 | "ramp": "file:../ramp", 25 | "wedge": "file:../wedge", 26 | "straight-skeleton": "1.1.0" 27 | }, 28 | "scripts": { 29 | "test": "c8 jest" 30 | }, 31 | "c8": { 32 | "all": true, 33 | "reporter": [ 34 | "lcov", 35 | "text-summary" 36 | ] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test/data/relation/4.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /test/data/map/4,4,4.001,4.001.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /test/data/relation/5.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /test/data/map/7,7,7.001,7.001.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | OpenStreetMap Building 3D Render 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Kevin Nowaczyk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | OpenStreetMap Building 3D Render 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 27 | 28 | 29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /test/data/way/915563505.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 2022, 4 | "sourceType": "module" 5 | }, 6 | "env": { 7 | "browser": true, 8 | "node": true, 9 | "es6": true, 10 | "jest": true 11 | }, 12 | "globals": { 13 | "Building": true, 14 | "BuildingPart": true, 15 | "BuildingShapeUtils": true, 16 | "MultiBuildingPart": true, 17 | "PyramidGeometry": true, 18 | "QUnit": true, 19 | "RampGeometry": true, 20 | "Shape": true, 21 | "THREE": true, 22 | "Vector2": true, 23 | "apis": true, 24 | "scene": true 25 | }, 26 | "rules": { 27 | "brace-style": [ 28 | "error" 29 | ], 30 | "camelcase": [ 31 | "error" 32 | ], 33 | "comma-dangle": [ 34 | "error", 35 | "always-multiline" 36 | ], 37 | "comma-spacing": [ 38 | "error" 39 | ], 40 | "comma-style": [ 41 | "error" 42 | ], 43 | "eqeqeq": [ 44 | "error" 45 | ], 46 | "indent": [ 47 | "error", 48 | 2 49 | ], 50 | "keyword-spacing": [ 51 | "error" 52 | ], 53 | "linebreak-style": [ 54 | "error", 55 | "unix" 56 | ], 57 | "no-console": [ 58 | 0 59 | ], 60 | "no-trailing-spaces": [ 61 | "error" 62 | ], 63 | "no-undef": [ 64 | "error" 65 | ], 66 | "quotes": [ 67 | "error", 68 | "single" 69 | ], 70 | "semi": [ 71 | "error", 72 | "always" 73 | ], 74 | "space-before-function-paren": [ 75 | "error", 76 | "never" 77 | ] 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /test/multipolygon.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { Shape, Mesh } from 'three'; 6 | import { TextEncoder } from 'node:util'; 7 | global.TextEncoder = TextEncoder; 8 | 9 | import { Building } from '../src/building.js'; 10 | import { MultiBuildingPart } from '../src/multibuildingpart.js'; 11 | 12 | const data = ` 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | `; 34 | 35 | beforeEach(() => { 36 | errors = []; 37 | }); 38 | 39 | test('Test Simple Multipolygon', () => { 40 | let xmlData = new window.DOMParser().parseFromString(data, 'text/xml'); 41 | const nodelist = Building.buildNodeList(xmlData); 42 | const shape = new MultiBuildingPart('4', xmlData, nodelist); 43 | expect(shape.id).toBe('4'); 44 | expect(shape.shape).toBeInstanceOf(Shape); 45 | // expect(shape.roof).toBeInstanceOf(Mesh); 46 | expect(errors.length).toBe(0); 47 | }); 48 | 49 | window.printError = printError; 50 | 51 | var errors = []; 52 | 53 | function printError(txt) { 54 | errors.push[txt]; 55 | } 56 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - name: Install modules 9 | run: | 10 | mkdir ../pyramid 11 | mkdir ../ramp 12 | mkdir ../wedge 13 | mkdir ../hipped 14 | wget -P ../pyramid https://beakerboy.github.io/Threejs-Geometries/src/PyramidGeometry.js 15 | wget -P ../ramp https://beakerboy.github.io/Threejs-Geometries/src/RampGeometry.js 16 | wget -P ../wedge https://beakerboy.github.io/Threejs-Geometries/src/WedgeGeometry.js 17 | wget -P ../hipped https://beakerboy.github.io/Threejs-Geometries/src/HippedGeometry.js 18 | cd ../pyramid 19 | echo '{"name":"pyramid","type":"module","private":true,"scripts":{"test":"npx jest"}}' > "./package.json" && npm init -y 20 | cd ../ramp 21 | echo '{"name":"ramp","type":"module","private":true,"scripts":{"test":"npx jest"}}' > "./package.json" && npm init -y 22 | cd ../wedge 23 | echo '{"name":"wedge","type":"module","private":true,"scripts":{"test":"npx jest"}}' > "./package.json" && npm init -y 24 | cd ../hipped 25 | echo '{"name":"hipped","type":"module","private":true,"scripts":{"test":"npx jest"}}' > "./package.json" && npm init -y 26 | cd ../OSMBuilding 27 | yarn --prod=false 28 | - name: Lint 29 | run: yarn run eslint . 30 | - name: Test 31 | run: CI=true NODE_OPTIONS="$NODE_OPTIONS --experimental-vm-modules" timeout 20s npm test 32 | - name: Coveralls 33 | uses: coverallsapp/github-action@v2 34 | -------------------------------------------------------------------------------- /test/split_way_multipolygon.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { Shape, Mesh } from 'three'; 6 | import { TextEncoder } from 'node:util'; 7 | global.TextEncoder = TextEncoder; 8 | 9 | import { Building } from '../src/building.js'; 10 | import { MultiBuildingPart } from '../src/multibuildingpart.js'; 11 | 12 | const data = ` 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | `; 48 | 49 | beforeEach(() => { 50 | errors = []; 51 | }); 52 | 53 | test('Test Simple Multipolygon', () => { 54 | let xmlData = new window.DOMParser().parseFromString(data, 'text/xml'); 55 | const nodelist = Building.buildNodeList(xmlData); 56 | const shape = new MultiBuildingPart('5', xmlData, nodelist); 57 | expect(shape.id).toBe('5'); 58 | expect(shape.shape).toBeInstanceOf(Shape); 59 | // expect(shape.roof).toBeInstanceOf(Mesh); 60 | expect(errors.length).toBe(0); 61 | }); 62 | 63 | window.printError = printError; 64 | 65 | var errors = []; 66 | 67 | function printError(txt) { 68 | errors.push[txt]; 69 | } 70 | -------------------------------------------------------------------------------- /test/split_way_multipolygon_reverse.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { Shape, Mesh } from 'three'; 6 | import { TextEncoder } from 'node:util'; 7 | global.TextEncoder = TextEncoder; 8 | 9 | import { Building } from '../src/building.js'; 10 | import { MultiBuildingPart } from '../src/multibuildingpart.js'; 11 | // ways 1 and 3 are tip-to-tip. 12 | const data = ` 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | `; 48 | 49 | beforeEach(() => { 50 | errors = []; 51 | }); 52 | 53 | test('Test Simple Multipolygon', () => { 54 | let xmlData = new window.DOMParser().parseFromString(data, 'text/xml'); 55 | const nodelist = Building.buildNodeList(xmlData); 56 | const shape = new MultiBuildingPart('5', xmlData, nodelist); 57 | expect(shape.id).toBe('5'); 58 | expect(shape.shape).toBeInstanceOf(Shape); 59 | // expect(shape.roof).toBeInstanceOf(Mesh); 60 | expect(errors.length).toBe(0); 61 | }); 62 | 63 | window.printError = printError; 64 | 65 | var errors = []; 66 | 67 | function printError(txt) { 68 | errors.push[txt]; 69 | } 70 | -------------------------------------------------------------------------------- /src/multibuildingpart.js: -------------------------------------------------------------------------------- 1 | import {BuildingShapeUtils} from './extras/BuildingShapeUtils.js'; 2 | import {BuildingPart} from './buildingpart.js'; 3 | /** 4 | * An OSM Building Part 5 | * 6 | * A building part includes a main building and a roof. 7 | */ 8 | class MultiBuildingPart extends BuildingPart { 9 | 10 | makeRings(members) { 11 | const ways = []; 12 | for (let j = 0; j < members.length; j++) { 13 | const wayID = members[j].getAttribute('ref'); 14 | const way = this.fullXmlData.getElementById(wayID); 15 | if (way) { 16 | ways.push(way.cloneNode(true)); 17 | } else { 18 | window.printError(`Missing way ${wayID} for relation ${this.id}`); 19 | ways.push(this.augmentedWays[wayID].cloneNode(true)); 20 | } 21 | } 22 | return BuildingShapeUtils.combineWays(ways); 23 | } 24 | 25 | /** 26 | * Create the shape of the outer relation. 27 | * 28 | * @return {THREE.Shape} shape - the shape 29 | */ 30 | buildShape() { 31 | this.type = 'multipolygon'; 32 | const innerMembers = this.way.querySelectorAll('member[role="inner"][type="way"]'); 33 | const outerMembers = this.way.querySelectorAll('member[role="outer"][type="way"]'); 34 | const shapes = []; 35 | const innerShapes = this.makeRings(innerMembers).map(ring => BuildingShapeUtils.createShape(ring, this.nodelist, this.augmentedNodelist)); 36 | const closedOuterWays = this.makeRings(outerMembers); 37 | for (let k = 0; k < closedOuterWays.length; k++) { 38 | const shape = BuildingShapeUtils.createShape(closedOuterWays[k], this.nodelist, this.augmentedNodelist); 39 | shape.holes.push(...innerShapes); 40 | shapes.push(shape); 41 | } 42 | if (closedOuterWays.length === 1) { 43 | return shapes[0]; 44 | } 45 | // Multiple outer members 46 | return shapes; 47 | } 48 | 49 | getWidth() { 50 | var xy = [[], []]; 51 | for (let i = 0; i < this.shape.length; i++){ 52 | const shape = this.shape[i]; 53 | const newXy = BuildingShapeUtils.combineCoordinates(shape); 54 | xy[0] = xy[0].concat(newXy[0]); 55 | xy[1] = xy[1].concat(newXy[1]); 56 | } 57 | 58 | const x = xy[0]; 59 | const y = xy[1]; 60 | window.printError('Multibuilding x: ' + x); 61 | window.printError('Multibuilding y: ' + y); 62 | const widths = Math.max(Math.max(...x) - Math.min(...x), Math.max(...y) - Math.min(...y)); 63 | window.printError('Multibuilding Width: ' + widths); 64 | return widths; 65 | } 66 | } 67 | export {MultiBuildingPart}; 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Badge](https://github.com/Beakerboy/OSMBuilding/actions/workflows/main.yml/badge.svg) 2 | [![Coverage Status](https://coveralls.io/repos/github/Beakerboy/OSMBuilding/badge.svg?branch=main)](https://coveralls.io/github/Beakerboy/OSMBuilding?branch=main) 3 | 4 | OSM Building Viewer 5 | ===================== 6 | 7 | ### Visualize an OSM Building in 3D 8 | 9 | To visualize a building tagged with a way, use the URL: 10 | https://beakerboy.github.io/OSMBuilding?id=[id] 11 | 12 | If the building is a multipolygon, or a relation, use: 13 | https://beakerboy.github.io/OSMBuilding?type=relation&id=[id] 14 | 15 | ...replacing [id] with the actual id of the way or relation. 16 | 17 | Additional details will be displayed if `&info` is appended to the URL. 18 | 19 | Console debug messages can be printed to the screen if `&errorBox` is appended to the url. Helpful since mobile browsers often lack any inspection capability. 20 | 21 | Use the left mouse button to rotate the camera. To move, use the right one. 22 | 23 | Supports: 24 | * Ways with a building tag 25 | * Ways with building parts inside. 26 | * Building relations with way and/or multipolygon parts 27 | * Multipolygon buildings 28 | * Multipolygon building with multiple open ways which combine to a closed way. 29 | 30 | Roof Types: 31 | * Flat 32 | * Skillion 33 | * Dome 34 | * Pyramidal 35 | * Gabled 36 | * Hipped 37 | 38 | Examples: 39 | * Simple building with no parts - [Washington Monument](https://beakerboy.github.io/OSMBuilding/index.html?id=766761337) 40 | * Glass - [Petronas Towers](https://beakerboy.github.io/OSMBuilding/index.html?id=279944536) 41 | * Dome roof, Gabled roof, and Skillion ramp - [Jefferson Memorial](https://beakerboy.github.io/OSMBuilding/index.html?type=relation&id=3461570) 42 | * Dome, Gabled, Hipped, and Pyramidal Roof - [US Capitol](https://beakerboy.github.io/OSMBuilding/index.html?type=relation&id=12286916) 43 | * [Chrysler Building](https://beakerboy.github.io/OSMBuilding/index.html?id=42500770) 44 | * Building Relation [Burj Khalifa](https://beakerboy.github.io/OSMBuilding/index.html?type=relation&id=7584462) 45 | * Multipolygon with no parts - [Freer Art Gallery](https://beakerboy.github.io/OSMBuilding/index.html?type=relation&id=1029355) 46 | * Relation with multipolygon parts - [Leaning Tower of Pisa](https://beakerboy.github.io/OSMBuilding/index.html?type=relation&id=12982338) 47 | 48 | Specify the `osmApiUrl` parameter to use other OSM APIs. Examples: 49 | * [OpenHistoricalMap](https://beakerboy.github.io/OSMBuilding/?id=2826540&osmApiUrl=https://api.openhistoricalmap.org/api/0.6&type=relation) 50 | * [OpenGeofiction](https://beakerboy.github.io/OSMBuilding/?id=461819&osmApiUrl=https://opengeofiction.net/api/0.6&type=relation) 51 | -------------------------------------------------------------------------------- /test/buildingpart.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import { BuildingPart } from '../src/buildingpart.js'; 5 | import { BuildingShapeUtils } from '../src/extras/BuildingShapeUtils.js'; 6 | import { TextEncoder } from 'node:util'; 7 | 8 | const data = ` 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | `; 31 | 32 | beforeEach(() => { 33 | errors = []; 34 | }); 35 | 36 | test('Test Cardinal to Degree', () => { 37 | expect(BuildingPart.cardinalToDegree('N')).toBe(0); 38 | expect(BuildingPart.cardinalToDegree('sSw')).toBe(202); 39 | expect(BuildingPart.cardinalToDegree('Wse')).toBeUndefined(); 40 | }); 41 | 42 | test('radToDeg', () => { 43 | expect (BuildingPart.atanRadToCompassDeg(0)).toBe(90); 44 | expect (BuildingPart.atanRadToCompassDeg(Math.PI / 2)).toBe(0); 45 | }); 46 | 47 | test('Constructor', () => { 48 | let xmlData = new window.DOMParser().parseFromString(data, 'text/xml'); 49 | const nodes = { 50 | '349300285': [4.332747472106493, -5.882209888874915], 51 | '349300289': [-4.332738077015795, 5.88221335051411], 52 | '349300292': [-4.332738077015795, -5.882209888874915], 53 | '349300295': [4.332747472106493, 5.88221335051411], 54 | }; 55 | const part = new BuildingPart('31361386', xmlData, nodes); 56 | expect(part.options.building.levels).toBe(1); 57 | expect(part.options.roof.levels).toBe(2); 58 | expect(part.options.roof.shape).toBe('gabled'); 59 | 60 | // Gabled with unspecified orientation shal be 'along' 61 | expect(part.options.roof.orientation).toBe('along'); 62 | 63 | // toDo: Mock BuildingShapeUtils and test options 64 | expect(BuildingShapeUtils.edgeDirection(part.shape)).toStrictEqual([1.5707963267948966, 0, -1.5707963267948966, 3.141592653589793]); 65 | expect(BuildingShapeUtils.longestSideAngle(part.shape)).toBe(1.5707963267948966); 66 | expect(part.options.roof.direction).toBe(90); 67 | expect(errors.length).toBe(0); 68 | }); 69 | 70 | window.printError = printError; 71 | 72 | var errors = []; 73 | 74 | function printError(txt) { 75 | errors.push[txt]; 76 | } 77 | -------------------------------------------------------------------------------- /test/combine_ways.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import {toBeDeepCloseTo} from 'jest-matcher-deep-close-to'; 6 | expect.extend({toBeDeepCloseTo}); 7 | 8 | import { TextEncoder } from 'node:util'; 9 | global.TextEncoder = TextEncoder; 10 | 11 | import { Shape } from 'three'; 12 | 13 | import { BuildingShapeUtils } from '../src/extras/BuildingShapeUtils.js'; 14 | // import { JSDOM } from 'jsdom'; 15 | 16 | beforeEach(() => { 17 | errorMsgs = []; 18 | }); 19 | 20 | describe.each([ 21 | [ 22 | ['', 23 | ], 0, 0, [], 'Single Open Way', 24 | ], 25 | [ 26 | [ 27 | '', 28 | '', 29 | '', 30 | ], 1, 4, [], 'Test combining 3 ways 1->2->3', 31 | ], 32 | [ 33 | [ 34 | '', 35 | '', 36 | '', 37 | ], 1, 4, [], 'Test combining 3 ways 2->1->3', 38 | ], 39 | [ 40 | [ 41 | '', 42 | '', 43 | '', 44 | ], 1, 4, [], 'Test combining tip to tip', 45 | ], 46 | [ 47 | [ 48 | '', 49 | '', 50 | '', 51 | ], 1, 4, [], 'Test combining tail to tail', 52 | ], 53 | [ 54 | [ 55 | '', 56 | '', 57 | '', 58 | '', 59 | ], 1, 5, [], 'Test combining 4 ways', 60 | ], 61 | [ 62 | [ 63 | '', 64 | '', 65 | ], 0, 0, [], 'Test combining 2 open ways into one open way', 66 | ], 67 | [ 68 | [ 69 | '', 70 | '', 71 | '', 72 | ], 0, 0, [], 'Test combining 3 open ways into 2 open ways', 73 | ], 74 | [ 75 | [ 76 | '', 77 | '', 78 | '', 79 | '', 80 | ], 1, 4, [], 'Combining 4 open ways into 1 closed & 1 remaining open way', 81 | ], 82 | [ 83 | [ 84 | '', 85 | '', 86 | '', 87 | ], 1, 5, [], 'Dealing with amiguity. Only make one closed way', 88 | ], 89 | [ 90 | [ 91 | '', 92 | '', 93 | ], 0, 0, [], 'Closed way is self intersecting', 94 | ], 95 | [ 96 | [ 97 | '', 98 | '', 99 | ], 0, 0, ['Way 2 is self-intersecting'], 'Open way is self intersecting', 100 | ], 101 | [ 102 | [ 103 | '', 104 | '', 105 | '', 106 | ], 1, 4, ['Way 2 is self-intersecting'], 'Open way is self intersecting, but ring formed', 107 | ], 108 | ])('Combine Ways', (ways, length, nodes, errors, description) => { 109 | test(`${description}`, () => { 110 | let parser = new window.DOMParser(); 111 | const xml = []; 112 | for (const way of ways){ 113 | xml.push(parser.parseFromString(way, 'text/xml').getElementsByTagName('way')[0]); 114 | } 115 | let result = BuildingShapeUtils.combineWays(xml); 116 | expect(result.length).toBe(length); 117 | expect(errorMsgs.length).toBe(errors.length); 118 | if (errors.length) { 119 | for (const error of errors) { 120 | expect(errorMsgs.shift()).toBe(error); 121 | } 122 | } 123 | if (length) { 124 | expect(BuildingShapeUtils.isClosed(result[0])); 125 | expect(BuildingShapeUtils.isSelfIntersecting(result[0])).toBe(false); 126 | expect(result[0].getElementsByTagName('nd').length).toBe(nodes); 127 | } 128 | }); 129 | }); 130 | 131 | window.printError = printError; 132 | 133 | var errorMsgs = []; 134 | 135 | function printError(txt) { 136 | errorMsgs.push(txt); 137 | } 138 | -------------------------------------------------------------------------------- /test/data/map/-84.6401403,38.2796482,-84.639963,38.2797774.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /test/utils.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import {toBeDeepCloseTo} from 'jest-matcher-deep-close-to'; 6 | expect.extend({toBeDeepCloseTo}); 7 | 8 | import { TextEncoder } from 'node:util'; 9 | global.TextEncoder = TextEncoder; 10 | 11 | import { Shape } from 'three'; 12 | 13 | import { BuildingShapeUtils } from '../src/extras/BuildingShapeUtils.js'; 14 | // import { JSDOM } from 'jsdom'; 15 | 16 | 17 | /** Test createShape */ 18 | test('', () => { 19 | var way = ''; 20 | let parser = new window.DOMParser(); 21 | let xmlData = parser.parseFromString(way, 'text/xml'); 22 | let nodelist = { 23 | 1: [1, 1], 24 | 2: [1, -1], 25 | 3: [-1, 1], 26 | }; 27 | const shape = BuildingShapeUtils.createShape(xmlData, nodelist); 28 | expect(shape.extractPoints().shape.length).toBe(3); 29 | const points = shape.extractPoints().shape; 30 | expect([points[0].x, points[0].y]).toStrictEqual(nodelist[1]); 31 | expect([points[1].x, points[1].y]).toStrictEqual(nodelist[2]); 32 | expect([points[2].x, points[2].y]).toStrictEqual(nodelist[3]); 33 | }); 34 | 35 | /** Test isClosed */ 36 | test('Test Closed Way', () => { 37 | var way = ''; 38 | let parser = new window.DOMParser(); 39 | let xmlData = parser.parseFromString(way, 'text/xml'); 40 | expect(BuildingShapeUtils.isClosed(xmlData)).toBe(true); 41 | }); 42 | 43 | test('Test Open Way', () => { 44 | var way = ''; 45 | let parser = new window.DOMParser(); 46 | let xmlData = parser.parseFromString(way, 'text/xml'); 47 | expect(BuildingShapeUtils.isClosed(xmlData)).toBe(false); 48 | }); 49 | 50 | /** Test isSelfIntersecting */ 51 | describe.each([ 52 | ['', false, 'open non-intersecting'], 53 | ['', false, 'closed non-intersecting'], 54 | ['', true, 'open intersecting'], 55 | ['', true, 'closed intersecting'], 56 | ])('isSelfIntersecting', (way, expected, description) => { 57 | test(`${description}`, () => { 58 | let parser = new window.DOMParser(); 59 | let xml = parser.parseFromString(way, 'text/xml').getElementsByTagName('way')[0]; 60 | expect(BuildingShapeUtils.isSelfIntersecting(xml)).toBe(expected); 61 | }); 62 | }); 63 | 64 | /** Test joinWays */ 65 | test('Test joining 2 ways', () => { 66 | var way1 = ''; 67 | var way2 = ''; 68 | var way3 = ''; 69 | let parser = new window.DOMParser(); 70 | let xml1 = parser.parseFromString(way1, 'text/xml').getElementsByTagName('way')[0]; 71 | let xml2 = parser.parseFromString(way2, 'text/xml').getElementsByTagName('way')[0]; 72 | let result = BuildingShapeUtils.joinWays(xml1, xml2); 73 | expect(result.outerHTML).toBe(way3); 74 | }); 75 | 76 | /** Test joinAllWays */ 77 | test('Test joining 2 ways', () => { 78 | var way1 = ''; 79 | var way2 = ''; 80 | var way3 = ''; 81 | let parser = new window.DOMParser(); 82 | let xml1 = parser.parseFromString(way1, 'text/xml').getElementsByTagName('way')[0]; 83 | let xml2 = parser.parseFromString(way2, 'text/xml').getElementsByTagName('way')[0]; 84 | let result = BuildingShapeUtils.joinAllWays([xml1, xml2]); 85 | expect(result.outerHTML).toBe(way3); 86 | }); 87 | 88 | /** Test reverseWay */ 89 | test('Reverse way', () => { 90 | var way1 = ''; 91 | var way2 = ''; 92 | let parser = new window.DOMParser(); 93 | let xml1 = parser.parseFromString(way1, 'text/xml').getElementsByTagName('way')[0]; 94 | let result = BuildingShapeUtils.reverseWay(xml1); 95 | expect(result.outerHTML).toBe(way2); 96 | }); 97 | 98 | /** Test center */ 99 | test('Center', () => { 100 | expect(BuildingShapeUtils.center(rightTriangle)).toStrictEqual([0, 0]); 101 | }); 102 | 103 | /** Test getWidth */ 104 | test('Get Width', () => { 105 | expect(BuildingShapeUtils.getWidth(rightTriangle)).toBe(2); 106 | }); 107 | 108 | /** Test combineCoordinates */ 109 | test('Combine Coordinates', () => { 110 | expect(BuildingShapeUtils.combineCoordinates(rightTriangle)).toStrictEqual([[1, 1, -1], [1, -1, 1]]); 111 | }); 112 | 113 | /** Test extents */ 114 | const rightTriangle = new Shape(); 115 | rightTriangle.moveTo(1, 1); 116 | rightTriangle.lineTo(1, -1); 117 | rightTriangle.lineTo(-1, 1); 118 | 119 | const rightTriangle2 = new Shape(); 120 | rightTriangle2.moveTo(1, 1); 121 | rightTriangle2.lineTo(-1, 1); 122 | rightTriangle2.lineTo(1, -1); 123 | 124 | const rectangle = new Shape(); 125 | rectangle.moveTo(-4.332738077015795, -5.882209888874915); 126 | rectangle.lineTo(-4.332738077015795, 5.88221335051411); 127 | rectangle.lineTo(4.332747472106493, 5.88221335051411); 128 | rectangle.lineTo(4.332747472106493, -5.882209888874915); 129 | 130 | test('Extents no Rotation', () => { 131 | expect(BuildingShapeUtils.extents(rightTriangle)).toStrictEqual([-1, -1, 1, 1]); 132 | }); 133 | 134 | test('Extents Rotation', () => { 135 | const angle = 45 / 360 * 2 * 3.1415926535; 136 | const sqrt2 = Math.sqrt(2); 137 | expect(BuildingShapeUtils.extents(rightTriangle, angle)).toBeDeepCloseTo([-sqrt2, 0, sqrt2, sqrt2], 10); 138 | }); 139 | 140 | /** Test edgeLength */ 141 | test('Edge Lengths', () => { 142 | expect(BuildingShapeUtils.edgeLength(rightTriangle)).toBeDeepCloseTo([2, Math.sqrt(2) * 2, 2]); 143 | }); 144 | 145 | /** Test vertexAngle */ 146 | test('Vertex Angles', () => { 147 | expect(BuildingShapeUtils.vertexAngle(rightTriangle)).toStrictEqual([Math.PI / 2, Math.PI / 4, Math.PI / 4]); 148 | }); 149 | 150 | test('Vertex Angles counterclockwise', () => { 151 | expect(BuildingShapeUtils.vertexAngle(rightTriangle2)).toStrictEqual([-Math.PI / 2, -Math.PI / 4, -Math.PI / 4]); 152 | }); 153 | 154 | /** Test edgeDirection */ 155 | describe.each([ 156 | [rightTriangle, [-Math.PI / 2, 3 * Math.PI / 4, 0], 'CW'], 157 | [rightTriangle2, [Math.PI, -Math.PI / 4, Math.PI / 2], 'CCW'], 158 | [rectangle, [Math.PI / 2, 0, -Math.PI / 2, Math.PI], 'Rect'], 159 | ])('Edge Direction', (shape, expected, description) =>{ 160 | test(`${description}`, () => { 161 | expect(BuildingShapeUtils.edgeDirection(shape)).toStrictEqual(expected); 162 | }); 163 | }); 164 | 165 | /** Test surrounds */ 166 | describe.each([ 167 | [[-.5, -.5], false, 'Outside but crossing'], 168 | [[-1.5, -1.5], false, 'Outside no crossings'], 169 | [[1, 1], true, 'Share Node'], 170 | [[.5, .5], true, 'Inside'], 171 | [[0, 0], true, 'Border'], 172 | ])('Surrounds', (point, expected, description) => { 173 | test(`${description}`, () => { 174 | expect(BuildingShapeUtils.surrounds(rightTriangle, point)).toBe(expected); 175 | }); 176 | }); 177 | 178 | /** Test calculateRadius */ 179 | test('Calculate Radius', () => { 180 | expect(BuildingShapeUtils.calculateRadius(rightTriangle)).toBe(1); 181 | }); 182 | 183 | /** Test longestSideAngle */ 184 | test('Longest side angle', () => { 185 | expect(BuildingShapeUtils.longestSideAngle(rightTriangle)).toBe(3 * Math.PI / 4); 186 | }); 187 | 188 | /** Test repositionPoint */ 189 | test('Reposition Point', () => { 190 | const point = [11.0154519, 49.5834188]; 191 | const home = [11.015512, 49.5833659]; 192 | const expected = [-4.3327380768877335, 5.88221335051411]; 193 | expect(BuildingShapeUtils.repositionPoint(point, home)).toStrictEqual(expected); 194 | }); 195 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to CONTRIBUTING.md 2 | 3 | First off, thanks for taking the time to contribute! ❤️ 4 | 5 | All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 6 | 7 | > And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: 8 | > - Star the project 9 | > - Tweet about it 10 | > - Refer this project in your project's readme 11 | > - Mention the project at local meetups and tell your friends/colleagues 12 | 13 | 14 | ## Table of Contents 15 | 16 | - [I Have a Question](#i-have-a-question) 17 | - [I Want To Contribute](#i-want-to-contribute) 18 | - [Reporting Bugs](#reporting-bugs) 19 | - [Suggesting Enhancements](#suggesting-enhancements) 20 | - [Your First Code Contribution](#your-first-code-contribution) 21 | - [Improving The Documentation](#improving-the-documentation) 22 | - [Styleguides](#styleguides) 23 | - [Commit Messages](#commit-messages) 24 | - [Join The Project Team](#join-the-project-team) 25 | 26 | ## I Have a Question 27 | 28 | > If you want to ask a question, we assume that you have read the available [Documentation](). 29 | 30 | Before you ask a question, it is best to search for existing [Issues](/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. 31 | 32 | If you then still feel the need to ask a question and need clarification, we recommend the following: 33 | 34 | - Open an [Issue](/issues/new). 35 | - Provide as much context as you can about what you're running into. 36 | - Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. 37 | 38 | We will then take care of the issue as soon as possible. 39 | 40 | 41 | 42 | ## I Want To Contribute 43 | 44 | > ### Legal Notice 45 | > When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. 46 | 47 | ### Reporting Bugs 48 | 49 | 50 | #### Before Submitting a Bug Report 51 | 52 | A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. 53 | 54 | - Make sure that you are using the latest version. 55 | - Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](). If you are looking for support, you might want to check [this section](#i-have-a-question)). 56 | - To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](issues?q=label%3Abug). 57 | - Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue. 58 | - Collect information about the bug: 59 | - Stack trace (Traceback) 60 | - OS, Platform and Version (Windows, Linux, macOS, x86, ARM) 61 | - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant. 62 | - Possibly your input and the output 63 | - Can you reliably reproduce the issue? And can you also reproduce it with older versions? 64 | 65 | 66 | #### How Do I Submit a Good Bug Report? 67 | 68 | > You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to . 69 | 70 | 71 | We use GitHub issues to track bugs and errors. If you run into an issue with the project: 72 | 73 | - Open an [Issue](/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) 74 | - Explain the behavior you would expect and the actual behavior. 75 | - Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. 76 | - Provide the information you collected in the previous section. 77 | 78 | Once it's filed: 79 | 80 | - The project team will label the issue accordingly. 81 | - A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced. 82 | - If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution). 83 | 84 | ### Suggesting Enhancements 85 | 86 | This section guides you through submitting an enhancement suggestion for OSMBuilding, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. 87 | 88 | 89 | #### Before Submitting an Enhancement 90 | 91 | - Make sure that you are using the latest version. 92 | - Read the [documentation]() carefully and find out if the functionality is already covered, maybe by an individual configuration. 93 | - Perform a [search](/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. 94 | - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library. 95 | 96 | 97 | #### How Do I Submit a Good Enhancement Suggestion? 98 | 99 | Enhancement suggestions are tracked as [GitHub issues](/issues). 100 | 101 | - Use a **clear and descriptive title** for the issue to identify the suggestion. 102 | - Provide a **step-by-step description of the suggested enhancement** in as many details as possible. 103 | - **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. 104 | - You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. 105 | - **Explain why this enhancement would be useful** to most CONTRIBUTING.md users. You may also want to point out the other projects that solved it better and which could serve as inspiration. 106 | 107 | 108 | 109 | ### Your First Code Contribution 110 | 111 | #### Bugs 112 | If you are submitting a fix for a bug, please include in the pull request a minimal test that fails under the existing code, but passes when the pull request is applied. 113 | 114 | #### Features 115 | Please discuss features before submitting code. This is to minimize any possibly that your valuable time goes to waste. New features must include tests that cover all new code. 116 | 117 | ## Styleguides 118 | The repository contains an eslint configuration file that describes the recommended style. All contributions must pass the linting check. 119 | 120 | 121 | ## Attribution 122 | This guide is based on the **contributing.md**. [Make your own](https://contributing.md/)! 123 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | GridHelper, 3 | PerspectiveCamera, 4 | Scene, 5 | WebGLRenderer, 6 | AmbientLight, 7 | HemisphereLight, 8 | DirectionalLight, 9 | WireframeGeometry, 10 | } from 'three'; 11 | import {OrbitControls} from 'https://unpkg.com/three/examples/jsm/controls/OrbitControls.js'; 12 | import {Building} from './building.js'; 13 | import {GUI} from 'https://unpkg.com/three/examples/jsm/libs/lil-gui.module.min.js'; 14 | 15 | var camera; 16 | var renderer; 17 | var controls; 18 | var scene = new Scene(); 19 | var home; 20 | 21 | var helperSize; 22 | 23 | // The Building object that is being rendered. 24 | var mainBuilding; 25 | 26 | var building = {}; 27 | 28 | var errorBox = false; 29 | 30 | var gui; 31 | 32 | async function getFileFromForm() { 33 | return new Promise(resolve => { 34 | const overlay = document.createElement('div'); 35 | overlay.id = 'overlay'; 36 | Object.assign(overlay.style, { 37 | position: 'fixed', 38 | inset: '0', 39 | background: '#222', 40 | display: 'flex', 41 | alignItems: 'center', 42 | justifyContent: 'center', 43 | zIndex: '9999', 44 | }); 45 | 46 | const container = document.createElement('div'); 47 | Object.assign(container.style, { 48 | display: 'flex', 49 | alignItems: 'center', 50 | gap: '1rem', 51 | }); 52 | 53 | const text = document.createElement('p'); 54 | text.textContent = 'Select .osm file:'; 55 | Object.assign(text.style, { 56 | color: '#eee', 57 | margin: '0', 58 | fontSize: '1.5rem', 59 | fontFamily: 'Arial', 60 | }); 61 | 62 | const input = document.createElement('input'); 63 | input.type = 'file'; 64 | Object.assign(input.style, { 65 | fontSize: '1.5rem', 66 | padding: '0.5rem 1rem', 67 | borderRadius: '0.5rem', 68 | cursor: 'pointer', 69 | backgroundColor: '#333', 70 | color: '#eee', 71 | border: '1px solid #555', 72 | }); 73 | 74 | container.appendChild(text); 75 | container.appendChild(input); 76 | overlay.appendChild(container); 77 | document.body.appendChild(overlay); 78 | 79 | input.addEventListener('change', event => { 80 | const file = event.target.files[0]; 81 | if (file) { 82 | const reader = new FileReader(); 83 | reader.onload = () => resolve(reader.result); 84 | reader.readAsText(file); 85 | overlay.remove(); 86 | } 87 | }); 88 | 89 | overlay.addEventListener('dragover', (event) => { 90 | event.preventDefault(); 91 | overlay.style.cursor = 'copy'; 92 | }); 93 | 94 | overlay.addEventListener('dragleave', () => { 95 | overlay.style.cursor = ''; 96 | }); 97 | 98 | overlay.addEventListener('drop', async(event) => { 99 | event.preventDefault(); 100 | overlay.style.cursor = ''; 101 | 102 | const file = event.dataTransfer.files[0]; 103 | if (file) { 104 | const reader = new FileReader(); 105 | reader.onload = () => resolve(reader.result); 106 | reader.readAsText(file); 107 | overlay.remove(); 108 | } 109 | }); 110 | }); 111 | } 112 | 113 | /** 114 | * Initialize the screen 115 | */ 116 | function init() { 117 | let type = 'way'; 118 | let id = 66418809; 119 | 120 | let displayInfo = false; 121 | 122 | window.printError = printError; 123 | 124 | const params = new URLSearchParams(window.location.search); 125 | if (params.has('type')) { 126 | type = params.get('type'); 127 | } 128 | if (params.has('id')) { 129 | id = params.get('id'); 130 | } 131 | if (params.has('info')) { 132 | displayInfo = true; 133 | } 134 | if (params.has('errorBox')) { 135 | errorBox = true; 136 | } 137 | const fileUrl = new URLSearchParams(location.search).get('fromFile'); 138 | async function downloadInnerData() { 139 | if (fileUrl === '') { 140 | return await getFileFromForm(); 141 | } else if (fileUrl !== null) { 142 | printError('Loading map data from URL'); 143 | return await (await fetch(new URLSearchParams(location.search).get('fromFile'))).text(); 144 | } else { 145 | return await Building.downloadDataAroundBuilding(type, id); 146 | } 147 | } 148 | downloadInnerData().then(function(innerData){ 149 | mainBuilding = new Building(id, innerData); 150 | const helperSize = mainBuilding.outerElement.getWidth(); 151 | const helper = new GridHelper(helperSize / 0.9, helperSize / 9); 152 | scene.add(helper); 153 | 154 | const mesh = mainBuilding.render(); 155 | for (let i = 0; i < mesh.length; i++) { 156 | if (mesh[i] && mesh[i].isObject3D) { 157 | scene.add(mesh[i]); 158 | } else { 159 | window.printError('not Object'); 160 | } 161 | } 162 | if (displayInfo) { 163 | gui = new GUI(); 164 | const info = mainBuilding.getInfo(); 165 | const folder = gui.addFolder(info.type + ' - ' + info.id); 166 | createFolders(folder, info.options); 167 | for (let i = 0; i < info.parts.length; i++) { 168 | const part = info.parts[i]; 169 | part.options.id = part.id; 170 | const folder = gui.addFolder(part.type + ' - ' + part.id); 171 | createFolders(folder, part.options); 172 | } 173 | } 174 | }).catch(err => { 175 | window.printError(err); 176 | alert(err); 177 | }); 178 | camera = new PerspectiveCamera( 179 | 50, 180 | document.documentElement.clientWidth / 181 | document.documentElement.clientHeight, 182 | 0.1, 183 | 1000, 184 | ); 185 | renderer = new WebGLRenderer({ 186 | alpha: false, 187 | }); 188 | renderer.setSize( 189 | document.documentElement.clientWidth, 190 | document.documentElement.clientHeight-20, 191 | ); 192 | renderer.domElement.style.position = 'absolute'; 193 | renderer.domElement.style.zIndex = 0; 194 | renderer.domElement.style.top = 0; 195 | document.body.appendChild(renderer.domElement); 196 | } 197 | 198 | /** 199 | * Create GUI folders for the options of a building and roof. 200 | * 201 | * @param {GUI} folder The way or relation 202 | * @param {Object} options The data for a specific way 203 | */ 204 | function createFolders(folder, options) { 205 | const buildingFolder = folder.addFolder('Building'); 206 | const roofFolder = folder.addFolder('Roof'); 207 | for (var property in options.building) { 208 | const buildFunc = function() { 209 | const mesh = scene.getObjectByName('b' + options.id); 210 | mesh.visible = options.building.visible; 211 | }; 212 | if (options.building[property]) { 213 | if (property === 'colour') { 214 | // ToDo: add support for 'named' colours. 215 | buildingFolder.addColor(options.building, property); 216 | } else if (property === 'visible') { 217 | buildingFolder.add(options.building, property).onChange(buildFunc); 218 | } else { 219 | buildingFolder.add(options.building, property, 0, 100 ).step(.1); 220 | } 221 | buildingFolder.close(); 222 | } 223 | } 224 | for (var property in options.roof) { 225 | const roofFunc = function() { 226 | const mesh = scene.getObjectByName('r' + options.id); 227 | mesh.visible = options.roof.visible; 228 | }; 229 | const roofGeo = function() { 230 | const mesh = scene.getObjectByName('r' + options.id); 231 | const geo = mainBuilding.getPartGeometry(options)[0]; 232 | mesh.geometry.dispose(); 233 | mesh.geometry = geo; 234 | }; 235 | if (options.roof[property]) { 236 | if (property === 'colour') { 237 | roofFolder.addColor(options.roof, property); 238 | } else if (property === 'shape') { 239 | const roofTypesAvailable = ['dome', 'flat', 'gabled', 'onion', 'pyramidal', 'skillion', 'hipped', 'round', 'gambrel']; 240 | // If this roof is not supported, add it to the list for sanity. 241 | if (!roofTypesAvailable.includes(options.roof.shape)) { 242 | roofTypesAvailable.push(options.roof.shape); 243 | } 244 | roofFolder.add(options.roof, property, roofTypesAvailable).onChange(roofGeo); 245 | } else if (property === 'orientation') { 246 | const roofOrientationsAvailable = ['across', 'along']; 247 | roofFolder.add(options.roof, property, roofOrientationsAvailable); 248 | } else if (property === 'visible') { 249 | roofFolder.add(options.roof, property).onChange(roofFunc); 250 | } else if (property === 'direction') { 251 | roofFolder.add(options.roof, property, 0, 180 ).step(.5).onChange(roofGeo); 252 | } else { 253 | roofFolder.add(options.roof, property, 0, 100 ).step(.1); 254 | // .onChange(); 255 | } 256 | roofFolder.close(); 257 | } 258 | } 259 | folder.close(); 260 | } 261 | 262 | /** 263 | * Create the scene 264 | */ 265 | function createScene() { 266 | addLights(); 267 | camera.position.set(0, 0, 200); // x y z 268 | camera.far = 50000; 269 | camera.updateProjectionMatrix(); 270 | controls = new OrbitControls( camera, renderer.domElement ); 271 | 272 | function render() { 273 | requestAnimationFrame(render); 274 | 275 | renderer.render(scene, camera); 276 | } 277 | render(); 278 | } 279 | 280 | /** 281 | * Add lights to the scene 282 | */ 283 | function addLights() { 284 | const ambientLight = new AmbientLight( 0xcccccc, 0.2 ); 285 | scene.add( ambientLight ); 286 | 287 | var hemiLight = new HemisphereLight( 0xffffff, 0xffffff, 0.6 ); 288 | hemiLight.position.set( 0, 500, 0 ); 289 | scene.add( hemiLight ); 290 | 291 | var dirLight = new DirectionalLight( 0xffffff, 1 ); 292 | dirLight.position.set( -1, 0.75, 1 ); 293 | dirLight.position.multiplyScalar( 1000 ); 294 | scene.add( dirLight ); 295 | } 296 | 297 | init(); 298 | createScene(); 299 | window.addEventListener('resize', resize, false); 300 | 301 | /** 302 | * Set the camera position 303 | */ 304 | function resize() { 305 | camera.aspect = 306 | document.documentElement.clientWidth / 307 | document.documentElement.clientHeight; 308 | camera.updateProjectionMatrix(); 309 | renderer.setSize( 310 | document.documentElement.clientWidth, 311 | document.documentElement.clientHeight, 312 | ); 313 | } 314 | 315 | /** 316 | * Manage error messages by either printing to the console or 317 | * the configured errorBox element. 318 | * 319 | * @param {text} str The text to add to the error log 320 | */ 321 | function printError(txt) { 322 | if (errorBox) { 323 | const element = document.getElementById('errorBox'); 324 | element.insertAdjacentText('beforeend', txt + '\n'); 325 | } else { 326 | console.log(txt); 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /test/building.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import {toBeDeepCloseTo} from 'jest-matcher-deep-close-to'; 5 | expect.extend({toBeDeepCloseTo}); 6 | 7 | import { Shape, Mesh } from 'three'; 8 | import { TextEncoder } from 'node:util'; 9 | import {expect, test, beforeEach, describe} from '@jest/globals'; 10 | global.TextEncoder = TextEncoder; 11 | 12 | import {apis} from '../src/apis.js'; 13 | global.apis = apis; 14 | 15 | import { Building } from '../src/building.js'; 16 | 17 | import fetchMock from 'jest-fetch-mock'; 18 | fetchMock.enableMocks(); 19 | 20 | const data = ` 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | `; 43 | 44 | beforeEach(() => { 45 | fetchMock.resetMocks(); 46 | errors = []; 47 | }); 48 | 49 | describe.each([ 50 | [['way', -1], ['', { status: 404 }], /^The way -1 was not found on the server.\nURL: /], 51 | [['way', -1], ['', { status: 410 }], /^The way -1 was deleted.\nURL: /], 52 | [['way', -1], ['', { status: 509 }], /^HTTP 509.\nURL: /], 53 | [['relation', -1], ['', { status: 404 }], /^The relation -1 was not found on the server.\nURL: /], 54 | [['relation', -1], ['', { status: 410 }], /^The relation -1 was deleted.\nURL: /], 55 | [['relation', -1], ['', { status: 509 }], /^HTTP 509.\nURL: /], 56 | ])('Test API error handling', (args, response, matcher) => { 57 | test(`Test API error for ${args[0]} ${args[1]} with HTTP ${response[1].status}`, async() => { 58 | fetch.mockResponses(response); 59 | await expect(Building.downloadDataAroundBuilding(...args)).rejects.toMatch(matcher); 60 | }); 61 | }); 62 | 63 | test('Test data validation open outline', () => { 64 | const data = ` 65 | 66 | 67 | 68 | 69 | 70 | `; 71 | expect(() => new Building('1', data)) 72 | .toThrow(new Error('Rendering of way 1 is not possible. Error: Way 1 is not a closed way. 2 !== 4.')); 73 | }); 74 | 75 | test('Test data validation with not building', () => { 76 | const data = ` 77 | 78 | 79 | 80 | 81 | 82 | 83 | `; 84 | expect(() => new Building('1', data)) 85 | .toThrow(new Error('Rendering of way 1 is not possible. Error: Outer way is not a building')); 86 | }); 87 | 88 | test('Test data validation with empty way', () => { 89 | const data = ` 90 | 91 | 92 | `; 93 | expect(() => new Building('1', data)) 94 | .toThrow(new Error('Rendering of way 1 is not possible. Error: Way 1 has no nodes.')); 95 | }); 96 | 97 | test('Test Constructor', async() => { 98 | const bldg = new Building('31361386', data); 99 | expect(bldg.home).toBeDeepCloseTo([11.015512, 49.5833659], 10); 100 | expect(bldg.parts.length).toBe(0); 101 | expect(bldg.nodelist['349300285']).toStrictEqual([4.332747472106493, -5.882209888874915]); 102 | expect(bldg.nodelist['349300289']).toStrictEqual([-4.332738077015795, 5.88221335051411]); 103 | expect(errors.length).toBe(0); 104 | }); 105 | 106 | test('Create Nodelist', () => { 107 | let xmlData = new window.DOMParser().parseFromString(data, 'text/xml'); 108 | const list = Building.buildNodeList(xmlData); 109 | expect(Object.keys(list).length).toBe(4); 110 | // Long / Lat 111 | expect(list['349300285']).toStrictEqual(['11.0155721', '49.5833130']); 112 | expect(errors.length).toBe(0); 113 | }); 114 | 115 | 116 | test('Invisible Outer Building', () => { 117 | const bldg = new Building('31361386', data); 118 | bldg.parts = [bldg.outerElement]; 119 | const mesh = bldg.render(); 120 | //expect outer building and roof to not be visible 121 | expect(mesh[0].visible).toBe(false); 122 | expect(bldg.outerElement.options.building.visible).toBe(false); 123 | expect(mesh[1].visible).toBe(false); 124 | }); 125 | 126 | test('Visible Outer Building', () => { 127 | const bldg = new Building('31361386', data); 128 | const mesh = bldg.render(); 129 | //expect outer building and roof to be visible 130 | expect(mesh[0].visible).toBe(true); 131 | expect(mesh[1].visible).toBe(true); 132 | }); 133 | 134 | 135 | test('Test with neighboring incomplete building:part relation', () => { 136 | const data = ` 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | `; 174 | expect(new Building('42', data).id).toBe('42'); 175 | }); 176 | 177 | const typeBuildingWithMultipolygonOutline = ` 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | `; 231 | const typeBuildingRelationFullResponse = ` 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | `; 251 | const outlineRelationFullResponse = ` 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | `; 301 | 302 | test('Test downloading type=building with multipolygon outline and multiple inner ways', async() => { 303 | fetch.mockResponses( 304 | [typeBuildingRelationFullResponse], // /relation/42/full 305 | [outlineRelationFullResponse], // /relation/40/full 306 | [typeBuildingWithMultipolygonOutline], // /map call 307 | ); 308 | const innerData = await Building.downloadDataAroundBuilding('relation', '42'); 309 | const building = new Building('42', innerData); 310 | expect(building.id).toBe('42'); 311 | expect(building.outerElement.shape.holes.length).toBe(1); 312 | const urlBase = 'https://api.openstreetmap.org/api/0.6/'; 313 | expect(global.fetch.mock.calls[0][0]).toBe(urlBase + 'relation/42/full'); 314 | expect(global.fetch.mock.calls[1][0]).toBe(urlBase + 'relation/40/full'); 315 | expect(global.fetch.mock.calls[2][0]).toBe(urlBase + 'map?bbox=30.4980057,59.9380365,30.4993839,59.9385087'); 316 | }); 317 | 318 | test('Part must be within outline', () => { 319 | const data = ` 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | `; 345 | expect(new Building('11', data).parts.length).toBe(0); 346 | }); 347 | 348 | window.printError = printError; 349 | 350 | var errors = []; 351 | 352 | function printError(txt) { 353 | errors.push[txt]; 354 | } 355 | -------------------------------------------------------------------------------- /src/building.js: -------------------------------------------------------------------------------- 1 | import {apis} from './apis.js'; 2 | import {BuildingShapeUtils} from './extras/BuildingShapeUtils.js'; 3 | import {BuildingPart} from './buildingpart.js'; 4 | import {MultiBuildingPart} from './multibuildingpart.js'; 5 | /** 6 | * A class representing an OSM building 7 | * 8 | * The static factory is responsible for pulling all required 9 | * XML data from the API. 10 | */ 11 | class Building { 12 | /** 13 | * Latitude and longitude that transitions to (0, 0) 14 | * @type {number[2]} 15 | */ 16 | home = []; 17 | 18 | /** 19 | * The parts. 20 | * @type {BuildingPart[]} 21 | */ 22 | parts = []; 23 | 24 | /** 25 | * The building part of the outer parimeter. 26 | * @type {BuildingPart} 27 | */ 28 | outerElement; 29 | 30 | /** 31 | * DOM Tree of all elements to render 32 | * @type {DOM.Element} 33 | */ 34 | fullXmlData; 35 | 36 | id = '0'; 37 | 38 | // the list of all nodes with lat/lon coordinates. 39 | nodelist = []; 40 | 41 | // The type of building 42 | type; 43 | options; 44 | 45 | static async getRelationDataWithChildRelations(id) { 46 | const xmlData = new window.DOMParser().parseFromString(await Building.getRelationData(id), 'text/xml'); 47 | await Promise.all(Array.from(xmlData.querySelectorAll('member[type=relation]')).map(async r => { 48 | const childId = r.getAttribute('ref'); 49 | if (r.getAttribute('id') === childId) { 50 | return; 51 | } 52 | const childData = new window.DOMParser().parseFromString(await Building.getRelationData(childId), 'text/xml'); 53 | childData.querySelectorAll('node, way, relation').forEach(i => { 54 | if (xmlData.querySelector(`${i.tagName}[id="${i.getAttribute('id')}"]`)) { 55 | return; 56 | } 57 | xmlData.querySelector('osm').appendChild(i); 58 | }); 59 | })); 60 | return new XMLSerializer().serializeToString(xmlData); 61 | } 62 | 63 | /** 64 | * Download data for new building 65 | */ 66 | static async downloadDataAroundBuilding(type, id) { 67 | let data; 68 | if (type === 'way') { 69 | data = await Building.getWayData(id); 70 | } else { 71 | data = await Building.getRelationDataWithChildRelations(id); 72 | } 73 | let xmlData = new window.DOMParser().parseFromString(data, 'text/xml'); 74 | const nodelist = Building.buildNodeList(xmlData); 75 | const extents = Building.getExtents(id, xmlData, nodelist); 76 | return await Building.getInnerData(...extents); 77 | } 78 | 79 | /** 80 | * build an object 81 | * 82 | * @param {string} id - the unique XML id of the object. 83 | * @param {string} FullXmlData - XML data. 84 | */ 85 | constructor(id, FullXmlData) { 86 | this.id = id; 87 | this.fullXmlData = new window.DOMParser().parseFromString(FullXmlData, 'text/xml'); 88 | const outerElementXml = this.fullXmlData.getElementById(id); 89 | if (outerElementXml.tagName.toLowerCase() === 'way') { 90 | this.type = 'way'; 91 | } else if (outerElementXml.querySelector('[k="type"]').getAttribute('v') === 'multipolygon') { 92 | this.type = 'multipolygon'; 93 | } else { 94 | this.type = 'relation'; 95 | } 96 | try { 97 | this.validateData(outerElementXml); 98 | } catch (e) { 99 | throw new Error(`Rendering of ${outerElementXml.tagName.toLowerCase()} ${id} is not possible. ${e}`); 100 | } 101 | 102 | this.nodelist = Building.buildNodeList(this.fullXmlData); 103 | this.setHome(); 104 | this.repositionNodes(); 105 | if (this.type === 'way') { 106 | this.outerElement = new BuildingPart(id, this.fullXmlData, this.nodelist); 107 | } else if (this.type === 'multipolygon') { 108 | this.outerElement = new MultiBuildingPart(id, this.fullXmlData, this.nodelist); 109 | } else { 110 | const outlineRef = outerElementXml.querySelector('member[role="outline"]').getAttribute('ref'); 111 | const outline = this.fullXmlData.getElementById(outlineRef); 112 | const outlineType = outline.tagName.toLowerCase(); 113 | if (outlineType === 'way') { 114 | this.outerElement = new BuildingPart(id, this.fullXmlData, this.nodelist); 115 | } else { 116 | this.outerElement = new MultiBuildingPart(outlineRef, this.fullXmlData, this.nodelist); 117 | } 118 | } 119 | this.addParts(); 120 | } 121 | 122 | /** 123 | * the Home point is the center of the outer shape 124 | */ 125 | setHome() { 126 | const extents = Building.getExtents(this.id, this.fullXmlData, this.nodelist); 127 | // Set the "home point", the lat lon to center the structure. 128 | const homeLon = (extents[0] + extents[2]) / 2; 129 | const homeLat = (extents[1] + extents[3]) / 2; 130 | this.home = [homeLon, homeLat]; 131 | } 132 | 133 | /** 134 | * Extract all nodes from an XML file. 135 | * 136 | * @param {DOM.Element} fullXmlData - OSM XML with nodes 137 | * 138 | * @return {Object} dictionary of nodes 139 | */ 140 | static buildNodeList(fullXmlData) { 141 | const nodeElements = fullXmlData.getElementsByTagName('node'); 142 | let id = 0; 143 | var node; 144 | let coordinates = []; 145 | const nodeList = {}; 146 | for (let j = 0; j < nodeElements.length; j++) { 147 | node = nodeElements[j]; 148 | id = node.getAttribute('id'); 149 | coordinates = [node.getAttribute('lon'), node.getAttribute('lat')]; 150 | nodeList[id] = coordinates; 151 | } 152 | return nodeList; 153 | } 154 | 155 | /** 156 | * convert all the longitude latitude values 157 | * to meters from the home point. 158 | */ 159 | repositionNodes() { 160 | for (const key in this.nodelist) { 161 | this.nodelist[key] = BuildingShapeUtils.repositionPoint(this.nodelist[key], this.home); 162 | } 163 | } 164 | 165 | /** 166 | * Create the array of building parts. 167 | * 168 | * @return {array} mesh - an array or Three.Mesh objects 169 | */ 170 | render() { 171 | const mesh = []; 172 | if (this.parts.length > 0) { 173 | this.outerElement.options.building.visible = false; 174 | const outerMeshes = this.outerElement.render(); 175 | outerMeshes[0].visible = false; 176 | this.outerElement.options.roof.visible = false; 177 | outerMeshes[1].visible = false; 178 | this.outerElement.options.building.visible = false; 179 | mesh.push(...outerMeshes); 180 | for (let i = 0; i < this.parts.length; i++) { 181 | mesh.push(...this.parts[i].render()); 182 | } 183 | } else { 184 | const parts = this.outerElement.render(); 185 | mesh.push(parts[0], parts[1]); 186 | } 187 | return mesh; 188 | } 189 | 190 | /** 191 | * Inspect XML data for building parts and add them to the array. 192 | * 193 | */ 194 | addParts() { 195 | if (this.type === 'relation') { 196 | let parts = this.fullXmlData.getElementById(this.id).querySelectorAll('member[role="part"]'); 197 | for (let i = 0; i < parts.length; i++) { 198 | const ref = parts[i].getAttribute('ref'); 199 | const part = this.fullXmlData.getElementById(ref); 200 | if (part.tagName.toLowerCase() === 'way') { 201 | this.parts.push(new BuildingPart(ref, this.fullXmlData, this.nodelist, this.outerElement.options)); 202 | } else { 203 | this.parts.push(new MultiBuildingPart(ref, this.fullXmlData, this.nodelist, this.outerElement.options)); 204 | } 205 | } 206 | } else { 207 | // Filter to all ways 208 | var parts = this.fullXmlData.getElementsByTagName('way'); 209 | for (const xmlPart of parts) { 210 | if (xmlPart.querySelector('[k="building:part"]')) { 211 | const id = xmlPart.getAttribute('id'); 212 | const part = new BuildingPart(id, this.fullXmlData, this.nodelist, this.outerElement.options); 213 | if (this.partIsInside(part)) { 214 | this.parts.push(part); 215 | } 216 | } 217 | } 218 | // Filter all relations 219 | parts = this.fullXmlData.getElementsByTagName('relation'); 220 | for (let i = 0; i < parts.length; i++) { 221 | if (parts[i].querySelector('[k="building:part"]')) { 222 | const id = parts[i].getAttribute('id'); 223 | try { 224 | this.parts.push(new MultiBuildingPart(id, this.fullXmlData, this.nodelist, this.outerElement.options)); 225 | } catch (e) { 226 | window.printError(e); 227 | } 228 | } 229 | } 230 | } 231 | } 232 | 233 | /** 234 | * Fetch way data from OSM 235 | */ 236 | static async getWayData(id) { 237 | let restPath = apis.getWay.url(id); 238 | let response = await fetch(restPath); 239 | if (response.status === 404) { 240 | throw `The way ${id} was not found on the server.\nURL: ${restPath}`; 241 | } else if (response.status === 410) { 242 | throw `The way ${id} was deleted.\nURL: ${restPath}`; 243 | } else if (response.status !== 200) { 244 | throw `HTTP ${response.status}.\nURL: ${restPath}`; 245 | } 246 | return await response.text(); 247 | } 248 | 249 | static async getRelationData(id) { 250 | let restPath = apis.getRelation.url(id); 251 | let response = await fetch(restPath); 252 | if (response.status === 404) { 253 | throw `The relation ${id} was not found on the server.\nURL: ${restPath}`; 254 | } else if (response.status === 410) { 255 | throw `The relation ${id} was deleted.\nURL: ${restPath}`; 256 | } else if (response.status !== 200) { 257 | throw `HTTP ${response.status}.\nURL: ${restPath}`; 258 | } 259 | return await response.text(); 260 | } 261 | 262 | /** 263 | * Fetch map data data from OSM 264 | */ 265 | static async getInnerData(left, bottom, right, top) { 266 | let url = apis.bounding.url(left, bottom, right, top); 267 | let response = await fetch(url); 268 | if (response.status !== 200) { 269 | throw `HTTP ${response.status}.\nURL: ${url}`; 270 | } 271 | return await response.text(); 272 | } 273 | 274 | /** 275 | * validate that we have the ID of a building way. 276 | */ 277 | validateData(xmlData) { 278 | // Check that it is a building ( exists) 279 | const buildingType = xmlData.querySelector('[k="building"]'); 280 | const ways = []; 281 | if (xmlData.tagName === 'relation') { 282 | // get all building relation parts 283 | // todo: multipolygon inner and outer roles. 284 | let parts = xmlData.querySelectorAll('member[role="part"]'); 285 | let ref = 0; 286 | for (let i = 0; i < parts.length; i++) { 287 | ref = parts[i].getAttribute('ref'); 288 | const part = this.fullXmlData.getElementById(ref); 289 | if (part) { 290 | ways.push(this.fullXmlData.getElementById(ref)); 291 | } else { 292 | window.printError('Part #' + i + '(' + ref + ') is null.'); 293 | } 294 | } 295 | } else { 296 | if (!buildingType) { 297 | throw new Error('Outer way is not a building'); 298 | } 299 | ways.push(xmlData); 300 | } 301 | for (let i = 0; i < ways.length; i++) { 302 | const way = ways[i]; 303 | if (way.tagName.toLowerCase() === 'way') { 304 | const nodes = way.getElementsByTagName('nd'); 305 | if (nodes.length > 0) { 306 | // Check that it is a closed way 307 | const firstRef = nodes[0].getAttribute('ref'); 308 | const lastRef = nodes[nodes.length - 1].getAttribute('ref'); 309 | if (firstRef !== lastRef) { 310 | throw new Error('Way ' + way.getAttribute('id') + ' is not a closed way. ' + firstRef + ' !== ' + lastRef + '.'); 311 | } 312 | } else { 313 | throw new Error('Way ' + way.getAttribute('id') + ' has no nodes.'); 314 | } 315 | } else { 316 | let parts = way.querySelectorAll('member[role="part"]'); 317 | let ref = 0; 318 | for (let i = 0; i < parts.length; i++) { 319 | ref = parts[i].getAttribute('ref'); 320 | const part = this.fullXmlData.getElementById(ref); 321 | if (part) { 322 | ways.push(this.fullXmlData.getElementById(ref)); 323 | } else { 324 | window.printError('Part ' + ref + ' is null.'); 325 | } 326 | } 327 | } 328 | } 329 | return true; 330 | } 331 | 332 | /** 333 | * Get the extents of the top level building. 334 | * 335 | * @param {number} id - The id of the relation or way 336 | * @param {XML} fulXmlData - A complete XML file. 337 | * @param {[number => [number, number]]} nodelist - x/y or lon/lat coordinated keyed by id 338 | * 339 | * @param {[number, number, number, number]} extents - [left, bottom, right, top] of the entire building. 340 | */ 341 | static getExtents(id, fullXmlData, nodelist) { 342 | const xmlElement = fullXmlData.getElementById(id); 343 | const buildingType = xmlElement.tagName.toLowerCase(); 344 | var shape; 345 | var extents = []; 346 | if (buildingType === 'way') { 347 | shape = BuildingShapeUtils.createShape(xmlElement, nodelist); 348 | extents = BuildingShapeUtils.extents(shape); 349 | } else if (buildingType === 'relation'){ 350 | const relationType = xmlElement.querySelector('[k="type"]').getAttribute('v'); 351 | if (relationType === 'multipolygon') { 352 | let outerMembers = xmlElement.querySelectorAll('member[role="outer"]'); 353 | var shape; 354 | var way; 355 | for (let i = 0; i < outerMembers.length; i++) { 356 | way = fullXmlData.getElementById(outerMembers[i].getAttribute('ref')); 357 | shape = BuildingShapeUtils.createShape(way, nodelist); 358 | const wayExtents = BuildingShapeUtils.extents(shape); 359 | if (i === 0) { 360 | extents = wayExtents; 361 | } else { 362 | extents[0] = Math.min(extents[0], wayExtents[0]); 363 | extents[1] = Math.min(extents[1], wayExtents[1]); 364 | extents[2] = Math.max(extents[2], wayExtents[2]); 365 | extents[3] = Math.max(extents[3], wayExtents[3]); 366 | } 367 | } 368 | } else { 369 | // In a relation, the overall extents may be larger than the outline. 370 | // use the extents of all the provided nodes. 371 | extents[0] = 180; 372 | extents[1] = 90; 373 | extents[2] = -180; 374 | extents[3] = -90; 375 | for (const key in nodelist) { 376 | extents[0] = Math.min(extents[0], nodelist[key][0]); 377 | extents[1] = Math.min(extents[1], nodelist[key][1]); 378 | extents[2] = Math.max(extents[2], nodelist[key][0]); 379 | extents[3] = Math.max(extents[3], nodelist[key][1]); 380 | } 381 | } 382 | } else { 383 | window.printError('"' + buildingType + '" is neither "way" nor "relation". Check that the id is correct.'); 384 | } 385 | return extents; 386 | } 387 | 388 | getInfo() { 389 | var partsInfo = []; 390 | for (let i = 0; i < this.parts.length; i++) { 391 | partsInfo.push(this.parts[i].getInfo()); 392 | } 393 | return { 394 | id: this.id, 395 | type: this.type, 396 | options: this.outerElement.options, 397 | parts: partsInfo, 398 | }; 399 | } 400 | 401 | /** 402 | * Use the provided options to update and return the geometry 403 | * of a part. 404 | */ 405 | getPartGeometry(options) { 406 | for (let i = 0; i < this.parts.length; i++) { 407 | const part = this.parts[i]; 408 | if (part.id === options.id) { 409 | part.updateOptions(options); 410 | return part.render(); 411 | } 412 | } 413 | } 414 | 415 | /** 416 | * Check if any point in a part is within this building's outline. 417 | * It only checknof points are inside, not if crossing events occur, or 418 | * if the part completly surrounds the building. 419 | * @param {BuildingPart} part - the part to be tested 420 | * @returns {bool} is it? 421 | */ 422 | partIsInside(part) { 423 | const shape = part.shape; 424 | for (const vector of shape.extractPoints().shape) { 425 | if (BuildingShapeUtils.surrounds(this.outerElement.shape, [vector.x, vector.y])) { 426 | return true; 427 | } 428 | } 429 | return false; 430 | } 431 | } 432 | export {Building}; 433 | -------------------------------------------------------------------------------- /src/extras/BuildingShapeUtils.js: -------------------------------------------------------------------------------- 1 | import { 2 | Shape, 3 | ShapeUtils, 4 | } from 'three'; 5 | 6 | class BuildingShapeUtils extends ShapeUtils { 7 | 8 | /** 9 | * Create the shape of this way. 10 | * 11 | * @param {DOM.Element} way - OSM XML way element. 12 | * @param {number => number[2]} nodelist - dictionary of all nodes 13 | * 14 | * @return {THREE.Shape} shape - the shape 15 | */ 16 | static createShape(way, nodelist) { 17 | // Initialize objects 18 | const shape = new Shape(); 19 | var ref; 20 | const nodes = []; 21 | 22 | // Get all the nodes in the way of interest 23 | /** {HTMLCollection} */ 24 | const elements = way.getElementsByTagName('nd'); 25 | 26 | // Get the coordinates of all the nodes and add them to the shape outline. 27 | for (const element of elements) { 28 | ref = element.getAttribute('ref'); 29 | nodes.push(nodelist[ref]); 30 | } 31 | // If the first and last point are identical, remove the last copy. 32 | if (nodes.length > 1 && nodes[0][0] === nodes[elements.length - 1][0] && nodes[0][1] === nodes[elements.length - 1][1]) { 33 | nodes.pop(); 34 | } 35 | let first = true; 36 | for (const node of nodes) { 37 | // The first node requires a different function call. 38 | if (first) { 39 | first = false; 40 | shape.moveTo(parseFloat(node[0]), parseFloat(node[1])); 41 | } else { 42 | shape.lineTo(parseFloat(node[0]), parseFloat(node[1])); 43 | } 44 | } 45 | return shape; 46 | } 47 | /** 48 | * Check if a way is a closed shape. 49 | * 50 | * @param {DOM.Element} way - OSM XML way element. 51 | * 52 | * @return {boolean} 53 | */ 54 | static isClosed(way) { 55 | // Get all the nodes in the way of interest 56 | const elements = way.getElementsByTagName('nd'); 57 | return elements[0].getAttribute('ref') === elements[elements.length - 1].getAttribute('ref'); 58 | } 59 | 60 | /** 61 | * Check if a way is self-intersecting. 62 | * 63 | * @param {DOM.Element} way - OSM XML way element. 64 | * 65 | * @return {boolean} 66 | */ 67 | static isSelfIntersecting(way) { 68 | const nodes = Array.from(way.getElementsByTagName('nd')); 69 | if (BuildingShapeUtils.isClosed(way)){ 70 | nodes.pop(); 71 | } 72 | const refs = new Set(); 73 | for (const node of nodes) { 74 | const ref = node.getAttribute('ref'); 75 | if (refs.has(ref)){ 76 | return true; 77 | } 78 | refs.add(ref); 79 | } 80 | return false; 81 | } 82 | 83 | /** 84 | * Walk through an array and seperate any closed ways. 85 | * Attempt to find matching open ways to enclose them. 86 | * 87 | * @param {[DOM.Element]} ways - array of OSM XML way elements. 88 | * 89 | * @return {[DOM.Element]} array of closed ways. 90 | */ 91 | static combineWays(ways) { 92 | const validWays = []; 93 | 94 | // Check if the provided array contains any self-intersecting ways. 95 | // Remove them and notify the user. 96 | for (const way of ways) { 97 | if (BuildingShapeUtils.isSelfIntersecting(way)) { 98 | const id = way.getAttribute('id'); 99 | const msg = 'Way ' + id + ' is self-intersecting'; 100 | window.printError(msg); 101 | } else { 102 | validWays.push(way); 103 | } 104 | } 105 | 106 | const closedWays = []; 107 | const wayBegins = {}; 108 | const wayEnds = {}; 109 | 110 | // Create lists of the first and last nodes in each way. 111 | validWays.forEach(w => { 112 | const firstNodeID = w.querySelector('nd').getAttribute('ref'); 113 | if (wayBegins[firstNodeID]) { 114 | wayBegins[firstNodeID].push(w); 115 | } else { 116 | wayBegins[firstNodeID] = [w]; 117 | } 118 | 119 | const lastNodeID = w.querySelector('nd:last-of-type').getAttribute('ref'); 120 | if (wayEnds[lastNodeID]) { 121 | wayEnds[lastNodeID].push(w); 122 | } else { 123 | wayEnds[lastNodeID] = [w]; 124 | } 125 | }); 126 | 127 | const usedWays = new Set(); 128 | 129 | /** 130 | * Use recursion to attempt to build a ring from ways. 131 | * 132 | * @param {[DOM.Element]} currentRingWays - array of OSM XML way elements. 133 | */ 134 | function tryMakeRing(currentRingWays) { 135 | 136 | // Check if the array contains ways which will together form a ring. Return the array if it does. 137 | if (currentRingWays[0].querySelector('nd').getAttribute('ref') === 138 | currentRingWays[currentRingWays.length - 1].querySelector('nd:last-of-type').getAttribute('ref')) { 139 | if (BuildingShapeUtils.isSelfIntersecting(BuildingShapeUtils.joinAllWays(currentRingWays))) { 140 | return []; 141 | } 142 | return currentRingWays; 143 | } 144 | 145 | const lastWay = currentRingWays[currentRingWays.length - 1]; 146 | const lastNodeID = lastWay.querySelector('nd:last-of-type').getAttribute('ref'); 147 | 148 | // Check if any of the unused ways can complete a ring as the are. 149 | for (let way of wayBegins[lastNodeID] ?? []) { 150 | const wayID = way.getAttribute('id'); 151 | if (usedWays.has(wayID)) { 152 | continue; 153 | } 154 | usedWays.add(wayID); 155 | currentRingWays.push(way); 156 | if (tryMakeRing(currentRingWays).length) { 157 | return currentRingWays; 158 | } 159 | currentRingWays.pop(); 160 | usedWays.delete(wayID); 161 | } 162 | 163 | // Check if any of the unused ways can complete a ring if reversed. 164 | for (let way of wayEnds[lastNodeID] ?? []) { 165 | const wayID = way.getAttribute('id'); 166 | if (usedWays.has(wayID)) { 167 | continue; 168 | } 169 | usedWays.add(wayID); 170 | currentRingWays.push(BuildingShapeUtils.reverseWay(way)); 171 | if (tryMakeRing(currentRingWays).length) { 172 | return currentRingWays; 173 | } 174 | currentRingWays.pop(); 175 | usedWays.delete(wayID); 176 | } 177 | 178 | return []; 179 | } 180 | 181 | validWays.forEach(w => { 182 | const wayID = w.getAttribute('id'); 183 | if (usedWays.has(wayID)){ 184 | return; 185 | } 186 | usedWays.add(wayID); 187 | const result = tryMakeRing([w]); 188 | if (result.length) { 189 | const ring = this.joinAllWays(result); 190 | closedWays.push(ring); 191 | } 192 | }); 193 | 194 | // Notify the user if there are unused ways. 195 | // if (validWays.length !== usedWays.length) { 196 | // window.printError('Unused ways in relation') 197 | // } 198 | return closedWays; 199 | } 200 | 201 | /** 202 | * Append the nodes from one way into another. 203 | * 204 | * @param {DOM.Element} way1 - an open, non self-intersecring way 205 | * @param {DOM.Element} way2 206 | * 207 | * @return {DOM.Element} way 208 | */ 209 | static joinWays(way1, way2) { 210 | const nodes = way2.getElementsByTagName('nd'); 211 | const newWay = way1.cloneNode(true); 212 | for (let i = 1; i < nodes.length; i++) { 213 | let elem = nodes[i].cloneNode(); 214 | newWay.appendChild(elem); 215 | } 216 | return newWay; 217 | } 218 | 219 | /** 220 | * Append the nodes from one way into another. 221 | * 222 | * @param {DOM.Element} way1 - an open, non self-intersecring way 223 | * @param {DOM.Element} way2 224 | * 225 | * @return {DOM.Element} way 226 | */ 227 | static joinAllWays(ways) { 228 | let way = ways[0]; 229 | ways.slice(1).forEach(w => { 230 | way = this.joinWays(way, w); 231 | }); 232 | return way; 233 | } 234 | 235 | /** 236 | * Reverse the order of nodes in a way. 237 | * 238 | * @param {DOM.Element} way - a way 239 | * 240 | * @return {DOM.Element} way 241 | */ 242 | static reverseWay(way) { 243 | const elements = way.getElementsByTagName('nd'); 244 | const newWay = way.cloneNode(true); 245 | newWay.innerHTML = ''; 246 | for (let i = 0; i < elements.length; i++) { 247 | let elem = elements[elements.length - 1 - i].cloneNode(); 248 | newWay.appendChild(elem); 249 | } 250 | return newWay; 251 | } 252 | 253 | /** 254 | * Find the center of a closed way 255 | * 256 | * @param {THREE.Shape} shape - the shape 257 | * 258 | * @return {[number, number]} xy - x/y coordinates of the center 259 | */ 260 | static center(shape) { 261 | const extents = BuildingShapeUtils.extents(shape); 262 | const center = [(extents[0] + extents[2] ) / 2, (extents[1] + extents[3] ) / 2]; 263 | return center; 264 | } 265 | 266 | /** 267 | * Return the longest cardinal side length. 268 | * 269 | * @param {THREE.Shape} shape - the shape 270 | */ 271 | static getWidth(shape) { 272 | const xy = BuildingShapeUtils.combineCoordinates(shape); 273 | const x = xy[0]; 274 | const y = xy[1]; 275 | return Math.max(Math.max(...x) - Math.min(...x), Math.max(...y) - Math.min(...y)); 276 | } 277 | 278 | /** 279 | * Extract point data from a shape. 280 | * Combine all the x values into one array and 281 | * y values into another 282 | * 283 | * @param {THREE.Shape} shape - the shape 284 | * 285 | * @return {[number], [number]} array of xs and ys. 286 | */ 287 | static combineCoordinates(shape) { 288 | const points = shape.extractPoints().shape; 289 | var x = []; 290 | var y = []; 291 | var vec; 292 | for (let i = 0; i < points.length; i++) { 293 | vec = points[i]; 294 | x.push(vec.x); 295 | y.push(vec.y); 296 | } 297 | return [x, y]; 298 | } 299 | 300 | /** 301 | * Calculate the Cartesian extents of the shape after rotaing couterclockwise by a given angle. 302 | * 303 | * @param {THREE.Shape} pts - the shape or Array of shapes. 304 | * @param {number} angle - angle in radians to rotate shape 305 | * 306 | * @return {[number, number, number, number]} the extents of the object. 307 | */ 308 | static extents(shapes, angle = 0) { 309 | if (!Array.isArray(shapes)) { 310 | shapes = [shapes]; 311 | } 312 | var x = []; 313 | var y = []; 314 | var vec; 315 | for (const shape of shapes) { 316 | const points = shape.extractPoints().shape; 317 | for (const vec of points) { 318 | x.push(vec.x * Math.cos(angle) - vec.y * Math.sin(angle)); 319 | y.push(vec.x * Math.sin(angle) + vec.y * Math.cos(angle)); 320 | } 321 | } 322 | const left = Math.min(...x); 323 | const bottom = Math.min(...y); 324 | const right = Math.max(...x); 325 | const top = Math.max(...y); 326 | return [left, bottom, right, top]; 327 | } 328 | 329 | /** 330 | * Calculate the length of each of a shape's edge 331 | * 332 | * @param {THREE.Shape} shape - the shape 333 | * 334 | * @return {[number, ...]} the esge lwngths. 335 | */ 336 | static edgeLength(shape) { 337 | const points = shape.extractPoints().shape; 338 | const lengths = []; 339 | var p1; 340 | var p2; 341 | for (const i in points) { 342 | p1 = points[i]; 343 | p2 = points[(i + 1) % points.length]; 344 | lengths.push(Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2)); 345 | } 346 | return lengths; 347 | } 348 | 349 | /** 350 | * Calculate the angle at each of a shape's vertex. 351 | * The angle will be PI > x >= -PI 352 | * 353 | * @param {THREE.Shape} shape - the shape 354 | * 355 | * @return {[number, ...]} the angles in radians. 356 | */ 357 | static vertexAngle(shape) { 358 | const points = shape.extractPoints().shape; 359 | const angles = []; 360 | var p0; 361 | var p1; 362 | var p2; 363 | 364 | function calcAngle(p0, p1, p2) { 365 | let angle = Math.atan2(p2.y - p1.y, p2.x - p1.x) - Math.atan2(p0.y - p1.y, p0.x - p1.x); 366 | if (angle >= Math.PI) { 367 | angle -= 2 * Math.PI; 368 | } else if (angle < -Math.PI) { 369 | angle += 2 * Math.PI; 370 | } 371 | return angle; 372 | } 373 | 374 | for (const i in points) { 375 | p0 = points[i - 1 < 0 ? points.length - 1 : i - 1]; 376 | p1 = points[i]; 377 | p2 = points[(i + 1) % points.length]; 378 | angles.push(calcAngle(p0, p1, p2)); 379 | } 380 | return angles; 381 | } 382 | 383 | /** 384 | * Calculate the angle of each of a shape's edge. 385 | * the angle will be PI > x >= -PI 386 | * 387 | * @param {THREE.Shape} shape - the shape 388 | * 389 | * @return {[number, ...]} the angles in radians. 390 | */ 391 | static edgeDirection(shape) { 392 | const points = shape.extractPoints().shape; 393 | const angles = []; 394 | var p1; 395 | var p2; 396 | for (let i = 0; i < points.length; i++) { 397 | p1 = points[i]; 398 | p2 = points[(i + 1) % points.length]; 399 | let angle = Math.atan2((p2.y - p1.y), (p2.x - p1.x)); 400 | angles.push(angle); 401 | } 402 | return angles; 403 | } 404 | 405 | /** 406 | * Is the given point within the given shape? 407 | * 408 | * @param {THREE.Shape} shape - the shape 409 | * @param {[number, number]} point - an x, y pair. 410 | * 411 | * @return {boolean} 412 | */ 413 | static surrounds(shape, point) { 414 | var count = 0; 415 | const vecs = shape.extractPoints().shape; 416 | var vec; 417 | var nextvec; 418 | for (let i = 0; i < vecs.length; i++) { 419 | vec = vecs[i]; 420 | nextvec = vecs[(i + 1) % vecs.length]; 421 | if (vec.x === point[0] && vec.y === point[1]) { 422 | return true; 423 | } 424 | if (nextvec.x === vec.x) { 425 | // vertical line 426 | if (vec.x === point[0]) { 427 | return true; 428 | } 429 | if (vec.x > point[0] && (vec.y > point[1] || nextvec.y > point[1]) && !(vec.y > point[1] && nextvec.y > point[1])){ 430 | count++; 431 | } 432 | } else if (nextvec.y === vec.y) { 433 | if (vec.y === point[1] && (vec.x > point[0] || nextvec.x > point[0]) && !(vec.x > point[0] && nextvec.x > point[0])){ 434 | return true; 435 | } 436 | } else { 437 | const slope = (nextvec.y - vec.y) / (nextvec.x - vec.x); 438 | const intercept = vec.y - slope * vec.x; 439 | const intersection = (point[1] - intercept) / slope; 440 | if (intersection > point[0] && intersection < Math.max(nextvec.x, vec.x) && intersection > Math.min(nextvec.x, vec.x)) { 441 | count++; 442 | } else if (intersection === point[0]) { 443 | return true; 444 | } 445 | } 446 | } 447 | return count % 2 === 1; 448 | } 449 | 450 | /** 451 | * Calculate the radius of a circle that can fit within a shape. 452 | * 453 | * @param {THREE.Shape} shape - the shape 454 | */ 455 | static calculateRadius(shape) { 456 | const extents = BuildingShapeUtils.extents(shape); 457 | // return half of the shorter side-length. 458 | return Math.min(extents[2] - extents[0], extents[3] - extents[1]) / 2; 459 | } 460 | 461 | /** 462 | * Return the angle of the longest side of a shape with 90° vertices. 463 | * 464 | * @param {THREE.Shape} shape - the shape 465 | * @return {number} in radians from Pi > x > -Pi 466 | */ 467 | static longestSideAngle(shape) { 468 | const lengths = BuildingShapeUtils.edgeLength(shape); 469 | const directions = BuildingShapeUtils.edgeDirection(shape); 470 | var index; 471 | var maxLength = 0; 472 | for (let i = 0; i < lengths.length; i++) { 473 | if (lengths[i] > maxLength) { 474 | index = i; 475 | maxLength = lengths[i]; 476 | } 477 | } 478 | var angle = directions[index]; 479 | const extents = BuildingShapeUtils.extents(shape, -angle); 480 | // If the shape is taller than it is wide after rotation, we are off by 90 degrees. 481 | if ((extents[3] - extents[1]) > (extents[2] - extents[0])) { 482 | angle = angle > 0 ? angle - Math.PI / 2 : angle + Math.PI / 2; 483 | } 484 | return angle; 485 | } 486 | 487 | /** 488 | * Rotate lat/lon to reposition the home point onto 0,0. 489 | * 490 | * @param {[number, number]} lonLat - The longitute and latitude of a point. 491 | * 492 | * @return {[number, number]} x, y in meters 493 | */ 494 | static repositionPoint(lonLat, home) { 495 | const R = 6371 * 1000; // Earth radius in m 496 | const circ = 2 * Math.PI * R; // Circumference 497 | const phi = 90 - lonLat[1]; 498 | const theta = lonLat[0] - home[0]; 499 | const thetaPrime = home[1] / 180 * Math.PI; 500 | const x = R * Math.sin(theta / 180 * Math.PI) * Math.sin(phi / 180 * Math.PI); 501 | const y = R * Math.cos(phi / 180 * Math.PI); 502 | const z = R * Math.sin(phi / 180 * Math.PI) * Math.cos(theta / 180 * Math.PI); 503 | const abs = Math.sqrt(z**2 + y**2); 504 | const arg = Math.atan(y / z) - thetaPrime; 505 | 506 | return [x, Math.sin(arg) * abs]; 507 | } 508 | } 509 | export {BuildingShapeUtils}; 510 | -------------------------------------------------------------------------------- /src/buildingpart.js: -------------------------------------------------------------------------------- 1 | import { 2 | Color, 3 | ExtrudeGeometry, 4 | Shape, 5 | Mesh, 6 | MeshLambertMaterial, 7 | MeshPhysicalMaterial, 8 | SphereGeometry, 9 | } from 'three'; 10 | 11 | import {PyramidGeometry} from 'pyramid'; 12 | import {RampGeometry} from 'ramp'; 13 | import {WedgeGeometry} from 'wedge'; 14 | import {HippedGeometry} from 'hipped'; 15 | import {BuildingShapeUtils} from './extras/BuildingShapeUtils.js'; 16 | /** 17 | * An OSM Building Part 18 | * 19 | * A building part includes a main building and a roof. 20 | */ 21 | class BuildingPart { 22 | // DOM of the building part way 23 | way; 24 | 25 | // THREE.Shape of the outline. 26 | shape; 27 | 28 | // THREE.Mesh of the roof 29 | roof; 30 | 31 | // array of Cartesian coordinates of every node. 32 | nodelist = []; 33 | 34 | // Metadata of the building part. 35 | blankOptions = { 36 | building: { 37 | colour: null, 38 | ele: null, 39 | height: null, 40 | levels: null, 41 | levelsUnderground: null, 42 | material: null, 43 | minHeight: null, 44 | minLevel: null, 45 | walls: null, 46 | }, 47 | roof: { 48 | angle: null, 49 | colour: null, 50 | direction: null, 51 | height: null, 52 | levels: null, 53 | material: null, 54 | orientation: null, 55 | shape: null, 56 | }, 57 | }; 58 | 59 | fullXmlData; 60 | 61 | // The unique OSM ID of the object. 62 | id; 63 | 64 | // THREE.Mesh 65 | parts = []; 66 | /** 67 | * @param {number} id - the OSM id of the way or multipolygon. 68 | * @param {XMLDocument} fullXmlData - XML for the region. 69 | * @param {[[number, number],...]} nodelist - Cartesian coordinates of each node keyed by node refID 70 | * @param {object} options - default values for the building part. 71 | */ 72 | constructor(id, fullXmlData, nodelist, defaultOptions = {}) { 73 | this.options = this.blankOptions; 74 | if (Object.keys(defaultOptions).length === 0) { 75 | defaultOptions = this.blankOptions; 76 | } 77 | this.options.inherited = defaultOptions; 78 | this.fullXmlData = fullXmlData; 79 | this.id = id; 80 | this.way = fullXmlData.getElementById(id); 81 | this.nodelist = nodelist; 82 | this.shape = this.buildShape(); 83 | this.setOptions(); 84 | } 85 | 86 | /** 87 | * Create the shape of the outer way. 88 | * 89 | * @return {THREE.Shape} shape - the shape 90 | */ 91 | buildShape() { 92 | this.type = 'way'; 93 | return BuildingShapeUtils.createShape(this.way, this.nodelist); 94 | } 95 | 96 | /** 97 | * Set the object's options 98 | */ 99 | setOptions() { 100 | // if values are not set directly, inherit from the parent. 101 | // Somme require more extensive calculation. 102 | const specifiedOptions = this.blankOptions; 103 | 104 | specifiedOptions.building.colour = this.getAttribute('colour'); 105 | specifiedOptions.building.ele = this.getAttribute('ele'); 106 | specifiedOptions.building.height = BuildingPart.normalizeLength(this.getAttribute('height')); 107 | specifiedOptions.building.levels = BuildingPart.normalizeNumber(this.getAttribute('building:levels')); 108 | specifiedOptions.building.levelsUnderground = this.getAttribute('building:levels:underground'); 109 | specifiedOptions.building.material = this.getAttribute('building:material'); 110 | specifiedOptions.building.minHeight = BuildingPart.normalizeLength(this.getAttribute('min_height')); 111 | specifiedOptions.building.minLevel = this.getAttribute('building:min_level'); 112 | specifiedOptions.building.walls = this.getAttribute('walls'); 113 | specifiedOptions.roof.angle = this.getAttribute('roof:angle'); 114 | specifiedOptions.roof.colour = this.getAttribute('roof:colour'); 115 | specifiedOptions.roof.direction = BuildingPart.normalizeDirection(this.getAttribute('roof:direction')); 116 | specifiedOptions.roof.height = BuildingPart.normalizeLength(this.getAttribute('roof:height')); 117 | specifiedOptions.roof.levels = this.getAttribute('roof:levels') ? parseFloat(this.getAttribute('roof:levels')) : undefined; 118 | specifiedOptions.roof.material = this.getAttribute('roof:material'); 119 | specifiedOptions.roof.orientation = this.getAttribute('roof:orientation'); 120 | specifiedOptions.roof.shape = this.getAttribute('roof:shape'); 121 | 122 | this.options.specified = specifiedOptions; 123 | 124 | const calculatedOptions = this.blankOptions; 125 | // todo replace with some sort of foreach loop. 126 | calculatedOptions.building.colour = this.options.specified.building.colour ?? this.options.inherited.building.colour; 127 | calculatedOptions.building.ele = this.options.specified.building.ele ?? this.options.inherited.building.ele ?? 0; 128 | calculatedOptions.building.levels = this.options.specified.building.levels ?? this.options.inherited.building.levels; 129 | calculatedOptions.building.levelsUnderground = this.options.specified.building.levelsUnderground ?? this.options.inherited.building.levelsUnderground; 130 | calculatedOptions.building.material = this.options.specified.building.material ?? this.options.inherited.building.material; 131 | calculatedOptions.building.minLevel = this.options.specified.building.minLevel ?? this.options.inherited.building.minLevel; 132 | calculatedOptions.building.minHeight = this.options.specified.building.minHeight ?? this.options.inherited.building.minHeight ?? 0; 133 | calculatedOptions.building.walls = this.options.specified.building.walls ?? this.options.inherited.building.walls; 134 | calculatedOptions.roof.angle = this.options.specified.roof.angle ?? this.options.inherited.roof.angle; 135 | calculatedOptions.roof.colour = this.options.specified.roof.colour ?? this.options.inherited.roof.colour; 136 | 137 | calculatedOptions.roof.levels = this.options.specified.roof.levels ?? this.options.inherited.roof.levels; 138 | calculatedOptions.roof.material = this.options.specified.roof.material ?? this.options.inherited.roof.material; 139 | calculatedOptions.roof.orientation = this.options.specified.roof.orientation ?? this.options.inherited.roof.orientation; 140 | calculatedOptions.roof.shape = this.options.specified.roof.shape ?? this.options.inherited.roof.shape; 141 | calculatedOptions.roof.visible = true; 142 | 143 | // Set the default orientation if the roof shape dictates one. 144 | const orientableRoofs = ['gabled', 'round']; 145 | if (!calculatedOptions.roof.orientation && calculatedOptions.roof.shape && orientableRoofs.includes(calculatedOptions.roof.shape)) { 146 | calculatedOptions.roof.orientation = 'along'; 147 | } 148 | // Should Skillion be included here? 149 | const directionalRoofs = ['gabled', 'round']; 150 | calculatedOptions.roof.direction = this.options.specified.roof.direction ?? this.options.inherited.roof.direction; 151 | if (calculatedOptions.roof.direction === undefined && directionalRoofs.includes(calculatedOptions.roof.shape)) { 152 | let longestSide = BuildingShapeUtils.longestSideAngle(this.shape); 153 | 154 | // Convert to angle. 155 | calculatedOptions.roof.direction = (BuildingPart.atanRadToCompassDeg(longestSide) + 90) % 360; 156 | } 157 | const extents = BuildingShapeUtils.extents(this.shape, calculatedOptions.roof.direction / 360 * 2 * Math.PI); 158 | const shapeHeight = extents[3] - extents[1]; 159 | calculatedOptions.roof.height = this.options.specified.roof.height ?? 160 | this.options.inherited.roof.height ?? 161 | (isNaN(calculatedOptions.roof.levels) ? null : (calculatedOptions.roof.levels * 3)) ?? 162 | (calculatedOptions.roof.shape === 'flat' ? 0 : null) ?? 163 | (calculatedOptions.roof.shape === 'dome' || calculatedOptions.roof.shape === 'pyramidal' ? BuildingShapeUtils.calculateRadius(this.shape) : null) ?? 164 | (calculatedOptions.roof.shape === 'onion' ? BuildingShapeUtils.calculateRadius(this.shape) * 1.5 : null) ?? 165 | (calculatedOptions.roof.shape === 'skillion' ? (calculatedOptions.roof.angle ? Math.cos(calculatedOptions.roof.angle / 360 * 2 * Math.PI) * shapeHeight : 22.5) : null); 166 | 167 | calculatedOptions.building.height = this.options.specified.building.height ?? 168 | (isNaN(calculatedOptions.building.levels) ? null : (calculatedOptions.building.levels * 3) + calculatedOptions.roof.height) ?? 169 | calculatedOptions.roof.height + 3 ?? 170 | this.options.inherited.building.height; 171 | this.options.building = calculatedOptions.building; 172 | this.options.roof = calculatedOptions.roof; 173 | if (this.getAttribute('building:part') && this.options.building.height > this.options.inherited.building.height) { 174 | window.printError('Way ' + this.id + ' is taller than building. (' + this.options.building.height + '>' + this.options.inherited.building.height + ')'); 175 | } 176 | // Should skillion automatically calculate a direction perpendicular to the longest outside edge if unspecified? 177 | if (this.options.roof.shape === 'skillion' && this.options.roof.direction === undefined) { 178 | window.printError('Part ' + this.id + ' requires a direction. (https://wiki.openstreetmap.org/wiki/Key:roof:direction)'); 179 | } 180 | this.extrusionHeight = this.options.building.height - this.options.building.minHeight - this.options.roof.height; 181 | } 182 | 183 | /** 184 | * calculate the maximum building width in meters. 185 | */ 186 | getWidth() { 187 | return BuildingShapeUtils.getWidth(this.shape); 188 | } 189 | 190 | /** 191 | * Render the building part 192 | */ 193 | render() { 194 | this.createRoof(); 195 | this.parts.push(this.roof); 196 | const mesh = this.createBuilding(); 197 | if (this.getAttribute('building:part') === 'roof') { 198 | mesh.visible = false; 199 | this.options.building.visible = false; 200 | } 201 | this.parts.push(mesh); 202 | return this.parts; 203 | } 204 | 205 | createBuilding() { 206 | let extrusionHeight = this.options.building.height - this.options.building.minHeight - this.options.roof.height; 207 | 208 | let extrudeSettings = { 209 | bevelEnabled: false, 210 | depth: extrusionHeight, 211 | }; 212 | 213 | var geometry = new ExtrudeGeometry(this.shape, extrudeSettings); 214 | 215 | // Create the mesh. 216 | var mesh = new Mesh(geometry, [BuildingPart.getRoofMaterial(this.way), BuildingPart.getMaterial(this.way)]); 217 | 218 | // Change the position to compensate for the min_height 219 | mesh.rotation.x = -Math.PI / 2; 220 | mesh.position.set( 0, this.options.building.minHeight, 0); 221 | mesh.name = 'b' + this.id; 222 | return mesh; 223 | } 224 | 225 | /** 226 | * Create the 3D render of a roof. 227 | */ 228 | createRoof() { 229 | var way = this.way; 230 | var material; 231 | var roof; 232 | if (this.options.roof.shape === 'dome' || this.options.roof.shape === 'onion') { 233 | // find largest circle within the way 234 | // R, x, y 235 | var thetaStart = Math.PI / 2; 236 | const R = BuildingShapeUtils.calculateRadius(this.shape); 237 | var scale = this.options.roof.height / R; 238 | if (this.options.roof.shape === 'onion') { 239 | thetaStart = Math.PI / 4; 240 | scale = scale / 1.5; 241 | } 242 | const geometry = new SphereGeometry(R, 100, 100, 0, 2 * Math.PI, thetaStart); 243 | // Adjust the dome height if needed. 244 | geometry.scale(1, scale, 1); 245 | material = BuildingPart.getRoofMaterial(this.way); 246 | roof = new Mesh(geometry, material); 247 | const elevation = this.options.building.height - this.options.roof.height; 248 | const center = BuildingShapeUtils.center(this.shape); 249 | roof.rotation.x = -Math.PI; 250 | // TODO: onion probably need to be raised by an additional R/2. 251 | roof.position.set(center[0], elevation, -1 * center[1]); 252 | } else if (this.options.roof.shape === 'skillion') { 253 | const options = { 254 | angle: (360 - this.options.roof.direction) / 360 * 2 * Math.PI, 255 | depth: this.options.roof.height, 256 | pitch: this.options.roof.angle / 180 * Math.PI, 257 | }; 258 | const geometry = new RampGeometry(this.shape, options); 259 | 260 | material = BuildingPart.getRoofMaterial(this.way); 261 | roof = new Mesh( geometry, material ); 262 | roof.rotation.x = -Math.PI / 2; 263 | roof.position.set( 0, this.options.building.height - this.options.roof.height, 0); 264 | } else if (this.options.roof.shape === 'gabled') { 265 | var angle = this.options.roof.direction; 266 | if (this.options.roof.orientation === 'across') { 267 | angle = (angle + 90) % 360; 268 | } 269 | const center = BuildingShapeUtils.center(this.shape, angle / 180 * Math.PI); 270 | const options = { 271 | center: center, 272 | angle: angle / 180 * Math.PI, 273 | depth: this.options.roof.height ?? 3, 274 | }; 275 | const geometry = new WedgeGeometry(this.shape, options); 276 | 277 | material = BuildingPart.getRoofMaterial(this.way); 278 | roof = new Mesh(geometry, material); 279 | roof.rotation.x = -Math.PI / 2; 280 | roof.position.set(0, this.options.building.height - this.options.roof.height, 0); 281 | } else if (this.options.roof.shape === 'pyramidal') { 282 | const center = BuildingShapeUtils.center(this.shape); 283 | const options = { 284 | center: center, 285 | depth: this.options.roof.height, 286 | }; 287 | const geometry = new PyramidGeometry(this.shape, options); 288 | 289 | material = BuildingPart.getRoofMaterial(this.way); 290 | roof = new Mesh( geometry, material ); 291 | roof.rotation.x = -Math.PI / 2; 292 | roof.position.set( 0, this.options.building.height - this.options.roof.height, 0); 293 | } else if (this.options.roof.shape === 'hipped') { 294 | const options = { 295 | depth: this.options.roof.height, 296 | }; 297 | const geometry = new HippedGeometry(this.shape, options); 298 | material = BuildingPart.getRoofMaterial(this.way); 299 | roof = new Mesh( geometry, material ); 300 | roof.rotation.x = -Math.PI / 2; 301 | roof.position.set( 0, this.options.building.height - this.options.roof.height, 0); 302 | } else { 303 | let extrusionHeight = this.options.roof.height ?? 0; 304 | let extrudeSettings = { 305 | bevelEnabled: false, 306 | depth: extrusionHeight, 307 | }; 308 | var geometry = new ExtrudeGeometry(this.shape, extrudeSettings); 309 | // Create the mesh. 310 | roof = new Mesh(geometry, [BuildingPart.getRoofMaterial(this.way), BuildingPart.getMaterial(this.way)]); 311 | roof.rotation.x = -Math.PI / 2; 312 | roof.position.set(0, this.options.building.height - this.options.roof.height, 0); 313 | if (this.options.roof.shape !== 'flat') { 314 | window.printError('Unknown roof shape on '+ this.id + ': '+ this.options.roof.shape); 315 | } 316 | } 317 | roof.name = 'r' + this.id; 318 | this.roof = roof; 319 | } 320 | 321 | getAttribute(key) { 322 | if (this.way.querySelector('[k="' + key + '"]') !== null) { 323 | // if the buiilding part has a helght tag, use it. 324 | return this.way.querySelector('[k="' + key + '"]').getAttribute('v'); 325 | } 326 | } 327 | 328 | /** 329 | * The full height of the part in meters, roof and building. 330 | */ 331 | calculateHeight() { 332 | var height = 3; 333 | 334 | if (this.way.querySelector('[k="height"]') !== null) { 335 | // if the buiilding part has a helght tag, use it. 336 | height = this.way.querySelector('[k="height"]').getAttribute('v'); 337 | } else if (this.way.querySelector('[k="building:levels"]') !== null) { 338 | // if not, use building:levels and 3 meters per level. 339 | height = 3 * this.way.querySelector('[k="building:levels"]').getAttribute('v') + this.options.roof.height; 340 | } else if (this.way.querySelector('[k="building:part"]') !== null) { 341 | if (this.way.querySelector('[k="building:part"]').getAttribute('v') === 'roof') { 342 | // a roof has no building part by default. 343 | height = this.options.roof.height; 344 | } 345 | } 346 | 347 | return BuildingPart.normalizeLength(height); 348 | } 349 | 350 | /** 351 | * Convert an string of length units in various format to 352 | * a float in meters. 353 | * 354 | * Assuming string ends with the unit, no trailing whitespace. 355 | * If there is whitespace between the unit and the number, it will remain. 356 | */ 357 | static normalizeLength(length) { 358 | if (typeof length === 'string' || length instanceof String) { 359 | if (length.includes('km')){ 360 | // remove final character. 361 | return parseFloat(length.substring(0, length.length - 2)) * 1000; 362 | } 363 | if (length.includes('mi')){ 364 | // remove final character. 365 | return parseFloat(length.substring(0, length.length - 2)) * 5280 * 12 * 2.54 / 100; 366 | } 367 | if (length.includes('nmi')){ 368 | // remove final character. 369 | return parseFloat(length.substring(0, length.length - 3)) * 1852; 370 | } 371 | if (length.includes('m')){ 372 | // remove final character. 373 | return parseFloat(length.substring(0, length.length - 1)); 374 | } 375 | if (length.includes('\'')){ 376 | window.printError('Length includes a single quote.'); 377 | var position = length.indexOf('\''); 378 | var inches = parseFloat(length.substring(0, position)) * 12; 379 | if (length.length > position + 1) { 380 | inches += parseFloat(length.substring(position + 1, length.length - 1)); 381 | } 382 | return inches * 2.54 / 100; 383 | } 384 | if (length.includes('"')){ 385 | return parseFloat(length.substring(0, length.length - 1))* 2.54 / 100; 386 | } 387 | return parseFloat(length); 388 | } 389 | if (length) { 390 | return parseFloat(length); 391 | } 392 | } 393 | 394 | /** 395 | * Direction. In degrees, 0-360 396 | */ 397 | static normalizeDirection(direction) { 398 | const degrees = this.cardinalToDegree(direction); 399 | if (degrees !== undefined) { 400 | return degrees; 401 | } 402 | if (direction) { 403 | return parseFloat(direction); 404 | } 405 | } 406 | 407 | /** 408 | * Number. 409 | */ 410 | static normalizeNumber(number) { 411 | if (number) { 412 | return parseFloat(number); 413 | } 414 | } 415 | 416 | /** 417 | * Convert a cardinal direction to degrees. 418 | * North is zero and values increase clockwise. 419 | * 420 | * @param {string} cardinal - the direction. 421 | * 422 | * @return {int} degrees 423 | */ 424 | static cardinalToDegree(cardinal) { 425 | const cardinalUpperCase = `${cardinal}`.toUpperCase(); 426 | const index = 'N NNE NE ENE E ESE SE SSE S SSW SW WSW W WNW NW NNW'.split(' ').indexOf(cardinalUpperCase); 427 | if (index === -1) { 428 | return undefined; 429 | } 430 | const degreesTimesTwo = index * 45; 431 | // integer floor 432 | return degreesTimesTwo % 2 === 0 ? degreesTimesTwo / 2 : (degreesTimesTwo - 1) / 2; 433 | } 434 | 435 | /** 436 | * OSM compass degrees are 0-360 clockwise. 437 | * 0 degrees is North. 438 | * @return {number} degrees 439 | */ 440 | static atanRadToCompassDeg(rad) { 441 | return ((Math.PI - rad + 3 * Math.PI / 2) % (2 * Math.PI)) * 180 / Math.PI; 442 | } 443 | 444 | /** 445 | * Get the THREE.material for a given way 446 | * 447 | * This is complicated by inheritance 448 | */ 449 | static getMaterial(way) { 450 | var materialName = ''; 451 | var color = ''; 452 | if (way.querySelector('[k="building:facade:material"]') !== null) { 453 | // if the buiilding part has a designated material tag, use it. 454 | materialName = way.querySelector('[k="building:facade:material"]').getAttribute('v'); 455 | } else if (way.querySelector('[k="building:material"]') !== null) { 456 | // if the buiilding part has a designated material tag, use it. 457 | materialName = way.querySelector('[k="building:material"]').getAttribute('v'); 458 | } 459 | if (way.querySelector('[k="colour"]') !== null) { 460 | // if the buiilding part has a designated colour tag, use it. 461 | color = way.querySelector('[k="colour"]').getAttribute('v'); 462 | } else if (way.querySelector('[k="building:colour"]') !== null) { 463 | // if the buiilding part has a designated colour tag, use it. 464 | color = way.querySelector('[k="building:colour"]').getAttribute('v'); 465 | } else if (way.querySelector('[k="building:facade:colour"]') !== null) { 466 | // if the buiilding part has a designated colour tag, use it. 467 | color = way.querySelector('[k="building:facade:colour"]').getAttribute('v'); 468 | } 469 | const material = BuildingPart.getBaseMaterial(materialName); 470 | if (color !== '') { 471 | if (material instanceof MeshPhysicalMaterial) { 472 | material.emissive = new Color(color); 473 | material.emissiveIntensity = 0.5; 474 | material.roughness = 0.5; 475 | } else { 476 | material.color = new Color(color); 477 | } 478 | } else if (materialName === ''){ 479 | material.color = new Color('white'); 480 | } 481 | return material; 482 | } 483 | 484 | /** 485 | * Get the THREE.material for a given way 486 | * 487 | * This is complicated by inheritance 488 | */ 489 | static getRoofMaterial(way) { 490 | var materialName = ''; 491 | var color = ''; 492 | if (way.querySelector('[k="roof:material"]') !== null) { 493 | // if the buiilding part has a designated material tag, use it. 494 | materialName = way.querySelector('[k="roof:material"]').getAttribute('v'); 495 | } 496 | if (way.querySelector('[k="roof:colour"]') !== null) { 497 | // if the buiilding part has a designated mroof:colour tag, use it. 498 | color = way.querySelector('[k="roof:colour"]').getAttribute('v'); 499 | } 500 | var material; 501 | if (materialName === '') { 502 | material = BuildingPart.getMaterial(way); 503 | } else { 504 | material = BuildingPart.getBaseMaterial(materialName); 505 | } 506 | if (color !== '') { 507 | if (material instanceof MeshPhysicalMaterial) { 508 | material.emissive = new Color(color); 509 | } else { 510 | material.color = new Color(color); 511 | } 512 | } 513 | return material; 514 | } 515 | 516 | static getBaseMaterial(materialName) { 517 | var material; 518 | if (materialName === 'glass') { 519 | material = new MeshPhysicalMaterial( { 520 | color: 0x00374a, 521 | emissive: 0x011d57, 522 | reflectivity: 0.1409, 523 | clearcoat: 1, 524 | } ); 525 | } else if (materialName === 'grass'){ 526 | material = new MeshLambertMaterial({ 527 | color: 0x7ec850, 528 | emissive: 0x000000, 529 | }); 530 | } else if (materialName === 'bronze') { 531 | material = new MeshPhysicalMaterial({ 532 | color:0xcd7f32, 533 | emissive: 0x000000, 534 | metalness: 1, 535 | roughness: 0.127, 536 | }); 537 | } else if (materialName === 'copper') { 538 | material = new MeshLambertMaterial({ 539 | color: 0xa1c7b6, 540 | emissive: 0x00000, 541 | reflectivity: 0, 542 | }); 543 | } else if (materialName === 'stainless_steel' || materialName === 'metal') { 544 | material = new MeshPhysicalMaterial({ 545 | color: 0xaaaaaa, 546 | emissive: 0xaaaaaa, 547 | metalness: 1, 548 | roughness: 0.127, 549 | }); 550 | } else if (materialName === 'brick'){ 551 | material = new MeshLambertMaterial({ 552 | color: 0xcb4154, 553 | emissive: 0x1111111, 554 | }); 555 | } else if (materialName === 'concrete'){ 556 | material = new MeshLambertMaterial({ 557 | color: 0x555555, 558 | emissive: 0x1111111, 559 | }); 560 | } else if (materialName === 'marble') { 561 | material = new MeshLambertMaterial({ 562 | color: 0xffffff, 563 | emissive: 0x1111111, 564 | }); 565 | } else { 566 | material = new MeshLambertMaterial({ 567 | emissive: 0x1111111, 568 | }); 569 | } 570 | return material; 571 | } 572 | 573 | getInfo() { 574 | return { 575 | id: this.id, 576 | type: this.type, 577 | options: this.options, 578 | parts: [ 579 | ], 580 | }; 581 | } 582 | 583 | updateOptions(options) { 584 | this.options = options; 585 | } 586 | } 587 | export {BuildingPart}; 588 | --------------------------------------------------------------------------------