@ortho-earth/voxel 0.0.0-stage → 0.4.0

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.
package/dist/grid.js ADDED
@@ -0,0 +1,455 @@
1
+ const D2R = Math.PI / 180, R_SPHERE = 6371000, R_ELL = 6378137, WGS_A = 6378137, WGS_E2 = 6.69437999014e-3;
2
+ // ---- 色表 ----
3
+ /** 名前のある色表(matplotlib/turbo の要約=9 点を等間隔に。厳密な表ではない) */
4
+ export const COLORMAPS = Object.freeze({
5
+ viridis: ["#440154", "#482878", "#3e4a89", "#31688e", "#26828e", "#1f9e89", "#35b779", "#6ece58", "#b5de2b", "#fde725"],
6
+ magma: ["#000004", "#1c1044", "#4f127b", "#812581", "#b5367a", "#e55c30", "#fb8761", "#fec287", "#fcfdbf"],
7
+ inferno: ["#000004", "#1b0c41", "#4a0c6b", "#781c6d", "#a52c60", "#cf4446", "#ed6925", "#fb9b06", "#f7d13d", "#fcffa4"],
8
+ plasma: ["#0d0887", "#46039f", "#7201a8", "#9c179e", "#bd3786", "#d8576b", "#ed7953", "#fb9f3a", "#fdca26", "#f0f921"],
9
+ turbo: ["#30123b", "#4662d7", "#36aaf9", "#1ae4b6", "#72fe5e", "#c8ef34", "#faba39", "#f66b19", "#ca2a04", "#7a0403"],
10
+ grey: ["#000000", "#ffffff"],
11
+ heat: ["#000000", "#8b0000", "#ff4500", "#ffd700", "#ffffff"],
12
+ cool: ["#00ffff", "#ff00ff"],
13
+ blues: ["#f7fbff", "#c6dbef", "#6baed6", "#2171b5", "#08306b"],
14
+ reds: ["#fff5f0", "#fcbba1", "#fb6a4a", "#cb181d", "#67000d"],
15
+ });
16
+ export const COLORMAP_NAMES = Object.freeze(Object.keys(COLORMAPS));
17
+ export function parseColor(c) {
18
+ if (typeof c === "string") {
19
+ const m = /^#([0-9a-f]{6})([0-9a-f]{2})?$/i.exec(c.trim());
20
+ if (!m)
21
+ throw new TypeError(`voxel: color must be #rrggbb or #rrggbbaa (got ${JSON.stringify(c)})`);
22
+ const v = parseInt(m[1], 16);
23
+ return [(v >> 16) & 255, (v >> 8) & 255, v & 255, m[2] ? parseInt(m[2], 16) : null];
24
+ }
25
+ if (!Array.isArray(c) || c.length < 3 || c.length > 4 || !c.every(Number.isFinite))
26
+ throw new TypeError("voxel: color must be [r, g, b, a?] (0..255)");
27
+ return [c[0], c[1], c[2], c.length === 4 ? c[3] : null];
28
+ }
29
+ /** 色表の指定 → [位置, 色] の並び(位置は昇順・0 と 1 を含む) */
30
+ export function colorStops(spec) {
31
+ if (spec == null)
32
+ spec = "viridis";
33
+ let list;
34
+ if (typeof spec === "string") {
35
+ const m = COLORMAPS[spec];
36
+ if (!m)
37
+ throw new RangeError(`voxel: unknown colormap "${spec}" (${COLORMAP_NAMES.join(" | ")} or stops)`);
38
+ list = m.map((c, i) => [i / (m.length - 1), c]);
39
+ }
40
+ else if (!Array.isArray(spec) || !spec.length)
41
+ throw new TypeError("voxel: colormap must be a name, a list of colors or a list of [t, color]");
42
+ else if (Array.isArray(spec[0]) && typeof spec[0][0] === "number" && spec[0].length === 2 && (typeof spec[0][1] === "string" || Array.isArray(spec[0][1])))
43
+ list = spec.map(([t, c]) => [+t, c]);
44
+ else
45
+ list = spec.map((c, i, a) => [a.length === 1 ? 0 : i / (a.length - 1), c]);
46
+ const out = list.map(([t, c]) => { if (!(t >= 0 && t <= 1))
47
+ throw new RangeError("voxel: colormap stop positions must be within 0..1"); return [t, parseColor(c)]; }).sort((a, b) => a[0] - b[0]);
48
+ if (out[0][0] > 0)
49
+ out.unshift([0, out[0][1]]);
50
+ if (out[out.length - 1][0] < 1)
51
+ out.push([1, out[out.length - 1][1]]);
52
+ return out;
53
+ }
54
+ /** 色表=256 段の RGBA8(値 0〜1 → 色・α は stop に無ければ alphaRamp=[a0, a1] の線形) */
55
+ export function buildColormap(spec, alphaRamp = null) {
56
+ const st = colorStops(spec), out = new Uint8Array(256 * 4), [a0, a1] = alphaRamp ?? [0, 1];
57
+ let k = 0;
58
+ for (let i = 0; i < 256; i++) {
59
+ const t = i / 255;
60
+ while (k < st.length - 2 && t > st[k + 1][0])
61
+ k++;
62
+ const [t0, c0] = st[k], [t1, c1] = st[k + 1], f = t1 > t0 ? Math.min(1, Math.max(0, (t - t0) / (t1 - t0))) : 0;
63
+ const aa = c0[3] != null && c1[3] != null ? c0[3] + (c1[3] - c0[3]) * f : (a0 + (a1 - a0) * t) * 255;
64
+ out[i * 4] = Math.round(c0[0] + (c1[0] - c0[0]) * f);
65
+ out[i * 4 + 1] = Math.round(c0[1] + (c1[1] - c0[1]) * f);
66
+ out[i * 4 + 2] = Math.round(c0[2] + (c1[2] - c0[2]) * f);
67
+ out[i * 4 + 3] = Math.round(Math.min(255, Math.max(0, aa)));
68
+ }
69
+ return out;
70
+ }
71
+ // ---- 値の詰め(R8/RGBA8)----
72
+ /** 値の min/max(noData を除く・1 成分) */
73
+ export function valueRange(values, noData) {
74
+ let lo = Infinity, hi = -Infinity;
75
+ for (let i = 0; i < values.length; i++) {
76
+ const v = values[i];
77
+ if (v === noData || v !== v)
78
+ continue;
79
+ if (v < lo)
80
+ lo = v;
81
+ if (v > hi)
82
+ hi = v;
83
+ }
84
+ if (!(lo <= hi))
85
+ return [0, 1];
86
+ return lo < hi ? [lo, hi] : [lo, lo + 1];
87
+ }
88
+ /** 1 成分の値 → R8(0=空・1〜255=range を [0,1] に写した物。range の外は端に寄せる・threshold 未満は空) */
89
+ export function quantize(values, range, noData, threshold) {
90
+ const out = new Uint8Array(values.length), [lo, hi] = range, sc = 254 / (hi - lo), th = threshold ?? -Infinity;
91
+ for (let i = 0; i < values.length; i++) {
92
+ const v = values[i];
93
+ if (v === noData || v !== v || v < th)
94
+ continue;
95
+ const t = (v - lo) * sc;
96
+ out[i] = 1 + (t <= 0 ? 0 : t >= 254 ? 254 : Math.round(t));
97
+ }
98
+ return out;
99
+ }
100
+ /** 4 成分(RGBA)→ RGBA8(Uint8 はそのまま・Uint16 は 1/257・Float32 は 0〜1 を 255 倍。noData は α 0) */
101
+ export function toRgba8(values, noData) {
102
+ if (values instanceof Uint8Array && noData == null)
103
+ return values.slice(); // 写し=transfer しても渡した人の配列は手放さない
104
+ const out = new Uint8Array(values.length), sc = values instanceof Uint8Array ? 1 : values instanceof Uint16Array ? 1 / 257 : 255;
105
+ for (let i = 0; i < values.length; i += 4) {
106
+ if (noData != null && values[i + 3] === noData)
107
+ continue;
108
+ for (let c = 0; c < 4; c++) {
109
+ const t = values[i + c] * sc;
110
+ out[i + c] = t <= 0 ? 0 : t >= 255 ? 255 : Math.round(t);
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+ // ---- 置き方 ----
116
+ const unit = (lon, lat, rAx) => { const a = lon * D2R; let b = lat * D2R; if (rAx !== 1)
117
+ b = Math.atan(rAx * Math.tan(b)); const cb = Math.cos(b); return [cb * Math.cos(a), Math.sin(b), cb * Math.sin(a)]; };
118
+ /** 錨の東北上 m → 単位球(列=東・北・上×1/R)=本体の単位球(y が極)。splat の splat-gl.ts と同じ */
119
+ export function enuBasis(lon, lat, rAx = 1) {
120
+ const u = unit(lon, lat, rAx), cl = Math.hypot(u[0], u[2]), k = 1 / (rAx === 1 ? R_SPHERE : R_ELL);
121
+ const E = cl < 1e-12 ? [1, 0, 0] : [-u[2] / cl, 0, u[0] / cl], N = [E[1] * u[2] - E[2] * u[1], E[2] * u[0] - E[0] * u[2], E[0] * u[1] - E[1] * u[0]];
122
+ return { E: E.map(v => v * k), N: N.map(v => v * k), U: u.map(v => v * k), u };
123
+ }
124
+ /** ECEF(WGS84・z が極)→ 経緯度・楕円体高 */
125
+ export function ecefToLngLat(x, y, z) {
126
+ const lon = Math.atan2(y, x), p = Math.hypot(x, y);
127
+ let lat = Math.atan2(z, p * (1 - WGS_E2)), h = 0;
128
+ for (let i = 0; i < 6; i++) {
129
+ const s = Math.sin(lat), N = WGS_A / Math.sqrt(1 - WGS_E2 * s * s);
130
+ h = p / Math.cos(lat) - N;
131
+ lat = Math.atan2(z, p * (1 - WGS_E2 * N / (N + h)));
132
+ }
133
+ return [lon / D2R, lat / D2R, h];
134
+ }
135
+ /** ECEF の東北上の基底(行=東・北・上の単位ベクトル・ECEF m → ENU m は行優先 3×3) */
136
+ export function ecefEnuRows(lon, lat) {
137
+ const a = lon * D2R, b = lat * D2R, sa = Math.sin(a), ca = Math.cos(a), sb = Math.sin(b), cb = Math.cos(b);
138
+ return [-sa, ca, 0, -sb * ca, -sb * sa, cb, cb * ca, cb * sa, sb];
139
+ }
140
+ export const mul3 = (A, B) => { const C = new Array(9); for (let r = 0; r < 3; r++)
141
+ for (let c = 0; c < 3; c++)
142
+ C[r * 3 + c] = A[r * 3] * B[c] + A[r * 3 + 1] * B[3 + c] + A[r * 3 + 2] * B[6 + c]; return C; };
143
+ /** 行優先 3×3 の逆行列(特異なら投げる) */
144
+ export function inv3(m) {
145
+ const [a, b, c, d, e, f, g, h, i] = Array.from(m, Number);
146
+ const A = e * i - f * h, B = -(d * i - f * g), C = d * h - e * g, det = a * A + b * B + c * C;
147
+ if (!(Math.abs(det) > 1e-30))
148
+ throw new Error("voxel: the box is degenerate (singular placement)");
149
+ const k = 1 / det;
150
+ return [A * k, -(b * i - c * h) * k, (b * f - c * e) * k, B * k, (a * i - c * g) * k, -(a * f - c * d) * k, C * k, -(a * h - b * g) * k, (a * e - b * d) * k];
151
+ }
152
+ /** heading(北から時計回り)・pitch・roll(度)=東北上の中の向き(行優先 3×3・splat の placeMat と同じ約束) */
153
+ export function hprMat([hd = 0, pt = 0, rl = 0] = []) {
154
+ const h = -hd * D2R, p = pt * D2R, r = rl * D2R;
155
+ const Rz = [Math.cos(h), -Math.sin(h), 0, Math.sin(h), Math.cos(h), 0, 0, 0, 1], Rx = [1, 0, 0, 0, Math.cos(p), -Math.sin(p), 0, Math.sin(p), Math.cos(p)], Ry = [Math.cos(r), 0, Math.sin(r), 0, 1, 0, -Math.sin(r), 0, Math.cos(r)];
156
+ return mul3(mul3(Rz, Rx), Ry);
157
+ }
158
+ /** VoxelData の box → 錨+A(セル → 東北上 m)。transform=局所の単位立方体 [0,1]³ → ECEF m */
159
+ export function placeBox(box, dims) {
160
+ let anchor, T, placed = false;
161
+ if ("transform" in box && box.transform != null) {
162
+ const M = Array.from(box.transform, Number);
163
+ if (M.length !== 16 || !M.every(Number.isFinite))
164
+ throw new TypeError("voxel: box.transform must be a column-major 4×4 (16 finite numbers)");
165
+ anchor = ecefToLngLat(M[12], M[13], M[14]);
166
+ placed = true;
167
+ // 列優先 → 行優先の 3×3・列 i を dims[i] で割る=セル 1 個ぶんの ECEF ベクトル → 東北上 m
168
+ const lin = [M[0] / dims[0], M[4] / dims[1], M[8] / dims[2], M[1] / dims[0], M[5] / dims[1], M[9] / dims[2], M[2] / dims[0], M[6] / dims[1], M[10] / dims[2]];
169
+ T = mul3(ecefEnuRows(anchor[0], anchor[1]), lin);
170
+ }
171
+ else {
172
+ const b = box;
173
+ anchor = [b.origin[0], b.origin[1], b.origin[2] || 0];
174
+ const R = hprMat(b.orientation ?? []);
175
+ T = [R[0] * b.size[0] / dims[0], R[1] * b.size[1] / dims[1], R[2] * b.size[2] / dims[2], R[3] * b.size[0] / dims[0], R[4] * b.size[1] / dims[1], R[5] * b.size[2] / dims[2], R[6] * b.size[0] / dims[0], R[7] * b.size[1] / dims[1], R[8] * b.size[2] / dims[2]];
176
+ }
177
+ // 錨から箱の最遠点まで=各辺の長さの和(上界・地平線の向こうの判定に使うだけ)
178
+ const radius = Math.hypot(T[0] * dims[0], T[3] * dims[0], T[6] * dims[0]) + Math.hypot(T[1] * dims[1], T[4] * dims[1], T[7] * dims[1]) + Math.hypot(T[2] * dims[2], T[5] * dims[2], T[8] * dims[2]);
179
+ return { anchor, A: Float64Array.from(T), radius, placed };
180
+ }
181
+ /** 経緯度・高さ → 単位球(本体の単位球=y が極・rAx=楕円体の b/a で測地緯度を更成緯度に) */
182
+ export const unitAt = (lon, lat, h, rAx = 1) => { const u = unit(lon, lat, rAx), r = 1 + h / (rAx === 1 ? R_SPHERE : R_ELL); return [u[0] * r, u[1] * r, u[2] * r]; };
183
+ export const SHELL_N = 16;
184
+ /** VoxelData の region → 錨(中心の経緯度・h0)+殻+セルの大きさ。region=[w, s, e, n(度), h0, h1(m)]・e < w は日付変更線を跨ぐ */
185
+ export function placeRegion(region, dims, rAx = 1) {
186
+ const [w, s, e, n, h0, h1] = Array.from(region, Number);
187
+ const spanLon = ((e - w) % 360 + 360) % 360 || 360, spanLat = n - s;
188
+ const lonC = w + spanLon / 2, latC = (s + n) / 2, R = rAx === 1 ? R_SPHERE : R_ELL;
189
+ const anchor = [((lonC + 180) % 360 + 360) % 360 - 180, latC, h0];
190
+ const B = enuBasis(anchor[0], anchor[1], rAx), o = unitAt(anchor[0], anchor[1], h0, rAx), k = 1 / R;
191
+ const Eu = B.E.map(v => v / k), Nu = B.N.map(v => v / k), Uu = B.U.map(v => v / k); // 単位ベクトル
192
+ const enu = (lon, lat, h) => { const p = unitAt(lon, lat, h, rAx), d = [p[0] - o[0], p[1] - o[1], p[2] - o[2]]; return [(d[0] * Eu[0] + d[1] * Eu[1] + d[2] * Eu[2]) * R, (d[0] * Nu[0] + d[1] * Nu[1] + d[2] * Nu[2]) * R, (d[0] * Uu[0] + d[1] * Uu[1] + d[2] * Uu[2]) * R]; };
193
+ // 6 面を SHELL_N×SHELL_N に分け、面ごとの外向き(錨の東北上で 下・上・南・北・西・東)に合う向きで三角形に(丸みで面が傾くので「中心から外」では決めない)
194
+ const out = [], N = SHELL_N;
195
+ const quad = (p, o) => {
196
+ const nx = (p[1][1] - p[0][1]) * (p[3][2] - p[0][2]) - (p[1][2] - p[0][2]) * (p[3][1] - p[0][1]), ny = (p[1][2] - p[0][2]) * (p[3][0] - p[0][0]) - (p[1][0] - p[0][0]) * (p[3][2] - p[0][2]), nz = (p[1][0] - p[0][0]) * (p[3][1] - p[0][1]) - (p[1][1] - p[0][1]) * (p[3][0] - p[0][0]);
197
+ const order = nx * o[0] + ny * o[1] + nz * o[2] >= 0 ? [0, 1, 2, 0, 2, 3] : [0, 2, 1, 0, 3, 2];
198
+ for (const i of order)
199
+ out.push(p[i][0], p[i][1], p[i][2]);
200
+ };
201
+ const face = (f, o) => { for (let j = 0; j < N; j++)
202
+ for (let i = 0; i < N; i++)
203
+ quad([f(i / N, j / N), f((i + 1) / N, j / N), f((i + 1) / N, (j + 1) / N), f(i / N, (j + 1) / N)], o); };
204
+ const lonAt = (a) => w + spanLon * a, latAt = (b) => s + spanLat * b, hAt = (c) => h0 + (h1 - h0) * c;
205
+ face((a, b) => enu(lonAt(a), latAt(b), h0), [0, 0, -1]);
206
+ face((a, b) => enu(lonAt(a), latAt(b), h1), [0, 0, 1]); // 下・上
207
+ face((a, c) => enu(lonAt(a), s, hAt(c)), [0, -1, 0]);
208
+ face((a, c) => enu(lonAt(a), n, hAt(c)), [0, 1, 0]); // 南・北
209
+ face((b, c) => enu(w, latAt(b), hAt(c)), [-1, 0, 0]);
210
+ face((b, c) => enu(w + spanLon, latAt(b), hAt(c)), [1, 0, 0]); // 西・東
211
+ const shell = Float32Array.from(out);
212
+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity, z0 = Infinity, z1 = -Infinity;
213
+ for (let i = 0; i < out.length; i += 3) {
214
+ x0 = Math.min(x0, out[i]);
215
+ x1 = Math.max(x1, out[i]);
216
+ y0 = Math.min(y0, out[i + 1]);
217
+ y1 = Math.max(y1, out[i + 1]);
218
+ z0 = Math.min(z0, out[i + 2]);
219
+ z1 = Math.max(z1, out[i + 2]);
220
+ }
221
+ const bc = [(x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2];
222
+ let rb = 0;
223
+ for (let i = 0; i < out.length; i += 3)
224
+ rb = Math.max(rb, Math.hypot(out[i] - bc[0], out[i + 1] - bc[1], out[i + 2] - bc[2]));
225
+ const cellM = [spanLon * D2R * R * Math.cos(latC * D2R) / dims[0], spanLat * D2R * R / dims[1], (h1 - h0) / dims[2]];
226
+ const A = Float64Array.from([cellM[0], 0, 0, 0, cellM[1], 0, 0, 0, cellM[2]]);
227
+ return { anchor, A, radius: Math.hypot(...bc) + rb, placed: true, geo: { west: w, south: s, spanLon, spanLat, h0, h1, shell, bs: [bc[0], bc[1], bc[2], rb], cellM } };
228
+ }
229
+ const nums = (v, name, len) => {
230
+ const a = v != null && typeof v === "object" && typeof v.length === "number" ? Array.from(v, Number) : null;
231
+ if (!a || a.length !== len || !a.every(Number.isFinite))
232
+ throw new TypeError(`voxel: ${name} must be ${len} finite numbers`);
233
+ return a;
234
+ };
235
+ /** 断面の指定を検める(各軸 [lo, hi]・0〜1・lo < hi) */
236
+ export function checkClip(c) {
237
+ if (c == null)
238
+ return null;
239
+ const a = nums(c, "clip", 6);
240
+ for (let i = 0; i < 3; i++)
241
+ if (!(a[i * 2] >= 0 && a[i * 2 + 1] <= 1 && a[i * 2] < a[i * 2 + 1]))
242
+ throw new RangeError("voxel: clip must be [x0, x1, y0, y1, z0, z1] fractions with 0 <= lo < hi <= 1");
243
+ return a;
244
+ }
245
+ export function checkVoxelData(d) {
246
+ const v = d;
247
+ if (!v || typeof v !== "object" || !Array.isArray(v.dims) || v.dims.length !== 3 || !(v.values instanceof Float32Array || v.values instanceof Uint16Array || v.values instanceof Uint8Array))
248
+ throw new TypeError("voxel: data must be VoxelData (dims, values, box | region)");
249
+ const comps = (v.components ?? 1);
250
+ if (comps !== 1 && comps !== 4)
251
+ throw new RangeError("voxel: components must be 1 | 4");
252
+ const T = v.times == null ? 1 : v.times.length;
253
+ if (v.times != null && (!(T >= 1) || !Array.from(v.times).every((x, i, a) => Number.isFinite(x) && (i === 0 || x > a[i - 1]))))
254
+ throw new RangeError("voxel: times must be finite and strictly ascending (ms)");
255
+ if (v.values.length !== v.dims[0] * v.dims[1] * v.dims[2] * comps * T)
256
+ throw new RangeError(`voxel: values length ${v.values.length} != ${v.dims[0] * v.dims[1] * v.dims[2] * comps * T}`);
257
+ const shape = v.shape ?? (v.box ? "box" : v.region ? "region" : undefined);
258
+ if (shape !== "box" && shape !== "region")
259
+ throw new RangeError("voxel: shape must be box | region");
260
+ return { ...v, shape, components: comps };
261
+ }
262
+ /** こま t の値(times が無ければ全部)=values の部分(写さない) */
263
+ export function frameValues(values, n, comps, t) { const k = n * comps; return values.subarray(t * k, (t + 1) * k); }
264
+ /** 時刻 ms → 見せるこま=その時刻までで最後のこま(最初のこまより前は 0・最後より後は最後) */
265
+ export function frameAt(times, ms) { let lo = 0, hi = times.length - 1; if (!(ms >= times[0]))
266
+ return 0; while (lo < hi) {
267
+ const m = (lo + hi + 1) >> 1;
268
+ if (times[m] <= ms)
269
+ lo = m;
270
+ else
271
+ hi = m - 1;
272
+ } return lo; }
273
+ /** 閾(データの単位)→ R8 の 0〜1 */
274
+ export const thrOf = (threshold, range) => threshold == null ? 0 : Math.min(1, Math.max(0, (threshold - range[0]) / (range[1] - range[0])));
275
+ /** VoxelData → 描き手へ渡す物(worker で。配列は transfer できる物)。値は閾で切らない(閾は描き手が見る=等値面は場の全体が要る) */
276
+ export function prepare(data, o = {}) {
277
+ const d = checkVoxelData(data), dims = d.dims, comps = d.components, n = dims[0] * dims[1] * dims[2];
278
+ let place, geo = null;
279
+ if (d.shape === "box") {
280
+ if (!d.box)
281
+ throw new TypeError("voxel: shape \"box\" needs box");
282
+ place = placeBox(d.box, dims);
283
+ }
284
+ else {
285
+ if (!d.region)
286
+ throw new TypeError("voxel: shape \"region\" needs region");
287
+ const r = placeRegion(d.region, dims, o.rAx ?? 1);
288
+ place = r;
289
+ geo = r.geo;
290
+ }
291
+ const { anchor, A, radius, placed } = place;
292
+ let range = [0, 1], thr = 0;
293
+ if (comps === 1) { // 色表の幅はこまを通して一つ(こまで色が変わらない)
294
+ range = o.range ?? d.range ?? valueRange(d.values, d.noData);
295
+ if (!(range[0] < range[1]))
296
+ throw new RangeError("voxel: range must be [min, max] with min < max");
297
+ thr = thrOf(o.threshold, range);
298
+ }
299
+ const T = d.times?.length ?? 1, frames = [];
300
+ for (let t = 0; t < T; t++) {
301
+ const v = T > 1 ? frameValues(d.values, n, comps, t) : d.values;
302
+ frames.push(comps === 4 ? toRgba8(v, d.noData) : quantize(v, range, d.noData, null));
303
+ }
304
+ const cmap = buildColormap(o.colormap, o.alphaRamp ?? null);
305
+ return { shape: d.shape, dims: [dims[0], dims[1], dims[2]], comps, n, tex: frames[0], cmap, anchor, A, radius, range, thr, placed, geo, times: d.times ? Array.from(d.times) : null, frames: T > 1 ? frames : null };
306
+ }
307
+ export const transferables = (p) => [p.tex.buffer, p.cmap.buffer, p.A.buffer, ...(p.geo ? [p.geo.shell.buffer] : []), ...(p.frames ?? []).map(f => f.buffer),
308
+ ...("bricks" in p && p.bricks ? p.bricks.flatMap(b => [b.tex.buffer, b.A.buffer, ...(b.geo ? [b.geo.shell.buffer] : []), ...(b.frames ?? []).map(f => f.buffer)]) : [])].filter((b, i, a) => a.indexOf(b) === i);
309
+ // ---- 大きな格子=ブリック(第 5 段・2026-10-24)----
310
+ // 一軸でも BRICK_LIMIT(WebGL2 の 3D テクスチャの最低保証)を越える格子は、持ち分 BRICK セル角のブリックに分けて別々のテクスチャにする。
311
+ // 各ブリックのテクスチャは持ち分の前後に 1 セルずつ糊代(隣のセル)を足す=三線形・等値面の勾配が継ぎ目で途切れない。描くのは持ち分(own)だけ=体積が継ぎ目で二重に重ならない。
312
+ // box のブリック=親と同じ錨と A(セル → 東北上 m)+セルの位置ずれ off(描き手が A·off だけ動かす=相対高さでも地形でブリックごとにずれない)。
313
+ // region のブリック=経緯度高の部分の region(殻もそれぞれ)。当たり=ブリックの番号 + off → 親の番号(描き手が full と off で戻す)。
314
+ // vmax=ブリックの最大値(R8・RGBA は α)=閾より下のブリックは描かない(空白飛ばしのブリック単位)。
315
+ /** 一枚のテクスチャで描く格子の上限(各軸) */
316
+ export const BRICK_LIMIT = 256;
317
+ /** ブリックの持ち分(各軸のセル数)=テクスチャは糊代を足して最大 BRICK + 2 */
318
+ export const BRICK = 126;
319
+ /** 詰めた値の最大(R8 はそのまま・RGBA は α) */
320
+ export function maxOf(tex, comps) { let m = 0; for (let i = comps === 4 ? 3 : 0; i < tex.length; i += comps)
321
+ if (tex[i] > m) {
322
+ m = tex[i];
323
+ if (m === 255)
324
+ break;
325
+ } return m; }
326
+ /** 分けるか(一軸でも上限を越える) */
327
+ export const needsBricks = (dims, limit = BRICK_LIMIT) => dims[0] > limit || dims[1] > limit || dims[2] > limit;
328
+ /** 各軸の持ち分の区切り([始め, 終わり) の並び) */
329
+ export function brickSpans(n, brick = BRICK) { const out = []; for (let a = 0; a < n; a += brick)
330
+ out.push([a, Math.min(n, a + brick)]); return out; }
331
+ /** 格子の部分(off から bd セル角)を写す(x が最速・成分は comps 個ずつ) */
332
+ export function extract(values, dims, comps, off, bd) {
333
+ const out = new values.constructor(bd[0] * bd[1] * bd[2] * comps), row = bd[0] * comps;
334
+ for (let k = 0; k < bd[2]; k++)
335
+ for (let j = 0; j < bd[1]; j++) {
336
+ const src = (off[0] + dims[0] * (off[1] + j + dims[1] * (off[2] + k))) * comps;
337
+ out.set(values.subarray(src, src + row), (bd[0] * (j + bd[1] * k)) * comps);
338
+ }
339
+ return out;
340
+ }
341
+ /** ブリックの番号(テクスチャの中)→ 親の格子の番号 */
342
+ export function parentIndex(index, dims, off, full) {
343
+ const i = index % dims[0], j = Math.floor(index / dims[0]) % dims[1], k = Math.floor(index / (dims[0] * dims[1]));
344
+ const cell = [i + off[0], j + off[1], k + off[2]];
345
+ return { index: cell[0] + full[0] * (cell[1] + full[1] * cell[2]), cell };
346
+ }
347
+ /** prepare の大きな格子版=上限を越えればブリックに分ける(越えなければ prepare と同じ+vmax) */
348
+ export function prepareGrid(data, o = {}, brick = BRICK, limit = BRICK_LIMIT) {
349
+ const d = checkVoxelData(data), dims = d.dims;
350
+ if (!needsBricks(dims, limit)) {
351
+ const p = prepare(d, o);
352
+ return { ...p, vmax: Math.max(...(p.frames ?? [p.tex]).map(f => maxOf(f, p.comps))), bricks: null };
353
+ }
354
+ const T = d.times?.length ?? 1, nAll = dims[0] * dims[1] * dims[2];
355
+ const comps = d.components;
356
+ let range = [0, 1];
357
+ if (comps === 1) {
358
+ range = o.range ?? d.range ?? valueRange(d.values, d.noData);
359
+ if (!(range[0] < range[1]))
360
+ throw new RangeError("voxel: range must be [min, max] with min < max");
361
+ }
362
+ // 親の置き方(錨・A・半径)=box はブリックも同じ物を使う。region は部分の region を作り直す
363
+ let parent, pgeo = null;
364
+ if (d.shape === "box") {
365
+ if (!d.box)
366
+ throw new TypeError("voxel: shape \"box\" needs box");
367
+ parent = placeBox(d.box, dims);
368
+ }
369
+ else {
370
+ if (!d.region)
371
+ throw new TypeError("voxel: shape \"region\" needs region");
372
+ const r = placeRegion(d.region, dims, o.rAx ?? 1);
373
+ parent = r;
374
+ pgeo = r.geo;
375
+ }
376
+ const sx = brickSpans(dims[0], brick), sy = brickSpans(dims[1], brick), sz = brickSpans(dims[2], brick), bricks = [];
377
+ let vmax = 0;
378
+ for (const [z0, z1] of sz)
379
+ for (const [y0, y1] of sy)
380
+ for (const [x0, x1] of sx) {
381
+ const lo = [x0, y0, z0], hi = [x1, y1, z1];
382
+ const off = [0, 1, 2].map(i => Math.max(0, lo[i] - 1)), end = [0, 1, 2].map(i => Math.min(dims[i], hi[i] + 1));
383
+ const bd = [0, 1, 2].map(i => end[i] - off[i]);
384
+ const fr = [];
385
+ for (let t = 0; t < T; t++) {
386
+ const sub = extract(T > 1 ? frameValues(d.values, nAll, comps, t) : d.values, dims, comps, off, bd);
387
+ fr.push(comps === 4 ? toRgba8(sub, d.noData) : quantize(sub, range, d.noData, null));
388
+ }
389
+ const tex = fr[0], frames = T > 1 ? fr : null;
390
+ const vm = Math.max(...fr.map(f => maxOf(f, comps)));
391
+ vmax = Math.max(vmax, vm);
392
+ const own = [lo[0] - off[0], lo[1] - off[1], lo[2] - off[2], hi[0] - off[0], hi[1] - off[1], hi[2] - off[2]];
393
+ let b;
394
+ if (pgeo) {
395
+ const g = pgeo, f = (i, v) => v / dims[i];
396
+ const r = placeRegion([g.west + g.spanLon * f(0, off[0]), g.south + g.spanLat * f(1, off[1]), g.west + g.spanLon * f(0, end[0]), g.south + g.spanLat * f(1, end[1]), g.h0 + (g.h1 - g.h0) * f(2, off[2]), g.h0 + (g.h1 - g.h0) * f(2, end[2])], bd, o.rAx ?? 1);
397
+ b = { off, own, dims: bd, n: bd[0] * bd[1] * bd[2], tex, frames, vmax: vm, anchor: r.anchor, A: r.A, radius: r.radius, geo: r.geo };
398
+ }
399
+ else
400
+ b = { off, own, dims: bd, n: bd[0] * bd[1] * bd[2], tex, frames, vmax: vm, anchor: parent.anchor, A: Float64Array.from(parent.A), radius: parent.radius, geo: null };
401
+ bricks.push(b);
402
+ }
403
+ const cmap = buildColormap(o.colormap, o.alphaRamp ?? null);
404
+ return { shape: d.shape, dims: [dims[0], dims[1], dims[2]], comps, n: dims[0] * dims[1] * dims[2], tex: new Uint8Array(0), cmap, anchor: parent.anchor, A: parent.A, radius: parent.radius, range, thr: comps === 4 ? 0 : thrOf(o.threshold, range), placed: parent.placed, geo: null, times: d.times ? Array.from(d.times) : null, frames: null, vmax, bricks };
405
+ }
406
+ /** 光線と箱 [0, dims] の交わり(t0, t1)。無ければ null */
407
+ export function rayBox(ro, rd, dims) {
408
+ let t0 = -Infinity, t1 = Infinity, axis = -1;
409
+ for (let i = 0; i < 3; i++) {
410
+ if (Math.abs(rd[i]) < 1e-12) {
411
+ if (ro[i] < 0 || ro[i] > dims[i])
412
+ return null;
413
+ continue;
414
+ }
415
+ let a = (0 - ro[i]) / rd[i], b = (dims[i] - ro[i]) / rd[i];
416
+ if (a > b)
417
+ [a, b] = [b, a];
418
+ if (a > t0) {
419
+ t0 = a;
420
+ axis = i;
421
+ }
422
+ if (b < t1)
423
+ t1 = b;
424
+ }
425
+ return t1 > Math.max(t0, 0) ? [t0, t1, axis] : null;
426
+ }
427
+ /** ブロックの DDA:値(R8・0=空)が thr 以上の最初のセル。tMax=ここまで(シーンの深度)。cell 空間の光線 */
428
+ export function marchBlocks(tex, dims, ro, rd, thr = 0, tMax = Infinity, comps = 1) {
429
+ const hit = rayBox(ro, rd, dims);
430
+ if (!hit)
431
+ return null;
432
+ const t0 = Math.max(hit[0], 0), t1 = Math.min(hit[1], tMax);
433
+ if (t1 <= t0)
434
+ return null;
435
+ const p = [ro[0] + rd[0] * (t0 + 1e-6), ro[1] + rd[1] * (t0 + 1e-6), ro[2] + rd[2] * (t0 + 1e-6)];
436
+ const c = p.map((v, i) => Math.min(dims[i] - 1, Math.max(0, Math.floor(v)))), st = [0, 1, 2].map(i => rd[i] > 0 ? 1 : rd[i] < 0 ? -1 : 0);
437
+ const tMaxA = [0, 1, 2].map(i => st[i] === 0 ? Infinity : ((st[i] > 0 ? c[i] + 1 : c[i]) - ro[i]) / rd[i]), tDelta = [0, 1, 2].map(i => st[i] === 0 ? Infinity : Math.abs(1 / rd[i]));
438
+ let axis = hit[0] >= 0 ? hit[2] : -1, t = t0;
439
+ const solid = (i) => comps === 4 ? tex[i * 4 + 3] > 0 : tex[i] > 0 && (tex[i] - 1) / 254 >= thr;
440
+ for (let steps = 0; steps < dims[0] + dims[1] + dims[2] + 2; steps++) {
441
+ const idx = c[0] + dims[0] * (c[1] + dims[1] * c[2]);
442
+ if (solid(idx))
443
+ return { cell: [c[0], c[1], c[2]], t, axis, steps };
444
+ const ax = tMaxA[0] < tMaxA[1] ? (tMaxA[0] < tMaxA[2] ? 0 : 2) : (tMaxA[1] < tMaxA[2] ? 1 : 2);
445
+ t = tMaxA[ax];
446
+ if (t > t1)
447
+ return null;
448
+ c[ax] += st[ax];
449
+ if (c[ax] < 0 || c[ax] >= dims[ax])
450
+ return null;
451
+ tMaxA[ax] += tDelta[ax];
452
+ axis = ax;
453
+ }
454
+ return null;
455
+ }
@@ -0,0 +1,114 @@
1
+ import type { GlobeMap, OrthoPlugin, OverlayHandle, OverlayPick, VoxelData } from "@ortho-earth/globe";
2
+ import type { ColormapSpec, RenderMode, Clip } from "./grid.js";
3
+ export { prepare, checkVoxelData, checkClip, thrOf, buildColormap, colorStops, parseColor, quantize, toRgba8, valueRange, placeBox, placeRegion, unitAt, hprMat, enuBasis, ecefToLngLat, ecefEnuRows, inv3, mul3, rayBox, marchBlocks, COLORMAPS, COLORMAP_NAMES, SHELL_N, prepareGrid, maxOf, needsBricks, brickSpans, extract, parentIndex, frameValues, frameAt, BRICK, BRICK_LIMIT } from "./grid.js";
4
+ export type { PrepareOptions, Prepared, PreparedGrid, Brick, ColormapSpec, ColorStop, RenderMode, Clip, Hpr, Placement, GeoGrid, RayHit, EnuBasis, Mat3 } from "./grid.js";
5
+ export type { VoxelMessage, VoxelLayerBody, VoxelStyle, VoxelPickResult, VoxelAltitude, OverlayHost, CamState, VoxelApi, PickContext } from "./voxel-gl.js";
6
+ export type { VoxelBackend, VoxelDrawOpts } from "./voxel-draw.js";
7
+ export type { VoxelGpuModule } from "./voxel-gpu.js";
8
+ import type { VoxelBackend } from "./voxel-draw.js";
9
+ export declare const VERSION = "0.4.0";
10
+ export type VoxelSource = VoxelData | string | URL | ArrayBuffer | Uint8Array;
11
+ export interface VoxelOptions {
12
+ kind?: "voxel";
13
+ id?: string;
14
+ /** "volume" (default): trilinear samples composited front to back with the colormap's colour and alpha; "blocks": opaque cells, the first solid cell along the ray, shaded by its face;
15
+ * "iso": the surface where the value crosses `threshold`, shaded by the field's gradient */
16
+ render?: RenderMode;
17
+ /** keep only this part of the grid: [x0, x1, y0, y1, z0, z1] fractions 0..1 of each axis (a slice is a thin range). Changes without re-uploading the grid */
18
+ clip?: Clip | null;
19
+ /** a name (viridis | magma | inferno | plasma | turbo | grey | heat | cool | blues | reds), a list of colours (evenly spaced) or [t, colour] stops. Colours "#rrggbb" / "#rrggbbaa" / [r, g, b, a?]. Scalar grids only */
20
+ colormap?: ColormapSpec;
21
+ /** values mapped to the colormap's 0..1 (default data.range, else the data's min / max) */
22
+ range?: [number, number];
23
+ /** cells below this value (data units) are empty */
24
+ threshold?: number;
25
+ /** volume: alpha over the colormap's 0..1 when the colours carry none (default [0, 1]) */
26
+ alphaRamp?: [number, number];
27
+ /** volume: distance between samples in cells (default 0.5) */
28
+ step?: number;
29
+ /** multiplies every cell's opacity (default 1) */
30
+ opacity?: number;
31
+ /** "relative": the box origin's alt is above the ground (default for origin boxes); "absolute": above the ellipsoid (default for transform boxes and region grids) */
32
+ altitude?: "relative" | "absolute";
33
+ /** hidden by terrain and buildings (default true) */
34
+ depthTest?: boolean;
35
+ /** draw above the labels (default false) */
36
+ above?: boolean;
37
+ /** merged into every pick hit's properties (with index, cell, value) */
38
+ properties?: Record<string, unknown>;
39
+ /** grids with `times` (a time dimension): "clock" (default) shows the frame of the map's clock (map.clock: the last frame at or before its time) and follows it as it plays;
40
+ * a number (ms) pins that time. Changes without re-uploading the grid */
41
+ time?: number | "clock";
42
+ }
43
+ export interface VoxelPluginOptions {
44
+ /** "auto" (default): WebGPU when the map draws with WebGPU (shared device and depth texture), else WebGL2. "webgpu": WebGPU (own device under a WebGL2 map) or fail. "webgl2": always WebGL2 */
45
+ backend?: VoxelBackend | "auto";
46
+ }
47
+ /** handle.pick の 1 件=統一 picking の答え(layer=手綱の id・index=セルの番号・cell=[i, j, k]・value=セルの値) */
48
+ export interface VoxelHit extends OverlayPick {
49
+ id: number;
50
+ layer: string;
51
+ index: number;
52
+ cell: [number, number, number];
53
+ value: number | null;
54
+ distance: number;
55
+ lngLat: [number, number];
56
+ properties: Record<string, unknown> & {
57
+ index: number;
58
+ cell: [number, number, number];
59
+ value: number | null;
60
+ };
61
+ }
62
+ export interface VoxelHandle {
63
+ readonly id: string;
64
+ readonly kind: "voxel";
65
+ /** [nx, ny, nz] after the last bake */
66
+ dims: [number, number, number] | null;
67
+ /** number of cells after the last bake */
68
+ n: number;
69
+ /** [lng, lat, alt] of the box origin */
70
+ anchor: [number, number, number] | null;
71
+ /** values mapped to the colormap (scalar grids) */
72
+ range: [number, number] | null;
73
+ /** the overlay handle (map.overlay) */
74
+ readonly overlay: OverlayHandle;
75
+ /** which renderer draws this map's voxels: "webgpu" | "webgl2", null until the overlay has chosen */
76
+ readonly backend: VoxelBackend | null;
77
+ /** "box" | "region" after the last bake */
78
+ shape: "box" | "region" | null;
79
+ /** where the last source was prepared: "worker" (voxel-reader.ts) or "main"; null before the first bake */
80
+ reader: "worker" | "main" | null;
81
+ /** the renderer refused the layer (e.g. dims beyond MAX_3D_TEXTURE_SIZE); null when it draws */
82
+ error: string | null;
83
+ /** number of bricks the grid was split into (an axis beyond 256 cells); 0 = one texture */
84
+ bricks: number;
85
+ /** the frame times (ms) of a grid with a time dimension, null otherwise */
86
+ times: number[] | null;
87
+ /** the frame shown (0 without a time dimension) */
88
+ frame: number;
89
+ ready: Promise<VoxelHandle>;
90
+ setData(data: VoxelSource): Promise<VoxelHandle>;
91
+ setProps(p: Partial<VoxelOptions>): Promise<VoxelHandle>;
92
+ setVisible(on: boolean): VoxelHandle;
93
+ pick(x: number, y: number, r?: number): Promise<VoxelHit[]>;
94
+ remove(): void;
95
+ }
96
+ export interface VoxelPlugin extends OrthoPlugin {
97
+ name: "voxel";
98
+ version: string;
99
+ provides: string[];
100
+ install(map: GlobeMap): () => void;
101
+ }
102
+ export type VoxelMap = Pick<GlobeMap, "overlay" | "pickOverlays"> & {
103
+ ellipsoidOn?: () => boolean;
104
+ clock?: Pick<GlobeMap["clock"], "time" | "on" | "off">;
105
+ };
106
+ /** 手綱が全部外れてからオーバーレイを片付けるまでの猶予(ms)=場面の切り替え(remove → すぐ addData)で同じ名のオーバーレイを壊して作り直さない(render worker の片付けと新しい登録が競う) */
107
+ export declare const OVERLAY_LINGER_MS = 250;
108
+ /** 格子を上げ直さずに変えられる opts(setProps はこれだけなら "style" を送る) */
109
+ export declare const STYLE_KEYS: readonly (keyof VoxelOptions)[];
110
+ /** map.addData(data, { kind: "voxel", … }) の実体 */
111
+ export declare function createVoxel(map: VoxelMap, data: VoxelSource, opts?: VoxelOptions, { backend }?: VoxelPluginOptions): VoxelHandle;
112
+ /** map.use(voxelPlugin()) → map.addData(data, { kind: "voxel", render?, colormap?, range?, threshold?, alphaRamp?, step?, opacity?, altitude?, depthTest?, above?, properties? }) */
113
+ export declare function voxelPlugin({ backend }?: VoxelPluginOptions): VoxelPlugin;
114
+ export default voxelPlugin;