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,149 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// A minimal PDF 1.4 writer: one page, a JPEG image, Helvetica text, filled
|
|
3
|
+
// rectangles and lines. No dependencies; enough for a one-page analysis sheet.
|
|
4
|
+
|
|
5
|
+
const WINANSI_EXTRA = {
|
|
6
|
+
'€': 0x80, '‚': 0x82, '„': 0x84, '…': 0x85, '•': 0x95, '–': 0x96, '—': 0x97, '‘': 0x91, '’': 0x92,
|
|
7
|
+
'“': 0x93, '”': 0x94, '™': 0x99, 'Œ': 0x8c, 'œ': 0x9c, 'Š': 0x8a, 'š': 0x9a, 'Ž': 0x8e, 'ž': 0x9e, 'Ÿ': 0x9f
|
|
8
|
+
};
|
|
9
|
+
// Characters Helvetica/WinAnsi cannot show, replaced by the closest ASCII.
|
|
10
|
+
const FALLBACK = { 'ı': 'i', 'İ': 'I', 'ş': 's', 'Ş': 'S', 'ğ': 'g', 'Ğ': 'G', 'λ': 'lambda ', '≥': '>=', '≤': '<=',
|
|
11
|
+
'→': '->', '←': '<-', 'α': 'a', 'β': 'b', 'Δ': 'd', '−': '-', '·': '·' };
|
|
12
|
+
|
|
13
|
+
/** Document-info strings use PDFDocEncoding, not WinAnsi: write them as UTF-16BE hex. */
|
|
14
|
+
export function pdfInfoString(text) {
|
|
15
|
+
let hex = 'FEFF';
|
|
16
|
+
for (const ch of String(text ?? '')) {
|
|
17
|
+
const cp = ch.codePointAt(0);
|
|
18
|
+
if (cp > 0xffff) {
|
|
19
|
+
const v = cp - 0x10000;
|
|
20
|
+
hex += (0xd800 + (v >> 10)).toString(16).padStart(4, '0') + (0xdc00 + (v & 0x3ff)).toString(16).padStart(4, '0');
|
|
21
|
+
} else hex += cp.toString(16).padStart(4, '0');
|
|
22
|
+
}
|
|
23
|
+
return `<${hex.toUpperCase()}>`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Encode text for a PDF string in WinAnsiEncoding, escaping ( ) \ . */
|
|
27
|
+
export function pdfString(text) {
|
|
28
|
+
let out = '';
|
|
29
|
+
for (const ch of String(text ?? '')) {
|
|
30
|
+
const sub = FALLBACK[ch];
|
|
31
|
+
const s = sub !== undefined ? sub : ch;
|
|
32
|
+
for (const c of s) {
|
|
33
|
+
let code = c.codePointAt(0);
|
|
34
|
+
if (WINANSI_EXTRA[c] !== undefined) code = WINANSI_EXTRA[c];
|
|
35
|
+
else if (code > 255 || (code >= 0x80 && code < 0xa0)) code = 0x3f; // '?'
|
|
36
|
+
if (code === 0x28 || code === 0x29 || code === 0x5c) out += '\\';
|
|
37
|
+
out += String.fromCharCode(code);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Helvetica advance widths (1/1000 em) for ASCII 32..126; others ~ 556.
|
|
44
|
+
const HELV = [278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556,
|
|
45
|
+
278, 278, 584, 584, 584, 556, 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, 667, 778, 722, 667, 611, 722,
|
|
46
|
+
667, 944, 667, 667, 611, 278, 278, 278, 469, 556, 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, 556, 556,
|
|
47
|
+
333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584];
|
|
48
|
+
|
|
49
|
+
export function textWidth(text, size, bold = false) {
|
|
50
|
+
let w = 0;
|
|
51
|
+
for (const ch of pdfString(text)) {
|
|
52
|
+
const c = ch.charCodeAt(0);
|
|
53
|
+
w += c >= 32 && c <= 126 ? HELV[c - 32] : 556;
|
|
54
|
+
}
|
|
55
|
+
return (w / 1000) * size * (bold ? 1.06 : 1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Split text into lines no wider than maxWidth points. */
|
|
59
|
+
export function wrap(text, size, maxWidth) {
|
|
60
|
+
const words = String(text ?? '').split(/\s+/).filter(Boolean);
|
|
61
|
+
const lines = [];
|
|
62
|
+
let line = '';
|
|
63
|
+
for (const w of words) {
|
|
64
|
+
const t = line ? `${line} ${w}` : w;
|
|
65
|
+
if (textWidth(t, size) > maxWidth && line) { lines.push(line); line = w; } else line = t;
|
|
66
|
+
}
|
|
67
|
+
if (line) lines.push(line);
|
|
68
|
+
return lines;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class PdfPage {
|
|
72
|
+
/** @param {number} width points @param {number} height points */
|
|
73
|
+
constructor(width, height) {
|
|
74
|
+
this.width = width;
|
|
75
|
+
this.height = height;
|
|
76
|
+
this.ops = [];
|
|
77
|
+
this.image = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Top-left based coordinates are converted to PDF's bottom-left origin. */
|
|
81
|
+
_y(y) { return this.height - y; }
|
|
82
|
+
|
|
83
|
+
text(x, y, text, { size = 10, bold = false, color = [0, 0, 0] } = {}) {
|
|
84
|
+
this.ops.push(`BT /${bold ? 'F2' : 'F1'} ${size} Tf ${rgb(color)} rg ${n(x)} ${n(this._y(y))} Td (${pdfString(text)}) Tj ET`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
rect(x, y, w, h, { fill = null, stroke = null, lineWidth = 0.5 } = {}) {
|
|
88
|
+
const parts = [];
|
|
89
|
+
if (fill) parts.push(`${rgb(fill)} rg`);
|
|
90
|
+
if (stroke) parts.push(`${rgb(stroke)} RG ${n(lineWidth)} w`);
|
|
91
|
+
parts.push(`${n(x)} ${n(this._y(y + h))} ${n(w)} ${n(h)} re ${fill && stroke ? 'B' : fill ? 'f' : 'S'}`);
|
|
92
|
+
this.ops.push(parts.join(' '));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A filled polygon; points are top-left based [x, y]. */
|
|
96
|
+
polygon(points, { fill = [0, 0, 0] } = {}) {
|
|
97
|
+
if (points.length < 3) return;
|
|
98
|
+
const path = points.map(([x, y], i) => `${n(x)} ${n(this._y(y))} ${i ? 'l' : 'm'}`).join(' ');
|
|
99
|
+
this.ops.push(`${rgb(fill)} rg ${path} h f`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
line(x1, y1, x2, y2, { color = [0, 0, 0], width = 0.5 } = {}) {
|
|
103
|
+
this.ops.push(`${rgb(color)} RG ${n(width)} w ${n(x1)} ${n(this._y(y1))} m ${n(x2)} ${n(this._y(y2))} l S`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Place a JPEG (bytes, pixel size) in the box x, y, w, h (top-left). One image per page. */
|
|
107
|
+
jpeg(bytes, pxWidth, pxHeight, x, y, w, h) {
|
|
108
|
+
this.image = { bytes, pxWidth, pxHeight };
|
|
109
|
+
this.ops.push(`q ${n(w)} 0 0 ${n(h)} ${n(x)} ${n(this._y(y + h))} cm /Im1 Do Q`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @returns {Uint8Array} the PDF file */
|
|
113
|
+
build({ title = '', author = '', creator = '02 Geo3D Analyzer' } = {}) {
|
|
114
|
+
const enc = (s) => Uint8Array.from(s, (c) => c.charCodeAt(0) & 255);
|
|
115
|
+
const chunks = [];
|
|
116
|
+
const offsets = [];
|
|
117
|
+
let size = 0;
|
|
118
|
+
const push = (u8) => { chunks.push(u8); size += u8.length; };
|
|
119
|
+
const obj = (id, body) => { offsets[id] = size; push(enc(`${id} 0 obj\n`)); for (const b of body) push(typeof b === 'string' ? enc(b) : b); push(enc('\nendobj\n')); };
|
|
120
|
+
push(enc('%PDF-1.4\n%\xe2\xe3\xcf\xd3\n'));
|
|
121
|
+
const content = this.ops.join('\n');
|
|
122
|
+
const resources = `/Font << /F1 5 0 R /F2 6 0 R >>${this.image ? ' /XObject << /Im1 7 0 R >>' : ''}`;
|
|
123
|
+
obj(1, ['<< /Type /Catalog /Pages 2 0 R >>']);
|
|
124
|
+
obj(2, ['<< /Type /Pages /Kids [3 0 R] /Count 1 >>']);
|
|
125
|
+
obj(3, [`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${n(this.width)} ${n(this.height)}] /Resources << ${resources} >> /Contents 4 0 R >>`]);
|
|
126
|
+
obj(4, [`<< /Length ${content.length} >>\nstream\n`, content, '\nendstream']);
|
|
127
|
+
obj(5, ['<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>']);
|
|
128
|
+
obj(6, ['<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>']);
|
|
129
|
+
let next = 7;
|
|
130
|
+
if (this.image) {
|
|
131
|
+
const im = this.image;
|
|
132
|
+
obj(7, [`<< /Type /XObject /Subtype /Image /Width ${im.pxWidth} /Height ${im.pxHeight} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${im.bytes.length} >>\nstream\n`, im.bytes, '\nendstream']);
|
|
133
|
+
next = 8;
|
|
134
|
+
}
|
|
135
|
+
obj(next, [`<< /Title ${pdfInfoString(title)} /Author ${pdfInfoString(author)} /Creator ${pdfInfoString(creator)} /Producer ${pdfInfoString(creator)} >>`]);
|
|
136
|
+
const xref = size;
|
|
137
|
+
let table = `xref\n0 ${next + 1}\n0000000000 65535 f \n`;
|
|
138
|
+
for (let i = 1; i <= next; i++) table += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
|
|
139
|
+
push(enc(table));
|
|
140
|
+
push(enc(`trailer\n<< /Size ${next + 1} /Root 1 0 R /Info ${next} 0 R >>\nstartxref\n${xref}\n%%EOF\n`));
|
|
141
|
+
const out = new Uint8Array(size);
|
|
142
|
+
let o = 0;
|
|
143
|
+
for (const c of chunks) { out.set(c, o); o += c.length; }
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function n(v) { return Number(v).toFixed(2).replace(/\.?0+$/, '') || '0'; }
|
|
149
|
+
function rgb(c) { return c.map((v) => n(Math.max(0, Math.min(1, v)))).join(' '); }
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// A baked, self-contained copy of what the viewer shows: terrain (thinned,
|
|
3
|
+
// with its painted texture and the active analysis drape), extrusions with
|
|
4
|
+
// their current colours (the drape baked into roof vertices), trees and
|
|
5
|
+
// poles, facade tiles and the flood surface. GLB and interactive HTML
|
|
6
|
+
// exports are both built from it.
|
|
7
|
+
import * as THREE from 'three';
|
|
8
|
+
import { ExtrudedLayer, PointLayer } from '../scene/objects.js';
|
|
9
|
+
import { overlayPixels, gridRect, overlayUniforms } from '../scene/overlay.js';
|
|
10
|
+
import { rampStops, rampColor } from '../core/classify.js';
|
|
11
|
+
|
|
12
|
+
const MAX_TERRAIN_VERTS_SIDE = 512;
|
|
13
|
+
const MAX_TEXTURE = 2048;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {any} app
|
|
17
|
+
* @param {{ area?: { minX: number, maxX: number, minY: number, maxY: number } | null }} [opts]
|
|
18
|
+
*/
|
|
19
|
+
export function bakeScene(app, { area = null } = {}) {
|
|
20
|
+
const t = app.terrain;
|
|
21
|
+
const g = t.grid;
|
|
22
|
+
const a = area || app.sceneArea();
|
|
23
|
+
const inArea = (x, y) => x >= a.minX && x <= a.maxX && y >= a.minY && y <= a.maxY;
|
|
24
|
+
const r = app.activeResult?.grid ? app.activeResult : null;
|
|
25
|
+
const opacity = overlayUniforms.uOverlayOpacity.value;
|
|
26
|
+
const overlay = r ? { pixels: overlayPixels(r.grid, r.values, r.display), grid: r.grid } : null;
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------- terrain
|
|
29
|
+
const c0 = Math.max(0, Math.floor((a.minX - g.minX) / g.cell));
|
|
30
|
+
const c1 = Math.min(g.cols - 1, Math.ceil((a.maxX - g.minX) / g.cell));
|
|
31
|
+
const r0 = Math.max(0, Math.floor((g.maxY - a.maxY) / g.cell));
|
|
32
|
+
const r1 = Math.min(g.rows - 1, Math.ceil((g.maxY - a.minY) / g.cell));
|
|
33
|
+
const step = Math.max(1, Math.ceil(Math.max(c1 - c0, r1 - r0) / MAX_TERRAIN_VERTS_SIDE));
|
|
34
|
+
const cs = [], rs = [];
|
|
35
|
+
for (let c = c0; c < c1; c += step) cs.push(c);
|
|
36
|
+
cs.push(c1);
|
|
37
|
+
for (let rr = r0; rr < r1; rr += step) rs.push(rr);
|
|
38
|
+
rs.push(r1);
|
|
39
|
+
const nc = cs.length, nr = rs.length;
|
|
40
|
+
const tPos = new Float32Array(nc * nr * 3);
|
|
41
|
+
const tUv = new Float32Array(nc * nr * 2);
|
|
42
|
+
for (let j = 0; j < nr; j++) {
|
|
43
|
+
for (let i = 0; i < nc; i++) {
|
|
44
|
+
const k = j * nc + i;
|
|
45
|
+
tPos[k * 3] = g.minX + cs[i] * g.cell;
|
|
46
|
+
tPos[k * 3 + 1] = t.toY(g.values[rs[j] * g.cols + cs[i]]);
|
|
47
|
+
tPos[k * 3 + 2] = -(g.maxY - rs[j] * g.cell);
|
|
48
|
+
// Vertices sit at cell centres; the texture spans whole cells c0..c1.
|
|
49
|
+
tUv[k * 2] = (cs[i] - c0 + 0.5) / (c1 - c0 + 1);
|
|
50
|
+
tUv[k * 2 + 1] = 1 - (rs[j] - r0 + 0.5) / (r1 - r0 + 1); // image row 0 = north
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
let tIdx = new Uint32Array((nc - 1) * (nr - 1) * 6);
|
|
54
|
+
// Where the DEM has no data (a DEM clipped to a polygon) there is no ground.
|
|
55
|
+
const mask = t.mask;
|
|
56
|
+
const on = mask ? (i, j) => mask[rs[j] * g.cols + cs[i]] === 1 : () => true;
|
|
57
|
+
let q = 0;
|
|
58
|
+
for (let j = 0; j < nr - 1; j++) {
|
|
59
|
+
for (let i = 0; i < nc - 1; i++) {
|
|
60
|
+
const aa = j * nc + i, b = aa + 1, d = aa + nc, e = d + 1;
|
|
61
|
+
const oa = on(i, j), ob = on(i + 1, j), od = on(i, j + 1), oe = on(i + 1, j + 1);
|
|
62
|
+
if (oa && od && ob) { tIdx[q++] = aa; tIdx[q++] = d; tIdx[q++] = b; }
|
|
63
|
+
if (ob && od && oe) { tIdx[q++] = b; tIdx[q++] = d; tIdx[q++] = e; }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (q < tIdx.length) tIdx = tIdx.slice(0, q);
|
|
67
|
+
const terrainTexture = composeTerrainTexture(app, { c0, c1, r0, r1 }, overlay, opacity);
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------- extrusions
|
|
70
|
+
const meshes = [];
|
|
71
|
+
const sampleOverlay = makeSampler(overlay);
|
|
72
|
+
for (const layer of app.layers) {
|
|
73
|
+
if (!(layer instanceof ExtrudedLayer) || !layer.visible) continue;
|
|
74
|
+
for (const c of layer.chunks) {
|
|
75
|
+
const geom = c.mesh.geometry;
|
|
76
|
+
const pos = geom.attributes.position.array;
|
|
77
|
+
const nrm = geom.attributes.normal.array;
|
|
78
|
+
const col = geom.attributes.color.array;
|
|
79
|
+
const idx = geom.index.array;
|
|
80
|
+
const keep = new Int32Array(c.feat.length).fill(-1);
|
|
81
|
+
let n = 0;
|
|
82
|
+
for (let v = 0; v < c.feat.length; v++) {
|
|
83
|
+
const f = c.feat[v];
|
|
84
|
+
if (inArea(layer.centroid[f * 2], layer.centroid[f * 2 + 1])) keep[v] = n++;
|
|
85
|
+
}
|
|
86
|
+
if (!n) continue;
|
|
87
|
+
const P = new Float32Array(n * 3), N = new Float32Array(n * 3), C = new Uint8Array(n * 3);
|
|
88
|
+
const rgb = [0, 0, 0];
|
|
89
|
+
for (let v = 0; v < keep.length; v++) {
|
|
90
|
+
const k = keep[v];
|
|
91
|
+
if (k < 0) continue;
|
|
92
|
+
for (let m = 0; m < 3; m++) { P[k * 3 + m] = pos[v * 3 + m]; N[k * 3 + m] = nrm[v * 3 + m]; }
|
|
93
|
+
rgb[0] = col[v * 3]; rgb[1] = col[v * 3 + 1]; rgb[2] = col[v * 3 + 2];
|
|
94
|
+
if (overlay && !r.groundOnly && nrm[v * 3 + 1] > 0.55) {
|
|
95
|
+
const o = sampleOverlay(pos[v * 3], -pos[v * 3 + 2]);
|
|
96
|
+
if (o) for (let m = 0; m < 3; m++) rgb[m] = rgb[m] * (1 - opacity) + toLinear(o[m] / 255) * opacity;
|
|
97
|
+
}
|
|
98
|
+
for (let m = 0; m < 3; m++) C[k * 3 + m] = Math.round(toSrgb(rgb[m]) * 255);
|
|
99
|
+
}
|
|
100
|
+
const I = [];
|
|
101
|
+
for (let i = 0; i < idx.length; i += 3) {
|
|
102
|
+
const x = keep[idx[i]], y = keep[idx[i + 1]], z = keep[idx[i + 2]];
|
|
103
|
+
if (x >= 0 && y >= 0 && z >= 0) I.push(x, y, z);
|
|
104
|
+
}
|
|
105
|
+
meshes.push({ name: layer.entry.name, positions: P, normals: N, colors: C, index: n > 65535 ? Uint32Array.from(I) : Uint16Array.from(I) });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------- trees and poles
|
|
110
|
+
const instances = [];
|
|
111
|
+
for (const layer of app.layers) {
|
|
112
|
+
if (!(layer instanceof PointLayer) || !layer.visible) continue;
|
|
113
|
+
const rows = [];
|
|
114
|
+
for (const [f, x, y] of layer.points) {
|
|
115
|
+
if (!inArea(x, y)) continue;
|
|
116
|
+
const h = Math.max(0.5, layer.heights[f]);
|
|
117
|
+
rows.push(x, t.groundY(x, y), -y, h, layer.isTree ? layer.crownRadius(h) : 0.25,
|
|
118
|
+
layer.colors[f * 3], layer.colors[f * 3 + 1], layer.colors[f * 3 + 2]); // sRGB
|
|
119
|
+
}
|
|
120
|
+
if (rows.length) instances.push({ name: layer.entry.name, kind: layer.isTree ? 'tree' : 'pole', data: Float32Array.from(rows) });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------- facade tiles
|
|
124
|
+
let facade = null;
|
|
125
|
+
const fr = app.activeResult?.facade && app.activeResult.showFacade !== false ? app.activeResult.facade : null;
|
|
126
|
+
if (fr) {
|
|
127
|
+
const stops = rampStops(fr.ramp);
|
|
128
|
+
const keepIdx = [];
|
|
129
|
+
for (let i = 0; i < fr.count; i++) if (inArea(fr.positions[i * 3], -fr.positions[i * 3 + 2])) keepIdx.push(i);
|
|
130
|
+
const P = new Float32Array(keepIdx.length * 3), N = new Float32Array(keepIdx.length * 3), C = new Uint8Array(keepIdx.length * 3);
|
|
131
|
+
const cc = [0, 0, 0];
|
|
132
|
+
keepIdx.forEach((i, k) => {
|
|
133
|
+
for (let m = 0; m < 3; m++) { P[k * 3 + m] = fr.positions[i * 3 + m]; N[k * 3 + m] = fr.normals[i * 3 + m]; }
|
|
134
|
+
rampColor(stops, (fr.values[i] - fr.min) / Math.max(1e-9, fr.max - fr.min), cc);
|
|
135
|
+
for (let m = 0; m < 3; m++) C[k * 3 + m] = Math.round(cc[m] * 255);
|
|
136
|
+
});
|
|
137
|
+
facade = { positions: P, normals: N, colors: C, size: fr.spacing * 0.94 };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------- water
|
|
141
|
+
let water = null;
|
|
142
|
+
if (t.waterMesh) {
|
|
143
|
+
const p = t.waterMesh.geometry.attributes.position.array;
|
|
144
|
+
water = { positions: Float32Array.from(p) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const cam = app.viewer.camera;
|
|
148
|
+
return {
|
|
149
|
+
format: 'geo3d-snapshot', version: 1,
|
|
150
|
+
title: app.manifest.title,
|
|
151
|
+
created: new Date().toISOString(),
|
|
152
|
+
crs: app.manifest.crs.authid, origin: app.manifest.origin,
|
|
153
|
+
terrain: { positions: tPos, uvs: tUv, index: tIdx, texture: terrainTexture },
|
|
154
|
+
meshes, instances, facade, water,
|
|
155
|
+
sun: app.viewer.sunDir.toArray(),
|
|
156
|
+
camera: { position: cam.position.toArray(), target: app.viewer.controls.target.toArray(), fov: cam.fov },
|
|
157
|
+
legend: legendOf(app),
|
|
158
|
+
attribution: attributionOf(app)
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Terrain canvas cropped to the area, with the analysis drape drawn over it. */
|
|
163
|
+
function composeTerrainTexture(app, { c0, c1, r0, r1 }, overlay, opacity) {
|
|
164
|
+
const t = app.terrain;
|
|
165
|
+
const g = t.grid;
|
|
166
|
+
const src = t.canvas;
|
|
167
|
+
const sx = src.width / g.cols, sy = src.height / g.rows;
|
|
168
|
+
const w = Math.min(MAX_TEXTURE, Math.max(2, Math.round((c1 - c0 + 1) * sx)));
|
|
169
|
+
const h = Math.min(MAX_TEXTURE, Math.max(2, Math.round((r1 - r0 + 1) * sy)));
|
|
170
|
+
const canvas = document.createElement('canvas');
|
|
171
|
+
canvas.width = w;
|
|
172
|
+
canvas.height = h;
|
|
173
|
+
const ctx = /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d'));
|
|
174
|
+
ctx.drawImage(src, c0 * sx, r0 * sy, (c1 - c0 + 1) * sx, (r1 - r0 + 1) * sy, 0, 0, w, h);
|
|
175
|
+
if (overlay) {
|
|
176
|
+
const og = overlay.grid;
|
|
177
|
+
const img = new ImageData(new Uint8ClampedArray(overlay.pixels.buffer), og.cols, og.rows);
|
|
178
|
+
const oc = document.createElement('canvas');
|
|
179
|
+
oc.width = og.cols;
|
|
180
|
+
oc.height = og.rows;
|
|
181
|
+
/** @type {CanvasRenderingContext2D} */ (oc.getContext('2d')).putImageData(img, 0, 0);
|
|
182
|
+
const rect = gridRect(og);
|
|
183
|
+
// Area pixel coordinates of the overlay rectangle.
|
|
184
|
+
const ax0 = g.minX + (c0 - 0.5) * g.cell, ay0 = g.maxY - (r0 - 0.5) * g.cell;
|
|
185
|
+
const pxPerM = w / ((c1 - c0 + 1) * g.cell);
|
|
186
|
+
const pyPerM = h / ((r1 - r0 + 1) * g.cell);
|
|
187
|
+
ctx.globalAlpha = opacity;
|
|
188
|
+
ctx.imageSmoothingEnabled = false;
|
|
189
|
+
ctx.drawImage(oc, (rect.minX - ax0) * pxPerM, (ay0 - -rect.minZ) * pyPerM, (rect.maxX - rect.minX) * pxPerM, (rect.maxZ - rect.minZ) * pyPerM);
|
|
190
|
+
ctx.globalAlpha = 1;
|
|
191
|
+
}
|
|
192
|
+
return canvas.toDataURL('image/jpeg', 0.9);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function makeSampler(overlay) {
|
|
196
|
+
if (!overlay) return () => null;
|
|
197
|
+
const g = overlay.grid;
|
|
198
|
+
return (x, y) => {
|
|
199
|
+
const c = Math.round((x - g.minX) / g.cell), r = Math.round((g.maxY - y) / g.cell);
|
|
200
|
+
if (c < 0 || r < 0 || c >= g.cols || r >= g.rows) return null;
|
|
201
|
+
const i = (r * g.cols + c) * 4;
|
|
202
|
+
return overlay.pixels[i + 3] ? [overlay.pixels[i], overlay.pixels[i + 1], overlay.pixels[i + 2]] : null;
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function legendOf(app) {
|
|
207
|
+
const r = app.activeResult;
|
|
208
|
+
if (r) {
|
|
209
|
+
if (r.legend) return { title: r.title, items: r.legend };
|
|
210
|
+
if (r.display?.categorical) return { title: r.title, items: [] };
|
|
211
|
+
const stops = rampStops(r.display.ramp).map((s) => `#${s.map((v) => Math.round(v * 255).toString(16).padStart(2, '0')).join('')}`);
|
|
212
|
+
return { title: r.title, ramp: stops, min: r.display.min, max: r.display.max, unit: r.unit || '' };
|
|
213
|
+
}
|
|
214
|
+
return app.layerLegend || null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function attributionOf(app) {
|
|
218
|
+
const names = app.layers.map((l) => l.entry.name.toLowerCase()).join(' ');
|
|
219
|
+
const parts = ['02 Geo3D Analyzer'];
|
|
220
|
+
if (names.includes('osm')) parts.push('Map data © OpenStreetMap contributors');
|
|
221
|
+
if (app.manifest.terrain?.source) parts.push(`Terrain: ${app.manifest.terrain.source}`);
|
|
222
|
+
return parts.join(' · ');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function toSrgb(v) { return v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055; }
|
|
226
|
+
function toLinear(v) { return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }
|
|
227
|
+
|
|
228
|
+
/** Typed arrays <-> base64 for embedding. */
|
|
229
|
+
export function toBase64(arr) {
|
|
230
|
+
const bytes = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
|
|
231
|
+
let s = '';
|
|
232
|
+
for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + 0x8000)));
|
|
233
|
+
return btoa(s);
|
|
234
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Builds a Three.js group from a baked snapshot (export/snapshot.js).
|
|
2
|
+
// Self-contained on purpose: THREE is passed in, so the same file runs in the
|
|
3
|
+
// viewer (GLB export) and inside a standalone HTML export.
|
|
4
|
+
|
|
5
|
+
const srgbToLinear = (v) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
|
6
|
+
|
|
7
|
+
function linearColors(u8) {
|
|
8
|
+
const out = new Float32Array(u8.length);
|
|
9
|
+
for (let i = 0; i < u8.length; i++) out[i] = srgbToLinear(u8[i] / 255);
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Load the terrain texture first (GLB export needs it before parsing). */
|
|
14
|
+
export function loadTexture(THREE, dataUrl) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
if (!dataUrl) { resolve(null); return; }
|
|
17
|
+
const img = new Image();
|
|
18
|
+
img.onload = () => {
|
|
19
|
+
const tex = new THREE.Texture(img);
|
|
20
|
+
tex.colorSpace = THREE.SRGBColorSpace;
|
|
21
|
+
tex.needsUpdate = true;
|
|
22
|
+
resolve(tex);
|
|
23
|
+
};
|
|
24
|
+
img.onerror = () => resolve(null);
|
|
25
|
+
img.src = dataUrl;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @returns {Promise<any>} a THREE.Group */
|
|
30
|
+
export async function buildGroup(THREE, snap) {
|
|
31
|
+
const group = new THREE.Group();
|
|
32
|
+
group.name = snap.title || 'scene';
|
|
33
|
+
group.userData = { crs: snap.crs, origin: snap.origin, created: snap.created, generator: '02 Geo3D Analyzer' };
|
|
34
|
+
|
|
35
|
+
// Terrain.
|
|
36
|
+
const t = snap.terrain;
|
|
37
|
+
const tg = new THREE.BufferGeometry();
|
|
38
|
+
tg.setAttribute('position', new THREE.BufferAttribute(t.positions, 3));
|
|
39
|
+
tg.setAttribute('uv', new THREE.BufferAttribute(t.uvs, 2));
|
|
40
|
+
tg.setIndex(new THREE.BufferAttribute(t.index, 1));
|
|
41
|
+
tg.computeVertexNormals();
|
|
42
|
+
const tex = await loadTexture(THREE, t.texture);
|
|
43
|
+
const terrain = new THREE.Mesh(tg, new THREE.MeshStandardMaterial({ map: tex, color: tex ? 0xffffff : 0xb9b4a6, roughness: 0.95 }));
|
|
44
|
+
terrain.name = 'terrain';
|
|
45
|
+
terrain.receiveShadow = true;
|
|
46
|
+
group.add(terrain);
|
|
47
|
+
|
|
48
|
+
// Extrusions.
|
|
49
|
+
for (const m of snap.meshes) {
|
|
50
|
+
const g = new THREE.BufferGeometry();
|
|
51
|
+
g.setAttribute('position', new THREE.BufferAttribute(m.positions, 3));
|
|
52
|
+
g.setAttribute('normal', new THREE.BufferAttribute(m.normals, 3));
|
|
53
|
+
g.setAttribute('color', new THREE.BufferAttribute(linearColors(m.colors), 3));
|
|
54
|
+
g.setIndex(new THREE.BufferAttribute(m.index, 1));
|
|
55
|
+
const mesh = new THREE.Mesh(g, new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.82 }));
|
|
56
|
+
mesh.name = m.name;
|
|
57
|
+
mesh.castShadow = true;
|
|
58
|
+
mesh.receiveShadow = true;
|
|
59
|
+
group.add(mesh);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Trees and poles: instanced.
|
|
63
|
+
const mat4 = new THREE.Matrix4();
|
|
64
|
+
const q = new THREE.Quaternion();
|
|
65
|
+
const col = new THREE.Color();
|
|
66
|
+
for (const inst of snap.instances || []) {
|
|
67
|
+
const d = inst.data;
|
|
68
|
+
const count = d.length / 8;
|
|
69
|
+
if (inst.kind === 'tree') {
|
|
70
|
+
const crowns = new THREE.InstancedMesh(new THREE.IcosahedronGeometry(1, 1), new THREE.MeshStandardMaterial({ roughness: 0.9, flatShading: true }), count);
|
|
71
|
+
const trunkGeom = new THREE.CylinderGeometry(0.12, 0.16, 1, 6);
|
|
72
|
+
trunkGeom.translate(0, 0.5, 0);
|
|
73
|
+
const trunks = new THREE.InstancedMesh(trunkGeom, new THREE.MeshStandardMaterial({ color: 0x6b5139 }), count);
|
|
74
|
+
for (let i = 0; i < count; i++) {
|
|
75
|
+
const [x, y, z, h, r] = d.subarray(i * 8, i * 8 + 5);
|
|
76
|
+
const trunkH = Math.max(0.8, h - 1.6 * r);
|
|
77
|
+
mat4.compose(new THREE.Vector3(x, y, z), q, new THREE.Vector3(1.4, trunkH + 0.4, 1.4));
|
|
78
|
+
trunks.setMatrixAt(i, mat4);
|
|
79
|
+
mat4.compose(new THREE.Vector3(x, y + h - r, z), q, new THREE.Vector3(r, r * 1.05, r));
|
|
80
|
+
crowns.setMatrixAt(i, mat4);
|
|
81
|
+
col.setRGB(d[i * 8 + 5], d[i * 8 + 6], d[i * 8 + 7], THREE.SRGBColorSpace);
|
|
82
|
+
crowns.setColorAt(i, col);
|
|
83
|
+
}
|
|
84
|
+
crowns.name = `${inst.name} crowns`;
|
|
85
|
+
trunks.name = `${inst.name} trunks`;
|
|
86
|
+
for (const m of [crowns, trunks]) { m.castShadow = true; group.add(m); }
|
|
87
|
+
} else {
|
|
88
|
+
const geom = new THREE.CylinderGeometry(0.22, 0.26, 1, 8);
|
|
89
|
+
geom.translate(0, 0.5, 0);
|
|
90
|
+
const poles = new THREE.InstancedMesh(geom, new THREE.MeshStandardMaterial({ roughness: 0.6 }), count);
|
|
91
|
+
for (let i = 0; i < count; i++) {
|
|
92
|
+
const [x, y, z, h] = d.subarray(i * 8, i * 8 + 4);
|
|
93
|
+
mat4.compose(new THREE.Vector3(x, y - 0.2, z), q, new THREE.Vector3(1, h + 0.2, 1));
|
|
94
|
+
poles.setMatrixAt(i, mat4);
|
|
95
|
+
col.setRGB(d[i * 8 + 5], d[i * 8 + 6], d[i * 8 + 7], THREE.SRGBColorSpace);
|
|
96
|
+
poles.setColorAt(i, col);
|
|
97
|
+
}
|
|
98
|
+
poles.name = inst.name;
|
|
99
|
+
poles.castShadow = true;
|
|
100
|
+
group.add(poles);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Facade tiles.
|
|
105
|
+
if (snap.facade && snap.facade.positions.length) {
|
|
106
|
+
const f = snap.facade;
|
|
107
|
+
const n = f.positions.length / 3;
|
|
108
|
+
const tiles = new THREE.InstancedMesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 }), n);
|
|
109
|
+
const zAxis = new THREE.Vector3(0, 0, 1);
|
|
110
|
+
const nv = new THREE.Vector3();
|
|
111
|
+
const p = new THREE.Vector3();
|
|
112
|
+
const s = new THREE.Vector3(f.size, f.size, 1);
|
|
113
|
+
for (let i = 0; i < n; i++) {
|
|
114
|
+
nv.set(f.normals[i * 3], f.normals[i * 3 + 1], f.normals[i * 3 + 2]);
|
|
115
|
+
q.setFromUnitVectors(zAxis, nv);
|
|
116
|
+
p.set(f.positions[i * 3], f.positions[i * 3 + 1], f.positions[i * 3 + 2]).addScaledVector(nv, 0.08);
|
|
117
|
+
mat4.compose(p, q, s);
|
|
118
|
+
tiles.setMatrixAt(i, mat4);
|
|
119
|
+
col.setRGB(f.colors[i * 3] / 255, f.colors[i * 3 + 1] / 255, f.colors[i * 3 + 2] / 255, THREE.SRGBColorSpace);
|
|
120
|
+
tiles.setColorAt(i, col);
|
|
121
|
+
}
|
|
122
|
+
tiles.name = 'facade results';
|
|
123
|
+
group.add(tiles);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Flood surface.
|
|
127
|
+
if (snap.water) {
|
|
128
|
+
const g = new THREE.BufferGeometry();
|
|
129
|
+
g.setAttribute('position', new THREE.BufferAttribute(snap.water.positions, 3));
|
|
130
|
+
g.computeVertexNormals();
|
|
131
|
+
const w = new THREE.Mesh(g, new THREE.MeshStandardMaterial({ color: 0x2f7fc1, transparent: true, opacity: 0.62, roughness: 0.15 }));
|
|
132
|
+
w.name = 'water';
|
|
133
|
+
group.add(w);
|
|
134
|
+
}
|
|
135
|
+
return group;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** JSON with typed arrays as base64 (the HTML export embeds the snapshot like this). */
|
|
139
|
+
const TYPES = { Float32Array, Uint32Array, Uint16Array, Uint8Array, Int32Array };
|
|
140
|
+
export function decodeSnapshot(json) {
|
|
141
|
+
return JSON.parse(json, (k, v) => {
|
|
142
|
+
if (v && typeof v === 'object' && v.__ta && TYPES[v.__ta]) {
|
|
143
|
+
const bin = atob(v.b64);
|
|
144
|
+
const bytes = new Uint8Array(bin.length);
|
|
145
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
146
|
+
return new TYPES[v.__ta](bytes.buffer);
|
|
147
|
+
}
|
|
148
|
+
return v;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Entry point: load the scene named in ?scene=..., then build the panels.
|
|
3
|
+
import { App } from './app.js';
|
|
4
|
+
import { buildLayersPanel } from './ui/panel_layers.js';
|
|
5
|
+
import { buildAnalyzePanel } from './ui/panel_analyze.js';
|
|
6
|
+
import { buildTerrainPanel } from './ui/panel_terrain.js';
|
|
7
|
+
import { buildComparePanel } from './ui/panel_compare.js';
|
|
8
|
+
import { buildSunClock } from './ui/sunclock.js';
|
|
9
|
+
import { buildViewTools } from './ui/viewtools.js';
|
|
10
|
+
import { toast } from './ui/dom.js';
|
|
11
|
+
|
|
12
|
+
const TABS = [
|
|
13
|
+
['layers', 'Layers', buildLayersPanel],
|
|
14
|
+
['analyze', 'Analyze', buildAnalyzePanel],
|
|
15
|
+
['terrain', 'Terrain', buildTerrainPanel],
|
|
16
|
+
['compare', 'Compare', buildComparePanel]
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
async function start() {
|
|
20
|
+
const params = new URLSearchParams(location.search);
|
|
21
|
+
const sceneUrl = params.get('scene') || '/scene/scene.json';
|
|
22
|
+
const app = new App();
|
|
23
|
+
/** @type {any} */ (window).geo3d = app; // handy in the browser console
|
|
24
|
+
try {
|
|
25
|
+
await app.load(sceneUrl);
|
|
26
|
+
} catch (e) {
|
|
27
|
+
console.error(e);
|
|
28
|
+
app.status(`Could not open the scene: ${e.message}`);
|
|
29
|
+
const el = document.getElementById('empty');
|
|
30
|
+
if (el) { el.hidden = false; el.querySelector('.detail').textContent = e.message; }
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
document.title = `${app.manifest.title} · 02 Geo3D Analyzer`;
|
|
34
|
+
const title = document.getElementById('scene-title');
|
|
35
|
+
if (title) title.textContent = app.manifest.title;
|
|
36
|
+
buildSunClock(app, document.getElementById('sun-clock'));
|
|
37
|
+
buildViewTools(app);
|
|
38
|
+
const nav = document.getElementById('tabs');
|
|
39
|
+
const panels = document.getElementById('panels');
|
|
40
|
+
const built = new Map();
|
|
41
|
+
const show = (key) => {
|
|
42
|
+
for (const b of nav.querySelectorAll('button')) b.classList.toggle('active', b.dataset.tab === key);
|
|
43
|
+
for (const p of panels.children) /** @type {HTMLElement} */ (p).hidden = /** @type {HTMLElement} */ (p).dataset.tab !== key;
|
|
44
|
+
if (!built.has(key)) {
|
|
45
|
+
const [, , builder] = TABS.find(([k]) => k === key);
|
|
46
|
+
builder(app, panels.querySelector(`[data-tab="${key}"]`));
|
|
47
|
+
built.set(key, true);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
for (const [key, label] of TABS) {
|
|
51
|
+
const b = document.createElement('button');
|
|
52
|
+
b.textContent = label;
|
|
53
|
+
b.dataset.tab = key;
|
|
54
|
+
b.onclick = () => show(key);
|
|
55
|
+
nav.appendChild(b);
|
|
56
|
+
const p = document.createElement('div');
|
|
57
|
+
p.dataset.tab = key;
|
|
58
|
+
p.className = 'panel';
|
|
59
|
+
p.hidden = true;
|
|
60
|
+
panels.appendChild(p);
|
|
61
|
+
}
|
|
62
|
+
show('layers');
|
|
63
|
+
// Build the analysis panel up front: it owns the shared parameters.
|
|
64
|
+
buildAnalyzePanel(app, panels.querySelector('[data-tab="analyze"]'));
|
|
65
|
+
built.set('analyze', true);
|
|
66
|
+
const fps = document.getElementById('fps');
|
|
67
|
+
setInterval(() => {
|
|
68
|
+
if (fps) fps.textContent = `${app.viewer.stats.fps.toFixed(0)} fps · ${app.viewer.stats.drawCalls} draws`;
|
|
69
|
+
}, 1000);
|
|
70
|
+
const warn = app.manifest.warnings || [];
|
|
71
|
+
if (warn.length) toast(warn[0], 'warn');
|
|
72
|
+
if (!app.qgis) toast('Opened without QGIS: results can be saved as files, not sent to QGIS.');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
start();
|