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,294 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// "Compare" tab: scenario A/B switch and difference analyses, and the
|
|
3
|
+
// building-height optimiser.
|
|
4
|
+
import { h, clear, row, select, number, button, card, stats, toast, download, toggle } from './dom.js';
|
|
5
|
+
import { ExtrudedLayer } from '../scene/objects.js';
|
|
6
|
+
import { optimiseHeights } from '../analysis/optimize.js';
|
|
7
|
+
import { planningCheck } from '../analysis/compliance.js';
|
|
8
|
+
import { drawScatter } from './charts.js';
|
|
9
|
+
import { sendFields } from './qgis_link.js';
|
|
10
|
+
import { gridStats } from '../core/terrain_math.js';
|
|
11
|
+
import { MONTH_NAMES } from '../core/weather.js';
|
|
12
|
+
import { solarOptions, comfortConditions } from './panel_analyze.js';
|
|
13
|
+
|
|
14
|
+
export function buildComparePanel(app, root) {
|
|
15
|
+
clear(root);
|
|
16
|
+
root.appendChild(scenarioCard(app));
|
|
17
|
+
root.appendChild(optimiseCard(app));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function scenarioLayers(app) {
|
|
21
|
+
return {
|
|
22
|
+
a: app.layers.filter((l) => l.entry.scenario === 'A'),
|
|
23
|
+
b: app.layers.filter((l) => l.entry.scenario === 'B')
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function showScenario(app, which) {
|
|
28
|
+
const { a, b } = scenarioLayers(app);
|
|
29
|
+
for (const l of a) l.setVisible(which === 'A');
|
|
30
|
+
for (const l of b) l.setVisible(which === 'B');
|
|
31
|
+
app.analyst.cache.clear();
|
|
32
|
+
app.scenario = which;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function scenarioCard(app) {
|
|
36
|
+
const { a, b } = scenarioLayers(app);
|
|
37
|
+
if (!a.length && !b.length) {
|
|
38
|
+
return card('Scenarios', h('p', { class: 'muted', text: 'To compare two plans, set building layers to "Scenario A" and "Scenario B" in the QGIS panel (step 2). Layers marked "Existing" appear in both.' }), { open: true });
|
|
39
|
+
}
|
|
40
|
+
const state = { kind: 'sun' };
|
|
41
|
+
const info = h('div', {});
|
|
42
|
+
const runPair = async () => {
|
|
43
|
+
const p = app.params;
|
|
44
|
+
const runners = {
|
|
45
|
+
sun: (prog) => app.analyst.sunHours({ area: app.area(), cell: app.cell(), date: { month: p.sun.month, day: p.sun.day },
|
|
46
|
+
startHour: p.sun.start, endHour: p.sun.end, stepMinutes: p.sun.step, onProgress: prog }),
|
|
47
|
+
svf: (prog) => app.analyst.skyView({ area: app.area(), cell: app.cell(), directions: p.svf.directions, onProgress: prog }),
|
|
48
|
+
solar: (prog) => app.analyst.solarRadiation({ ...solarOptions(app), onProgress: prog }),
|
|
49
|
+
wind: (prog) => app.analyst.wind({ area: app.area(), cell: Math.max(2, app.cell(600)), windFromDeg: p.wind.from, speed10: p.wind.speed, onProgress: prog }),
|
|
50
|
+
vsc: (prog) => app.analyst.daylightVsc({ area: app.area(), onProgress: prog }),
|
|
51
|
+
comfort: (prog) => app.analyst.comfort({ area: app.area(), cell: Math.max(1, app.cell(700)), month: p.comfort.month, day: p.comfort.day, hour: p.comfort.hour, conditions: comfortConditions(app), material: p.comfort.material, lai: p.comfort.lai, onProgress: prog }),
|
|
52
|
+
vent: (prog) => app.analyst.ventilation({ area: app.area(), cell: Math.max(2, app.cell(600)), windFromDeg: p.vent.from, speed10: p.vent.speed, emissionRateUgMS: p.vent.emission, backgroundUgM3: p.vent.background, treeDepositionFactor: p.vent.deposition, onProgress: prog }),
|
|
53
|
+
noise: (prog) => app.analyst.noise({ area: app.area(), cell: Math.max(2, app.cell(600)), soundPowerDb: p.noise.power, receiverHeight: p.noise.receiverHeight, groundFactor: p.noise.ground, onProgress: prog }),
|
|
54
|
+
gvi: (prog) => app.analyst.greenView({ area: app.area(), cell: Math.max(2, app.cell(600)), eyeHeight: p.form.eyeHeight, maxRadius: p.form.radius, onProgress: prog })
|
|
55
|
+
};
|
|
56
|
+
const before = app.scenario || 'A';
|
|
57
|
+
await app.run('Scenario comparison', async (prog) => {
|
|
58
|
+
showScenario(app, 'A');
|
|
59
|
+
const ra = (await runners[state.kind]((f) => prog(f / 2)))[0];
|
|
60
|
+
showScenario(app, 'B');
|
|
61
|
+
const rb = (await runners[state.kind]((f) => prog(0.5 + f / 2)))[0];
|
|
62
|
+
showScenario(app, before);
|
|
63
|
+
if (!ra.grid) return [pairOnWalls(app, ra, rb, state.kind), { ...ra, title: `A: ${ra.title}`, fields: [] }, { ...rb, title: `B: ${rb.title}`, fields: [] }];
|
|
64
|
+
const diff = new Float32Array(ra.values.length).fill(NaN);
|
|
65
|
+
let gain = 0, loss = 0, n = 0;
|
|
66
|
+
const tol = state.kind === 'svf' ? 0.02 : state.kind === 'wind' ? 0.2 : state.kind === 'solar' ? 20 : state.kind === 'comfort' ? 0.5 : state.kind === 'noise' ? 1.0 : state.kind === 'vent' ? 0.05 : state.kind === 'gvi' ? 1.0 : 0.25;
|
|
67
|
+
for (let i = 0; i < diff.length; i++) {
|
|
68
|
+
if (!Number.isFinite(ra.values[i]) || !Number.isFinite(rb.values[i])) continue;
|
|
69
|
+
// Only open ground in both: a new building's footprint is not a "change of sun".
|
|
70
|
+
if ((ra.ids && ra.ids[i]) || (rb.ids && rb.ids[i])) continue;
|
|
71
|
+
diff[i] = rb.values[i] - ra.values[i];
|
|
72
|
+
n++;
|
|
73
|
+
if (diff[i] > tol) gain++; else if (diff[i] < -tol) loss++;
|
|
74
|
+
}
|
|
75
|
+
const st = gridStats(diff);
|
|
76
|
+
const m = Math.max(Math.abs(st.min), Math.abs(st.max), tol * 2);
|
|
77
|
+
// Per building: the value in A, in B and the change, so the report can
|
|
78
|
+
// say what the proposal does to each existing building.
|
|
79
|
+
const perBuilding = [];
|
|
80
|
+
for (const fa of ra.fields || []) {
|
|
81
|
+
const fb = (rb.fields || []).find((x) => x.layer === fa.layer && x.name === fa.name);
|
|
82
|
+
if (!fb) continue;
|
|
83
|
+
const d = new Float32Array(fa.values.length).fill(NaN);
|
|
84
|
+
for (let i = 0; i < d.length; i++) if (Number.isFinite(fa.values[i]) && Number.isFinite(fb.values[i])) d[i] = fb.values[i] - fa.values[i];
|
|
85
|
+
perBuilding.push({ layer: fa.layer, name: `${fa.name}_A`, values: fa.values }, { layer: fa.layer, name: `${fa.name}_B`, values: fb.values },
|
|
86
|
+
{ layer: fa.layer, name: `d_${fa.name}`, values: d });
|
|
87
|
+
}
|
|
88
|
+
for (const fb of rb.fields || []) {
|
|
89
|
+
if (!(ra.fields || []).some((x) => x.layer === fb.layer && x.name === fb.name)) perBuilding.push({ layer: fb.layer, name: `${fb.name}_B`, values: fb.values });
|
|
90
|
+
}
|
|
91
|
+
app.lastScenario = { kind: state.kind, title: ra.title };
|
|
92
|
+
return [{
|
|
93
|
+
kind: `diff_${state.kind}`, title: `B − A: ${ra.title}`, subtitle: 'Open ground in both scenarios; blue = more in B', unit: ra.unit,
|
|
94
|
+
grid: ra.grid, values: diff, display: { min: -m, max: m, ramp: (state.kind === 'wind' || state.kind === 'comfort' || state.kind === 'noise') ? 'rdbu-r' : 'rdbu' }, fields: perBuilding,
|
|
95
|
+
stats: [['Mean change', `${st.mean.toFixed(2)} ${ra.unit}`], ['Area gaining', `${n ? ((100 * gain) / n).toFixed(0) : 0} %`],
|
|
96
|
+
['Area losing', `${n ? ((100 * loss) / n).toFixed(0) : 0} %`]],
|
|
97
|
+
notes: `Uses the parameters of the ${state.kind} card in Analyze. Changes smaller than ${tol} ${ra.unit} count as no change.`
|
|
98
|
+
}, { ...ra, title: `A: ${ra.title}`, fields: [] }, { ...rb, title: `B: ${rb.title}`, fields: [] }];
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
const body = h('div', {},
|
|
102
|
+
stats([['Scenario A layers', a.map((l) => l.entry.name).join(', ') || '–'], ['Scenario B layers', b.map((l) => l.entry.name).join(', ') || '–']]),
|
|
103
|
+
row('Show', select([['A', 'Existing + A'], ['B', 'Existing + B']], app.scenario || 'A', (v) => showScenario(app, v))),
|
|
104
|
+
row('Compare', select([['sun', 'Sun hours'], ['svf', 'Sky view factor'], ['vsc', 'Daylight on facades (VSC)'], ['solar', 'Solar irradiation'], ['wind', 'Pedestrian wind'], ['comfort', 'Thermal comfort (UTCI)'], ['vent', 'Canyon ventilation (VEI)'], ['noise', 'Environmental noise (Lden)'], ['gvi', 'Green View Index (GVI)']], state.kind, (v) => { state.kind = v; })),
|
|
105
|
+
h('div', { class: 'btn-row' }, button('Run A vs B', runPair, { kind: 'primary run' })),
|
|
106
|
+
info);
|
|
107
|
+
app.scenario = 'A';
|
|
108
|
+
return card('Scenarios', body, { open: true });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Per-building fields of a pair of wall results (A, B, change) and the BRE 0.8 test for daylight. */
|
|
112
|
+
function pairOnWalls(app, ra, rb, kind) {
|
|
113
|
+
const fields = [];
|
|
114
|
+
let losing = 0, compared = 0;
|
|
115
|
+
for (const fa of ra.fields || []) {
|
|
116
|
+
const fb = (rb.fields || []).find((x) => x.layer === fa.layer && x.name === fa.name);
|
|
117
|
+
if (!fb) continue;
|
|
118
|
+
const d = new Float32Array(fa.values.length).fill(NaN);
|
|
119
|
+
const ratio = new Float32Array(fa.values.length).fill(NaN);
|
|
120
|
+
for (let i = 0; i < d.length; i++) {
|
|
121
|
+
if (!Number.isFinite(fa.values[i]) || !Number.isFinite(fb.values[i])) continue;
|
|
122
|
+
d[i] = fb.values[i] - fa.values[i];
|
|
123
|
+
if (fa.values[i] > 0) ratio[i] = fb.values[i] / fa.values[i];
|
|
124
|
+
if (fa.name === 'vsc_facade') { compared++; if (ratio[i] < 0.8) losing++; }
|
|
125
|
+
}
|
|
126
|
+
fields.push({ layer: fa.layer, name: `${fa.name}_A`, values: fa.values }, { layer: fa.layer, name: `${fa.name}_B`, values: fb.values },
|
|
127
|
+
{ layer: fa.layer, name: `d_${fa.name}`, values: d });
|
|
128
|
+
if (fa.name === 'vsc_facade') fields.push({ layer: fa.layer, name: 'vsc_ratio_BA', values: ratio });
|
|
129
|
+
}
|
|
130
|
+
app.lastScenario = { kind, title: ra.title };
|
|
131
|
+
// Range of the per-building change, for legends and exports.
|
|
132
|
+
let m = 0;
|
|
133
|
+
for (const f of fields) if (f.name.startsWith('d_')) for (const v of f.values) if (Number.isFinite(v)) m = Math.max(m, Math.abs(v));
|
|
134
|
+
return {
|
|
135
|
+
kind: `diff_${kind}`, title: `B − A: ${ra.title}`, subtitle: 'Per building: value in A, in B and the change', unit: ra.unit, fields,
|
|
136
|
+
display: { min: -(m || 1), max: m || 1, ramp: 'rdbu' },
|
|
137
|
+
stats: kind === 'vsc' ? [['Buildings compared', String(compared)], ['Losing more than a fifth of their VSC (BRE 209)', `${losing}`]] : [['Buildings compared', String(compared)]],
|
|
138
|
+
notes: 'BRE 209: a window left with less than 0.8 times its former vertical sky component loses daylight noticeably. The planning check reads vsc_facade_A and vsc_facade_B for its "daylight kept after scenario B" test.'
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function optimiseCard(app) {
|
|
143
|
+
const o = { floorHeight: 3.2, minFloors: 1, maxFloors: 12, minGfa: 0, month: 12, day: 21, startHour: 8, endHour: 16, margin: 60,
|
|
144
|
+
populationSize: 16, generations: 12, seed: 1,
|
|
145
|
+
// Constraints; the sun share starts at the planning profile's amenity threshold.
|
|
146
|
+
amenityOn: false, amenityPct: app.params?.check?.thresholds?.amenity_sun ?? 50, daylightOn: false };
|
|
147
|
+
const chart = h('canvas', { class: 'chart' });
|
|
148
|
+
const info = h('div', {});
|
|
149
|
+
const progressEl = h('div', { class: 'muted small' });
|
|
150
|
+
let last = null;
|
|
151
|
+
let stop = false;
|
|
152
|
+
let selected = null;
|
|
153
|
+
const targetsNow = () => {
|
|
154
|
+
const out = [];
|
|
155
|
+
for (const layer of app.layers) {
|
|
156
|
+
if (!(layer instanceof ExtrudedLayer) || layer.isWall) continue;
|
|
157
|
+
for (const f of app.selectedIndices(layer)) out.push({ layer, f });
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
};
|
|
161
|
+
const targetInfo = h('div', { class: 'muted small' });
|
|
162
|
+
const refreshTargets = () => {
|
|
163
|
+
const t = targetsNow();
|
|
164
|
+
const gfaNow = t.reduce((s, { layer, f }) => s + Math.max(1, Math.round(layer.heights[f] / o.floorHeight)) * layer.footprint[f], 0);
|
|
165
|
+
targetInfo.textContent = t.length ? `${t.length} selected building(s), floor area now ≈ ${Math.round(gfaNow).toLocaleString('en-US')} m²` :
|
|
166
|
+
'Select the buildings to optimise: click them (Shift+click adds) or select them in QGIS.';
|
|
167
|
+
};
|
|
168
|
+
app.on((w) => { if (w === 'selection') refreshTargets(); });
|
|
169
|
+
refreshTargets();
|
|
170
|
+
const apply = (ind) => {
|
|
171
|
+
if (!last) return;
|
|
172
|
+
selected = ind;
|
|
173
|
+
const fields = new Map();
|
|
174
|
+
last.targets.forEach(({ layer, f }, i) => {
|
|
175
|
+
layer.heights[f] = ind.x[i] * o.floorHeight;
|
|
176
|
+
if (!fields.has(layer)) fields.set(layer, { floors: new Float32Array(layer.count).fill(NaN), h: new Float32Array(layer.count).fill(NaN) });
|
|
177
|
+
fields.get(layer).floors[f] = ind.x[i];
|
|
178
|
+
fields.get(layer).h[f] = ind.x[i] * o.floorHeight;
|
|
179
|
+
});
|
|
180
|
+
for (const layer of fields.keys()) layer.applyHeightsFor(last.targets.filter((t) => t.layer === layer).map((t) => t.f));
|
|
181
|
+
last.fields = [];
|
|
182
|
+
for (const [layer, v] of fields) {
|
|
183
|
+
layer.setResultField('opt_floors', v.floors);
|
|
184
|
+
layer.setResultField('opt_height_m', v.h);
|
|
185
|
+
last.fields.push({ layer, name: 'opt_floors', values: v.floors }, { layer, name: 'opt_height_m', values: v.h });
|
|
186
|
+
}
|
|
187
|
+
app.analyst.cache.clear();
|
|
188
|
+
// Kept for the building report and its PDF.
|
|
189
|
+
app.lastOptimisation = { targets: last.targets, now: last.baseline, design: ind, floorHeight: o.floorHeight,
|
|
190
|
+
date: { month: o.month, day: o.day }, hours: [o.startHour, o.endHour] };
|
|
191
|
+
draw();
|
|
192
|
+
clear(info);
|
|
193
|
+
const b = last.baseline.extra, e = ind.extra;
|
|
194
|
+
const rows2 = [['Sun on open ground', `${e.sun.toFixed(2)} h (now ${b.sun.toFixed(2)} h)`],
|
|
195
|
+
['Ground with ≥ 2 h sun', `${(e.twoHourShare * 100).toFixed(0)} % (now ${(b.twoHourShare * 100).toFixed(0)} %)`],
|
|
196
|
+
['Floor area', `${Math.round(e.gfa).toLocaleString('en-US')} m² (now ${Math.round(b.gfa).toLocaleString('en-US')} m²)`]];
|
|
197
|
+
if (e.amenityChecked !== undefined) {
|
|
198
|
+
rows2.push([`Buildings with ≥ ${last.amenityPct} % sunny ground`, `${e.amenityPass} / ${e.amenityChecked} (now ${b.amenityPass} / ${b.amenityChecked})`
|
|
199
|
+
+ (e.amenityWorse ? `; ${e.amenityWorse} below it and under 0.8 × now` : '')]);
|
|
200
|
+
}
|
|
201
|
+
if (e.windowsOver !== undefined) {
|
|
202
|
+
rows2.push(['Neighbour windows losing daylight', `${e.windowsOver} of ${last.windows} (${e.neighboursHit} buildings)`]);
|
|
203
|
+
}
|
|
204
|
+
rows2.push(['Meets the constraints', ind.violation === 0 ? 'yes' : 'no']);
|
|
205
|
+
rows2.push(['Floors', ind.x.join(', ')]);
|
|
206
|
+
info.appendChild(stats(rows2));
|
|
207
|
+
};
|
|
208
|
+
const draw = () => {
|
|
209
|
+
if (!last) return;
|
|
210
|
+
const pts = last.population.map((p, i) => ({ x: p.extra.gfa, y: p.extra.sun, front: p.rank === 0 && p.violation === 0, infeasible: p.violation > 0, id: i }));
|
|
211
|
+
pts.push({ x: last.baseline.extra.gfa, y: last.baseline.extra.sun, front: false, current: true, id: -1 });
|
|
212
|
+
const pick = drawScatter(chart, pts, { xLabel: 'Floor area (m²) →', yLabel: 'Sun on open ground (h) →', selected: selected ? last.population.indexOf(selected) : null });
|
|
213
|
+
chart.onclick = (e) => { const id = pick(e); if (id !== null && id >= 0) apply(last.population[id]); };
|
|
214
|
+
};
|
|
215
|
+
const run = async () => {
|
|
216
|
+
const targets = targetsNow();
|
|
217
|
+
if (!targets.length) { toast('Select buildings first.'); return; }
|
|
218
|
+
if (targets.length > 80) { toast('Select at most 80 buildings.'); return; }
|
|
219
|
+
stop = false;
|
|
220
|
+
const res = await app.run('Height optimisation', async (prog) => {
|
|
221
|
+
const constraints = { amenity: { on: o.amenityOn, share: o.amenityPct / 100 }, daylight: { on: o.daylightOn } };
|
|
222
|
+
const r = await optimiseHeights(app, { ...o, targets, date: { month: o.month, day: o.day }, constraints,
|
|
223
|
+
onGeneration: (g) => { prog(g.generation / g.generations); progressEl.textContent = `Generation ${g.generation}/${g.generations}, ${g.evaluations} designs evaluated, ${g.front.length} on the front`; },
|
|
224
|
+
shouldStop: () => stop });
|
|
225
|
+
last = { ...r, targets, amenityPct: o.amenityPct };
|
|
226
|
+
return [];
|
|
227
|
+
});
|
|
228
|
+
if (res === null || !last) return;
|
|
229
|
+
draw();
|
|
230
|
+
const best = last.front.filter((p) => p.violation === 0).sort((a2, b2) => b2.extra.sun - a2.extra.sun);
|
|
231
|
+
clear(info);
|
|
232
|
+
const constrained = o.amenityOn || o.daylightOn || o.minGfa > 0;
|
|
233
|
+
const feasible = last.population.filter((p) => p.violation === 0).length;
|
|
234
|
+
let msg = `${last.evaluations} designs, ${best.length} on the Pareto front (blue). The ringed point marked "now" is the current design. Click a point to apply it.`;
|
|
235
|
+
if (constrained) {
|
|
236
|
+
msg += feasible ? ` ${feasible} of the final ${last.population.length} meet every constraint; open orange rings break one.`
|
|
237
|
+
: ' No design met every constraint: the orange rings are the closest. Relax a constraint or allow fewer floors.';
|
|
238
|
+
}
|
|
239
|
+
info.appendChild(h('p', { class: 'muted small', text: msg }));
|
|
240
|
+
};
|
|
241
|
+
const body = h('div', {},
|
|
242
|
+
h('p', { class: 'muted small', text: 'Searches floor counts of the selected buildings for the best trade-offs between floor area and sun on the open ground around them (NSGA-II).' }),
|
|
243
|
+
targetInfo,
|
|
244
|
+
row('Floor height (m)', number(o.floorHeight, { min: 2.4, max: 6, step: 0.1, onChange: (v) => { o.floorHeight = v; refreshTargets(); } })),
|
|
245
|
+
row('Floors min / max', h('span', { class: 'inline' }, number(o.minFloors, { min: 1, step: 1, width: '4em', onChange: (v) => { o.minFloors = v; } }),
|
|
246
|
+
number(o.maxFloors, { min: 1, step: 1, width: '4em', onChange: (v) => { o.maxFloors = v; } }))),
|
|
247
|
+
row('Required floor area (m²)', number(o.minGfa, { min: 0, step: 100, onChange: (v) => { o.minGfa = v; } }), '0 = no requirement; designs below it are infeasible'),
|
|
248
|
+
row('Sun on', h('span', { class: 'inline' }, select(MONTH_NAMES.map((m, i) => [i + 1, m]), o.month, (v) => { o.month = Number(v); }),
|
|
249
|
+
number(o.day, { min: 1, max: 31, step: 1, width: '4em', onChange: (v) => { o.day = v; } }))),
|
|
250
|
+
row('Hours', h('span', { class: 'inline' }, number(o.startHour, { min: 0, max: 24, step: 1, width: '4em', onChange: (v) => { o.startHour = v; } }),
|
|
251
|
+
number(o.endHour, { min: 0, max: 24, step: 1, width: '4em', onChange: (v) => { o.endHour = v; } }))),
|
|
252
|
+
row('Ground scored within (m)', number(o.margin, { min: 10, step: 10, onChange: (v) => { o.margin = v; } })),
|
|
253
|
+
h('div', { class: 'muted small', text: 'Constraints (no extra analysis per design, so no slower):' }),
|
|
254
|
+
h('div', { class: 'check-row', title: 'Each selected building: at least this share of the open ground within 15 m gets 2 hours of sun on the day and hours above, or, where it falls short, at least 0.8 times the sunny area now (BRE 209, which uses 21 March and 50 %).' },
|
|
255
|
+
toggle(o.amenityOn, (v) => { o.amenityOn = v; }, 'Sun around each building'),
|
|
256
|
+
h('span', { class: 'muted small', text: '≥' }),
|
|
257
|
+
number(o.amenityPct, { min: 1, max: 100, step: 5, width: '4.5em', onChange: (v) => { if (v > 0) o.amenityPct = Math.min(100, v); } }),
|
|
258
|
+
h('span', { class: 'muted small', text: '% with 2 h' })),
|
|
259
|
+
h('div', { class: 'check-row', title: 'BRE 209: seen from the centre of each neighbouring lowest window, the new buildings stay under 25° above the horizontal, or the window keeps at least 0.8 times its vertical sky component now (taken for a long obstruction, on the safe side).' },
|
|
260
|
+
toggle(o.daylightOn, (v) => { o.daylightOn = v; }, 'Neighbours\u2019 daylight (25° test)')),
|
|
261
|
+
row('Population / generations', h('span', { class: 'inline' }, number(o.populationSize, { min: 8, step: 4, width: '4em', onChange: (v) => { o.populationSize = v; } }),
|
|
262
|
+
number(o.generations, { min: 2, step: 2, width: '4em', onChange: (v) => { o.generations = v; } }))),
|
|
263
|
+
row('Random seed', number(o.seed, { min: 1, step: 1, onChange: (v) => { o.seed = v; } }), 'Same seed and settings give the same result'),
|
|
264
|
+
h('div', { class: 'btn-row' }, button('Optimise', run, { kind: 'primary run' }), button('Stop', () => { stop = true; })),
|
|
265
|
+
progressEl, chart, info,
|
|
266
|
+
h('div', { class: 'btn-row' },
|
|
267
|
+
button('Reset heights', () => {
|
|
268
|
+
if (!last) return;
|
|
269
|
+
last.targets.forEach(({ layer, f }) => { layer.heights[f] = layer.sourceHeights[f]; });
|
|
270
|
+
for (const layer of new Set(last.targets.map((t) => t.layer))) layer.applyHeights();
|
|
271
|
+
selected = null;
|
|
272
|
+
draw();
|
|
273
|
+
}),
|
|
274
|
+
app.qgis ? button('To QGIS', async () => {
|
|
275
|
+
if (!last?.fields?.length) { toast('Apply a design first (click a point).'); return; }
|
|
276
|
+
const names = await sendFields(last.fields, 'viridis');
|
|
277
|
+
toast(`opt_floors and opt_height_m sent to ${names.join(', ')}.`);
|
|
278
|
+
}, { kind: 'primary' }) : null,
|
|
279
|
+
button('Check this design', async () => {
|
|
280
|
+
if (!last || !selected) { toast('Apply a design first (click a point).'); return; }
|
|
281
|
+
const p = app.params?.check || {};
|
|
282
|
+
await app.run('Planning check of the design', (prog) => planningCheck(app, {
|
|
283
|
+
scope: 'targets', targets: last.targets, fresh: true, criteria: p.criteria || ['amenity_sun', 'facade_vsc', 'wind', 'heat'],
|
|
284
|
+
thresholds: p.thresholds || {}, profile: p.name ? `${p.name} · optimised design` : 'Optimised design', onProgress: prog }));
|
|
285
|
+
}, { title: 'Full planning check (sun on 21 March, facade daylight, wind and heat with an EPW) of the applied design, with the planning profile in use' }),
|
|
286
|
+
button('CSV', () => {
|
|
287
|
+
if (!last) return;
|
|
288
|
+
const lines = ['design,on_front,meets_constraints,sun_h,ground_2h_share,floor_area_m2,windows_over_25deg,buildings_with_sunny_ground,floors'];
|
|
289
|
+
last.population.forEach((p, i) => lines.push(`${i},${p.rank === 0},${p.violation === 0},${p.extra.sun.toFixed(3)},${p.extra.twoHourShare.toFixed(3)},${Math.round(p.extra.gfa)},`
|
|
290
|
+
+ `${p.extra.windowsOver ?? ''},${p.extra.amenityPass ?? ''},"${p.x.join(' ')}"`));
|
|
291
|
+
download('height_designs.csv', lines.join('\n'), 'text/csv');
|
|
292
|
+
})));
|
|
293
|
+
return card('Optimise building heights', body);
|
|
294
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// "Layers" tab: scene and weather summary, per-layer colour, filter and height.
|
|
3
|
+
import { h, clear, row, select, number, toggle, button, card, stats, toast } from './dom.js';
|
|
4
|
+
import { applyHeightRule, fieldRange } from './colorize.js';
|
|
5
|
+
import { RAMP_LABELS } from '../core/classify.js';
|
|
6
|
+
import { parseEpw, monthlySummary, windRose } from '../core/weather.js';
|
|
7
|
+
import { drawMonthly, drawWindRose } from './charts.js';
|
|
8
|
+
import { ExtrudedLayer, PointLayer, SurfaceLayer } from '../scene/objects.js';
|
|
9
|
+
import * as THREE from 'three';
|
|
10
|
+
|
|
11
|
+
const ROLE_NAMES = { buildings: 'Buildings', walls: 'Walls', trees: 'Trees', poles: 'Poles', surfaces: 'Surfaces', platforms: 'Platforms', sections: 'Sections', roads: 'Roads' };
|
|
12
|
+
const RAMP_OPTIONS = Object.entries(RAMP_LABELS).flatMap(([k, v]) => [[k, v], [`${k}-r`, `${v} (reversed)`]]);
|
|
13
|
+
|
|
14
|
+
export function buildLayersPanel(app, root) {
|
|
15
|
+
const render = () => {
|
|
16
|
+
clear(root);
|
|
17
|
+
root.appendChild(sceneCard(app, render));
|
|
18
|
+
for (const layer of app.layers) root.appendChild(layerCard(app, layer, render));
|
|
19
|
+
for (const raster of app.manifest.rasters || []) root.appendChild(rasterCard(app, raster));
|
|
20
|
+
};
|
|
21
|
+
app.on((what) => { if (what === 'results' || what === 'weather') render(); });
|
|
22
|
+
render();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sceneCard(app, rerender) {
|
|
26
|
+
const m = app.manifest;
|
|
27
|
+
const w = m.extent.maxX - m.extent.minX;
|
|
28
|
+
const d = m.extent.maxY - m.extent.minY;
|
|
29
|
+
const t = app.terrainStats();
|
|
30
|
+
const body = h('div', {},
|
|
31
|
+
stats([
|
|
32
|
+
['CRS', m.crs.authid],
|
|
33
|
+
['Area', `${(w / 1000).toFixed(2)} × ${(d / 1000).toFixed(2)} km`],
|
|
34
|
+
['Centre', `${app.site.lat.toFixed(4)}°, ${app.site.lon.toFixed(4)}°`],
|
|
35
|
+
['Elevation', m.terrain ? `${t.min.toFixed(0)} – ${t.max.toFixed(0)} m` : 'flat (no DEM)'],
|
|
36
|
+
['Grid', `${m.grid.cols} × ${m.grid.rows}, ${m.grid.cell.toFixed(1)} m`]
|
|
37
|
+
]),
|
|
38
|
+
row('Time zone (UTC +h)', number(app.site.utcOffset, { step: 0.5, onChange: (v) => { app.site.utcOffset = v; app.updateSun(); } }),
|
|
39
|
+
'Clock time used for the sun. An EPW file sets it automatically.'));
|
|
40
|
+
// Weather.
|
|
41
|
+
const wbox = h('div', { class: 'weather' });
|
|
42
|
+
const fileInput = h('input', { type: 'file', accept: '.epw', hidden: true, onchange: async () => {
|
|
43
|
+
const file = fileInput.files?.[0];
|
|
44
|
+
if (!file) return;
|
|
45
|
+
try {
|
|
46
|
+
app.setWeather(parseEpw(await file.text()), file.name);
|
|
47
|
+
toast(`Weather loaded: ${file.name}`);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
toast(e.message, 'error');
|
|
50
|
+
}
|
|
51
|
+
} });
|
|
52
|
+
if (app.weather?.dryBulb) {
|
|
53
|
+
const loc = app.weather.location;
|
|
54
|
+
const rose = windRose(app.weather);
|
|
55
|
+
wbox.appendChild(stats([
|
|
56
|
+
['Weather', `${loc.city || app.weatherSource} (${loc.lat.toFixed(2)}, ${loc.lon.toFixed(2)})`],
|
|
57
|
+
['Prevailing wind', `from ${rose.prevailingDirection}°, ${rose.prevailingSpeed.toFixed(1)} m/s`],
|
|
58
|
+
['Calm hours', `${(rose.calm * 100).toFixed(0)} %`]
|
|
59
|
+
]));
|
|
60
|
+
const monthly = h('canvas', { class: 'chart' });
|
|
61
|
+
const roseCanvas = h('canvas', { class: 'chart' });
|
|
62
|
+
wbox.appendChild(h('div', { class: 'chart-title', text: 'Monthly global radiation (bars) and mean temperature (line)' }));
|
|
63
|
+
wbox.appendChild(monthly);
|
|
64
|
+
wbox.appendChild(h('div', { class: 'chart-title', text: 'Wind rose (frequency by direction and speed)' }));
|
|
65
|
+
wbox.appendChild(roseCanvas);
|
|
66
|
+
requestAnimationFrame(() => { drawMonthly(monthly, monthlySummary(app.weather)); drawWindRose(roseCanvas, rose); });
|
|
67
|
+
} else {
|
|
68
|
+
wbox.appendChild(h('p', { class: 'muted', text: 'No weather file: radiation uses a clear-sky estimate, wind and comfort use the values you enter. Pick an EPW in QGIS or load one here.' }));
|
|
69
|
+
}
|
|
70
|
+
wbox.appendChild(h('div', { class: 'btn-row' }, button('Load EPW file…', () => fileInput.click()), fileInput));
|
|
71
|
+
body.appendChild(wbox);
|
|
72
|
+
void rerender;
|
|
73
|
+
return card('Scene', body, { open: true, badge: `${app.layers.length} layers` });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function layerCard(app, layer, rerender) {
|
|
77
|
+
const rule = app.rules.get(layer);
|
|
78
|
+
const body = h('div', {});
|
|
79
|
+
const fields = layer.fieldNames();
|
|
80
|
+
const numeric = fields.filter((f) => f.type === 'number').map((f) => [f.name, f.result ? `${f.name} (result)` : f.name]);
|
|
81
|
+
const all = fields.map((f) => [f.name, f.name]);
|
|
82
|
+
const c = rule.color;
|
|
83
|
+
const colorRows = h('div', {});
|
|
84
|
+
const refresh = () => { app.recolor(layer); drawColorRows(); };
|
|
85
|
+
const drawColorRows = () => {
|
|
86
|
+
clear(colorRows);
|
|
87
|
+
if (c.mode === 'single') {
|
|
88
|
+
colorRows.appendChild(row('Colour', h('input', { type: 'color', value: c.color, oninput: (e) => { c.color = e.target.value; app.recolor(layer); } })));
|
|
89
|
+
} else if (c.mode === 'graduated') {
|
|
90
|
+
if (!c.field || !numeric.some(([n]) => n === c.field)) c.field = numeric[0]?.[0] || '';
|
|
91
|
+
colorRows.appendChild(row('Field', select(numeric, c.field, (v) => { c.field = v; refresh(); })));
|
|
92
|
+
colorRows.appendChild(row('Method', select([['jenks', 'Natural breaks'], ['quantile', 'Quantile'], ['equal', 'Equal interval']], c.method, (v) => { c.method = v; refresh(); })));
|
|
93
|
+
colorRows.appendChild(row('Classes', select([3, 4, 5, 6, 7, 8, 9], c.classes, (v) => { c.classes = Number(v); refresh(); })));
|
|
94
|
+
colorRows.appendChild(row('Ramp', select(RAMP_OPTIONS, c.ramp, (v) => { c.ramp = v; refresh(); })));
|
|
95
|
+
} else if (c.mode === 'categorized') {
|
|
96
|
+
if (!c.field) c.field = all[0]?.[0] || '';
|
|
97
|
+
colorRows.appendChild(row('Field', select(all, c.field, (v) => { c.field = v; refresh(); })));
|
|
98
|
+
}
|
|
99
|
+
// Filter.
|
|
100
|
+
if (numeric.length && c.mode !== 'source') {
|
|
101
|
+
const f = c.filter;
|
|
102
|
+
if (!f.field) f.field = c.field && numeric.some(([n]) => n === c.field) ? c.field : numeric[0][0];
|
|
103
|
+
const [lo, hi] = fieldRange(layer, f.field);
|
|
104
|
+
if (!f.on) { f.min = lo; f.max = hi; }
|
|
105
|
+
const minIn = number(round(f.min), { onChange: (v) => { f.min = v; app.recolor(layer); } });
|
|
106
|
+
const maxIn = number(round(f.max), { onChange: (v) => { f.max = v; app.recolor(layer); } });
|
|
107
|
+
colorRows.appendChild(h('div', { class: 'filter' },
|
|
108
|
+
toggle(f.on, (on) => { f.on = on; app.recolor(layer); }, 'Highlight only'),
|
|
109
|
+
select(numeric, f.field, (v) => { f.field = v; f.on = false; drawColorRows(); app.recolor(layer); }),
|
|
110
|
+
h('span', { class: 'range' }, minIn, h('span', { text: '–' }), maxIn),
|
|
111
|
+
h('span', { class: 'muted small', text: `range ${round(lo)} – ${round(hi)}` })));
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
if (!(layer.entry.role === 'platforms' || layer.entry.role === 'sections' || layer.entry.role === 'roads')) {
|
|
115
|
+
body.appendChild(row('Colour by', select([['source', 'QGIS style'], ['single', 'Single colour'], ['graduated', 'Graduated (numbers)'], ['categorized', 'Categories']],
|
|
116
|
+
c.mode, (v) => { c.mode = v; refresh(); })));
|
|
117
|
+
body.appendChild(colorRows);
|
|
118
|
+
drawColorRows();
|
|
119
|
+
}
|
|
120
|
+
if (layer instanceof ExtrudedLayer || layer instanceof PointLayer) {
|
|
121
|
+
const hr = rule.height;
|
|
122
|
+
const hRows = h('div', {});
|
|
123
|
+
const drawH = () => {
|
|
124
|
+
clear(hRows);
|
|
125
|
+
if (hr.mode === 'field') {
|
|
126
|
+
if (!hr.field) hr.field = numeric[0]?.[0] || '';
|
|
127
|
+
hRows.appendChild(row('Field', select(numeric, hr.field, (v) => { hr.field = v; applyHeightRule(layer, hr); })));
|
|
128
|
+
hRows.appendChild(row('× factor', number(hr.factor, { step: 0.1, onChange: (v) => { hr.factor = v; applyHeightRule(layer, hr); } }),
|
|
129
|
+
'e.g. 3.2 to turn floors into metres, or a scale for a 3D bar chart'));
|
|
130
|
+
} else if (hr.mode === 'uniform') {
|
|
131
|
+
hRows.appendChild(row('Height (m)', number(hr.value, { step: 0.5, onChange: (v) => { hr.value = v; applyHeightRule(layer, hr); } })));
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
body.appendChild(row('Height', select([['source', 'From QGIS expression'], ['field', 'Field × factor'], ['uniform', 'Uniform']], hr.mode,
|
|
135
|
+
(v) => { hr.mode = v; drawH(); applyHeightRule(layer, hr); app.analyst?.cache.clear(); })));
|
|
136
|
+
body.appendChild(hRows);
|
|
137
|
+
drawH();
|
|
138
|
+
}
|
|
139
|
+
body.appendChild(h('div', { class: 'btn-row' }, button('Zoom to', () => {
|
|
140
|
+
const box = new THREE.Box3().setFromObject(layer.group);
|
|
141
|
+
if (layer instanceof SurfaceLayer || box.isEmpty()) return;
|
|
142
|
+
app.viewer.frame(box);
|
|
143
|
+
})));
|
|
144
|
+
const vis = toggle(layer.visible, (on) => { layer.setVisible(on); app.analyst?.cache.clear(); });
|
|
145
|
+
vis.addEventListener('click', (e) => e.stopPropagation());
|
|
146
|
+
const el = card(layer.entry.name, body, { badge: `${ROLE_NAMES[layer.entry.role] || layer.entry.role} · ${layer.count.toLocaleString('en-US')}`,
|
|
147
|
+
subtitle: layer.entry.scenario && layer.entry.scenario !== 'both' ? `Scenario ${layer.entry.scenario}` : '' });
|
|
148
|
+
el.querySelector('summary').prepend(vis);
|
|
149
|
+
void rerender;
|
|
150
|
+
return el;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function rasterCard(app, raster) {
|
|
154
|
+
const state = { on: false, ramp: 'rdylbu-r', opacity: 0.7 };
|
|
155
|
+
let values = null;
|
|
156
|
+
const apply = async () => {
|
|
157
|
+
if (!state.on) { app.terrain.setDrape(null); return; }
|
|
158
|
+
if (!values) values = new Float32Array(await (await fetch(app.base + raster.file, { cache: 'no-store' })).arrayBuffer());
|
|
159
|
+
app.terrain.setDrape({ values, min: raster.min, max: raster.max, ramp: state.ramp, opacity: state.opacity });
|
|
160
|
+
};
|
|
161
|
+
const body = h('div', {},
|
|
162
|
+
stats([['Range', `${raster.min.toFixed(2)} – ${raster.max.toFixed(2)}`], ['Building field', raster.field]]),
|
|
163
|
+
row('Drape on ground', toggle(state.on, (on) => { state.on = on; apply(); })),
|
|
164
|
+
row('Ramp', select(RAMP_OPTIONS, state.ramp, (v) => { state.ramp = v; apply(); })),
|
|
165
|
+
row('Opacity', number(state.opacity, { min: 0.1, max: 1, step: 0.1, onChange: (v) => { state.opacity = v; apply(); } })),
|
|
166
|
+
h('p', { class: 'muted small', text: `Each building carries the raster's mean over its footprint as "${raster.field}" — colour buildings by it in their layer card.` }));
|
|
167
|
+
return card(`Climate raster: ${raster.name}`, body);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function round(v) {
|
|
171
|
+
return Math.abs(v) >= 100 ? Math.round(v) : Math.round(v * 100) / 100;
|
|
172
|
+
}
|