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,74 @@
1
+ // @ts-check
2
+ // One-page planning-check sheet: criteria with thresholds and sources, the
3
+ // summary, and a pass / fail table of the buildings (worst first).
4
+ import { PdfPage, wrap } from './pdf.js';
5
+ import { fmt } from '../core/classify.js';
6
+
7
+ const GREEN = [0.12, 0.55, 0.3], RED = [0.78, 0.2, 0.2], MUTED = [0.42, 0.45, 0.5], INK = [0.11, 0.14, 0.19];
8
+
9
+ export function planningSheet(app, result, { title = 'Planning check' } = {}) {
10
+ const [PW, PH] = [842, 595];
11
+ const m = 28;
12
+ const page = new PdfPage(PW, PH);
13
+ page.text(m, m + 18, title, { size: 18, bold: true, color: INK });
14
+ page.text(m, m + 36, `${app.manifest.title} · ${result.rows.length} buildings${result.profile ? ` · Profile: ${result.profile}` : ''} · ${new Date().toISOString().slice(0, 10)}`, { size: 10, color: MUTED });
15
+ // Criteria column.
16
+ const side = 250;
17
+ let y = m + 62;
18
+ page.text(m, y, 'Criteria', { size: 10, bold: true, color: INK });
19
+ y += 6;
20
+ for (const c of result.criteria) {
21
+ y += 13;
22
+ const lim = `${c.test === 'min' ? '≥' : '≤'} ${fmt(c.threshold, c.unit)}`;
23
+ page.text(m, y, `${c.label} ${lim}`, { size: 8.5, bold: true, color: INK });
24
+ for (const line of wrap(c.help, 7.5, side)) { y += 10; page.text(m, y, line, { size: 7.5, color: MUTED }); }
25
+ }
26
+ y += 22;
27
+ page.text(m, y, 'Summary', { size: 10, bold: true, color: INK });
28
+ for (const [k, v] of result.stats) {
29
+ y += 12;
30
+ page.text(m, y, k, { size: 8.5, color: MUTED });
31
+ page.text(m + side - Math.min(side * 0.45, 6 + String(v).length * 4.6), y, v, { size: 8.5, color: INK });
32
+ }
33
+ y += 18;
34
+ for (const line of wrap(result.notes, 7.5, side)) { page.text(m, y, line, { size: 7.5, color: MUTED }); y += 10; }
35
+ // Table of buildings.
36
+ const x0 = m * 2 + side;
37
+ const tableW = PW - x0 - m;
38
+ const nameW = 90, hW = 40;
39
+ const colW = (tableW - nameW - hW) / Math.max(1, result.criteria.length);
40
+ let ty = m + 62;
41
+ page.rect(x0, ty - 10, tableW, 16, { fill: [0.95, 0.96, 0.97] });
42
+ page.text(x0 + 4, ty + 2, 'Building', { size: 8, bold: true, color: INK });
43
+ page.text(x0 + nameW, ty + 2, 'Height', { size: 8, bold: true, color: INK });
44
+ result.criteria.forEach((c, i) => page.text(x0 + nameW + hW + colW * i, ty + 2, c.short, { size: 8, bold: true, color: INK }));
45
+ const maxRows = Math.floor((PH - m - 40 - ty) / 14);
46
+ for (const r of result.rows.slice(0, maxRows)) {
47
+ ty += 14;
48
+ page.line(x0, ty + 4, x0 + tableW, ty + 4, { color: [0.9, 0.91, 0.93], width: 0.4 });
49
+ page.text(x0 + 4, ty + 1, r.name.slice(0, 18), { size: 8, color: INK });
50
+ page.text(x0 + nameW, ty + 1, `${r.height.toFixed(0)} m`, { size: 8, color: MUTED });
51
+ r.checks.forEach((ch, i) => {
52
+ const cx = x0 + nameW + hW + colW * i;
53
+ if (ch.pass === null) { page.text(cx, ty + 1, 'n/a', { size: 8, color: MUTED }); return; }
54
+ page.text(cx, ty + 1, ch.pass ? 'PASS' : 'FAIL', { size: 8, bold: true, color: ch.pass ? GREEN : RED });
55
+ page.text(cx + 26, ty + 1, fmt(ch.value, result.criteria[i].unit), { size: 7.5, color: MUTED });
56
+ });
57
+ }
58
+ if (result.rows.length > maxRows) {
59
+ page.text(x0 + 4, ty + 18, `${result.rows.length - maxRows} more buildings in the CSV (sorted by the number of failed checks).`, { size: 8, color: MUTED });
60
+ }
61
+ page.line(m, PH - m - 8, PW - m, PH - m - 8, { color: [0.85, 0.86, 0.88] });
62
+ page.text(m, PH - m + 4, `${app.manifest.crs.authid} · 02 Geo3D Analyzer · screening proxies, not formal assessments`, { size: 7.5, color: MUTED });
63
+ return new Blob([page.build({ title })], { type: 'application/pdf' });
64
+ }
65
+
66
+ export function planningCsv(result) {
67
+ const head = ['layer', 'feature_id', 'name', 'height_m', 'failed', ...result.criteria.flatMap((c) => [`${c.id}_value`, `${c.id}_pass`])];
68
+ const lines = result.profile ? [`# profile: ${String(result.profile).replace(/[\r\n]+/g, ' ')}`, head.join(',')] : [head.join(',')];
69
+ for (const r of result.rows) {
70
+ lines.push([`"${r.layer.entry.name}"`, r.f + 1, `"${r.name}"`, r.height.toFixed(2), r.failed,
71
+ ...r.checks.flatMap((c) => [Number.isFinite(c.value) ? c.value.toFixed(3) : '', c.pass === null ? '' : c.pass ? 1 : 0])].join(','));
72
+ }
73
+ return lines.join('\n');
74
+ }
@@ -0,0 +1,556 @@
1
+ // @ts-check
2
+ // Image, PDF, GLB, interactive HTML and video exports of the current view.
3
+ import * as THREE from 'three';
4
+ import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
5
+ import { bakeScene, toBase64 } from './snapshot.js';
6
+ import { buildGroup } from './snapshot_scene.js';
7
+ import { PdfPage, wrap } from './pdf.js';
8
+ import { fmt } from '../core/classify.js';
9
+ import { MONTH_NAMES, typicalDoy } from '../core/weather.js';
10
+ import { sunriseSunset } from '../core/solar.js';
11
+
12
+ export function fileStem(app) {
13
+ return String(app.manifest.title || 'scene').replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 60) || 'scene';
14
+ }
15
+
16
+ // ---------------------------------------------------------------- map furniture
17
+ /** What the legend, north arrow and scale bar need from the current view. */
18
+ export function viewInfo(app) {
19
+ const v = app.viewer;
20
+ const cam = v.camera;
21
+ const dist = cam.position.distanceTo(v.controls.target);
22
+ const f = v.controls.target.clone().sub(cam.position);
23
+ const heading = Math.atan2(f.x, -f.z); // clockwise from north
24
+ const r = app.activeResult;
25
+ let legend = null;
26
+ if (r) {
27
+ if (r.legend) legend = { title: r.title, items: r.legend };
28
+ else if (!r.display?.categorical) {
29
+ const el = document.querySelector('#legend .legend-ramp');
30
+ legend = { title: r.title, gradient: el ? getComputedStyle(el).backgroundImage : null, min: fmt(r.display.min, r.unit), max: fmt(r.display.max, r.unit), result: r };
31
+ }
32
+ } else if (app.layerLegend) legend = app.layerLegend;
33
+ const p = app.sunPosition();
34
+ const when = `${app.date.day} ${MONTH_NAMES[app.date.month - 1]} ${String(Math.floor(app.time)).padStart(2, '0')}:${String(Math.round((app.time % 1) * 60) % 60).padStart(2, '0')} (UTC${app.site.utcOffset >= 0 ? '+' : ''}${app.site.utcOffset})`;
35
+ return {
36
+ title: app.manifest.title, subtitle: r ? `${r.title}${r.subtitle ? ` — ${r.subtitle}` : ''}` : `Sun ${when}`,
37
+ legend, heading, fov: cam.fov, dist, sunUp: p.elevation > 0, when,
38
+ attribution: attribution(app)
39
+ };
40
+ }
41
+
42
+ function attribution(app) {
43
+ const names = app.layers.map((l) => l.entry.name.toLowerCase()).join(' ');
44
+ const parts = [];
45
+ if (names.includes('osm')) parts.push('Map data © OpenStreetMap contributors');
46
+ if (app.manifest.terrain?.source) parts.push(`Terrain: ${app.manifest.terrain.source}`);
47
+ parts.push(`${app.manifest.crs.authid} · 02 Geo3D Analyzer`);
48
+ return parts.join(' · ');
49
+ }
50
+
51
+ function niceLength(m) {
52
+ const p = Math.pow(10, Math.floor(Math.log10(m)));
53
+ for (const k of [5, 2, 1]) if (k * p <= m) return k * p;
54
+ return p;
55
+ }
56
+
57
+ /** Draw title, legend, north arrow, scale bar and attribution on a 2D canvas. */
58
+ export function drawFurniture(ctx, w, h, info, { title = true, legend = true, north = true, scale = true, credits = true } = {}) {
59
+ const u = Math.max(1, Math.min(w, h) / 900); // scale with the output size
60
+ const pad = 18 * u;
61
+ ctx.save();
62
+ ctx.textBaseline = 'top';
63
+ const box = (x, y, bw, bh) => {
64
+ ctx.fillStyle = 'rgba(255,255,255,0.9)';
65
+ ctx.strokeStyle = 'rgba(30,40,55,0.18)';
66
+ ctx.lineWidth = 1 * u;
67
+ ctx.beginPath();
68
+ ctx.roundRect(x, y, bw, bh, 8 * u);
69
+ ctx.fill();
70
+ ctx.stroke();
71
+ };
72
+ if (title && info.title) {
73
+ ctx.font = `600 ${22 * u}px system-ui, sans-serif`;
74
+ const tw = ctx.measureText(info.title).width;
75
+ ctx.font = `${14 * u}px system-ui, sans-serif`;
76
+ const sw = info.subtitle ? ctx.measureText(info.subtitle).width : 0;
77
+ box(pad, pad, Math.max(tw, sw) + 24 * u, (info.subtitle ? 58 : 38) * u);
78
+ ctx.fillStyle = '#1d2430';
79
+ ctx.font = `600 ${22 * u}px system-ui, sans-serif`;
80
+ ctx.fillText(info.title, pad + 12 * u, pad + 9 * u);
81
+ if (info.subtitle) {
82
+ ctx.font = `${14 * u}px system-ui, sans-serif`;
83
+ ctx.fillStyle = '#4a5260';
84
+ ctx.fillText(info.subtitle, pad + 12 * u, pad + 36 * u);
85
+ }
86
+ }
87
+ if (legend && info.legend) {
88
+ const L = info.legend;
89
+ ctx.font = `${13 * u}px system-ui, sans-serif`;
90
+ const items = L.items || [];
91
+ const bw = 260 * u;
92
+ const bh = (L.items ? 34 + items.slice(0, 14).length * 20 : 70) * u;
93
+ const x = pad, y = h - pad - bh - (credits ? 22 * u : 0);
94
+ box(x, y, bw, bh);
95
+ ctx.fillStyle = '#1d2430';
96
+ ctx.font = `600 ${13 * u}px system-ui, sans-serif`;
97
+ ctx.fillText(clip(ctx, L.title, bw - 24 * u), x + 12 * u, y + 10 * u);
98
+ ctx.font = `${12 * u}px system-ui, sans-serif`;
99
+ if (L.items) {
100
+ items.slice(0, 14).forEach((it, i) => {
101
+ ctx.fillStyle = it.color;
102
+ ctx.fillRect(x + 12 * u, y + (32 + i * 20) * u, 14 * u, 14 * u);
103
+ ctx.fillStyle = '#333a45';
104
+ ctx.fillText(clip(ctx, it.label, bw - 50 * u), x + 34 * u, y + (32 + i * 20) * u);
105
+ });
106
+ } else if (L.result) {
107
+ const grad = ctx.createLinearGradient(x + 12 * u, 0, x + bw - 12 * u, 0);
108
+ const stops = L.stops || ['#888888', '#dddddd'];
109
+ stops.forEach((c, i) => grad.addColorStop(i / (stops.length - 1), c));
110
+ ctx.fillStyle = grad;
111
+ ctx.fillRect(x + 12 * u, y + 32 * u, bw - 24 * u, 12 * u);
112
+ ctx.fillStyle = '#4a5260';
113
+ ctx.fillText(L.min, x + 12 * u, y + 50 * u);
114
+ const mw = ctx.measureText(L.max).width;
115
+ ctx.fillText(L.max, x + bw - 12 * u - mw, y + 50 * u);
116
+ }
117
+ }
118
+ if (north) {
119
+ const cx = w - pad - 26 * u, cy = pad + 30 * u;
120
+ ctx.beginPath();
121
+ ctx.arc(cx, cy, 24 * u, 0, Math.PI * 2);
122
+ ctx.fillStyle = 'rgba(255,255,255,0.9)';
123
+ ctx.fill();
124
+ ctx.translate(cx, cy);
125
+ ctx.rotate(-info.heading);
126
+ ctx.beginPath();
127
+ ctx.moveTo(0, -18 * u); ctx.lineTo(8 * u, 8 * u); ctx.lineTo(0, 3 * u); ctx.lineTo(-8 * u, 8 * u); ctx.closePath();
128
+ ctx.fillStyle = '#1d2430';
129
+ ctx.fill();
130
+ ctx.font = `600 ${11 * u}px system-ui, sans-serif`;
131
+ ctx.textAlign = 'center';
132
+ ctx.fillText('N', 0, -32 * u);
133
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
134
+ ctx.textAlign = 'left';
135
+ }
136
+ if (scale) {
137
+ const mPerPx = (2 * info.dist * Math.tan((info.fov * Math.PI) / 360)) / (info.panelH || h);
138
+ const len = niceLength(mPerPx * (info.panelW || w) * 0.16);
139
+ const px = len / mPerPx;
140
+ ctx.font = `${10 * u}px system-ui, sans-serif`;
141
+ const noteW = ctx.measureText('at the view centre').width;
142
+ const x = w - pad - Math.max(px, noteW), y = h - pad - (credits ? 40 : 18) * u;
143
+ ctx.fillStyle = 'rgba(255,255,255,0.85)';
144
+ ctx.fillRect(x - 8 * u, y - 18 * u, Math.max(px, noteW) + 16 * u, 30 * u);
145
+ ctx.fillStyle = '#1d2430';
146
+ ctx.fillRect(x, y, px, 4 * u);
147
+ ctx.fillRect(x, y - 4 * u, 2 * u, 8 * u);
148
+ ctx.fillRect(x + px - 2 * u, y - 4 * u, 2 * u, 8 * u);
149
+ ctx.font = `${12 * u}px system-ui, sans-serif`;
150
+ ctx.fillText(len >= 1000 ? `${len / 1000} km` : `${len} m`, x, y - 16 * u);
151
+ ctx.fillStyle = '#6b7380';
152
+ ctx.font = `${10 * u}px system-ui, sans-serif`;
153
+ ctx.fillText('at the view centre', x, y + 7 * u);
154
+ }
155
+ if (credits) {
156
+ ctx.font = `${11 * u}px system-ui, sans-serif`;
157
+ const tw = ctx.measureText(info.attribution).width;
158
+ ctx.fillStyle = 'rgba(255,255,255,0.8)';
159
+ ctx.fillRect(w - tw - 16 * u, h - 20 * u, tw + 16 * u, 20 * u);
160
+ ctx.fillStyle = '#4a5260';
161
+ ctx.fillText(info.attribution, w - tw - 8 * u, h - 16 * u);
162
+ }
163
+ ctx.restore();
164
+ }
165
+
166
+ function clip(ctx, text, max) {
167
+ let t = String(text ?? '');
168
+ while (t.length > 3 && ctx.measureText(t).width > max) t = `${t.slice(0, -2)}…`;
169
+ return t;
170
+ }
171
+
172
+ // ---------------------------------------------------------------- image
173
+ /**
174
+ * Render the current view at a target size and return a canvas with the
175
+ * map furniture drawn on top.
176
+ */
177
+ export function renderImage(app, { width = 0, height = 0, furniture = {} } = {}) {
178
+ const v = app.viewer;
179
+ const r = v.renderer;
180
+ const cam = v.camera;
181
+ const canvas = r.domElement;
182
+ const oldW = canvas.clientWidth, oldH = canvas.clientHeight;
183
+ const oldRatio = r.getPixelRatio();
184
+ const w = Math.round(width || oldW * oldRatio);
185
+ const h = Math.round(height || oldH * oldRatio);
186
+ const maxSide = r.capabilities.maxTextureSize || 8192;
187
+ const k = Math.min(1, maxSide / Math.max(w, h));
188
+ const W = Math.round(w * k), H = Math.round(h * k);
189
+ r.setPixelRatio(1);
190
+ r.setSize(W, H, false);
191
+ cam.aspect = W / H;
192
+ cam.updateProjectionMatrix();
193
+ v._fitClipping();
194
+ v._fitShadow();
195
+ v.draw(); // compare views draw their panels
196
+ const out = document.createElement('canvas');
197
+ out.width = W;
198
+ out.height = H;
199
+ const ctx = /** @type {CanvasRenderingContext2D} */ (out.getContext('2d'));
200
+ ctx.drawImage(canvas, 0, 0);
201
+ // Restore the live view.
202
+ r.setPixelRatio(oldRatio);
203
+ r.setSize(oldW, oldH, false);
204
+ cam.aspect = oldW / oldH;
205
+ cam.updateProjectionMatrix();
206
+ v.requestRender();
207
+ const info = viewInfo(app);
208
+ if (info.legend?.result) info.legend.stops = rampStops(app);
209
+ const cmp = app.compare?.mode ? app.compare : null;
210
+ if (cmp) {
211
+ // Each panel carries its own caption and legend; the scene title moves
212
+ // into the credits line so it does not cover the first panel's caption.
213
+ const ps = cmp.panelSize(W, H);
214
+ info.panelW = ps.w;
215
+ info.panelH = ps.h;
216
+ if (furniture.title !== false && info.title) info.attribution = `${info.title} · ${info.attribution}`;
217
+ const u = Math.max(1, Math.min(W, H) / 900);
218
+ cmp.drawOverlay(ctx, W, H, { legends: furniture.legend !== false, reserveRight: furniture.north !== false ? 70 * u : 0,
219
+ reserveBottom: furniture.credits !== false ? 20 * u : 0, reserveCorner: furniture.scale !== false ? 46 * u : 0 });
220
+ drawFurniture(ctx, W, H, info, { ...furniture, title: false, legend: false });
221
+ } else {
222
+ drawFurniture(ctx, W, H, info, furniture);
223
+ }
224
+ return out;
225
+ }
226
+
227
+ function rampStops(app) {
228
+ const el = document.querySelector('#legend .legend-ramp');
229
+ if (!el) return undefined;
230
+ const m = getComputedStyle(el).backgroundImage.match(/rgb\([^)]+\)/g);
231
+ return m || undefined;
232
+ }
233
+
234
+ export function canvasBlob(canvas, type = 'image/png', quality = 0.92) {
235
+ return new Promise((resolve) => canvas.toBlob((b) => resolve(b), type, quality));
236
+ }
237
+
238
+ // ---------------------------------------------------------------- PDF
239
+ const PAPER = { a4: [842, 595], a3: [1191, 842] };
240
+
241
+ export async function exportPdf(app, { paper = 'a4', title = '' } = {}) {
242
+ const [PW, PH] = PAPER[paper] || PAPER.a4;
243
+ const margin = 28;
244
+ const side = 230;
245
+ const imgW = PW - margin * 3 - side;
246
+ const imgH = PH - margin * 2 - 64;
247
+ const scale = 2.2; // pixels per point for a sharp print
248
+ const canvas = renderImage(app, { width: Math.round(imgW * scale), height: Math.round(imgH * scale),
249
+ furniture: { title: false, legend: false, north: true, scale: true, credits: false } });
250
+ const jpegUrl = canvas.toDataURL('image/jpeg', 0.9);
251
+ const bytes = Uint8Array.from(atob(jpegUrl.split(',')[1]), (c) => c.charCodeAt(0));
252
+ const info = viewInfo(app);
253
+ const page = new PdfPage(PW, PH);
254
+ const ink = [0.11, 0.14, 0.19];
255
+ const muted = [0.42, 0.45, 0.5];
256
+ page.text(margin, margin + 18, title || info.title, { size: 18, bold: true, color: ink });
257
+ page.text(margin, margin + 36, info.subtitle, { size: 10, color: muted });
258
+ page.jpeg(bytes, canvas.width, canvas.height, margin, margin + 50, imgW, imgH);
259
+ page.rect(margin, margin + 50, imgW, imgH, { stroke: [0.8, 0.82, 0.85] });
260
+ // Side column: legend, statistics, notes.
261
+ let x = margin * 2 + imgW, y = margin + 64;
262
+ const L = info.legend;
263
+ if (L) {
264
+ page.text(x, y, L.title, { size: 10, bold: true, color: ink });
265
+ y += 10;
266
+ if (L.items) {
267
+ for (const it of L.items.slice(0, 16)) {
268
+ page.rect(x, y, 10, 10, { fill: hexRgb(it.color) });
269
+ page.text(x + 16, y + 9, it.label, { size: 8.5, color: ink });
270
+ y += 14;
271
+ }
272
+ } else if (L.result) {
273
+ const stops = (rampStops(app) || []).map(parseRgb);
274
+ const n = 40;
275
+ for (let i = 0; i < n; i++) {
276
+ const t = i / (n - 1);
277
+ const k = t * (stops.length - 1);
278
+ const a = stops[Math.floor(k)] || [0, 0, 0], b = stops[Math.min(stops.length - 1, Math.floor(k) + 1)] || a;
279
+ const f = k - Math.floor(k);
280
+ page.rect(x + (side / n) * i, y, side / n + 0.3, 10, { fill: a.map((v, j) => v + (b[j] - v) * f) });
281
+ }
282
+ y += 22;
283
+ page.text(x, y, L.min, { size: 8.5, color: muted });
284
+ page.text(x + side - 60, y, L.max, { size: 8.5, color: muted });
285
+ y += 6;
286
+ }
287
+ y += 14;
288
+ }
289
+ const r = app.activeResult;
290
+ if (r?.stats?.length) {
291
+ page.text(x, y, 'Results', { size: 10, bold: true, color: ink });
292
+ y += 6;
293
+ for (const [k, val] of r.stats.slice(0, 14)) {
294
+ y += 13;
295
+ page.text(x, y, k, { size: 8.5, color: muted });
296
+ page.text(x + side - Math.min(side * 0.45, 7 + String(val).length * 4.6), y, val, { size: 8.5, color: ink });
297
+ page.line(x, y + 3, x + side, y + 3, { color: [0.88, 0.89, 0.91], width: 0.4 });
298
+ }
299
+ y += 18;
300
+ }
301
+ if (r?.notes) {
302
+ for (const line of wrap(r.notes, 8, side).slice(0, 12)) { page.text(x, y, line, { size: 8, color: muted }); y += 10.5; }
303
+ y += 8;
304
+ }
305
+ const s = app.site;
306
+ const doy = typicalDoy(app.date.month, app.date.day);
307
+ const { sunrise, sunset } = sunriseSunset(doy, s.lat, s.lon, s.utcOffset);
308
+ const facts = [['View lit', info.when], ['Daylight', sunrise !== null ? `${hm(sunrise)} – ${hm(sunset)}` : '–'],
309
+ ['Centre', `${s.lat.toFixed(4)}°, ${s.lon.toFixed(4)}°`], ['CRS', app.manifest.crs.authid]];
310
+ for (const [k, val] of facts) {
311
+ if (y > PH - margin - 30) break;
312
+ page.text(x, y, k, { size: 8, color: muted });
313
+ page.text(x + 52, y, val, { size: 8, color: ink });
314
+ y += 11;
315
+ }
316
+ page.line(margin, PH - margin - 8, PW - margin, PH - margin - 8, { color: [0.85, 0.86, 0.88] });
317
+ page.text(margin, PH - margin + 4, info.attribution, { size: 7.5, color: muted });
318
+ page.text(PW - margin - 110, PH - margin + 4, new Date().toISOString().slice(0, 16).replace('T', ' '), { size: 7.5, color: muted });
319
+ return new Blob([page.build({ title: title || info.title, author: '' })], { type: 'application/pdf' });
320
+ }
321
+
322
+ function hm(h) { return `${String(Math.floor(h)).padStart(2, '0')}:${String(Math.round((h % 1) * 60) % 60).padStart(2, '0')}`; }
323
+ function hexRgb(hex) { const n = parseInt(String(hex).slice(1), 16); return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]; }
324
+ function parseRgb(s) { const m = String(s).match(/\d+(\.\d+)?/g) || ['0', '0', '0']; return m.slice(0, 3).map((v) => Number(v) / 255); }
325
+
326
+ // ---------------------------------------------------------------- GLB
327
+ export async function exportGlb(app, { area = null } = {}) {
328
+ const snap = bakeScene(app, { area });
329
+ const group = await buildGroup(THREE, snap);
330
+ const exporter = new GLTFExporter();
331
+ const buffer = await new Promise((resolve, reject) => exporter.parse(group, resolve, reject, { binary: true }));
332
+ return new Blob([buffer], { type: 'model/gltf-binary' });
333
+ }
334
+
335
+ // ---------------------------------------------------------------- interactive HTML
336
+ function encodeSnapshot(snap) {
337
+ return JSON.stringify(snap, (k, v) => (ArrayBuffer.isView(v) ? { __ta: v.constructor.name, b64: toBase64(v) } : v));
338
+ }
339
+
340
+ function dataModule(text) {
341
+ const bytes = new TextEncoder().encode(text);
342
+ let s = '';
343
+ for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + 0x8000)));
344
+ return `data:text/javascript;base64,${btoa(s)}`;
345
+ }
346
+
347
+ function esc(s) { return String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]); }
348
+
349
+ export async function exportHtml(app, { area = null } = {}) {
350
+ const snap = bakeScene(app, { area });
351
+ const [three, orbit, builder] = await Promise.all([
352
+ fetch('vendor/three/three.module.js').then((r) => r.text()),
353
+ fetch('vendor/three/addons/controls/OrbitControls.js').then((r) => r.text()),
354
+ fetch('src/export/snapshot_scene.js').then((r) => r.text())
355
+ ]);
356
+ const info = viewInfo(app);
357
+ const stops = rampStops(app);
358
+ let legend = '';
359
+ if (info.legend?.items) {
360
+ legend = `<b>${esc(info.legend.title)}</b>${info.legend.items.slice(0, 16).map((i) => `<div class="li"><i style="background:${esc(i.color)}"></i>${esc(i.label)}</div>`).join('')}`;
361
+ } else if (info.legend?.result && stops) {
362
+ legend = `<b>${esc(info.legend.title)}</b><div class="ramp" style="background:linear-gradient(90deg,${stops.join(',')})"></div><div class="sc"><span>${esc(info.legend.min)}</span><span>${esc(info.legend.max)}</span></div>`;
363
+ }
364
+ const importMap = { imports: { three: dataModule(three), 'three/addons/controls/OrbitControls.js': dataModule(orbit), 'g3d/scene': dataModule(builder) } };
365
+ const html = `<!doctype html>
366
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
367
+ <title>${esc(info.title)}</title>
368
+ <style>
369
+ html,body{margin:0;height:100%;overflow:hidden;font:13px system-ui,sans-serif;color:#1d2430;background:#dfe8f0}
370
+ #c{position:fixed;inset:0}
371
+ .card{position:fixed;background:rgba(255,255,255,.93);border:1px solid #e2e5ea;border-radius:8px;padding:10px 12px;box-shadow:0 4px 18px rgba(20,30,50,.12)}
372
+ #t{left:12px;top:12px;max-width:60vw}#t h1{font-size:17px;margin:0 0 3px}#t p{margin:0;color:#4a5260}
373
+ #l{left:12px;bottom:34px;min-width:200px;max-width:280px}.li{display:flex;gap:8px;align-items:center;margin-top:4px}.li i{width:13px;height:13px;border-radius:3px;display:inline-block}
374
+ .ramp{height:12px;border-radius:3px;margin-top:6px}.sc{display:flex;justify-content:space-between;color:#687384;font-size:11px;margin-top:3px}
375
+ #a{position:fixed;right:8px;bottom:6px;color:#4a5260;font-size:11px;background:rgba(255,255,255,.8);padding:2px 6px;border-radius:4px}
376
+ #h{position:fixed;left:50%;bottom:6px;transform:translateX(-50%);color:#4a5260;font-size:11px}
377
+ </style>
378
+ <script type="importmap">${JSON.stringify(importMap)}</script>
379
+ </head><body>
380
+ <canvas id="c"></canvas>
381
+ <div id="t" class="card"><h1>${esc(info.title)}</h1><p>${esc(info.subtitle)}</p></div>
382
+ ${legend ? `<div id="l" class="card">${legend}</div>` : ''}
383
+ <div id="h">Drag to orbit · right-drag to pan · wheel to zoom</div>
384
+ <div id="a">${esc(info.attribution)}</div>
385
+ <script type="application/json" id="snap">${encodeSnapshot(snap).replace(/</g, '\\u003c')}</script>
386
+ <script type="module">
387
+ import * as THREE from 'three';
388
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
389
+ import { buildGroup, decodeSnapshot } from 'g3d/scene';
390
+ const snap = decodeSnapshot(document.getElementById('snap').textContent);
391
+ const canvas = document.getElementById('c');
392
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
393
+ renderer.setPixelRatio(Math.min(2, devicePixelRatio));
394
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
395
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
396
+ renderer.shadowMap.enabled = true;
397
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
398
+ const scene = new THREE.Scene();
399
+ scene.background = new THREE.Color(0xdfe8f0);
400
+ const camera = new THREE.PerspectiveCamera(snap.camera.fov, 1, 0.5, 200000);
401
+ camera.position.fromArray(snap.camera.position);
402
+ const controls = new OrbitControls(camera, canvas);
403
+ controls.target.fromArray(snap.camera.target);
404
+ controls.enableDamping = true;
405
+ controls.maxPolarAngle = Math.PI * 0.495;
406
+ scene.add(new THREE.HemisphereLight(0xeaf2ff, 0x8a8174, 1.2));
407
+ const sun = new THREE.DirectionalLight(0xfff4e0, 2.4);
408
+ const dir = new THREE.Vector3().fromArray(snap.sun).normalize();
409
+ sun.castShadow = dir.y > 0;
410
+ sun.visible = dir.y > 0;
411
+ sun.shadow.mapSize.set(4096, 4096);
412
+ sun.shadow.bias = -0.0004;
413
+ scene.add(sun, sun.target);
414
+ const group = await buildGroup(THREE, snap);
415
+ scene.add(group);
416
+ const box = new THREE.Box3().setFromObject(group);
417
+ const radius = box.getSize(new THREE.Vector3()).length() / 2;
418
+ function fitShadow() {
419
+ const r = Math.max(150, Math.min(radius, camera.position.distanceTo(controls.target) * 1.2));
420
+ const sc = sun.shadow.camera;
421
+ sc.left = -r; sc.right = r; sc.top = r; sc.bottom = -r; sc.near = 1; sc.far = 4 * r + 2000;
422
+ sc.updateProjectionMatrix();
423
+ sun.position.copy(controls.target).addScaledVector(dir, 2 * r + 800);
424
+ sun.target.position.copy(controls.target);
425
+ sun.shadow.normalBias = Math.max(0.03, (2 * r) / 4096 * 0.8);
426
+ }
427
+ function resize() {
428
+ renderer.setSize(innerWidth, innerHeight, false);
429
+ camera.aspect = innerWidth / innerHeight;
430
+ camera.updateProjectionMatrix();
431
+ }
432
+ addEventListener('resize', resize);
433
+ resize();
434
+ let dirty = true;
435
+ controls.addEventListener('change', () => { dirty = true; });
436
+ renderer.setAnimationLoop(() => {
437
+ const moved = controls.update();
438
+ if (!dirty && !moved) return;
439
+ dirty = false;
440
+ const d = camera.position.distanceTo(controls.target);
441
+ camera.near = Math.max(0.2, Math.min(50, d / 1500));
442
+ camera.far = Math.max(d * 40, radius * 6, 5000);
443
+ camera.updateProjectionMatrix();
444
+ fitShadow();
445
+ renderer.render(scene, camera);
446
+ });
447
+ </script>
448
+ </body></html>`;
449
+ return new Blob([html], { type: 'text/html' });
450
+ }
451
+
452
+ // ---------------------------------------------------------------- video
453
+ const VIDEO_TYPES = ['video/mp4;codecs=avc1.640028', 'video/mp4;codecs=avc1', 'video/mp4', 'video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'];
454
+
455
+ export function videoType() {
456
+ if (typeof MediaRecorder === 'undefined') return null;
457
+ return VIDEO_TYPES.find((t) => MediaRecorder.isTypeSupported(t)) || null;
458
+ }
459
+
460
+ /**
461
+ * Record the canvas. mode: 'orbit' (one turn around the view centre),
462
+ * 'day' (sunrise to sunset) or 'free' (until stop()).
463
+ */
464
+ export class Recorder {
465
+ constructor(app) {
466
+ this.app = app;
467
+ this.active = false;
468
+ }
469
+
470
+ async start({ mode = 'orbit', seconds = 12, fps = 30, width = 0, height = 0, onTick = null } = {}) {
471
+ const app = this.app;
472
+ const v = app.viewer;
473
+ const r = v.renderer;
474
+ const type = videoType();
475
+ if (!type) throw new Error('This browser cannot record video (MediaRecorder unavailable).');
476
+ const canvas = r.domElement;
477
+ this.saved = { w: canvas.clientWidth, h: canvas.clientHeight, ratio: r.getPixelRatio(), time: app.time,
478
+ pos: v.camera.position.clone(), target: v.controls.target.clone() };
479
+ if (width && height) {
480
+ r.setPixelRatio(1);
481
+ r.setSize(width, height, false);
482
+ v.camera.aspect = width / height;
483
+ v.camera.updateProjectionMatrix();
484
+ }
485
+ // Compare views: the panel captions and legends are page elements, not
486
+ // part of the 3D canvas, so frames are composited on a 2D canvas.
487
+ let source = canvas;
488
+ this.unhook = null;
489
+ const cmp = app.compare?.mode ? app.compare : null;
490
+ if (cmp) {
491
+ const comp = document.createElement('canvas');
492
+ comp.width = canvas.width;
493
+ comp.height = canvas.height;
494
+ const ctx = /** @type {CanvasRenderingContext2D} */ (comp.getContext('2d'));
495
+ const paint = () => {
496
+ if (comp.width !== canvas.width || comp.height !== canvas.height) { comp.width = canvas.width; comp.height = canvas.height; }
497
+ ctx.drawImage(canvas, 0, 0);
498
+ cmp.drawOverlay(ctx, comp.width, comp.height);
499
+ };
500
+ paint();
501
+ this.unhook = v.onPostRender(paint);
502
+ source = comp;
503
+ }
504
+ const stream = source.captureStream(fps);
505
+ const bits = (width || canvas.width) * (height || canvas.height) * fps * 0.12;
506
+ this.rec = new MediaRecorder(stream, { mimeType: type, videoBitsPerSecond: Math.round(Math.min(40e6, Math.max(6e6, bits))) });
507
+ this.chunks = [];
508
+ this.rec.ondataavailable = (e) => { if (e.data.size) this.chunks.push(e.data); };
509
+ this.type = type;
510
+ this.active = true;
511
+ const done = new Promise((resolve) => { this.rec.onstop = () => resolve(new Blob(this.chunks, { type: type.split(';')[0] })); });
512
+ this.rec.start(250);
513
+ const t0 = performance.now();
514
+ const start = { offset: v.camera.position.clone().sub(v.controls.target) };
515
+ let dayFrom = 0, dayTo = 24;
516
+ if (mode === 'day') {
517
+ const s = app.site;
518
+ const { sunrise, sunset } = sunriseSunset(typicalDoy(app.date.month, app.date.day), s.lat, s.lon, s.utcOffset);
519
+ dayFrom = sunrise ?? 5; dayTo = sunset ?? 20;
520
+ }
521
+ const tick = () => {
522
+ if (!this.active) return;
523
+ const f = (performance.now() - t0) / (seconds * 1000);
524
+ if (mode !== 'free' && f >= 1) { this.stop(); return; }
525
+ if (mode === 'orbit') {
526
+ const p = start.offset.clone().applyAxisAngle(new THREE.Vector3(0, 1, 0), f * Math.PI * 2);
527
+ v.camera.position.copy(v.controls.target).add(p);
528
+ v.camera.lookAt(v.controls.target);
529
+ } else if (mode === 'day') {
530
+ app.time = dayFrom + (dayTo - dayFrom) * f;
531
+ app.updateSun();
532
+ }
533
+ v.requestRender();
534
+ onTick?.(mode === 'free' ? (performance.now() - t0) / 1000 : f);
535
+ requestAnimationFrame(tick);
536
+ };
537
+ requestAnimationFrame(tick);
538
+ return done;
539
+ }
540
+
541
+ stop() {
542
+ if (!this.active) return;
543
+ this.active = false;
544
+ this.rec.stop();
545
+ this.unhook?.();
546
+ this.unhook = null;
547
+ const v = this.app.viewer;
548
+ const r = v.renderer;
549
+ const s = this.saved;
550
+ r.setPixelRatio(s.ratio);
551
+ r.setSize(s.w, s.h, false);
552
+ v.camera.aspect = s.w / s.h;
553
+ v.camera.updateProjectionMatrix();
554
+ v.requestRender();
555
+ }
556
+ }