Skip to content

Commit 1848ce5

Browse files
aleksprogergithub-actions[bot]
authored andcommitted
Improve indoor buildings visibility check (internal-10029)
GitOrigin-RevId: 0c3a68bd91deb8df91951a027ab4544bb3b578d5
1 parent 7a72385 commit 1848ce5

8 files changed

Lines changed: 612 additions & 101 deletions

File tree

src/render/indoor_parser.ts

Lines changed: 30 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,34 @@
11
import {warnOnce} from "../util/util";
2+
import {getLngLatPoint} from "../style-spec/expression/definitions/distance";
3+
import {IndoorActiveFloorStrategy} from "../style/indoor_active_floor_strategy";
24

35
import type {VectorTile, VectorTileFeature} from "@mapbox/vector-tile";
46
import type {IndoorBuilding, IndoorData, IndoorTileOptions} from "../style/indoor_data";
57
import type Actor from "../util/actor";
8+
import type {CanonicalTileID} from "../source/tile_id";
9+
import type {Polygon, MultiPolygon} from "geojson";
610

7-
export function parseActiveFloors(data: VectorTile, indoorTileOptions: IndoorTileOptions, actor: Actor): Set<string> | undefined {
11+
export function parseActiveFloors(data: VectorTile, indoorTileOptions: IndoorTileOptions, actor: Actor, tileID: CanonicalTileID): Set<string> | undefined {
812
const activeFloorsVisible = indoorTileOptions.indoorState.activeFloorsVisible;
913
if (!indoorTileOptions.sourceLayers) {
1014
return activeFloorsVisible ? indoorTileOptions.indoorState.activeFloors : undefined;
1115
}
1216
const sourceLayers = calculateIndoorSourceLayers(indoorTileOptions.sourceLayers, new Set(Object.keys(data.layers)));
1317
const indoorState = indoorTileOptions.indoorState;
14-
const indoorData = parseData(data, sourceLayers, indoorState.activeFloors, indoorState.selectedFloorId);
18+
const indoorData = parseData(data, sourceLayers, indoorState.activeFloors, indoorState.selectedFloorId, tileID);
1519
actor.send('setIndoorData', indoorData);
1620
return activeFloorsVisible ? indoorData.activeFloors : undefined;
1721
}
1822

19-
// This function is used to parse the indoor data from the vector tile
20-
// And resolve set of currently active floors based on lastActiveFloors and selectedFloorId
21-
// 1. Parse the indoor data from the vector tile
22-
// 2. selectedFloorId will be used to determine if the floor is active (either directly or via connected_floor_ids)
23-
// 3. if lastActiveFloors is provided, we will also add floors from that set that are not conflicting with the new active floors from step 2
24-
// 4. we also add default floors that are not conflicting with the new active floors from steps 2 and 3
25-
// ResolveD data will be passed to the bucket and emitted to indoor manager to create new indoorState
2623
function parseData(
2724
data: VectorTile,
2825
sourceLayers: Set<string>,
2926
lastActiveFloors: Set<string>,
30-
selectedFloorId: string
27+
selectedFloorId: string,
28+
tileID: CanonicalTileID
3129
): IndoorData {
32-
const newActiveFloors = new Set<string>();
33-
const allFloors = new Set<string>();
34-
const allDefaultFloors = new Set<string>();
35-
36-
const floorIdToConflicts = new Map<string, Set<string>>();
3730
const buildings: Record<string, IndoorBuilding> = {};
3831

39-
// If any active floor lists candidate as conflict, or candidate lists any active as conflict
40-
const conflictsWithActive = (candidateId: string): boolean => {
41-
const candidateConflicts = floorIdToConflicts.get(candidateId) || new Set<string>();
42-
for (const activeId of newActiveFloors) {
43-
const activeConflicts = floorIdToConflicts.get(activeId) || new Set<string>();
44-
if (activeConflicts.has(candidateId) || candidateConflicts.has(activeId)) return true;
45-
}
46-
return false;
47-
};
48-
4932
for (const layerId of sourceLayers) {
5033
const sourceLayer = data.layers[layerId];
5134
if (!sourceLayer) {
@@ -59,52 +42,21 @@ function parseData(
5942
if (isValidBuildingFeature(feature)) {
6043
const {id, center} = parseBuilding(feature);
6144
upsertBuilding(buildings, id, center);
62-
newActiveFloors.add(id);
6345
continue;
6446
}
6547

6648
// Next step: Introduce better logging in case of invalid feature with valid type
6749
if (isValidFloorFeature(feature)) {
68-
const {id, isDefault, connections, conflicts, buildings: buildingIds, name, zIndex} = parseFloor(feature);
69-
assignFloorToBuildings(buildings, buildingIds, id, {name, zIndex});
70-
71-
floorIdToConflicts.set(id, conflicts);
72-
73-
const isActiveFloor = (id === selectedFloorId) || connections.has(selectedFloorId);
74-
if (isActiveFloor) {
75-
newActiveFloors.add(id);
76-
}
77-
78-
allFloors.add(id);
79-
if (isDefault) {
80-
allDefaultFloors.add(id);
81-
}
50+
const floor = parseFloor(feature, tileID);
51+
assignFloorToBuildings(buildings, floor.buildings, floor.id, floor);
8252
}
8353
}
8454
}
8555

86-
// Add last active floors that still exist and don't conflict with current active floors
87-
if (lastActiveFloors) {
88-
for (const lastActiveFloorId of lastActiveFloors) {
89-
// lastActiveFloors may contain floors that are not in the current tile data, so we need to check if they exist and skip if they don't
90-
if (!allFloors.has(lastActiveFloorId)) continue;
91-
if (!conflictsWithActive(lastActiveFloorId)) {
92-
newActiveFloors.add(lastActiveFloorId);
93-
}
94-
}
95-
}
96-
97-
// Add default floors that don't conflict with the active floors
98-
for (const defaultFloorId of allDefaultFloors) {
99-
if (newActiveFloors.has(defaultFloorId)) continue;
100-
if (!conflictsWithActive(defaultFloorId)) {
101-
newActiveFloors.add(defaultFloorId);
102-
}
103-
}
104-
56+
const activeFloors = IndoorActiveFloorStrategy.calculate(buildings, selectedFloorId, lastActiveFloors);
10557
return {
10658
buildings,
107-
activeFloors: newActiveFloors
59+
activeFloors
10860
};
10961
}
11062

@@ -128,7 +80,7 @@ function assignFloorToBuildings(
12880
buildings: Record<string, IndoorBuilding>,
12981
buildingIds: Set<string>,
13082
floorId: string,
131-
floor: {name: string, zIndex: number}
83+
floor: {name: string, zIndex: number, connections: Set<string>, conflicts: Set<string>, isDefault: boolean, buildings: Set<string>}
13284
): void {
13385
for (const buildingId of buildingIds) {
13486
upsertBuilding(buildings, buildingId);
@@ -143,7 +95,7 @@ function parseBuilding(feature: VectorTileFeature): {id: string, center: [number
14395
return {id, center};
14496
}
14597

146-
function parseFloor(feature: VectorTileFeature): {id: string, isDefault: boolean, connections: Set<string>, conflicts: Set<string>, buildings: Set<string>, zIndex: number, name: string} {
98+
function parseFloor(feature: VectorTileFeature, tileID: CanonicalTileID): {id: string, isDefault: boolean, connections: Set<string>, conflicts: Set<string>, buildings: Set<string>, zIndex: number, name: string, geometry: Polygon | MultiPolygon | undefined} {
14799
const id = feature.properties.id.toString();
148100
const isDefault = feature.properties.is_default ?
149101
feature.properties.is_default as boolean :
@@ -159,8 +111,23 @@ function parseFloor(feature: VectorTileFeature): {id: string, isDefault: boolean
159111
new Set();
160112
const name = feature.properties.name.toString();
161113
const zIndex = feature.properties.z_index as number;
114+
const geometry = extractGeometry(feature, tileID);
115+
return {id, isDefault, connections, conflicts, buildings, name, zIndex, geometry};
116+
}
117+
118+
function extractGeometry(feature: VectorTileFeature, tileID: CanonicalTileID): Polygon | MultiPolygon | undefined {
119+
const geometry = feature.loadGeometry();
120+
if (!geometry || geometry.length === 0) return undefined;
121+
122+
const coordinates = geometry.map(ring => {
123+
return ring.map(p => {
124+
return getLngLatPoint(p, tileID, feature.extent);
125+
});
126+
});
127+
if (coordinates.length === 0) return undefined;
162128

163-
return {id, isDefault, connections, conflicts, buildings, name, zIndex};
129+
// Just construct a Polygon with the first ring as exterior.
130+
return {type: 'Polygon', coordinates: [coordinates[0]]};
164131
}
165132

166133
function hasRequiredProperties(feature: VectorTileFeature, requiredProps: string[]): boolean {

src/source/worker_tile.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ class WorkerTile {
140140
};
141141

142142
if (this.indoor) {
143-
options.activeFloors = parseActiveFloors(data, this.indoor, actor);
143+
options.activeFloors = parseActiveFloors(data, this.indoor, actor, this.canonical);
144144
}
145145

146146
const asyncBucketLoads: Promise<unknown>[] = [];
@@ -233,7 +233,7 @@ class WorkerTile {
233233
if (layer.maxzoom && this.zoom >= layer.maxzoom) continue;
234234
if (layer.visibility === 'none') continue;
235235

236-
recalculateLayers(family, this.zoom, options.brightness, availableImages, this.worldview);
236+
recalculateLayers(family, this.zoom, options.brightness, availableImages, this.worldview, options.activeFloors);
237237

238238
// @ts-expect-error: Type 'TypedStyleLayer' doesn't have a 'createBucket' method in all of its subtypes
239239
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
@@ -317,7 +317,7 @@ class WorkerTile {
317317
for (const key in buckets) {
318318
const bucket = buckets[key];
319319
if (bucket instanceof SymbolBucket) {
320-
recalculateLayers(bucket.layers, this.zoom, options.brightness, availableImages, this.worldview);
320+
recalculateLayers(bucket.layers, this.zoom, options.brightness, availableImages, this.worldview, options.activeFloors);
321321
symbolLayoutData[key] =
322322
performSymbolLayout(bucket,
323323
glyphMap,
@@ -358,7 +358,7 @@ class WorkerTile {
358358
(bucket instanceof LineBucket ||
359359
bucket instanceof FillBucket ||
360360
bucket instanceof FillExtrusionBucket)) {
361-
recalculateLayers(bucket.layers, this.zoom, options.brightness, availableImages, this.worldview);
361+
recalculateLayers(bucket.layers, this.zoom, options.brightness, availableImages, this.worldview, options.activeFloors);
362362
const imagePositions: SpritePositions = Object.fromEntries(imageAtlas.patternPositions);
363363
bucket.addFeatures(options, this.tileID.canonical, imagePositions, availableImages, this.tileTransform, this.brightness);
364364
}
@@ -531,9 +531,9 @@ class WorkerTile {
531531
}
532532
}
533533

534-
function recalculateLayers(layers: ReadonlyArray<TypedStyleLayer>, zoom: number, brightness: number, availableImages: ImageId[], worldview: string | undefined) {
534+
function recalculateLayers(layers: ReadonlyArray<TypedStyleLayer>, zoom: number, brightness: number, availableImages: ImageId[], worldview: string | undefined, activeFloors: Set<string> | undefined) {
535535
// Layers are shared and may have been used by a WorkerTile with a different zoom.
536-
const parameters = new EvaluationParameters(zoom, {brightness, worldview});
536+
const parameters = new EvaluationParameters(zoom, {brightness, worldview, activeFloors});
537537
for (const layer of layers) {
538538
layer.recalculate(parameters, availableImages);
539539
}

src/style-spec/expression/definitions/distance.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,10 @@ function latFromMercatorY(y: number): number {
135135
return 360 / Math.PI * Math.atan(Math.exp(y2 * Math.PI / 180)) - 90;
136136
}
137137

138-
function getLngLatPoint(coord: Point, canonical: CanonicalTileID): [number, number] {
138+
export function getLngLatPoint(coord: Point, canonical: CanonicalTileID, extent: number = EXTENT): [number, number] {
139139
const tilesAtZoom = Math.pow(2, canonical.z);
140-
const x = (coord.x / EXTENT + canonical.x) / tilesAtZoom;
141-
const y = (coord.y / EXTENT + canonical.y) / tilesAtZoom;
140+
const x = (coord.x / extent + canonical.x) / tilesAtZoom;
141+
const y = (coord.y / extent + canonical.y) / tilesAtZoom;
142142
return [lngFromMercatorX(x), latFromMercatorY(y)];
143143
}
144144

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type {IndoorBuilding} from './indoor_data';
2+
3+
export class IndoorActiveFloorStrategy {
4+
static calculate(
5+
buildings: Record<string, IndoorBuilding>,
6+
selectedFloorId: string | null,
7+
lastActiveFloors: Set<string> | null
8+
): Set<string> {
9+
const newActiveFloors = new Set<string>();
10+
const floorIdToConflicts = new Map<string, Set<string>>();
11+
const allFloors = new Set<string>();
12+
const allDefaultFloors = new Set<string>();
13+
14+
// Build graph from all loaded buildings
15+
for (const building of Object.values(buildings)) {
16+
for (const [floorId, floor] of Object.entries(building.floors)) {
17+
allFloors.add(floorId);
18+
if (floor.isDefault) allDefaultFloors.add(floorId);
19+
if (floor.conflicts) floorIdToConflicts.set(floorId, floor.conflicts);
20+
21+
// Add selected floor and its connections
22+
const isSelected = (floorId === selectedFloorId);
23+
const isConnected = floor.connections && selectedFloorId && floor.connections.has(selectedFloorId);
24+
if (isSelected || isConnected) {
25+
newActiveFloors.add(floorId);
26+
}
27+
}
28+
}
29+
30+
const conflictsWithActive = (candidateId: string): boolean => {
31+
const candidateConflicts = floorIdToConflicts.get(candidateId) || new Set<string>();
32+
for (const activeId of newActiveFloors) {
33+
const activeConflicts = floorIdToConflicts.get(activeId) || new Set<string>();
34+
if (activeConflicts.has(candidateId) || candidateConflicts.has(activeId)) return true;
35+
}
36+
return false;
37+
};
38+
39+
// Add last active floors that still exist and don't conflict
40+
if (lastActiveFloors) {
41+
for (const lastActiveFloorId of lastActiveFloors) {
42+
if (!allFloors.has(lastActiveFloorId)) continue;
43+
if (!conflictsWithActive(lastActiveFloorId)) {
44+
newActiveFloors.add(lastActiveFloorId);
45+
}
46+
}
47+
}
48+
49+
// Add default floors that don't conflict
50+
for (const defaultFloorId of allDefaultFloors) {
51+
if (newActiveFloors.has(defaultFloorId)) continue;
52+
if (!conflictsWithActive(defaultFloorId)) {
53+
newActiveFloors.add(defaultFloorId);
54+
}
55+
}
56+
57+
return newActiveFloors;
58+
}
59+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import Point from '@mapbox/point-geometry';
2+
import {polygonIntersectsPolygon, polygonIntersectsMultiPolygon} from '../util/intersection_tests';
3+
4+
import type LngLat from '../geo/lng_lat';
5+
import type {LngLatBounds} from '../geo/lng_lat';
6+
import type {IndoorBuilding} from './indoor_data';
7+
8+
export class ViewportIntersectionStrategy {
9+
_previousClosestBuildingId: string | null = null;
10+
_hysteresisRatio: number = 0.8;
11+
_indoorMinimumZoom: number = 16.0;
12+
13+
findClosestBuilding(
14+
buildings: Record<string, IndoorBuilding>,
15+
mapCenter: LngLat,
16+
zoom: number,
17+
mapBounds: LngLatBounds,
18+
viewportPolygon?: Point[]
19+
): string | null {
20+
if (zoom < this._indoorMinimumZoom) {
21+
this._previousClosestBuildingId = null;
22+
return null;
23+
}
24+
25+
let nextClosestBuildingId: string | null = null;
26+
let minDistance = Number.MAX_VALUE;
27+
28+
let currentBuildingDistance = Number.MAX_VALUE;
29+
let currentInBounds = false;
30+
31+
const previousBuilding = this._previousClosestBuildingId ? buildings[this._previousClosestBuildingId] : null;
32+
33+
if (this._previousClosestBuildingId && previousBuilding) {
34+
const isVisible = this._isBuildingVisible(previousBuilding, mapBounds, viewportPolygon);
35+
if (isVisible) {
36+
currentInBounds = true;
37+
currentBuildingDistance = this._calculateDistance(mapCenter, previousBuilding);
38+
}
39+
}
40+
41+
for (const [id, building] of Object.entries(buildings)) {
42+
const isVisible = this._isBuildingVisible(building, mapBounds, viewportPolygon);
43+
if (!isVisible) continue;
44+
45+
const distance = this._calculateDistance(mapCenter, building);
46+
47+
if (distance < minDistance) {
48+
minDistance = distance;
49+
nextClosestBuildingId = id;
50+
}
51+
}
52+
53+
// Switch to a new closest building only if it is significantly closer (hysteresis)
54+
if (currentInBounds && nextClosestBuildingId && this._previousClosestBuildingId && nextClosestBuildingId !== this._previousClosestBuildingId) {
55+
if (minDistance > currentBuildingDistance * this._hysteresisRatio) {
56+
nextClosestBuildingId = this._previousClosestBuildingId;
57+
}
58+
} else if (currentInBounds && !nextClosestBuildingId) {
59+
nextClosestBuildingId = this._previousClosestBuildingId;
60+
}
61+
62+
this._previousClosestBuildingId = nextClosestBuildingId;
63+
return nextClosestBuildingId;
64+
}
65+
66+
_calculateDistance(mapCenter: LngLat, building: IndoorBuilding): number {
67+
if (!building.center) return Number.MAX_VALUE;
68+
// Simple squared euclidean distance
69+
const dLat = mapCenter.lat - building.center[1];
70+
const dLng = mapCenter.lng - building.center[0];
71+
return dLat * dLat + dLng * dLng;
72+
}
73+
74+
_isBuildingVisible(building: IndoorBuilding, mapBounds: LngLatBounds, viewportPolygon?: Point[]): boolean {
75+
if (!viewportPolygon) {
76+
return false;
77+
}
78+
79+
// Check intersection with viewport polygon, if any floor is visible
80+
for (const floorId of building.floorIds) {
81+
const floor = building.floors[floorId];
82+
if (!floor.geometry) continue;
83+
84+
const geometry = floor.geometry;
85+
if (geometry.type === 'Polygon') {
86+
const floorPoly = this._convertRingToPoints(geometry.coordinates[0]);
87+
if (polygonIntersectsPolygon(floorPoly, viewportPolygon)) {
88+
return true;
89+
}
90+
} else if (geometry.type === 'MultiPolygon') {
91+
const floorMultiPoly = this._convertMultiPolygonToPoints(geometry.coordinates);
92+
if (polygonIntersectsMultiPolygon(viewportPolygon, floorMultiPoly)) {
93+
return true;
94+
}
95+
}
96+
}
97+
98+
return false;
99+
}
100+
101+
_convertRingToPoints(ring: number[][]): Point[] {
102+
return ring.map(c => new Point(c[0], c[1]));
103+
}
104+
105+
_convertMultiPolygonToPoints(multiPoly: number[][][][]): Point[][] {
106+
// Convert each polygon's exterior ring
107+
return multiPoly.map(poly => {
108+
if (poly.length === 0) return [];
109+
return poly[0].map(c => new Point(c[0], c[1]));
110+
});
111+
}
112+
}

0 commit comments

Comments
 (0)