@bitruvius/geo-core 0.3.1

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.
@@ -0,0 +1,304 @@
1
+ /**
2
+ * The shared **mesh vocabulary** for OGC 3D-Tiles / I3S textured-mesh streaming —
3
+ * the analog of {@link GltfSplatCloud} / `PackedPointTile` for triangle (and
4
+ * point) meshes. It lives in core so the parser (`@bitruvius/gltf`, produces the
5
+ * normalized model), the ENU packer ({@link packMeshEnu}), the engine, and the
6
+ * renderer (`@bitruvius/mesh`, consumes the packed model) all depend only on core.
7
+ *
8
+ * Two layers:
9
+ * - **Normalized** (glb-local, indirection-free) — what the parser emits.
10
+ * - **Packed** (ENU-rebased, f32-safe, GPU-ready) — what the renderer uploads.
11
+ *
12
+ * The material model is the **unlit-now / PBR-later seam**: the MVP renderer reads
13
+ * only baseColor + alpha; the metallic/roughness/normal/emissive/occlusion fields
14
+ * are carried so the Phase-2 PBR path is purely additive.
15
+ */
16
+ import type { Mat4 } from '../geo/mat4';
17
+ import type { TypedArray } from './mesh-codecs';
18
+ import type { FeaturePropertiesData } from './feature-metadata';
19
+ export type AlphaMode = 'OPAQUE' | 'MASK' | 'BLEND';
20
+ export type PrimitiveMode = 'TRIANGLES' | 'POINTS';
21
+ /** A texture binding: which texture + which UV set (TEXCOORD_n). */
22
+ export interface NormalizedTextureRef {
23
+ readonly textureIndex: number;
24
+ readonly texCoord: number;
25
+ }
26
+ export interface NormalizedMaterial {
27
+ readonly baseColorFactor: readonly [number, number, number, number];
28
+ readonly baseColorTexture?: NormalizedTextureRef;
29
+ readonly alphaMode: AlphaMode;
30
+ readonly alphaCutoff: number;
31
+ readonly doubleSided: boolean;
32
+ /** KHR_materials_unlit present (photogrammetry / reality meshes). */
33
+ readonly unlit: boolean;
34
+ readonly metallicFactor: number;
35
+ readonly roughnessFactor: number;
36
+ readonly metallicRoughnessTexture?: NormalizedTextureRef;
37
+ readonly normalTexture?: NormalizedTextureRef & {
38
+ readonly scale: number;
39
+ };
40
+ readonly occlusionTexture?: NormalizedTextureRef & {
41
+ readonly strength: number;
42
+ };
43
+ readonly emissiveFactor: readonly [number, number, number];
44
+ readonly emissiveTexture?: NormalizedTextureRef;
45
+ }
46
+ /** The glTF default material (no `material` on a primitive). */
47
+ export declare const DEFAULT_MATERIAL: NormalizedMaterial;
48
+ /** A decoded-but-unmapped vertex attribute carried through from Draco. `componentType` is the raw
49
+ * GL enum (e.g. 5121 UNSIGNED_BYTE, 5126 FLOAT) so downstream widening (u8→u16 intensity) is correct. */
50
+ export interface ExtraVertexAttribute {
51
+ readonly array: TypedArray;
52
+ readonly componentType: number;
53
+ readonly componentCount: number;
54
+ }
55
+ export interface NormalizedPrimitive {
56
+ readonly mode: PrimitiveMode;
57
+ /** 3·N vertex positions (glb-local meters; ENU rebasing happens in packMeshEnu). */
58
+ readonly positions: Float32Array;
59
+ readonly normals?: Float32Array;
60
+ readonly uv0?: Float32Array;
61
+ /** 4·N RGBA — `Float32Array` normalized 0..1 (glTF COLOR_0) or RAW `Uint8Array` 0..255
62
+ * (I3S `color`, GPU-normalized at upload). The renderer branches on the array type. */
63
+ readonly color0?: Float32Array | Uint8Array;
64
+ /** 4·N I3S texture-atlas region `(uMin,vMin,uMax,vMax)` as raw u16 — present only on
65
+ * atlased I3S 3DObject; the shader wraps uv0 into this region. */
66
+ readonly uvRegion?: Uint16Array;
67
+ readonly indices?: Uint16Array | Uint32Array;
68
+ /** Per-vertex feature id (EXT_mesh_features / _FEATURE_ID_0) for picking. */
69
+ readonly featureIds?: Uint32Array;
70
+ /** Decoded Draco attributes not consumed as a geometry semantic (e.g. `_INTENSITY`,
71
+ * `_CLASSIFICATION`), keyed by glTF attribute semantic. `EXT_structural_metadata` reads these for
72
+ * Draco point clouds, whose property-attribute accessors carry no bufferView and otherwise read as
73
+ * all-zeros. Undefined for uncompressed primitives. */
74
+ readonly extraAttributes?: ReadonlyMap<string, ExtraVertexAttribute>;
75
+ readonly materialIndex?: number;
76
+ /** Column-major 4×4 node world transform (glb root → this primitive's node). */
77
+ readonly nodeMatrix: Mat4;
78
+ }
79
+ export interface NormalizedMesh {
80
+ readonly primitives: readonly NormalizedPrimitive[];
81
+ readonly materials: readonly NormalizedMaterial[];
82
+ /**
83
+ * Which node produced which run of {@link NormalizedMesh.primitives}, populated ONLY
84
+ * when at least one node carries `EXT_mesh_gpu_instancing`. Ordinary tiles leave it
85
+ * undefined and allocate nothing.
86
+ *
87
+ * @remarks
88
+ * Node identity is normally flattened away here, and for a plain tile that is the
89
+ * right trade. A 1.1 instanced tile needs it back: instancing is per-node, so the
90
+ * decoder has to know which primitives belong to an instanced node (their placement
91
+ * goes into instance matrices) and which do not. For those nodes the primitive's
92
+ * `nodeMatrix` is left as identity and the real transform travels here instead, so
93
+ * it is not also baked into the vertices.
94
+ */
95
+ readonly nodeSpans?: readonly {
96
+ /** `[start, end)` into {@link NormalizedMesh.primitives}. */
97
+ readonly primitiveRange: readonly [number, number];
98
+ /** The node's composed world transform (glTF model space). */
99
+ readonly worldMatrix: Mat4;
100
+ /** Present iff this node carries `EXT_mesh_gpu_instancing`. */
101
+ readonly instancing?: {
102
+ readonly attributes: Readonly<Record<string, number>>;
103
+ };
104
+ }[];
105
+ }
106
+ export interface PackedTextureLevel {
107
+ readonly width: number;
108
+ readonly height: number;
109
+ readonly data: Uint8Array;
110
+ }
111
+ export interface PackedTexture {
112
+ /** A GPU-compressed payload (KTX2/Basis-transcoded), OR `rgba` for raw upload, OR a decoded
113
+ * `bitmap` for a zero-readback upload. */
114
+ readonly compressed?: {
115
+ readonly format: number;
116
+ readonly levels: readonly PackedTextureLevel[];
117
+ };
118
+ readonly rgba?: PackedTextureLevel;
119
+ /** A decoded `ImageBitmap` (JPEG/PNG) uploaded DIRECTLY via `texImage2D` — skips the OffscreenCanvas
120
+ * `getImageData` RGBA readback (a full-res CPU copy per tile) the `rgba` path pays. The renderer closes
121
+ * it after upload. Transferable across the worker→main boundary. (The I3S decoder still uses `rgba`.) */
122
+ readonly bitmap?: ImageBitmap;
123
+ /** baseColor textures are sRGB. */
124
+ readonly srgb: boolean;
125
+ readonly sampler: {
126
+ readonly wrapS: number;
127
+ readonly wrapT: number;
128
+ readonly minFilter: number;
129
+ readonly magFilter: number;
130
+ };
131
+ }
132
+ export interface PackedInstances {
133
+ readonly count: number;
134
+ /** 16·count column-major instance matrices (ENU, relative to the tile origin). */
135
+ readonly matrices: Float32Array;
136
+ }
137
+ /**
138
+ * One instanced draw group: a run of {@link PackedMeshTile.primitives} that shares a
139
+ * single set of per-instance matrices.
140
+ *
141
+ * @remarks
142
+ * A tile is not always instanced as a whole. `EXT_mesh_gpu_instancing` (3D Tiles 1.1)
143
+ * is a **per-node** extension, so one glTF can carry several independently instanced
144
+ * nodes beside plain non-instanced ones, and a `cmpt` composite can hold an instanced
145
+ * `i3dm` next to a plain `b3dm`. `primitiveRange` is what lets one tile express that.
146
+ *
147
+ * Primitives outside every group's range are drawn normally (no instancing). The
148
+ * renderer already worked this way internally via its per-primitive instance-count
149
+ * override; this is the contract catching up.
150
+ */
151
+ export interface PackedInstanceGroup extends PackedInstances {
152
+ /** `[start, end)` into {@link PackedMeshTile.primitives}. */
153
+ readonly primitiveRange: readonly [number, number];
154
+ }
155
+ /**
156
+ * The instance groups of a tile, normalized across both spellings.
157
+ *
158
+ * @remarks
159
+ * Every reader must go through this rather than branching on which field is set.
160
+ * {@link PackedMeshTile.instances} is the older per-tile form and is still produced
161
+ * directly by hand-built tiles (the model-instances layers), so it is normalized here
162
+ * to a single group spanning every primitive.
163
+ *
164
+ * @param tile - The packed tile.
165
+ * @returns Its groups, or a shared empty array when the tile is not instanced.
166
+ */
167
+ export declare function instanceGroupsOf(tile: PackedMeshTile): readonly PackedInstanceGroup[];
168
+ export interface PackedMeshPrimitive {
169
+ readonly mode: PrimitiveMode;
170
+ /** 3·N positions in ENU meters relative to the tile origin (f32-safe). */
171
+ readonly positions: Float32Array;
172
+ /** 3·N TRUE web-mercator positions ([0,1] x/y, altitude·meterInMercatorUnits z) relative to
173
+ * {@link PackedMeshTile.mercatorOrigin} — baked per vertex at decode (Cesium SceneMode2DPipelineStage
174
+ * style) when `bakeMercator` is set, so the renderer places each vertex EXACTLY (no per-tile affine
175
+ * tangent → no intra-tile parallax-swim on global datasets). Absent for localized datasets (the ENU
176
+ * `positions` + per-tile placement path is byte-identical). The `positions` array is still produced
177
+ * (ENU) for normals/shadow. */
178
+ readonly mercatorOffset?: Float32Array;
179
+ readonly normals?: Float32Array;
180
+ readonly uv0?: Float32Array;
181
+ readonly color0?: Float32Array | Uint8Array;
182
+ /** 4·N I3S texture-atlas region `(uMin,vMin,uMax,vMax)` as raw u16 (GPU-normalized). */
183
+ readonly uvRegion?: Uint16Array;
184
+ readonly indices?: Uint16Array | Uint32Array;
185
+ readonly featureIds?: Uint32Array;
186
+ /** Resolved material (textures referenced by index into {@link PackedMeshTile.textures}). */
187
+ readonly material: NormalizedMaterial;
188
+ }
189
+ /**
190
+ * A tile's hard edges as one flat line list — the lines a person would draw sketching the model.
191
+ *
192
+ * @remarks
193
+ * Derived at decode (no service ships edge geometry), merged across the tile's primitives so the
194
+ * edge pass is one draw. Positions are in the same frame as {@link PackedMeshPrimitive.positions};
195
+ * {@link PackedEdges.mercatorOffsets} mirrors {@link PackedMeshPrimitive.mercatorOffset} for baked
196
+ * planet-scale tiles, which are placed from mercator rather than ENU.
197
+ */
198
+ export interface PackedEdges {
199
+ /** 6·count ENU positions relative to the tile origin: `[x0,y0,z0, x1,y1,z1]` per edge. */
200
+ readonly positions: Float32Array;
201
+ /** 6·count baked web-mercator positions, present iff the tile was baked. */
202
+ readonly mercatorOffsets?: Float32Array;
203
+ /** Per-edge feature ordinal, so an edge hides with the feature it outlines. */
204
+ readonly featureIds?: Uint32Array;
205
+ readonly count: number;
206
+ /** True when the per-tile edge budget cut the result short. */
207
+ readonly truncated: boolean;
208
+ }
209
+ export interface PackedMeshTile {
210
+ /** Per-tile ENU origin (meters); add in the shader to recover world ENU. */
211
+ readonly origin: readonly [number, number, number];
212
+ /** Per-tile TRUE web-mercator reference point ([0,1] x/y, altitude·meterInMercatorUnits z) that
213
+ * {@link PackedMeshPrimitive.mercatorOffset} is relative to (the tile's geodetic-center mercator).
214
+ * Present iff the tile was baked (`bakeMercator`). The renderer places mercator tiles with
215
+ * `mvpWorld · translate(mercatorOrigin + datumMercZ)` — no per-tile placement tangent. */
216
+ readonly mercatorOrigin?: readonly [number, number, number];
217
+ readonly primitives: readonly PackedMeshPrimitive[];
218
+ /** Decoded textures, indexed by glTF texture index. Populated by the decoder. */
219
+ readonly textures: readonly PackedTexture[];
220
+ /**
221
+ * Per-tile instancing: one group over every primitive.
222
+ *
223
+ * @deprecated Prefer {@link PackedMeshTile.instanceGroups}, which can also express a
224
+ * tile that is only partly instanced. Still read (via {@link instanceGroupsOf}) and
225
+ * still produced by the hand-built model-instances layers, so it is not going away
226
+ * without notice.
227
+ */
228
+ readonly instances?: PackedInstances;
229
+ /** Instanced draw groups, each covering a run of {@link PackedMeshTile.primitives}.
230
+ * Read it through {@link instanceGroupsOf}, never directly. */
231
+ readonly instanceGroups?: readonly PackedInstanceGroup[];
232
+ readonly featureCount: number;
233
+ /** Hard edges for the whole tile, present iff the decoder was asked for them
234
+ * ({@link MeshDecodeContext.edges}). */
235
+ readonly edges?: PackedEdges;
236
+ /** Per-feature BIM metadata (3D Tiles batch table / EXT_structural_metadata), in a structured-
237
+ * cloneable plain form so it survives the worker→main hand-off; materialized to
238
+ * {@link FeatureProperties} for picking/styling. Indexed by the `featureIds` ordinal. CPU-side. */
239
+ readonly featureMetadata?: FeaturePropertiesData;
240
+ /** Resident byte estimate (geometry + textures) for the engine memory budget. */
241
+ readonly bytes: number;
242
+ /** Content data attribution from the glTF `asset.copyright` (semicolon-separated
243
+ * data providers, e.g. Google Photorealistic 3D Tiles). The engine aggregates these
244
+ * across visible tiles so the consumer can display the required dynamic credits. */
245
+ readonly copyright?: string;
246
+ }
247
+ /** Everything a {@link MeshTileDecoder} needs to georeference a tile into the
248
+ * shared scene ENU frame. */
249
+ export interface MeshDecodeContext {
250
+ /** ECEF → shared-scene ENU (meters), column-major 4×4. */
251
+ readonly enuFromEcef: Mat4;
252
+ /** The EFFECTIVE cumulative tile transform: tile-local → ECEF. Normally the tileset's own composed
253
+ * transform; for a tileset that DECLARES a gravity-related (orthometric) vertical CRS the engine
254
+ * pre-multiplies a rigid ECEF translation of the geoid undulation N along the ellipsoid normal, so
255
+ * the decoded geometry is ellipsoidal-absolute — the height reference every consumer of this frame
256
+ * assumes. Decoders just use it; nothing here needs to know which case it is. */
257
+ readonly tileTransformEcef: Mat4;
258
+ /** Up-axis the tileset declares for its glb content (`asset.gltfUpAxis`). Default `'y'`,
259
+ * the 3D Tiles spec default. `'z'` means the content is already z-up and the y-up→z-up
260
+ * flip must be SKIPPED — see `tileToEnu`. Declared per tileset, so an external tileset
261
+ * can differ from its parent and each tile carries its own. */
262
+ readonly gltfUpAxis?: 'y' | 'z';
263
+ readonly geometricError: number;
264
+ /**
265
+ * Absolute URL the tile's content was fetched from, used to resolve URIs the content
266
+ * itself references (today: an `i3dm` with `gltfFormat` 0, whose body is a relative
267
+ * path to a shared glTF rather than an embedded GLB).
268
+ *
269
+ * @remarks
270
+ * Optional because most content is self-contained. When it is absent and a tile does
271
+ * reference an external model, decode fails naming this field rather than guessing at
272
+ * a base.
273
+ */
274
+ readonly contentBaseUrl?: string;
275
+ /** Bake the exact per-vertex web-mercator offset at decode (Cesium SceneMode2DPipelineStage style) so
276
+ * the renderer places each vertex without a per-tile ENU tangent → no intra-tile parallax-swim on
277
+ * global tilesets. Off (undefined/false) ⇒ the ENU-only path, byte-identical. Set by the engine for
278
+ * planet-scale datasets. */
279
+ readonly bakeMercator?: boolean;
280
+ /** Extract hard edges for this tile ({@link PackedMeshTile.edges}). Absent ⇒ no extraction and
281
+ * no cost; the work is real (one pass over every triangle) so it is never done speculatively. */
282
+ readonly edges?: EdgeExtractOptions;
283
+ }
284
+ /** How much geometry to turn into edges, and how sharp a fold has to be to count. */
285
+ export interface EdgeExtractOptions {
286
+ /** Dihedral angle above which a shared edge is a crease. Default 25°. */
287
+ readonly creaseAngleDeg?: number;
288
+ /** Cap on edges per tile. Default 400 000. */
289
+ readonly maxEdges?: number;
290
+ }
291
+ /** Decodes one 3D-Tiles mesh tile (b3dm/i3dm/cmpt/glb) into a GPU-ready,
292
+ * ENU-rebased {@link PackedMeshTile}, regardless of which compression the tile
293
+ * uses. The engine and renderer depend only on this interface. */
294
+ export interface MeshTileDecoder {
295
+ /** @param buffer - The tile bytes (b3dm/i3dm/cmpt/glb).
296
+ * @param ctx - Georeference context ({@link MeshDecodeContext}).
297
+ * @returns The ENU-rebased {@link PackedMeshTile}. */
298
+ decode(buffer: ArrayBuffer, ctx: MeshDecodeContext): Promise<PackedMeshTile>;
299
+ /** Worker pool size (worker variants) — size the engine's decode concurrency to match. */
300
+ readonly concurrency?: number;
301
+ dispose?(): void;
302
+ }
303
+ /** Built-in mesh-decoder identifiers a layer/engine can resolve to an impl. */
304
+ export type MeshTileDecoderKind = 'gltf';
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Renderer contract — viewer-agnostic. A renderer consumes decoded
3
+ * (quantized) tiles and a {@link ViewerAdapter}, owns its GPU
4
+ * resources, and draws each frame using the adapter's matrices. The
5
+ * same renderer instance is reused across MapLibre, three.js, Cesium,
6
+ * etc. — only the adapter differs.
7
+ */
8
+ import type { DecodedTile } from './decoder';
9
+ import type { FrameState, ViewerAdapter } from './viewer-adapter';
10
+ /** An opaque handle to one uploaded tile's GPU resources (returned by
11
+ * {@link Renderer.upload}, passed back to {@link Renderer.release}). */
12
+ export type TileHandle = number & {
13
+ readonly __brand: 'TileHandle';
14
+ };
15
+ /** A viewer-agnostic renderer: it consumes decoded (quantized) tiles and a
16
+ * {@link ViewerAdapter}, owns its GPU resources, and draws each frame using the
17
+ * adapter's matrices. The same instance is reused across MapLibre / three.js / Cesium.
18
+ * @typeParam TTile - The decoded tile shape this renderer accepts. */
19
+ export interface Renderer<TTile extends DecodedTile = DecodedTile> {
20
+ /** Bind to a viewer's GL context; compile programs, allocate static state.
21
+ * @param adapter - The host {@link ViewerAdapter}. */
22
+ init(adapter: ViewerAdapter): void;
23
+ /** Upload one decoded tile to GPU buffers/textures.
24
+ * @param tile - The decoded tile. @returns A {@link TileHandle} for later release. */
25
+ upload(tile: TTile): TileHandle;
26
+ /** Render all uploaded tiles for the current frame.
27
+ * @param frame - The current {@link FrameState}. */
28
+ render(frame: FrameState): void;
29
+ /** Drop one tile's GPU resources.
30
+ * @param handle - The {@link TileHandle} from {@link Renderer.upload}. */
31
+ release(handle: TileHandle): void;
32
+ /** Tear down all GL resources (does not own the context). */
33
+ dispose(): void;
34
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Viewer adapter contract — the thin, per-viewer seam.
3
+ *
4
+ * Every renderer ({@link ./renderer}) is viewer-agnostic and is driven
5
+ * entirely through this interface, so a new viewer is a ~1-file adapter.
6
+ * Each target supplies the same four things per frame:
7
+ *
8
+ * - a WebGL2 context
9
+ * - a view matrix (world → camera)
10
+ * - a projection matrix (camera → clip)
11
+ * - a model matrix placing the local-meters scene in the viewer's world
12
+ *
13
+ * Mapping per viewer (only MapLibre, then three.js, are P1/P2):
14
+ * - MapLibre: gl from onAdd; projection from render args; VIEW recovered
15
+ * as P^-1 · MVP; model from MercatorCoordinate placement.
16
+ * - three.js: gl = renderer.getContext(); view = camera.matrixWorldInverse;
17
+ * projection = camera.projectionMatrix; model = anchor
18
+ * Object3D.matrixWorld. (No recovery needed — heavy overlap
19
+ * with MapLibre; same renderer core.)
20
+ * - Cesium: view = camera.viewMatrix; projection =
21
+ * camera.frustum.projectionMatrix; model from
22
+ * Transforms.eastNorthUpToFixedFrame.
23
+ * - ArcGIS: view/projection from RenderCamera; model from the render
24
+ * coordinate transform.
25
+ */
26
+ import type { Mat4 } from '../geo/mat4';
27
+ import type { GeoAnchor } from '../geo/mercator';
28
+ /** The per-frame matrices and viewport a {@link ViewerAdapter} snapshots for the
29
+ * renderer. All matrices are column-major f64 {@link Mat4}s. */
30
+ export interface FrameState {
31
+ /** world → view (camera). Splat covariance projection needs this alone. */
32
+ readonly viewMatrix: Mat4;
33
+ /** view → clip (projection). */
34
+ readonly projectionMatrix: Mat4;
35
+ /** local-scene (meters) → world (geo anchor + meter scaling). */
36
+ readonly modelMatrix: Mat4;
37
+ /** Convenience: `projection · view · model` (what the viewer ultimately applies). */
38
+ readonly modelViewProjection: Mat4;
39
+ /** Camera position in world space `[x, y, z]`. */
40
+ readonly cameraPositionWorld: readonly [number, number, number];
41
+ /** Near clip plane distance. */
42
+ readonly nearZ: number;
43
+ /** Far clip plane distance. */
44
+ readonly farZ: number;
45
+ /** Vertical field of view in radians. */
46
+ readonly fovY: number;
47
+ /** Viewport width in device pixels. */
48
+ readonly viewportWidth: number;
49
+ /** Viewport height in device pixels. */
50
+ readonly viewportHeight: number;
51
+ /** Device pixel ratio. */
52
+ readonly devicePixelRatio: number;
53
+ /** `true` under a non-affine projection (e.g. MapLibre globe) — renderers degrade. */
54
+ readonly nonAffineProjection: boolean;
55
+ }
56
+ /** The thin, per-viewer seam every renderer is driven through. Implement one per host
57
+ * (MapLibre, three.js, Cesium, …); the renderer core is otherwise viewer-agnostic. */
58
+ export interface ViewerAdapter {
59
+ /** The live WebGL2 context owned by the viewer. */
60
+ readonly gl: WebGL2RenderingContext;
61
+ /** Snapshot the per-frame matrices/viewport for the renderer.
62
+ * @returns The current {@link FrameState}. */
63
+ getFrameState(): FrameState;
64
+ /** Ask the viewer to schedule another frame (e.g. when a tile finishes decoding). */
65
+ requestRender(): void;
66
+ /** Where the scene is anchored, so the adapter can build the model matrix. */
67
+ readonly anchor: GeoAnchor;
68
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Canonical BVC dequantization reference (CPU).
3
+ *
4
+ * These are the exact inverse-quantization formulas the decoder's
5
+ * quantized output must be expanded with. The WebGL2 render shaders
6
+ * port THESE functions; this module is the parity oracle the shaders
7
+ * are tested against, so it must stay byte-faithful to the canonical
8
+ * dequantization.
9
+ *
10
+ * The decoder hands back QUANTIZED structure-of-arrays; nothing here is
11
+ * applied on the CPU at load time (dequant is deferred to shaders).
12
+ * This module exists for (a) parity tests and (b) the rare CPU paths
13
+ * that need real coordinates (e.g. computing splat depth for sorting).
14
+ */
15
+ /** Spherical-harmonics band-0 basis constant (Y_0^0). @internal */
16
+ export declare const SH_C0 = 0.28209479177387814;
17
+ /** Smallest-three quaternion magnitude scale: 1 / (sqrt(2) * 511). @internal */
18
+ export declare const ROT_INV: number;
19
+ /** Per-degree SH band increments (channel-inner, RGB interleaved). @internal */
20
+ export declare const SH_BAND_SIZES: {
21
+ readonly sh1: 9;
22
+ readonly sh2: 15;
23
+ readonly sh3: 21;
24
+ };
25
+ /** Total SH float coefficients per splat for a given degree.
26
+ * @param degree - SH degree 0..3. @returns Cumulative coefficient count (0, 9, 24, or 45). */
27
+ export declare function shCoeffsPerSplat(degree: number): number;
28
+ /** Splat position: quantized i32 grid → meters in the local frame.
29
+ * @param q - Quantized grid value. @param fractionalBits - Grid divisor exponent.
30
+ * @returns Position in meters (`q / 2^fractionalBits`). */
31
+ export declare function splatPositionToMeters(q: number, fractionalBits: number): number;
32
+ /** Splat scale: log-quantized u8 → linear scale.
33
+ * @param b - The scale byte. @returns The linear scale `exp(b/16 − 10)`. */
34
+ export declare function splatScale(b: number): number;
35
+ /** Splat opacity: u8 → alpha in `[0,1]` (BVC opacity is the rendered, post-sigmoid byte).
36
+ * @param b - The opacity byte. @returns Alpha in `[0,1]`. */
37
+ export declare function splatOpacity(b: number): number;
38
+ /** Splat SH degree-0 DC coefficient from a color byte.
39
+ * @param b - The color byte. @returns The DC SH coefficient. */
40
+ export declare function splatColorDc(b: number): number;
41
+ /** Splat rendered base-color channel (degree-0 SH → linear color in `[0,1]`-ish).
42
+ * @param b - The color byte. @returns The base-color channel value. */
43
+ export declare function splatBaseColorChannel(b: number): number;
44
+ /** Splat SH rest-band coefficient: u8 grid → float in `[-1,1)`.
45
+ * @param b - The coefficient byte. @returns `(b − 128) / 128`. */
46
+ export declare function splatShCoeff(b: number): number;
47
+ /**
48
+ * Reconstruct a unit quaternion (x, y, z, w) from the smallest-three
49
+ * encoding: `rotIdx` is the slot of the omitted (largest) component;
50
+ * `a`, `b`, `c` are the three stored i16 components (in slot order,
51
+ * skipping `rotIdx`). The largest component is non-negative by encoder
52
+ * canonicalization, so it is reconstructed as +sqrt(1 - a²-b²-c²).
53
+ *
54
+ * @param rotIdx - Slot (0..3) of the omitted (largest) component.
55
+ * @param a - First stored i16 component (in slot order, skipping `rotIdx`).
56
+ * @param b - Second stored component.
57
+ * @param c - Third stored component.
58
+ * @returns A length-4 `Float32Array` unit quaternion `(x, y, z, w)`.
59
+ */
60
+ export declare function decodeSmallestThree(rotIdx: number, a: number, b: number, c: number): Float32Array;
61
+ /**
62
+ * Encode a quaternion (x, y, z, w) into the smallest-three form — the exact
63
+ * inverse of {@link decodeSmallestThree}. The input is defensively
64
+ * normalized (a zero quaternion encodes identity). `rotIdx` is the slot
65
+ * (0..3, order x,y,z,w) of the largest-magnitude component (first-max
66
+ * tiebreak); if that component is negative the whole quaternion is negated
67
+ * (same rotation) so the decoder's `+sqrt` reconstruction is exact. The
68
+ * remaining three components are stored in ascending slot order skipping
69
+ * `rotIdx`, each quantized as `round(comp * sqrt(2) * 511)` — the inverse
70
+ * of the {@link ROT_INV} scale — and clamped to `[-511, 511]` so
71
+ * slightly-super-unit inputs cannot overflow an `Int16Array` slot.
72
+ *
73
+ * @param x - Quaternion x component.
74
+ * @param y - Quaternion y component.
75
+ * @param z - Quaternion z component.
76
+ * @param w - Quaternion w component.
77
+ * @returns `{ rotIdx, a, b, c }`: the omitted slot and the three stored
78
+ * i16 integers (in slot order, skipping `rotIdx`), ready for
79
+ * `decodeSmallestThree(rotIdx, a, b, c)`.
80
+ */
81
+ export declare function encodeSmallestThree(x: number, y: number, z: number, w: number): {
82
+ rotIdx: number;
83
+ a: number;
84
+ b: number;
85
+ c: number;
86
+ };
87
+ /** Point-cloud coordinate: quantized i32 → world via per-axis scale/offset.
88
+ * @param q - Quantized value. @param scale - Per-axis scale (m). @param offset - Per-axis offset (m).
89
+ * @returns `q * scale + offset`. */
90
+ export declare function pointToWorld(q: number, scale: number, offset: number): number;
91
+ /** Normalize a LAS-native u16 channel (rgb / intensity) to `[0, 1]`.
92
+ * @param v - The u16 value (0..65535). @returns `v / 65535`. */
93
+ export declare function normU16(v: number): number;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Stray-splat culling over QUANTIZED decoded tiles — the decoder-agnostic
3
+ * runtime analog of the Bitruvius BVC encoder's stray-cull transcode (and of turbo-spz's
4
+ * float-side `computeStrayMask`): 3DGS captures surround their subject with a
5
+ * sparse ball of huge far-field background splats (±200 m around an 8 m
6
+ * subject is typical) that renders as a giant ghost sphere over the map when
7
+ * the container was transcoded 1:1. Splats farther from the robust (per-axis
8
+ * MEDIAN) center than `factor ×` the 90th-percentile distance are culled.
9
+ * Load-time only (a few typed-array sorts); splat tiles only — LiDAR point
10
+ * clouds are real data, never culled.
11
+ */
12
+ import type { ContainerMeta, DecodedTile } from '../contracts/decoder';
13
+ /**
14
+ * Cull far-outlier stray splats across a container's decoded splat tiles.
15
+ *
16
+ * @remarks
17
+ * Distances are measured in WORLD units (the container's dequant applied in
18
+ * f64), from the per-axis median center; the cutoff is `factor × r90` (the
19
+ * 90th-percentile distance) — self-scaling, so halo-free captures pass
20
+ * through untouched (the input array is returned as-is). Point-cloud tiles
21
+ * are never modified.
22
+ *
23
+ * @param tiles - The decoded tiles (splat tiles are filtered; others pass through).
24
+ * @param meta - The container dequant metadata.
25
+ * @param factor - Cutoff multiple of the 90th-percentile distance. Default 1.5
26
+ * (matches the look of a stray-culled BVC transcode).
27
+ * @returns The tiles with strays removed (same array instance when nothing was culled).
28
+ */
29
+ export declare function cullStraySplats(tiles: readonly DecodedTile[], meta: ContainerMeta, factor?: number): readonly DecodedTile[];
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Quantized tile bounds accumulator.
3
+ *
4
+ * Streams over decoded tiles' quantized i32 position planes and keeps a
5
+ * running min/max in the QUANTIZED grid, then dequantizes the six
6
+ * extremes once at the end via the container metadata. This keeps the
7
+ * per-splat work to a plain integer compare (allocation-free, O(count))
8
+ * and defers every float multiply to `toLocalBounds`.
9
+ */
10
+ import type { ContainerMeta, DecodedTile } from '../contracts/decoder';
11
+ /**
12
+ * Axis-aligned bounding box in the model's SOURCE coordinates (meters).
13
+ *
14
+ * The shared bounds type for model placement: engines derive anchor
15
+ * points, footprints, and pick volumes from it. `min`/`max` are ordered
16
+ * per axis (`min[i] <= max[i]`) regardless of the sign of the dequant
17
+ * scale that produced them.
18
+ */
19
+ export interface LocalBounds {
20
+ /** Per-axis minimum corner `(x, y, z)` in source meters. */
21
+ readonly min: readonly [number, number, number];
22
+ /** Per-axis maximum corner `(x, y, z)` in source meters. */
23
+ readonly max: readonly [number, number, number];
24
+ /**
25
+ * Robust per-axis 5th percentile, present when the producer ran the
26
+ * histogram pass (see `QuantBoundsAccumulator.beginHistogram`). Gaussian-
27
+ * splat captures surround their subject with a sparse halo of huge
28
+ * background splats (a ±200 m halo around an 8 m subject is typical), so
29
+ * raw `min`/`max` are outlier-hostage — anchoring uses these instead.
30
+ */
31
+ readonly p05?: readonly [number, number, number];
32
+ /** Robust per-axis median (see {@link LocalBounds.p05}). */
33
+ readonly p50?: readonly [number, number, number];
34
+ /** Robust per-axis 95th percentile (see {@link LocalBounds.p05}). */
35
+ readonly p95?: readonly [number, number, number];
36
+ }
37
+ /**
38
+ * Running min/max accumulator over the quantized position planes of
39
+ * {@link DecodedTile}s.
40
+ *
41
+ * Feed every streamed tile to {@link QuantBoundsAccumulator.add}, then call
42
+ * {@link QuantBoundsAccumulator.toLocalBounds} with the container's
43
+ * {@link ContainerMeta} to get the dequantized {@link LocalBounds}.
44
+ * State is six scalars, so the accumulator is safe to keep across an
45
+ * entire decode stream without retaining any tile memory.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * const acc = new QuantBoundsAccumulator();
50
+ * for await (const tile of decoder.decodeTiles(model, bytes)) {
51
+ * acc.add(tile);
52
+ * }
53
+ * const bounds = acc.toLocalBounds(model.meta);
54
+ * ```
55
+ */
56
+ export declare class QuantBoundsAccumulator {
57
+ private minX;
58
+ private minY;
59
+ private minZ;
60
+ private maxX;
61
+ private maxY;
62
+ private maxZ;
63
+ private hist;
64
+ private histBins;
65
+ private histCount;
66
+ private histMin;
67
+ private histInv;
68
+ private histWidth;
69
+ /**
70
+ * Fold one tile's quantized positions into the running min/max.
71
+ * Allocation-free O(count) scan; a `count === 0` tile is a no-op.
72
+ * @param tile - A decoded splat or point-cloud tile (only `x`/`y`/`z`/`count` are read).
73
+ */
74
+ add(tile: DecodedTile): void;
75
+ /** `true` until the first non-empty {@link QuantBoundsAccumulator.add} (and again after {@link QuantBoundsAccumulator.reset}). */
76
+ get isEmpty(): boolean;
77
+ /**
78
+ * Arm the optional percentile pass: allocate per-axis histograms over the
79
+ * min/max range captured so far, then feed every tile a SECOND time through
80
+ * {@link QuantBoundsAccumulator.addToHistogram}. Percentile precision is
81
+ * `range / bins` per axis (a 400 m capture at the default 2048 bins resolves
82
+ * to ~20 cm — far finer than the seating it drives).
83
+ *
84
+ * @param bins - Histogram resolution per axis (default 2048; 3×bins u32 ≈ 24 KB).
85
+ */
86
+ beginHistogram(bins?: number): void;
87
+ /**
88
+ * Fold one tile's quantized positions into the armed histograms
89
+ * (allocation-free O(count); a no-op before {@link QuantBoundsAccumulator.beginHistogram}).
90
+ * @param tile - A decoded splat or point-cloud tile (only `x`/`y`/`z`/`count` are read).
91
+ */
92
+ addToHistogram(tile: DecodedTile): void;
93
+ /** Nearest-rank quantile of one axis in QUANTIZED units (bin-center estimate). */
94
+ private histQuantile;
95
+ /**
96
+ * Dequantize the accumulated extremes into source-coordinate bounds.
97
+ *
98
+ * Splat containers use `meters = q / 2^fractionalBits`; point-cloud
99
+ * containers use the per-axis f64 affine `world = q * scale + offset`.
100
+ * A negative `scale[axis]` flips ordering on that axis, so the
101
+ * dequantized min/max are swapped per axis to keep `min <= max`.
102
+ *
103
+ * @param meta - The container's dequant metadata ({@link ContainerMeta}).
104
+ * @returns The {@link LocalBounds}, or `null` when nothing was accumulated.
105
+ */
106
+ toLocalBounds(meta: ContainerMeta): LocalBounds | null;
107
+ /** Clear the accumulated extremes (and any histogram) back to the empty state. */
108
+ reset(): void;
109
+ }