zero2geospatial3d-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. zero2geospatial3d/__init__.py +105 -0
  2. zero2geospatial3d/acoustics.py +284 -0
  3. zero2geospatial3d/classify.py +379 -0
  4. zero2geospatial3d/cli.py +367 -0
  5. zero2geospatial3d/comfort.py +687 -0
  6. zero2geospatial3d/data/epw_stations.json.gz +0 -0
  7. zero2geospatial3d/daylight.py +337 -0
  8. zero2geospatial3d/dispersion.py +249 -0
  9. zero2geospatial3d/epw.py +162 -0
  10. zero2geospatial3d/geo.py +138 -0
  11. zero2geospatial3d/grid.py +119 -0
  12. zero2geospatial3d/microclimate.py +388 -0
  13. zero2geospatial3d/morphology.py +283 -0
  14. zero2geospatial3d/nsga2.py +315 -0
  15. zero2geospatial3d/osm.py +450 -0
  16. zero2geospatial3d/osm_tags.py +180 -0
  17. zero2geospatial3d/py.typed +0 -0
  18. zero2geospatial3d/results.py +231 -0
  19. zero2geospatial3d/road_sources.py +141 -0
  20. zero2geospatial3d/scene.py +555 -0
  21. zero2geospatial3d/server.py +332 -0
  22. zero2geospatial3d/shading.py +443 -0
  23. zero2geospatial3d/solar.py +207 -0
  24. zero2geospatial3d/terrain.py +610 -0
  25. zero2geospatial3d/urban_form.py +326 -0
  26. zero2geospatial3d/walkability.py +380 -0
  27. zero2geospatial3d/weather.py +373 -0
  28. zero2geospatial3d/web/index.html +50 -0
  29. zero2geospatial3d/web/src/analysis/climate.js +278 -0
  30. zero2geospatial3d/web/src/analysis/compliance.js +345 -0
  31. zero2geospatial3d/web/src/analysis/gpu.js +431 -0
  32. zero2geospatial3d/web/src/analysis/optimize.js +208 -0
  33. zero2geospatial3d/web/src/analysis/runners.js +1644 -0
  34. zero2geospatial3d/web/src/analysis/terrain_results.js +145 -0
  35. zero2geospatial3d/web/src/app.js +630 -0
  36. zero2geospatial3d/web/src/core/acoustics.js +341 -0
  37. zero2geospatial3d/web/src/core/classify.js +158 -0
  38. zero2geospatial3d/web/src/core/comfort.js +686 -0
  39. zero2geospatial3d/web/src/core/daylight_rule.js +199 -0
  40. zero2geospatial3d/web/src/core/dispersion.js +264 -0
  41. zero2geospatial3d/web/src/core/microclimate.js +372 -0
  42. zero2geospatial3d/web/src/core/morphology.js +153 -0
  43. zero2geospatial3d/web/src/core/nsga2.js +181 -0
  44. zero2geospatial3d/web/src/core/party_walls.js +60 -0
  45. zero2geospatial3d/web/src/core/road_sources.js +127 -0
  46. zero2geospatial3d/web/src/core/solar.js +112 -0
  47. zero2geospatial3d/web/src/core/terrain_analysis.js +267 -0
  48. zero2geospatial3d/web/src/core/terrain_math.js +279 -0
  49. zero2geospatial3d/web/src/core/urban_form.js +405 -0
  50. zero2geospatial3d/web/src/core/walkability.js +316 -0
  51. zero2geospatial3d/web/src/core/weather.js +225 -0
  52. zero2geospatial3d/web/src/core/wind.js +131 -0
  53. zero2geospatial3d/web/src/export/check_pdf.js +74 -0
  54. zero2geospatial3d/web/src/export/exporters.js +556 -0
  55. zero2geospatial3d/web/src/export/pdf.js +149 -0
  56. zero2geospatial3d/web/src/export/snapshot.js +234 -0
  57. zero2geospatial3d/web/src/export/snapshot_scene.js +150 -0
  58. zero2geospatial3d/web/src/main.js +75 -0
  59. zero2geospatial3d/web/src/scene/objects.js +645 -0
  60. zero2geospatial3d/web/src/scene/overlay.js +145 -0
  61. zero2geospatial3d/web/src/scene/terrain.js +546 -0
  62. zero2geospatial3d/web/src/scene/viewer.js +275 -0
  63. zero2geospatial3d/web/src/scene/wind_streamlines.js +336 -0
  64. zero2geospatial3d/web/src/style.css +225 -0
  65. zero2geospatial3d/web/src/ui/building_report.js +404 -0
  66. zero2geospatial3d/web/src/ui/charts.js +441 -0
  67. zero2geospatial3d/web/src/ui/colorize.js +101 -0
  68. zero2geospatial3d/web/src/ui/compare.js +362 -0
  69. zero2geospatial3d/web/src/ui/dom.js +104 -0
  70. zero2geospatial3d/web/src/ui/panel_analyze.js +742 -0
  71. zero2geospatial3d/web/src/ui/panel_compare.js +294 -0
  72. zero2geospatial3d/web/src/ui/panel_layers.js +172 -0
  73. zero2geospatial3d/web/src/ui/panel_terrain.js +277 -0
  74. zero2geospatial3d/web/src/ui/qgis_link.js +127 -0
  75. zero2geospatial3d/web/src/ui/sunclock.js +45 -0
  76. zero2geospatial3d/web/src/ui/tools.js +197 -0
  77. zero2geospatial3d/web/src/ui/viewtools.js +173 -0
  78. zero2geospatial3d/web/vendor/three/addons/controls/OrbitControls.js +1417 -0
  79. zero2geospatial3d/web/vendor/three/addons/exporters/GLTFExporter.js +3309 -0
  80. zero2geospatial3d/web/vendor/three/addons/utils/TextureUtils.js +98 -0
  81. zero2geospatial3d/web/vendor/three/three.module.js +53044 -0
  82. zero2geospatial3d/wind.py +178 -0
  83. zero2geospatial3d_sdk-0.1.0.dist-info/METADATA +219 -0
  84. zero2geospatial3d_sdk-0.1.0.dist-info/RECORD +89 -0
  85. zero2geospatial3d_sdk-0.1.0.dist-info/WHEEL +5 -0
  86. zero2geospatial3d_sdk-0.1.0.dist-info/entry_points.txt +2 -0
  87. zero2geospatial3d_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
  88. zero2geospatial3d_sdk-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +52 -0
  89. zero2geospatial3d_sdk-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,405 @@
