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.
- zero2geospatial3d/__init__.py +105 -0
- zero2geospatial3d/acoustics.py +284 -0
- zero2geospatial3d/classify.py +379 -0
- zero2geospatial3d/cli.py +367 -0
- zero2geospatial3d/comfort.py +687 -0
- zero2geospatial3d/data/epw_stations.json.gz +0 -0
- zero2geospatial3d/daylight.py +337 -0
- zero2geospatial3d/dispersion.py +249 -0
- zero2geospatial3d/epw.py +162 -0
- zero2geospatial3d/geo.py +138 -0
- zero2geospatial3d/grid.py +119 -0
- zero2geospatial3d/microclimate.py +388 -0
- zero2geospatial3d/morphology.py +283 -0
- zero2geospatial3d/nsga2.py +315 -0
- zero2geospatial3d/osm.py +450 -0
- zero2geospatial3d/osm_tags.py +180 -0
- zero2geospatial3d/py.typed +0 -0
- zero2geospatial3d/results.py +231 -0
- zero2geospatial3d/road_sources.py +141 -0
- zero2geospatial3d/scene.py +555 -0
- zero2geospatial3d/server.py +332 -0
- zero2geospatial3d/shading.py +443 -0
- zero2geospatial3d/solar.py +207 -0
- zero2geospatial3d/terrain.py +610 -0
- zero2geospatial3d/urban_form.py +326 -0
- zero2geospatial3d/walkability.py +380 -0
- zero2geospatial3d/weather.py +373 -0
- zero2geospatial3d/web/index.html +50 -0
- zero2geospatial3d/web/src/analysis/climate.js +278 -0
- zero2geospatial3d/web/src/analysis/compliance.js +345 -0
- zero2geospatial3d/web/src/analysis/gpu.js +431 -0
- zero2geospatial3d/web/src/analysis/optimize.js +208 -0
- zero2geospatial3d/web/src/analysis/runners.js +1644 -0
- zero2geospatial3d/web/src/analysis/terrain_results.js +145 -0
- zero2geospatial3d/web/src/app.js +630 -0
- zero2geospatial3d/web/src/core/acoustics.js +341 -0
- zero2geospatial3d/web/src/core/classify.js +158 -0
- zero2geospatial3d/web/src/core/comfort.js +686 -0
- zero2geospatial3d/web/src/core/daylight_rule.js +199 -0
- zero2geospatial3d/web/src/core/dispersion.js +264 -0
- zero2geospatial3d/web/src/core/microclimate.js +372 -0
- zero2geospatial3d/web/src/core/morphology.js +153 -0
- zero2geospatial3d/web/src/core/nsga2.js +181 -0
- zero2geospatial3d/web/src/core/party_walls.js +60 -0
- zero2geospatial3d/web/src/core/road_sources.js +127 -0
- zero2geospatial3d/web/src/core/solar.js +112 -0
- zero2geospatial3d/web/src/core/terrain_analysis.js +267 -0
- zero2geospatial3d/web/src/core/terrain_math.js +279 -0
- zero2geospatial3d/web/src/core/urban_form.js +405 -0
- zero2geospatial3d/web/src/core/walkability.js +316 -0
- zero2geospatial3d/web/src/core/weather.js +225 -0
- zero2geospatial3d/web/src/core/wind.js +131 -0
- zero2geospatial3d/web/src/export/check_pdf.js +74 -0
- zero2geospatial3d/web/src/export/exporters.js +556 -0
- zero2geospatial3d/web/src/export/pdf.js +149 -0
- zero2geospatial3d/web/src/export/snapshot.js +234 -0
- zero2geospatial3d/web/src/export/snapshot_scene.js +150 -0
- zero2geospatial3d/web/src/main.js +75 -0
- zero2geospatial3d/web/src/scene/objects.js +645 -0
- zero2geospatial3d/web/src/scene/overlay.js +145 -0
- zero2geospatial3d/web/src/scene/terrain.js +546 -0
- zero2geospatial3d/web/src/scene/viewer.js +275 -0
- zero2geospatial3d/web/src/scene/wind_streamlines.js +336 -0
- zero2geospatial3d/web/src/style.css +225 -0
- zero2geospatial3d/web/src/ui/building_report.js +404 -0
- zero2geospatial3d/web/src/ui/charts.js +441 -0
- zero2geospatial3d/web/src/ui/colorize.js +101 -0
- zero2geospatial3d/web/src/ui/compare.js +362 -0
- zero2geospatial3d/web/src/ui/dom.js +104 -0
- zero2geospatial3d/web/src/ui/panel_analyze.js +742 -0
- zero2geospatial3d/web/src/ui/panel_compare.js +294 -0
- zero2geospatial3d/web/src/ui/panel_layers.js +172 -0
- zero2geospatial3d/web/src/ui/panel_terrain.js +277 -0
- zero2geospatial3d/web/src/ui/qgis_link.js +127 -0
- zero2geospatial3d/web/src/ui/sunclock.js +45 -0
- zero2geospatial3d/web/src/ui/tools.js +197 -0
- zero2geospatial3d/web/src/ui/viewtools.js +173 -0
- zero2geospatial3d/web/vendor/three/addons/controls/OrbitControls.js +1417 -0
- zero2geospatial3d/web/vendor/three/addons/exporters/GLTFExporter.js +3309 -0
- zero2geospatial3d/web/vendor/three/addons/utils/TextureUtils.js +98 -0
- zero2geospatial3d/web/vendor/three/three.module.js +53044 -0
- zero2geospatial3d/wind.py +178 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/METADATA +219 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/RECORD +89 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/WHEEL +5 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +52 -0
- 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
|
+
}
|