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,225 @@
1
+ [hidden] { display: none !important; }
2
+ :root {
3
+ --bg: #f4f5f7;
4
+ --panel: #ffffff;
5
+ --ink: #1d2430;
6
+ --muted: #687384;
7
+ --line: #e2e5ea;
8
+ --accent: #1f6feb;
9
+ --accent-ink: #ffffff;
10
+ --accent-soft: #e8f0fe;
11
+ --warn: #b26a00;
12
+ --error: #c62828;
13
+ --radius: 8px;
14
+ --sidebar: 340px;
15
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
16
+ font-size: 13px;
17
+ color: var(--ink);
18
+ }
19
+ * { box-sizing: border-box; }
20
+ html, body { margin: 0; height: 100%; background: var(--bg); overflow: hidden; }
21
+ #app {
22
+ display: grid; height: 100vh;
23
+ grid-template-columns: var(--sidebar) 1fr;
24
+ grid-template-rows: 46px 1fr 28px;
25
+ grid-template-areas: "top top" "side view" "status status";
26
+ }
27
+ #topbar {
28
+ grid-area: top; display: flex; align-items: center; gap: 16px; padding: 0 14px;
29
+ background: var(--panel); border-bottom: 1px solid var(--line);
30
+ }
31
+ .brand { display: flex; align-items: center; gap: 8px; font-weight: 600; white-space: nowrap; }
32
+ .logo { background: var(--accent); color: var(--accent-ink); border-radius: 6px; padding: 2px 6px; font-size: 12px; }
33
+ .scene-title { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 22vw; }
34
+ .sun-clock { display: flex; align-items: center; gap: 8px; margin-left: auto; }
35
+ .clock-icon { color: #e0a100; font-size: 16px; }
36
+ .clock-range { width: 220px; }
37
+ .clock-time { font-variant-numeric: tabular-nums; font-weight: 600; width: 3.2em; }
38
+ .clock-sun { color: var(--muted); min-width: 170px; }
39
+ .fps { color: var(--muted); font-size: 11px; min-width: 110px; text-align: right; }
40
+ .topbar-manual {
41
+ color: var(--muted); text-decoration: none; font-size: 12px; font-weight: 500;
42
+ padding: 3px 8px; border: 1px solid var(--line); border-radius: 4px;
43
+ white-space: nowrap; transition: color 0.15s, border-color 0.15s;
44
+ }
45
+ .topbar-manual:hover { color: var(--accent); border-color: var(--accent); }
46
+ #sidebar { grid-area: side; display: flex; flex-direction: column; background: var(--panel); border-right: 1px solid var(--line); min-height: 0; }
47
+ #tabs { display: flex; border-bottom: 1px solid var(--line); }
48
+ #tabs button {
49
+ flex: 1; border: 0; background: none; padding: 10px 4px; font: inherit; color: var(--muted);
50
+ border-bottom: 2px solid transparent; cursor: pointer;
51
+ }
52
+ #tabs button.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
53
+ #panels { overflow-y: auto; flex: 1; padding: 10px; }
54
+ .panel { display: flex; flex-direction: column; gap: 8px; }
55
+ #view { grid-area: view; position: relative; min-width: 0; min-height: 0; }
56
+ #view canvas { display: block; width: 100%; height: 100%; }
57
+ #statusbar {
58
+ grid-area: status; display: flex; align-items: center; gap: 12px; padding: 0 12px;
59
+ background: var(--panel); border-top: 1px solid var(--line); color: var(--muted); font-size: 12px;
60
+ }
61
+ #statusbar .hint { margin-left: auto; }
62
+ .progress { width: 160px; height: 6px; background: var(--line); border-radius: 3px; overflow: hidden; }
63
+ .progress > span { display: block; height: 100%; width: 0; background: var(--accent); transition: width 0.15s; }
64
+
65
+ /* cards and rows */
66
+ .card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
67
+ .card > summary {
68
+ list-style: none; cursor: pointer; padding: 9px 10px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
69
+ }
70
+ .card > summary::-webkit-details-marker { display: none; }
71
+ .card > summary::before { content: "▸"; color: var(--muted); font-size: 11px; transition: transform 0.15s; }
72
+ .card[open] > summary::before { transform: rotate(90deg); }
73
+ .card-title { font-weight: 600; }
74
+ .card-sub { color: var(--muted); font-size: 11px; width: 100%; padding-left: 18px; }
75
+ .badge { margin-left: auto; color: var(--muted); font-size: 11px; background: var(--bg); border-radius: 10px; padding: 1px 8px; }
76
+ .card-body { padding: 0 10px 10px; display: flex; flex-direction: column; gap: 6px; }
77
+ .row { display: grid; grid-template-columns: 42% 1fr; align-items: center; gap: 8px; }
78
+ .row-label { color: var(--muted); }
79
+ .inline { display: flex; gap: 6px; align-items: center; }
80
+ select, input[type=number], input[type=text] {
81
+ height: 28px;
82
+ font: inherit; padding: 4px 6px; border: 1px solid var(--line); border-radius: 6px; background: #fff; color: var(--ink); min-width: 0; width: 100%;
83
+ }
84
+ .inline select, .inline input { width: auto; }
85
+ input[type=color] { width: 44px; height: 26px; border: 1px solid var(--line); border-radius: 6px; padding: 0; background: none; }
86
+ .slider { display: flex; align-items: center; gap: 8px; }
87
+ .slider input { flex: 1; }
88
+ .slider-value { min-width: 4.5em; text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); }
89
+ .toggle { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
90
+ .btn-row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
91
+ .btn {
92
+ font: inherit; border: 1px solid var(--line); background: #fff; border-radius: 6px; padding: 5px 10px; cursor: pointer; color: var(--ink);
93
+ }
94
+ .btn:hover { border-color: #c7ccd4; background: #fafbfc; }
95
+ .btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
96
+ .btn.primary:hover { filter: brightness(1.06); }
97
+ .btn.on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
98
+ .btn:disabled { opacity: 0.5; cursor: progress; }
99
+ .icon-btn { padding: 3px 8px; }
100
+ .muted { color: var(--muted); }
101
+ .small { font-size: 11px; }
102
+ p { margin: 0; line-height: 1.45; }
103
+ h3.section { margin: 8px 2px 0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
104
+ table.stats { width: 100%; border-collapse: collapse; font-size: 12px; }
105
+ table.stats td { padding: 3px 0; border-bottom: 1px dashed var(--line); vertical-align: top; }
106
+ table.stats td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
107
+ .filter { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; align-items: center; padding: 6px; background: var(--bg); border-radius: 6px; }
108
+ .filter .range { display: flex; gap: 4px; align-items: center; }
109
+ .chart { width: 100%; display: block; }
110
+ .chart-title { color: var(--muted); font-size: 11px; margin-top: 6px; }
111
+ .results { display: flex; flex-direction: column; gap: 8px; }
112
+ .result { border: 1px solid var(--line); border-radius: var(--radius); padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; }
113
+ .result.active { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
114
+ .result-title { font-weight: 600; }
115
+ .note { font-size: 11px; color: var(--muted); background: var(--bg); padding: 6px 8px; border-radius: 6px; line-height: 1.4; }
116
+
117
+ /* floating */
118
+ .legend, .inspector, .tool-hint, .empty {
119
+ position: absolute; background: rgba(255, 255, 255, 0.95); border: 1px solid var(--line); border-radius: var(--radius);
120
+ box-shadow: 0 4px 18px rgba(20, 30, 50, 0.12);
121
+ }
122
+ .legend { left: 12px; bottom: 12px; padding: 10px 12px; min-width: 200px; max-width: 300px; max-height: 50%; overflow-y: auto; }
123
+ .legend-title { font-weight: 600; margin-bottom: 6px; }
124
+ .legend-ramp { height: 12px; border-radius: 3px; }
125
+ .legend-scale { display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; margin-top: 3px; }
126
+ .legend-item { display: flex; align-items: center; gap: 8px; font-size: 12px; margin: 2px 0; }
127
+ .legend-note { color: var(--muted); font-size: 11px; margin-top: 4px; }
128
+ .swatch { width: 14px; height: 14px; border-radius: 3px; flex: none; border: 1px solid rgba(0, 0, 0, 0.1); }
129
+ .inspector { right: 12px; top: 12px; width: 300px; max-height: calc(100% - 24px); overflow-y: auto; padding: 10px 12px; }
130
+ .insp-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
131
+ .insp-sub { margin: 8px 0 2px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
132
+ .close { border: 0; background: none; font-size: 18px; cursor: pointer; color: var(--muted); }
133
+ .tool-hint { left: 50%; top: 12px; transform: translateX(-50%); padding: 7px 14px; font-weight: 500; }
134
+ .empty { left: 50%; top: 40%; transform: translate(-50%, -50%); padding: 24px 28px; text-align: center; max-width: 460px; }
135
+ .empty .detail { color: var(--muted); margin-top: 8px; font-size: 12px; }
136
+ .toast {
137
+ position: absolute; left: 50%; bottom: 18px; transform: translate(-50%, 20px); opacity: 0; pointer-events: none;
138
+ background: #1d2430; color: #fff; padding: 8px 14px; border-radius: 6px; transition: all 0.2s; max-width: 70%; text-align: center;
139
+ }
140
+ .toast.show { opacity: 0.95; transform: translate(-50%, 0); }
141
+ .toast.warn { background: var(--warn); }
142
+ .toast.error { background: var(--error); }
143
+ @media (max-width: 900px) {
144
+ :root { --sidebar: 290px; }
145
+ .clock-range { width: 120px; }
146
+ .clock-sun, .fps, #statusbar .hint { display: none; }
147
+ }
148
+ .sun-clock select { width: auto; flex: none; }
149
+ .sun-clock input[type=number] { width: 4.2em !important; flex: none; text-align: center; }
150
+ #panels { overflow-x: hidden; }
151
+ .slider-value { flex: none; }
152
+ .slider input { min-width: 0; }
153
+ .row > * { min-width: 0; }
154
+ .slider-value { min-width: 3.4em; }
155
+
156
+ /* view tools: minimal icon bar */
157
+ .viewtools {
158
+ position: absolute; right: 12px; top: 12px; display: flex; flex-direction: column; gap: 4px; z-index: 5;
159
+ background: rgba(255, 255, 255, 0.92); border: 1px solid var(--line); border-radius: 10px; padding: 4px;
160
+ box-shadow: 0 4px 18px rgba(20, 30, 50, 0.12);
161
+ }
162
+ .vt-btn {
163
+ width: 34px; height: 34px; border: 0; background: none; border-radius: 7px; cursor: pointer; color: #3a4350;
164
+ display: grid; place-items: center; padding: 0;
165
+ }
166
+ .vt-btn:hover { background: var(--bg); color: var(--ink); }
167
+ .vt-btn.on { background: var(--accent-soft); color: var(--accent); }
168
+ .vt-icon svg { width: 20px; height: 20px; display: block; }
169
+ .vt-sep { height: 1px; background: var(--line); margin: 2px 4px; }
170
+ .viewtools.recording .vt-btn[title="Record a video"] { color: #d6352e; animation: rec 1s infinite; }
171
+ @keyframes rec { 50% { opacity: 0.35; } }
172
+ .vt-pop {
173
+ position: absolute; right: 62px; width: 270px; z-index: 6; background: rgba(255, 255, 255, 0.97);
174
+ border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; box-shadow: 0 8px 28px rgba(20, 30, 50, 0.16);
175
+ display: flex; flex-direction: column; gap: 7px;
176
+ }
177
+ .vt-title { font-weight: 600; }
178
+ .vt-row { display: grid; grid-template-columns: 34% 1fr; align-items: center; gap: 8px; }
179
+ .vt-row > span { color: var(--muted); }
180
+ .vt-checks { display: flex; flex-wrap: wrap; gap: 4px 12px; }
181
+ .vt-actions { display: flex; gap: 6px; flex-wrap: wrap; }
182
+ .vt-note { color: var(--muted); font-size: 11px; line-height: 1.4; }
183
+ .inspector { right: 64px; }
184
+
185
+ /* compare views */
186
+ .compare-layer { position: absolute; inset: 0; pointer-events: none; z-index: 3; }
187
+ .cmp-panel { position: absolute; box-sizing: border-box; }
188
+ .compare-layer.grid .cmp-panel { outline: 1px solid rgba(255, 255, 255, 0.85); }
189
+ .cmp-head { position: absolute; left: 8px; top: 8px; display: flex; gap: 4px; pointer-events: auto; max-width: calc(100% - 60px); }
190
+ .cmp-head select { height: 26px; font-size: 12px; max-width: 220px; background: rgba(255, 255, 255, 0.95); }
191
+ .cmp-head .cmp-scn { width: auto; }
192
+ .cmp-legend { position: absolute; left: 8px; bottom: 8px; width: 150px; background: rgba(255, 255, 255, 0.92); border-radius: 6px; padding: 5px 7px; }
193
+ .cmp-ramp { height: 8px; border-radius: 2px; }
194
+ .cmp-legend.cmp-classes { width: auto; max-width: 170px; font-size: 11px; display: grid; gap: 2px; }
195
+ .cmp-class { display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
196
+ .cmp-class .swatch { width: 10px; height: 10px; border-radius: 2px; flex: none; }
197
+ .cmp-scale { display: flex; justify-content: space-between; font-size: 10px; color: var(--muted); margin-top: 2px; }
198
+ .compare-layer.swipe .cmp-panel.right .cmp-legend { left: auto; right: 8px; }
199
+ .cmp-divider { position: absolute; top: 0; bottom: 0; width: 18px; margin-left: -9px; pointer-events: auto; cursor: ew-resize; touch-action: none; }
200
+ .cmp-divider::before { content: ''; position: absolute; top: 0; bottom: 0; left: 7.5px; width: 3px; background: #fff; box-shadow: 0 0 6px rgba(0, 0, 0, 0.35); }
201
+ .cmp-divider.dragging .cmp-grip { transform: translate(-50%, -50%) scale(1.12); }
202
+ .cmp-grip { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 30px; height: 30px; border-radius: 50%; background: #fff; display: grid; place-items: center; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); font-size: 14px; color: var(--accent); }
203
+ .btn-row.study .slider { flex: 1; }
204
+ .report { display: flex; flex-direction: column; gap: 6px; }
205
+ .rep-row { padding: 4px 0; border-bottom: 1px dashed var(--line); }
206
+ .rep-head { display: flex; justify-content: space-between; gap: 8px; }
207
+ .rep-bar { height: 4px; background: var(--bg); border-radius: 2px; margin: 4px 0 2px; overflow: hidden; }
208
+ .rep-bar i { display: block; height: 100%; background: var(--accent); }
209
+ .stats td.neg { color: #c0392b; }
210
+ .stats td.pos { color: #1f7a3f; }
211
+ .rose-wrap { display: flex; flex-direction: column; align-items: center; gap: 4px; }
212
+ .rose { width: 180px; height: 180px; }
213
+ .btn-row.chips .btn { padding: 2px 8px; font-size: 11px; }
214
+ .stats.seasons td { text-align: right; }
215
+ .stats.seasons td:first-child { text-align: left; }
216
+ .checks { display: flex; flex-direction: column; gap: 4px; }
217
+ .check-row { display: grid; grid-template-columns: 1fr auto auto auto; gap: 6px; align-items: center; }
218
+ .check-table-wrap { overflow-x: auto; max-height: 260px; overflow-y: auto; border: 1px solid var(--line); border-radius: 6px; }
219
+ .check-table { border-collapse: collapse; font-size: 11px; width: 100%; }
220
+ .check-table th { position: sticky; top: 0; background: var(--bg); text-align: left; padding: 4px; font-weight: 600; }
221
+ .check-table td { padding: 3px 4px; border-top: 1px solid var(--line); }
222
+ .check-table tr:hover td { background: var(--accent-soft); cursor: pointer; }
223
+ .check-table td.pass { color: #1f7a3f; font-weight: 700; text-align: center; }
224
+ .check-table td.fail { color: #c0392b; font-weight: 700; text-align: center; }
225
+ .check-table td.na { color: var(--muted); text-align: center; }
@@ -0,0 +1,404 @@
1
+ // @ts-check
2
+ // Building report: everything known about one building (facts, analysis
3
+ // results with their rank among all buildings, attributes), a one-click
4
+ // analysis of its surroundings, and a one-page PDF sheet.
5
+ import * as THREE from 'three';
6
+ import { h, button, toast, download } from './dom.js';
7
+ import { fmt } from '../core/classify.js';
8
+ import { renderImage } from '../export/exporters.js';
9
+ import { PdfPage, wrap } from '../export/pdf.js';
10
+ import { featureKey } from '../scene/objects.js';
11
+ import { seasonNames } from '../analysis/climate.js';
12
+ import { rampStops, rampColor } from '../core/classify.js';
13
+ import { OPEN_PEDESTRIAN_FACTOR } from '../core/wind.js';
14
+
15
+ /** Label, unit and one-line explanation of each result field. */
16
+ export const FIELD_INFO = {
17
+ sun_h_roof: ['Sun on the roof', 'h', 'Direct sun hours on the roof on the analysed day'],
18
+ sun_h_facade: ['Sun on the facades', 'h', 'Direct sun hours on the walls (mean over all facades)'],
19
+ shade_h_roof: ['Roof in shade', 'h', 'Hours the roof is shaded while the sun is up'],
20
+ shade_pct_roof: ['Roof shade share', '%', 'Share of the sunny period the roof is shaded'],
21
+ svf_roof: ['Sky view, roof', '', 'Visible sky from the roof (1 = open)'],
22
+ svf_facade: ['Sky view, facades', '', 'Visible sky from the walls (0.5 = unobstructed wall)'],
23
+ vsc_facade: ['Daylight on the walls (VSC)', '%', 'Mean vertical sky component of the walls; 39.6 % is an open wall, BRE 209 looks for 27 % at a window'],
24
+ vsc_ground: ['Daylight, lowest storey (VSC)', '%', 'Mean VSC of the walls in the lowest 3 m, where daylight is scarcest'],
25
+ vsc27_share: ['Walls at 27 % VSC', '%', 'Share of the wall area with a VSC of 27 % or more (BRE 209)'],
26
+ vsc_ratio_BA: ['Daylight kept under B', '×', 'Wall VSC with scenario B divided by that with A; under 0.8 is a noticeable loss (BRE 209)'],
27
+ roof_kwh_m2: ['Roof irradiation', 'kWh/m²', 'Solar energy reaching the roof per square metre in the period'],
28
+ pv_kwh_yr: ['Rooftop PV yield', 'kWh', 'Electricity from panels on the suitable part of the roof'],
29
+ pv_area_m2: ['Panel area', 'm²', 'Roof area that keeps enough sun for panels'],
30
+ facade_kwh_m2: ['Facade irradiation', 'kWh/m²', 'Solar energy reaching the walls per square metre'],
31
+ facade_pv_kwh_yr: ['Facade PV yield', 'kWh', 'Electricity from panels on the walls'],
32
+ visible_roof_pct: ['Roof visible', '%', 'Share of the roof seen from the observer'],
33
+ lambda_p: ['Built fraction (block)', '', 'Plan area fraction of the surrounding block'],
34
+ h_w: ['Canyon ratio H/W (block)', '', 'Street canyon height-to-width ratio around the building'],
35
+ lcz: ['Local Climate Zone', '', 'Approximate LCZ of the surrounding block'],
36
+ flood_depth_m: ['Flood depth at the door', 'm', 'Water depth at the lowest ground point for the chosen level'],
37
+ opt_floors: ['Optimised floors', '', 'Floor count of the applied optimisation design'],
38
+ comfort_pct: ['Comfortable hours around it', '%', 'Share of the year (chosen hours) with UTCI 9–26 °C on the surrounding open ground'],
39
+ heat_stress_h: ['Heat-stress hours', 'h', 'Hours a year with UTCI of 32 °C or more around the building'],
40
+ cold_stress_h: ['Cold-stress hours', 'h', 'Hours a year with UTCI below 0 °C around the building'],
41
+ ped_wind_ms: ['Pedestrian wind (year)', 'm/s', 'Mean sheltered wind at 1.5 m around the building'],
42
+ windy_pct: ['Windy hours', '%', 'Share of hours with pedestrian wind above 5 m/s'],
43
+ svf_ground: ['Sky view around it', '', 'Mean sky view factor of the surrounding open ground'],
44
+ opt_height_m: ['Optimised height', 'm', 'Height of the applied optimisation design'],
45
+ utci_c: ['Outdoor heat stress (UTCI)', '°C', 'Mean UTCI on the open ground around the building'],
46
+ pet_c: ['Physiological Eq. Temp. (PET)', '°C', 'Mean PET thermal comfort index around the building'],
47
+ tsurf_c: ['Ground surface temperature', '°C', 'Mean ground surface temperature around the building'],
48
+ dt_cool_c: ['Microclimate air cooling (ΔTa)', '°C', 'Local air temperature anomaly from trees and street geometry'],
49
+ vent_index: ['Canyon ventilation (VEI)', '', 'Ventilation efficiency index around the building'],
50
+ air_no2_ugm3: ['Street pollutant conc.', 'µg/m³', 'Modelled traffic pollutant concentration around the building'],
51
+ noise_dba_max: ['Max facade noise (Lden)', 'dB(A)', 'Highest environmental noise level at the building perimeter'],
52
+ noise_dba_mean: ['Mean facade noise (Lden)', 'dB(A)', 'Mean environmental noise level around the building perimeter'],
53
+ quiet_facade: ['Has quiet facade', '', '1 if the building has a quiet side (< 55 dB(A) or ≥ 6 dB quieter than the loudest side)'],
54
+ gvi_pct: ['Green View Index (GVI)', '%', 'Visible green canopy and ground vegetation share from pedestrian level around the building'],
55
+ env_max_h: ['Solar envelope height limit', 'm', 'Maximum height over the footprint that preserves neighbours’ solar access'],
56
+ walk_score: ['Pedestrian walkability (PCWI)', '/100', 'Composite 0–100 cool walkability score (UTCI, shade, GVI, noise and slope) around the building']
57
+ };
58
+
59
+ function infoFor(name) {
60
+ if (FIELD_INFO[name]) return FIELD_INFO[name];
61
+ const sm = name.match(/^(.+)_(djf|mam|jja|son)$/);
62
+ if (sm && FIELD_INFO[sm[1]]) {
63
+ // By months: the season of Dec–Feb depends on the hemisphere.
64
+ const season = { djf: 'Dec–Feb', mam: 'Mar–May', jja: 'Jun–Aug', son: 'Sep–Nov' }[sm[2]];
65
+ const [label, unit, help] = FIELD_INFO[sm[1]];
66
+ return [`${label}, ${season}`, unit, `${help} (${season} only)`];
67
+ }
68
+ const m = name.match(/^(d_)?(.+?)(_A|_B)?$/);
69
+ if (m && (m[1] || m[3]) && FIELD_INFO[m[2]]) {
70
+ const [label, unit, help] = FIELD_INFO[m[2]];
71
+ if (m[1]) return [`${label}: change B − A`, unit, `How scenario B changes it: ${help.toLowerCase()}`];
72
+ return [`${label} (scenario ${m[3].slice(1)})`, unit, help];
73
+ }
74
+ if (name.startsWith('clim_')) return [`${name.slice(5)} (climate raster)`, '', 'Mean of the climate raster over the footprint'];
75
+ return [name, '', ''];
76
+ }
77
+
78
+ /** Share of buildings (with a value) whose value is below this one, 0..100. */
79
+ export function percentileRank(values, v) {
80
+ let below = 0, n = 0;
81
+ for (const x of values) {
82
+ if (!Number.isFinite(x)) continue;
83
+ n++;
84
+ if (x < v) below++;
85
+ }
86
+ return n ? (100 * below) / n : NaN;
87
+ }
88
+
89
+ const FLOOR_FIELDS = ['floors', 'levels', 'building_levels', 'kat', 'kat_sayisi', 'num_floors', 'storeys'];
90
+
91
+ export function buildingFacts(app, layer, f) {
92
+ const props = layer.features[f].properties;
93
+ const lower = Object.fromEntries(Object.keys(props).map((k) => [k.toLowerCase(), k]));
94
+ const floorKey = FLOOR_FIELDS.find((k) => lower[k] && Number.isFinite(Number(props[lower[k]])));
95
+ const floors = floorKey ? Number(props[lower[floorKey]]) : Math.max(1, Math.round(layer.heights[f] / 3));
96
+ const facts = [
97
+ ['Height', `${layer.heights[f].toFixed(1)} m`],
98
+ ['Floors', `${floors}${floorKey ? '' : ' (from the height)'}`],
99
+ ['Footprint', `${Math.round(layer.footprint[f]).toLocaleString('en-US')} m²`],
100
+ ['Floor area (est.)', `${Math.round(layer.footprint[f] * floors).toLocaleString('en-US')} m²`],
101
+ ['Ground level', `${layer.baseZ[f].toFixed(1)} m`]
102
+ ];
103
+ const analyses = [];
104
+ const scenario = [];
105
+ for (const [name, arr] of layer.extraFields) {
106
+ const v = arr[f];
107
+ if (!Number.isFinite(v)) continue;
108
+ const sm = name.match(/^d_(.+)$/);
109
+ if (sm) {
110
+ const a = layer.extraFields.get(`${sm[1]}_A`)?.[f];
111
+ const b = layer.extraFields.get(`${sm[1]}_B`)?.[f];
112
+ const [label, unit] = infoFor(sm[1]);
113
+ scenario.push({ name: sm[1], label, unit, a, b, delta: v });
114
+ continue;
115
+ }
116
+ if (/_(A|B)$/.test(name) && layer.extraFields.has(`d_${name.slice(0, -2)}`)) continue;
117
+ const [label, unit, help] = infoFor(name);
118
+ const rank = percentileRank(arr, v);
119
+ let mean = 0, n = 0;
120
+ for (const x of arr) if (Number.isFinite(x)) { mean += x; n++; }
121
+ analyses.push({ name, label, unit, help, value: v, rank, mean: n ? mean / n : NaN, count: n });
122
+ }
123
+ const attributes = Object.entries(props).filter(([k, v]) => !k.startsWith('_') && v !== null && v !== '').slice(0, 24)
124
+ .map(([k, v]) => [k, typeof v === 'number' ? fmt(v) : String(v)]);
125
+ const name = props.name || props.NAME || props.ad || `#${f + 1}`;
126
+ // Optimisation: this building's floors now and in the applied design.
127
+ let optimisation = null;
128
+ const o = app.lastOptimisation;
129
+ const k = o ? o.targets.findIndex((t) => t.layer === layer && t.f === f) : -1;
130
+ if (o && k >= 0) {
131
+ optimisation = {
132
+ floorsNow: o.now.x[k], floorsDesign: o.design.x[k], floorHeight: o.floorHeight,
133
+ gfaChange: (o.design.x[k] - o.now.x[k]) * layer.footprint[f],
134
+ groundSunNow: o.now.extra.sun, groundSunDesign: o.design.extra.sun,
135
+ areaNow: o.now.extra.gfa, areaDesign: o.design.extra.gfa, targets: o.targets.length, date: o.date
136
+ };
137
+ }
138
+ return { name: String(name), facts, analyses, attributes, scenario, optimisation };
139
+ }
140
+
141
+ /** Report block for the inspector panel. */
142
+ export function reportBlock(app, layer, f) {
143
+ const info = buildingFacts(app, layer, f);
144
+ const wrapEl = h('div', { class: 'report' });
145
+ wrapEl.appendChild(h('table', { class: 'stats' }, ...info.facts.map(([k, v]) => h('tr', {}, h('td', { text: k }), h('td', { text: v })))));
146
+ if (info.analyses.length) {
147
+ wrapEl.appendChild(h('div', { class: 'insp-sub', text: 'Analysis results' }));
148
+ for (const a of info.analyses) {
149
+ wrapEl.appendChild(h('div', { class: 'rep-row', title: a.help },
150
+ h('div', { class: 'rep-head' }, h('span', { text: a.label }), h('b', { text: fmt(a.value, a.unit) })),
151
+ h('div', { class: 'rep-bar' }, h('i', { style: { width: `${Math.max(2, a.rank).toFixed(0)}%` } })),
152
+ h('div', { class: 'muted small', text: `Higher than ${a.rank.toFixed(0)} % of ${a.count} buildings · mean ${fmt(a.mean, a.unit)}` })));
153
+ }
154
+ } else if (!info.scenario.length) {
155
+ wrapEl.appendChild(h('p', { class: 'muted small', text: 'No analysis results for this building yet: press "Analyse this building".' }));
156
+ }
157
+ if (info.scenario.length) {
158
+ wrapEl.appendChild(h('div', { class: 'insp-sub', text: `Scenario impact (B − A)` }));
159
+ wrapEl.appendChild(h('table', { class: 'stats' }, ...info.scenario.map((s) => h('tr', {},
160
+ h('td', { text: s.label }),
161
+ h('td', { class: s.delta < 0 ? 'neg' : s.delta > 0 ? 'pos' : '', text: `${fmtOr(s.a, s.unit)} → ${fmtOr(s.b, s.unit)} (${sign(s.delta)}${fmt(s.delta, s.unit)})` })))));
162
+ }
163
+ const seasons = buildingSeasons(app, layer, f);
164
+ if (seasons) {
165
+ wrapEl.appendChild(h('div', { class: 'insp-sub', text: 'Seasons around it' }));
166
+ wrapEl.appendChild(h('table', { class: 'stats seasons' },
167
+ h('tr', {}, h('td', { text: '' }), ...seasonNames(app.site.lat).map((s) => h('td', { text: s }))),
168
+ h('tr', {}, h('td', { text: 'Comfortable' }), ...seasons.map((x) => h('td', { text: x ? `${x.comfort.toFixed(0)} %` : '–' }))),
169
+ h('tr', {}, h('td', { text: 'Heat stress' }), ...seasons.map((x) => h('td', { text: x ? `${x.heat} h` : '–' }))),
170
+ h('tr', {}, h('td', { text: 'Cold stress' }), ...seasons.map((x) => h('td', { text: x ? `${x.cold} h` : '–' }))),
171
+ h('tr', {}, h('td', { text: 'Wind' }), ...seasons.map((x) => h('td', { text: x ? `${x.wind.toFixed(1)} m/s` : '–' })))));
172
+ }
173
+ const rose = buildingRose(app, layer, f);
174
+ if (rose) {
175
+ wrapEl.appendChild(h('div', { class: 'insp-sub', text: 'Wind around it' }));
176
+ const cv = h('canvas', { class: 'rose', width: 220, height: 220 });
177
+ const note = h('div', { class: 'muted small' });
178
+ const show = (season) => {
179
+ const r2 = buildingRose(app, layer, f, season) || rose;
180
+ const ctx = cv.getContext('2d');
181
+ ctx.clearRect(0, 0, 220, 220);
182
+ drawRose(ctx, r2, 110, 110, 92);
183
+ note.textContent = `Petal length: how often the wind blows from there; colour: the sheltered pedestrian speed beside this building (blue calm, red strong). Calm ${(r2.calm * 100).toFixed(0)} %.`;
184
+ chips.querySelectorAll('button').forEach((b, i) => b.classList.toggle('on', (i === 0 && season === null) || i - 1 === season));
185
+ };
186
+ const chips = h('div', { class: 'btn-row chips' }, button('Year', () => show(null)), ...seasonNames(app.site.lat).map((s, i) => button(s, () => show(i))));
187
+ wrapEl.appendChild(h('div', { class: 'rose-wrap' }, app.climateRoses?.bySeason ? chips : null, cv, note));
188
+ show(null);
189
+ }
190
+ if (info.optimisation) {
191
+ const o = info.optimisation;
192
+ wrapEl.appendChild(h('div', { class: 'insp-sub', text: 'Optimisation (applied design)' }));
193
+ wrapEl.appendChild(h('table', { class: 'stats' },
194
+ h('tr', {}, h('td', { text: 'Floors' }), h('td', { text: `${o.floorsNow} → ${o.floorsDesign}` })),
195
+ h('tr', {}, h('td', { text: 'Floor area change' }), h('td', { text: `${sign(o.gfaChange)}${Math.round(o.gfaChange).toLocaleString('en-US')} m²` })),
196
+ h('tr', {}, h('td', { text: `Sun on open ground (${o.targets} buildings)` }), h('td', { text: `${o.groundSunNow.toFixed(2)} → ${o.groundSunDesign.toFixed(2)} h` }))));
197
+ }
198
+ wrapEl.appendChild(h('div', { class: 'btn-row' },
199
+ button('Analyse this building', () => analyseBuilding(app, layer, f), { kind: 'primary', title: 'Winter sun, sky view and annual solar, with facades, around this building' }),
200
+ button('Report PDF', async () => {
201
+ try {
202
+ const { blob, name } = await buildingPdf(app, layer, f);
203
+ download(`building_${name.replace(/[^A-Za-z0-9_-]+/g, '_')}.pdf`, blob);
204
+ toast('Building report saved.');
205
+ } catch (e) { toast(e.message, 'error'); }
206
+ })));
207
+ return wrapEl;
208
+ }
209
+
210
+ /** Sun (21 Dec), sky view and annual solar around one building, facades included. */
211
+ export async function analyseBuilding(app, layer, f) {
212
+ const x = layer.centroid[f * 2], y = layer.centroid[f * 2 + 1];
213
+ const r = Math.max(120, 2.5 * layer.heights[f], Math.sqrt(layer.footprint[f]) * 2);
214
+ const sc = app.sceneArea();
215
+ const area = { minX: Math.max(sc.minX, x - r), maxX: Math.min(sc.maxX, x + r), minY: Math.max(sc.minY, y - r), maxY: Math.min(sc.maxY, y + r) };
216
+ const prev = { ...app.analysisArea };
217
+ app.analysisArea = { mode: 'rect', rect: area, radius: prev.radius };
218
+ const done = await app.run('Building analysis', async (prog) => {
219
+ const a = app.analyst;
220
+ const sun = await a.sunHours({ area, cell: 1, date: { month: 12, day: 21 }, facades: true, onProgress: (q) => prog(q * 0.3) });
221
+ const svf = await a.skyView({ area, cell: 1, directions: 144, facades: true, onProgress: (q) => prog(0.3 + q * 0.3) });
222
+ const solar = await a.solarRadiation({ area, cell: 1, facades: true, onProgress: (q) => prog(0.6 + q * 0.4) });
223
+ return [...solar, ...svf, ...sun];
224
+ });
225
+ app.analysisArea = prev;
226
+ if (done) app.inspect(layer, f);
227
+ }
228
+
229
+ /** One-page building sheet: { blob, name }. */
230
+ export async function buildingPdf(app, layer, f) {
231
+ const info = buildingFacts(app, layer, f);
232
+ const v = app.viewer;
233
+ const cam = v.camera;
234
+ const saved = { pos: cam.position.clone(), target: v.controls.target.clone() };
235
+ const c = layer.featureCentre(f);
236
+ const size = Math.max(60, 3 * Math.max(layer.heights[f], Math.sqrt(layer.footprint[f])));
237
+ const dir = cam.position.clone().sub(v.controls.target).normalize();
238
+ if (dir.y < 0.35) dir.y = 0.6;
239
+ dir.normalize();
240
+ v.controls.target.copy(c);
241
+ cam.position.copy(c).addScaledVector(dir, size * 1.6);
242
+ cam.lookAt(c);
243
+ const [PW, PH] = [842, 595];
244
+ const margin = 28, side = 260;
245
+ const imgW = PW - margin * 3 - side, imgH = PH - margin * 2 - 64;
246
+ let canvas;
247
+ try {
248
+ canvas = renderImage(app, { width: Math.round(imgW * 2.2), height: Math.round(imgH * 2.2),
249
+ furniture: { title: false, legend: true, north: true, scale: true, credits: false } });
250
+ } finally {
251
+ cam.position.copy(saved.pos);
252
+ v.controls.target.copy(saved.target);
253
+ cam.lookAt(saved.target);
254
+ v.requestRender();
255
+ }
256
+ const jpeg = canvas.toDataURL('image/jpeg', 0.9);
257
+ const bytes = Uint8Array.from(atob(jpeg.split(',')[1]), (ch) => ch.charCodeAt(0));
258
+ const page = new PdfPage(PW, PH);
259
+ const ink = [0.11, 0.14, 0.19], muted = [0.42, 0.45, 0.5];
260
+ page.text(margin, margin + 18, `Building report: ${info.name}`, { size: 18, bold: true, color: ink });
261
+ page.text(margin, margin + 36, `${app.manifest.title} · ${layer.entry.name}`, { size: 10, color: muted });
262
+ page.jpeg(bytes, canvas.width, canvas.height, margin, margin + 50, imgW, imgH);
263
+ page.rect(margin, margin + 50, imgW, imgH, { stroke: [0.8, 0.82, 0.85] });
264
+ const x = margin * 2 + imgW;
265
+ let y = margin + 64;
266
+ const table = (title, rows) => {
267
+ page.text(x, y, title, { size: 10, bold: true, color: ink });
268
+ y += 4;
269
+ for (const [k, val] of rows) {
270
+ if (y > PH - margin - 40) return;
271
+ y += 13;
272
+ page.text(x, y, k, { size: 8.5, color: muted });
273
+ page.text(x + side - Math.min(side * 0.5, 6 + String(val).length * 4.6), y, val, { size: 8.5, color: ink });
274
+ page.line(x, y + 3, x + side, y + 3, { color: [0.88, 0.89, 0.91], width: 0.4 });
275
+ }
276
+ y += 20;
277
+ };
278
+ table('Building', info.facts);
279
+ if (info.analyses.length) {
280
+ page.text(x, y, 'Analysis results', { size: 10, bold: true, color: ink });
281
+ y += 6;
282
+ for (const a of info.analyses) {
283
+ if (y > PH - margin - 60) break;
284
+ y += 12;
285
+ page.text(x, y, a.label, { size: 8.5, color: ink });
286
+ page.text(x + side - Math.min(side * 0.5, 6 + fmt(a.value, a.unit).length * 4.6), y, fmt(a.value, a.unit), { size: 8.5, bold: true, color: ink });
287
+ y += 5;
288
+ page.rect(x, y, side, 4, { fill: [0.9, 0.91, 0.93] });
289
+ page.rect(x, y, (side * Math.max(2, a.rank)) / 100, 4, { fill: [0.12, 0.44, 0.92] });
290
+ y += 12;
291
+ page.text(x, y, `Higher than ${a.rank.toFixed(0)} % of ${a.count} buildings, mean ${fmt(a.mean, a.unit)}`, { size: 7.5, color: muted });
292
+ }
293
+ y += 18;
294
+ } else {
295
+ for (const line of wrap('No analysis results yet. Use "Analyse this building" in the viewer first.', 8.5, side)) { page.text(x, y, line, { size: 8.5, color: muted }); y += 11; }
296
+ y += 10;
297
+ }
298
+ if (info.scenario.length && y < PH - margin - 80) {
299
+ table('Scenario impact (B − A)', info.scenario.map((s) => [s.label, `${fmtOr(s.a, s.unit)} → ${fmtOr(s.b, s.unit)} (${sign(s.delta)}${fmt(s.delta, s.unit)})`]));
300
+ }
301
+ const rose = buildingRose(app, layer, f);
302
+ if (rose && y < PH - margin - 120) {
303
+ page.text(x, y, 'Wind around it (year)', { size: 10, bold: true, color: ink });
304
+ const R = 42, cx = x + 50, cy = y + 56;
305
+ const maxF = Math.max(...rose.petals.map((p) => p.freq), 0.01);
306
+ const width = (2 * Math.PI) / rose.sectors;
307
+ rose.petals.forEach((p, k) => {
308
+ if (!p.freq) return;
309
+ const a = (k * 2 * Math.PI) / rose.sectors - Math.PI / 2;
310
+ const r = (R * p.freq) / maxF;
311
+ const pts = [[cx, cy]];
312
+ for (let t = 0; t <= 6; t++) {
313
+ const ang = a - width / 2 + 0.03 + ((width - 0.06) * t) / 6;
314
+ pts.push([cx + Math.cos(ang) * r, cy + Math.sin(ang) * r]);
315
+ }
316
+ page.polygon(pts, { fill: roseColor(p.speed) });
317
+ });
318
+ page.text(cx - 3, cy - R - 4, 'N', { size: 8, color: muted });
319
+ let ly = y + 22;
320
+ for (const line of wrap(`Petals: how often the wind comes from there. Colour: sheltered pedestrian speed here, blue calm to red ${ROSE_MAX_SPEED} m/s. Calm ${(rose.calm * 100).toFixed(0)} %.`, 7.5, side - 110)) {
321
+ page.text(x + 110, ly, line, { size: 7.5, color: muted });
322
+ ly += 10;
323
+ }
324
+ y += 2 * R + 30;
325
+ }
326
+ const seasonsPdf = buildingSeasons(app, layer, f);
327
+ if (seasonsPdf && y < PH - margin - 90) {
328
+ page.text(x, y, 'Seasons around it', { size: 10, bold: true, color: ink });
329
+ y += 14;
330
+ const colW = (side - 70) / 4;
331
+ seasonNames(app.site.lat).forEach((s, i) => page.text(x + 70 + colW * i, y, s, { size: 7.5, color: muted }));
332
+ const rows2 = [['Comfortable', (v) => `${v.comfort.toFixed(0)} %`], ['Heat stress', (v) => `${v.heat} h`],
333
+ ['Cold stress', (v) => `${v.cold} h`], ['Wind', (v) => `${v.wind.toFixed(1)} m/s`]];
334
+ for (const [label2, fmt2] of rows2) {
335
+ y += 12;
336
+ page.text(x, y, label2, { size: 8, color: muted });
337
+ seasonsPdf.forEach((v, i) => page.text(x + 70 + colW * i, y, v ? fmt2(v) : '–', { size: 8, color: ink }));
338
+ }
339
+ y += 18;
340
+ }
341
+ if (info.optimisation && y < PH - margin - 80) {
342
+ const o = info.optimisation;
343
+ table('Optimisation (applied design)', [['Floors', `${o.floorsNow} → ${o.floorsDesign}`],
344
+ ['Floor area change', `${sign(o.gfaChange)}${Math.round(o.gfaChange)} m²`],
345
+ ['Sun on open ground', `${o.groundSunNow.toFixed(2)} → ${o.groundSunDesign.toFixed(2)} h`]]);
346
+ }
347
+ if (y < PH - margin - 80) table('Attributes', info.attributes.slice(0, Math.floor((PH - margin - 40 - y) / 13) - 1));
348
+ page.line(margin, PH - margin - 8, PW - margin, PH - margin - 8, { color: [0.85, 0.86, 0.88] });
349
+ page.text(margin, PH - margin + 4, `${app.manifest.crs.authid} · 02 Geo3D Analyzer`, { size: 7.5, color: muted });
350
+ page.text(PW - margin - 110, PH - margin + 4, new Date().toISOString().slice(0, 16).replace('T', ' '), { size: 7.5, color: muted });
351
+ const blob = new Blob([page.build({ title: `Building report: ${info.name}` })], { type: 'application/pdf' });
352
+ return { blob, name: info.name };
353
+ }
354
+
355
+ function sign(v) { return v > 0 ? '+' : ''; }
356
+
357
+ /** Wind rose of one building (season: null for the year, or 0-3): EPW sector frequencies, speeds scaled by its shelter. */
358
+ export function buildingRose(app, layer, f, season = null) {
359
+ const c = app.climateRoses;
360
+ const ratios = c?.ratios.get(featureKey(layer.index, f + 1));
361
+ if (!ratios) return null;
362
+ const src = season === null || !c.bySeason ? c : c.bySeason[season];
363
+ return { sectors: c.sectors, calm: src.calm, petals: src.freq.map((fr, k) => ({ freq: fr, speed: src.speed[k] * ratios[k] * OPEN_PEDESTRIAN_FACTOR })) };
364
+ }
365
+
366
+ /** Per-season comfort, heat, cold and wind around one building, or null. */
367
+ export function buildingSeasons(app, layer, f) {
368
+ return app.climateSeasonal?.get(featureKey(layer.index, f + 1)) || null;
369
+ }
370
+
371
+
372
+ const ROSE_MAX_SPEED = 8;
373
+ function roseColor(speed) {
374
+ const c = rampColor(rampStops('spectral-r'), Math.min(1, speed / ROSE_MAX_SPEED), [0, 0, 0]);
375
+ return c;
376
+ }
377
+
378
+ /** Draw a rose on a 2D canvas, petals pointing to where the wind comes from. */
379
+ export function drawRose(ctx, rose, cx, cy, R) {
380
+ const maxF = Math.max(...rose.petals.map((p) => p.freq), 0.01);
381
+ ctx.strokeStyle = 'rgba(120,130,140,0.35)';
382
+ for (const q of [0.5, 1]) { ctx.beginPath(); ctx.arc(cx, cy, R * q, 0, 2 * Math.PI); ctx.stroke(); }
383
+ const width = (2 * Math.PI) / rose.sectors;
384
+ rose.petals.forEach((p, k) => {
385
+ if (!p.freq) return;
386
+ const a = (k * 360) / rose.sectors * (Math.PI / 180) - Math.PI / 2;
387
+ const r = (R * p.freq) / maxF;
388
+ const c = roseColor(p.speed);
389
+ ctx.beginPath();
390
+ ctx.moveTo(cx, cy);
391
+ ctx.arc(cx, cy, r, a - width / 2 + 0.03, a + width / 2 - 0.03);
392
+ ctx.closePath();
393
+ ctx.fillStyle = `rgb(${c.map((v) => Math.round(v * 255)).join(',')})`;
394
+ ctx.fill();
395
+ });
396
+ ctx.fillStyle = '#4a5260';
397
+ ctx.font = '11px system-ui, sans-serif';
398
+ ctx.textAlign = 'center';
399
+ ctx.fillText('N', cx, cy - R - 4);
400
+ ctx.textAlign = 'left';
401
+ }
402
+ function fmtOr(v, unit) { return Number.isFinite(v) ? fmt(v, unit) : '–'; }
403
+
404
+ export { THREE };