1
+ // @ts-check
2
+ // 3D urban-design and biophilic planning indicators:
3
+ // 1. Green View Index (GVI) - pedestrian-level panoramic biophilic green view percentage.
4
+ // 2. Solar Envelope - Knowles (1981) / BRE right-to-light maximum buildable heights and floor budget.
5
+
6
+ /**
7
+ * GVI qualitative classification categories (Treepedia / MIT Senseable City Lab & urban forestry thresholds).
8
+ */
9
+ export const GVI_CLASSES = [
10
+ { min: 0, max: 10, key: 'bare', label: 'Bare / low green', color: '#d73027' },
11
+ { min: 10, max: 20, key: 'moderate', label: 'Moderate green', color: '#fee08b' },
12
+ { min: 20, max: 35, key: 'street', label: 'Green street', color: '#a6d96a' },
13
+ { min: 35, max: 100, key: 'high', label: 'High biophilic canopy', color: '#1a9850' }
14
+ ];
15
+
16
+ /**
17
+ * Classifies a Green View Index percentage (0..100) into a qualitative tier.
18
+ * @param {number} pct
19
+ * @returns {{ min: number, max: number, key: string, label: string, color: string } | null}
20
+ */
21
+ export function gviClass(pct) {
22
+ if (!Number.isFinite(pct)) return null;
23
+ if (pct < 10) return GVI_CLASSES[0];
24
+ if (pct < 20) return GVI_CLASSES[1];
25
+ if (pct < 35) return GVI_CLASSES[2];
26
+ return GVI_CLASSES[3];
27
+ }
28
+
29
+ /**
30
+ * Inserts a 1D elevation angle interval [start, end] into a sorted disjoint list.
31
+ * @param {Array<[number, number]>} intervals
32
+ * @param {number} start
33
+ * @param {number} end
34
+ * @returns {Array<[number, number]>}
35
+ */
36
+ function addInterval(intervals, start, end) {
37
+ if (end <= start) return intervals;
38
+ let s = start;
39
+ let e = end;
40
+ /** @type {Array<[number, number]>} */
41
+ const next = [];
42
+ let inserted = false;
43
+ for (let i = 0; i < intervals.length; i++) {
44
+ const [is, ie] = intervals[i];
45
+ if (ie < s) {
46
+ next.push([is, ie]);
47
+ } else if (is > e) {
48
+ if (!inserted) {
49
+ next.push([s, e]);
50
+ inserted = true;
51
+ }
52
+ next.push([is, ie]);
53
+ } else {
54
+ s = Math.min(s, is);
55
+ e = Math.max(e, ie);
56
+ }
57
+ }
58
+ if (!inserted) next.push([s, e]);
59
+ return next;
60
+ }
61
+
62
+ /**
63
+ * Computes pedestrian-level Green View Index (GVI) on a 2.5D raster grid.
64
+ *
65
+ * @param {object} params
66
+ * @param {Float32Array|Array<number>} params.buildingHeights Obstacle / building height above ground per cell (0 = open)
67
+ * @param {Array<[number, number, number, number]>} [params.treeObstacles] Array of [x, y, radius, height]
68
+ * @param {Uint8Array|Float32Array|Array<number>|null} [params.greenMask] 1 where ground is vegetated, 0 elsewhere
69
+ * @param {number} params.cols Number of grid columns
70
+ * @param {number} params.rows Number of grid rows
71
+ * @param {number} params.cell Cell size in metres
72
+ * @param {number} [params.minX] Western geographic coordinate
73
+ * @param {number} [params.maxY] Northern geographic coordinate
74
+ * @param {number} [params.eyeHeight] Pedestrian eye level in metres (default 1.6 m)
75
+ * @param {number} [params.maxRadius] Maximum sight distance in metres (default 80 m)
76
+ * @param {number} [params.azimuthSteps] Number of horizontal ray azimuths sampled across 360 degrees (default 24)
77
+ * @param {((f: number) => void)|null} [params.onProgress] progress 0..1; the page stays responsive
78
+ * @returns {Promise<Float32Array>} Green View Index in percent (0..100 %), NaN inside solid buildings
79
+ */
80
+ export async function greenViewIndex({
81
+ buildingHeights,
82
+ treeObstacles = [],
83
+ greenMask = null,
84
+ cols,
85
+ rows,
86
+ cell,
87
+ minX = 0,
88
+ maxY = 0,
89
+ eyeHeight = 1.6,
90
+ maxRadius = 80,
91
+ azimuthSteps = 24,
92
+ onProgress = null
93
+ }) {
94
+ const out = new Float32Array(cols * rows);
95
+ const bHeights = buildingHeights || new Float32Array(cols * rows);
96
+
97
+ // 1. Rasterize tree obstacles into fast O(1) lookup grids for treeTop and treeBase.
98
+ const treeTop = new Float32Array(cols * rows);
99
+ const treeBase = new Float32Array(cols * rows);
100
+
101
+ for (let t = 0; t < treeObstacles.length; t++) {
102
+ const [tx, ty, trad, th] = treeObstacles[t];
103
+ if (!(th > 0)) continue;
104
+ const rad = Math.max(trad, cell * 0.5);
105
+ const c0 = Math.max(0, Math.floor((tx - rad - minX) / cell));
106
+ const c1 = Math.min(cols - 1, Math.ceil((tx + rad - minX) / cell));
107
+ const r0 = Math.max(0, Math.floor((maxY - ty - rad) / cell));
108
+ const r1 = Math.min(rows - 1, Math.ceil((maxY - ty + rad) / cell));
109
+ const tb = Math.max(1.2, 0.35 * th);
110
+
111
+ for (let r = r0; r <= r1; r++) {
112
+ for (let c = c0; c <= c1; c++) {
113
+ const cx = minX + (c + 0.5) * cell;
114
+ const cy = maxY - (r + 0.5) * cell;
115
+ if (Math.hypot(cx - tx, cy - ty) <= rad) {
116
+ const idx = r * cols + c;
117
+ if (th > treeTop[idx]) {
118
+ treeTop[idx] = th;
119
+ treeBase[idx] = tb;
120
+ }
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ // 2. Raymarch panoramic horizontal rays for each open cell.
127
+ const stepDist = cell;
128
+ const maxSteps = Math.ceil(maxRadius / stepDist);
129
+ // Total vertical field of view of standard horizontal panoramic eye view ([-45 deg, +45 deg] = pi / 2 rad)
130
+ const TOTAL_VFOV = Math.PI / 2;
131
+ const HALF_VFOV = TOTAL_VFOV / 2;
132
+ let lastYield = Date.now();
133
+
134
+ for (let r = 0; r < rows; r++) {
135
+ for (let c = 0; c < cols; c++) {
136
+ const originIdx = r * cols + c;
137
+ if (bHeights[originIdx] > 0) {
138
+ out[originIdx] = NaN;
139
+ continue;
140
+ }
141
+
142
+ let totalRayPct = 0;
143
+
144
+ for (let a = 0; a < azimuthSteps; a++) {
145
+ const theta = (a * 2 * Math.PI) / azimuthSteps;
146
+ const dc = Math.cos(theta);
147
+ const dr = -Math.sin(theta); // north is -row
148
+
149
+ let maxBuildTan = 0;
150
+ /** @type {Array<[number, number]>} */
151
+ let treeIntervals = [];
152
+ let groundAngleSum = 0;
153
+
154
+ for (let s = 1; s <= maxSteps; s++) {
155
+ const dist = s * stepDist;
156
+ const curC = Math.round(c + (dist / cell) * dc);
157
+ const curR = Math.round(r + (dist / cell) * dr);
158
+
159
+ if (curC < 0 || curC >= cols || curR < 0 || curR >= rows) break;
160
+
161
+ const idx = curR * cols + curC;
162
+ const bH = bHeights[idx] || 0;
163
+
164
+ // Track solid building elevation angle
165
+ if (bH > 0) {
166
+ const buildTan = (bH - eyeHeight) / dist;
167
+ if (buildTan > maxBuildTan) maxBuildTan = buildTan;
168
+ // Stop early if occluded by a tall wall right in front
169
+ if (maxBuildTan > 2.5) break;
170
+ }
171
+
172
+ // Visible tree canopy vertical angular span
173
+ const tTop = treeTop[idx];
174
+ if (tTop > eyeHeight) {
175
+ const topH = tTop - eyeHeight;
176
+ const tBase = treeBase[idx];
177
+ const botH = Math.max(0, tBase - eyeHeight, dist * maxBuildTan);
178
+ if (topH > botH) {
179
+ // Only what lies in the ±45° vertical field of view counts.
180
+ const phiTop = Math.min(HALF_VFOV, Math.atan2(topH, dist));
181
+ const phiBot = Math.atan2(botH, dist);
182
+ if (phiTop > phiBot) {
183
+ treeIntervals = addInterval(treeIntervals, phiBot, phiTop);
184
+ }
185
+ }
186
+ }
187
+
188
+ // Ground vegetation downward view contribution (within 30 m, unoccluded)
189
+ if (greenMask && greenMask[idx] > 0 && dist <= 30 && maxBuildTan <= 0) {
190
+ const dNear = Math.max(cell * 0.5, dist - cell * 0.5);
191
+ const dFar = dist + cell * 0.5;
192
+ const gAngle = Math.min(HALF_VFOV, Math.atan2(eyeHeight, dNear)) - Math.min(HALF_VFOV, Math.atan2(eyeHeight, dFar));
193
+ if (gAngle > 0) groundAngleSum += gAngle;
194
+ }
195
+ }
196
+
197
+ let treeAngleSum = 0;
198
+ for (let k = 0; k < treeIntervals.length; k++) {
199
+ treeAngleSum += (treeIntervals[k][1] - treeIntervals[k][0]);
200
+ }
201
+
202
+ const rayGreen = treeAngleSum + groundAngleSum;
203
+ const rayPct = Math.min(100, Math.max(0, (rayGreen / TOTAL_VFOV) * 100));
204
+ totalRayPct += rayPct;
205
+ }
206
+
207
+ out[originIdx] = totalRayPct / azimuthSteps;
208
+ }
209
+ if (onProgress && Date.now() - lastYield > 50) {
210
+ onProgress((r + 1) / rows);
211
+ await new Promise((res) => setTimeout(res, 0));
212
+ lastYield = Date.now();
213
+ }
214
+ }
215
+
216
+ return out;
217
+ }
218
+
219
+ /**
220
+ * Computes the 3D Solar Envelope / Right-to-Light maximum buildable heights and floor capacities
221
+ * based on Knowles (1981) "Sun Rhythm Form" and the BRE Right-to-Light principles.
222
+ *
223
+ * @param {object} params
224
+ * @param {Float32Array|Array<number>} params.buildingHeights Height of existing buildings in metres
225
+ * @param {Int32Array|Float32Array|Array<number>|null} [params.ids] Optional feature ID grid
226
+ * @param {number} params.cols Number of grid columns
227
+ * @param {number} params.rows Number of grid rows
228
+ * @param {number} params.cell Cell size in metres
229
+ * @param {Array<{ dir: [number, number, number], weight?: number }>} [params.sunVectors]
230
+ * Sun directions [east (+col), up (+height), south (+row / -north)] with duration weights in hours
231
+ * @param {number} [params.minSunHours] Minimum hours of direct sunlight required at sensitive receptors (default 2)
232
+ * @param {number} [params.stepHours] Nominal time duration of each sun vector step (default 0.5 h)
233
+ * @param {number} [params.maxBuildableHeight] Global height limit cap in metres (default 80 m)
234
+ * @param {number} [params.floorHeight] Typical story / floor height in metres (default 3.2 m)
235
+ * @param {Uint8Array|Float32Array|Array<number>|null} [params.targetMask] Development site mask (cells to be built, not receptors)
236
+ * @returns {{ maxHeight: Float32Array, maxFloors: Float32Array }}
237
+ */
238
+ export function solarEnvelope({
239
+ buildingHeights,
240
+ ids = null,
241
+ cols,
242
+ rows,
243
+ cell,
244
+ sunVectors = [],
245
+ minSunHours = 2,
246
+ stepHours = 0.5,
247
+ maxBuildableHeight = 80,
248
+ floorHeight = 3.2,
249
+ targetMask = null
250
+ }) {
251
+ const maxHeight = new Float32Array(cols * rows).fill(maxBuildableHeight);
252
+ const maxFloors = new Float32Array(cols * rows);
253
+
254
+ if (cols <= 0 || rows <= 0 || cell <= 0) {
255
+ return { maxHeight, maxFloors };
256
+ }
257
+
258
+ const bHeights = buildingHeights || new Float32Array(cols * rows);
259
+
260
+ // 1. Identify existing building cells outside the development target site.
261
+ const isBuilding = new Uint8Array(cols * rows);
262
+ let buildingCount = 0;
263
+ for (let i = 0; i < cols * rows; i++) {
264
+ if ((bHeights[i] > 0 || (ids && ids[i] > 0)) && (!targetMask || !targetMask[i])) {
265
+ isBuilding[i] = 1;
266
+ buildingCount++;
267
+ }
268
+ }
269
+
270
+ if (buildingCount === 0) {
271
+ for (let i = 0; i < cols * rows; i++) {
272
+ maxFloors[i] = Math.max(0, Math.floor(maxHeight[i] / floorHeight));
273
+ }
274
+ return { maxHeight, maxFloors };
275
+ }
276
+
277
+ // 2. Identify sensitive receptors: perimeter facades of existing buildings and immediate base cells.
278
+ /** @type {Array<{ c: number, r: number, height: number }>} */
279
+ const receptors = [];
280
+ const isReceptor = new Uint8Array(cols * rows);
281
+
282
+ for (let r = 0; r < rows; r++) {
283
+ for (let c = 0; c < cols; c++) {
284
+ const i = r * cols + c;
285
+ if (targetMask && targetMask[i]) continue;
286
+ if (isBuilding[i]) {
287
+ let isPerim = (c === 0 || c === cols - 1 || r === 0 || r === rows - 1);
288
+ if (!isPerim) {
289
+ if (!isBuilding[i - 1] || !isBuilding[i + 1] || !isBuilding[i - cols] || !isBuilding[i + cols]) {
290
+ isPerim = true;
291
+ }
292
+ }
293
+ if (isPerim && !isReceptor[i]) {
294
+ isReceptor[i] = 1;
295
+ receptors.push({ c, r, height: 0 });
296
+ }
297
+ }
298
+ }
299
+ }
300
+
301
+ // Add immediately adjacent open base perimeter cells
302
+ const recInitialCount = receptors.length;
303
+ for (let k = 0; k < recInitialCount; k++) {
304
+ const rc = receptors[k].c;
305
+ const rr = receptors[k].r;
306
+ for (let dr = -1; dr <= 1; dr++) {
307
+ for (let dc = -1; dc <= 1; dc++) {
308
+ const nc = rc + dc;
309
+ const nr = rr + dr;
310
+ if (nc >= 0 && nc < cols && nr >= 0 && nr < rows) {
311
+ const ni = nr * cols + nc;
312
+ if (!isBuilding[ni] && (!targetMask || !targetMask[ni]) && !isReceptor[ni]) {
313
+ isReceptor[ni] = 1;
314
+ receptors.push({ c: nc, r: nr, height: 0 });
315
+ }
316
+ }
317
+ }
318
+ }
319
+ }
320
+
321
+ // 3. Filter sun vectors above horizon and select active solar access window
322
+ let activeVectors = sunVectors.filter(sv => sv && sv.dir && sv.dir[1] > 0.05);
323
+ if (activeVectors.length === 0) {
324
+ // Default 45 deg solar noon vector from south if none provided
325
+ activeVectors = [{ dir: [0, Math.SQRT1_2, Math.SQRT1_2], weight: minSunHours }];
326
+ }
327
+
328
+ // If total hours exceed minSunHours, prioritize the peak hours (highest solar elevation)
329
+ let totalHours = 0;
330
+ for (let k = 0; k < activeVectors.length; k++) {
331
+ totalHours += (activeVectors[k].weight || stepHours);
332
+ }
333
+
334
+ if (totalHours > minSunHours) {
335
+ activeVectors.sort((a, b) => b.dir[1] - a.dir[1]);
336
+ let cum = 0;
337
+ /** @type {typeof activeVectors} */
338
+ const subset = [];
339
+ for (let k = 0; k < activeVectors.length; k++) {
340
+ subset.push(activeVectors[k]);
341
+ cum += (activeVectors[k].weight || stepHours);
342
+ if (cum >= minSunHours) break;
343
+ }
344
+ activeVectors = subset;
345
+ }
346
+
347
+ // 4. Project solar envelope constraints from every receptor towards each active sun vector
348
+ for (let s = 0; s < activeVectors.length; s++) {
349
+ const sv = activeVectors[s];
350
+ const dx = sv.dir[0];
351
+ const dy = sv.dir[1];
352
+ const dz = sv.dir[2];
353
+ const ux = dx; // +x = +col (east)
354
+ const uy = -dz; // -z = +north; so +uy = +north = -row
355
+ const hLen = Math.hypot(ux, uy);
356
+ if (hLen < 1e-5) continue;
357
+
358
+ const hx = ux / hLen;
359
+ const hy = uy / hLen;
360
+ const tanAlt = dy / hLen;
361
+ const maxD = maxBuildableHeight / tanAlt;
362
+ const stepDist = cell * 0.5;
363
+
364
+ for (let k = 0; k < receptors.length; k++) {
365
+ const rec = receptors[k];
366
+ const recIdx = rec.r * cols + rec.c;
367
+ const recId = ids ? ids[recIdx] : 0;
368
+ // Cells along the ray towards the sun, past the receptor's own cell and
369
+ // its own building (a facade is not shaded by the building behind it).
370
+ /** @type {Array<{ idx: number, allowedH: number }>} */
371
+ const path = [];
372
+ let ownBuilding = isBuilding[recIdx] === 1;
373
+ let shaded = false;
374
+ for (let d = stepDist; d <= maxD; d += stepDist) {
375
+ const ic = Math.round(rec.c + (hx * d) / cell);
376
+ const ir = Math.round(rec.r - (hy * d) / cell); // +north is -row
377
+ if (ic < 0 || ic >= cols || ir < 0 || ir >= rows) break;
378
+ const targetIdx = ir * cols + ic;
379
+ if (targetIdx === recIdx) continue;
380
+ if (ownBuilding) {
381
+ if (isBuilding[targetIdx] && (!ids || !recId || ids[targetIdx] === recId)) continue;
382
+ ownBuilding = false;
383
+ }
384
+ const actualDist = Math.hypot((ic - rec.c) * cell, (ir - rec.r) * cell);
385
+ const allowedH = rec.height + actualDist * tanAlt;
386
+ // An existing building already takes this sun from the receptor:
387
+ // nothing more is lost by building here, so this ray sets no limit.
388
+ if (isBuilding[targetIdx] && bHeights[targetIdx] > allowedH) { shaded = true; break; }
389
+ path.push({ idx: targetIdx, allowedH });
390
+ }
391
+ if (shaded) continue;
392
+ for (const { idx, allowedH } of path) {
393
+ if (allowedH < maxHeight[idx]) maxHeight[idx] = allowedH;
394
+ }
395
+ }
396
+ }
397
+
398
+ // 5. Clamp results and calculate maximum buildable floor counts
399
+ for (let i = 0; i < cols * rows; i++) {
400
+ maxHeight[i] = Math.max(0, Math.min(maxBuildableHeight, maxHeight[i]));
401
+ maxFloors[i] = Math.max(0, Math.floor(maxHeight[i] / floorHeight));
402
+ }
403
+
404
+ return { maxHeight, maxFloors };
405
+ }