dengdeyan commited on
Commit
be9292f
·
1 Parent(s): a41651a

initial file uploading

Browse files
.dockerignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ dist
3
+ build
4
+ .git
5
+ .env
6
+ __pycache__
7
+ *.pyc
8
+ *.pyo
9
+ .DS_Store
10
+ coverage
.gitignore ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Node.js / React Client
2
+ node_modules/
3
+ client/dist/
4
+ client/build/
5
+ client/coverage/
6
+ npm-debug.log*
7
+ yarn-debug.log*
8
+ yarn-error.log*
9
+ .env
10
+ .env.local
11
+ .env.development.local
12
+ .env.test.local
13
+ .env.production.local
14
+
15
+ # Python Server
16
+ __pycache__/
17
+ *.py[cod]
18
+ *$py.class
19
+ venv/
20
+ .venv/
21
+ env/
22
+ .pytest_cache/
23
+
24
+ # C++ SplatTiler
25
+ SplatTiler/build/
26
+ SplatTiler/out/
27
+ *.o
28
+ *.obj
29
+ *.exe
30
+
31
+ # IDE / OS
32
+ .vscode/
33
+ .idea/
34
+ .DS_Store
35
+ Thumbs.db
Dockerfile ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage 1: Build C++ Converter (SplatTiler)
2
+ FROM ubuntu:22.04 AS cpp-builder
3
+
4
+ # Install build dependencies
5
+ RUN apt-get update && apt-get install -y \
6
+ build-essential \
7
+ cmake \
8
+ zlib1g-dev \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ WORKDIR /src/SplatTiler
12
+ COPY SplatTiler/ .
13
+
14
+ # Build the project (Assuming CMakeLists.txt exists in SplatTiler root)
15
+ RUN mkdir build && cd build && cmake .. && make -j$(nproc)
16
+
17
+ # Stage 2: Build React Client
18
+ FROM node:18-alpine AS client-builder
19
+
20
+ WORKDIR /src/client
21
+ COPY client/package*.json ./
22
+ RUN npm ci
23
+
24
+ COPY client/ .
25
+ # Build for production
26
+ RUN npm run build
27
+
28
+ # Stage 3: Final Server Image
29
+ FROM python:3.10-slim
30
+
31
+ WORKDIR /app
32
+
33
+ # Install runtime dependencies for C++ binary (zlib)
34
+ RUN apt-get update && apt-get install -y \
35
+ zlib1g \
36
+ && rm -rf /var/lib/apt/lists/*
37
+
38
+ # Install Python dependencies
39
+ COPY server/requirements.txt .
40
+ RUN pip install --no-cache-dir -r requirements.txt
41
+
42
+ # Copy compiled C++ binary from Stage 1
43
+ # Note: Adjust 'SplatTiler' if your binary name differs
44
+ COPY --from=cpp-builder /src/SplatTiler/build/SplatTiler /usr/local/bin/SplatTiler
45
+
46
+ # Copy built client static files from Stage 2
47
+ COPY --from=client-builder /src/client/dist /app/static
48
+
49
+ # Copy server code
50
+ COPY server/ .
51
+
52
+ # Create directory for uploads and converted data
53
+ RUN mkdir -p /app/data/uploads /app/data/converted
54
+
55
+ # Expose Hugging Face Spaces port
56
+ EXPOSE 7860
57
+
58
+ # Run FastAPI
59
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
SplatTiler/CMakeLists.txt ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ cmake_minimum_required(VERSION 3.14)
2
+
3
+ project(SplatTiler LANGUAGES CXX)
4
+
5
+ set(CMAKE_CXX_STANDARD 17)
6
+ set(CMAKE_CXX_STANDARD_REQUIRED ON)
7
+
8
+ find_package(ZLIB REQUIRED)
9
+
10
+ # Include the root directory so that #include "spz/..." directives work.
11
+ include_directories(${CMAKE_CURRENT_SOURCE_DIR})
12
+
13
+ add_executable(SplatTiler
14
+ main.cpp
15
+ SplatTiler.cpp
16
+ SplatTiler.h
17
+ spz/load-spz.cc
18
+ spz/load-spz.h
19
+ spz/splat-types.cc
20
+ spz/splat-types.h
21
+ spz/splat-c-types.cc
22
+ spz/splat-c-types.h
23
+ )
24
+
25
+ target_link_libraries(SplatTiler PRIVATE ZLIB::ZLIB)
SplatTiler/SplatTiler.cpp ADDED
@@ -0,0 +1,647 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #include "SplatTiler.h"
2
+
3
+ #include <algorithm>
4
+ #include <cmath>
5
+ #include <filesystem>
6
+ #include <fstream>
7
+ #include <iostream>
8
+ #include <iomanip>
9
+ #include <numeric>
10
+ #include <queue>
11
+ #include <functional>
12
+ #include <limits>
13
+ #include <memory>
14
+
15
+ #ifndef M_PI
16
+ #define M_PI 3.14159265358979323846
17
+ #endif
18
+
19
+ namespace tile3dgs {
20
+
21
+ namespace fs = std::filesystem;
22
+
23
+ struct AABB {
24
+ float min[3];
25
+ float max[3];
26
+
27
+ float center(int axis) const { return (min[axis] + max[axis]) * 0.5f; }
28
+ float size(int axis) const { return max[axis] - min[axis]; }
29
+
30
+ bool contains(float x, float y, float z) const {
31
+ return x >= min[0] && x <= max[0] &&
32
+ y >= min[1] && y <= max[1] &&
33
+ z >= min[2] && z <= max[2];
34
+ }
35
+ };
36
+
37
+ struct OctreeNode {
38
+ AABB aabb;
39
+ std::vector<int32_t> pointIndices;
40
+ std::vector<std::unique_ptr<OctreeNode>> children;
41
+ int depth = 0;
42
+ int x = 0;
43
+ int y = 0;
44
+ int z = 0;
45
+ std::string filename;
46
+ float geometricError = 0.0f;
47
+
48
+ bool isLeaf() const { return children.empty(); }
49
+ };
50
+
51
+ SplatTiler::SplatTiler() {}
52
+ SplatTiler::~SplatTiler() {}
53
+
54
+ bool SplatTiler::load(const std::string& plyFilename) {
55
+ spz::UnpackOptions opts;
56
+ // Load as RDF (Raw) to avoid implicit conversion. We will handle orientation via the matrix.
57
+ opts.to = spz::CoordinateSystem::RDF;
58
+ m_cloud = spz::loadSplatFromPly(plyFilename, opts);
59
+ return m_cloud.numPoints > 0;
60
+ }
61
+
62
+ // Helper to extract a subset of the cloud
63
+ spz::GaussianCloud extractSubset(const spz::GaussianCloud& source, const std::vector<int32_t>& indices) {
64
+ spz::GaussianCloud dest;
65
+ dest.numPoints = static_cast<int32_t>(indices.size());
66
+ dest.shDegree = source.shDegree;
67
+ dest.antialiased = source.antialiased;
68
+
69
+ if (dest.numPoints == 0) return dest;
70
+
71
+ // Reserve memory
72
+ dest.positions.resize(dest.numPoints * 3);
73
+ dest.scales.resize(dest.numPoints * 3);
74
+ dest.rotations.resize(dest.numPoints * 4);
75
+ dest.alphas.resize(dest.numPoints);
76
+ dest.colors.resize(dest.numPoints * 3);
77
+
78
+ size_t shTotalPerPoint = 0;
79
+ if (source.numPoints > 0) {
80
+ shTotalPerPoint = source.sh.size() / source.numPoints;
81
+ }
82
+ dest.sh.resize(dest.numPoints * shTotalPerPoint);
83
+
84
+ for (size_t i = 0; i < indices.size(); ++i) {
85
+ int idx = indices[i];
86
+
87
+ // Copy Position
88
+ dest.positions[i * 3 + 0] = source.positions[idx * 3 + 0];
89
+ dest.positions[i * 3 + 1] = source.positions[idx * 3 + 1];
90
+ dest.positions[i * 3 + 2] = source.positions[idx * 3 + 2];
91
+
92
+ // Copy Scale
93
+ dest.scales[i * 3 + 0] = source.scales[idx * 3 + 0];
94
+ dest.scales[i * 3 + 1] = source.scales[idx * 3 + 1];
95
+ dest.scales[i * 3 + 2] = source.scales[idx * 3 + 2];
96
+
97
+ // Copy Rotation
98
+ dest.rotations[i * 4 + 0] = source.rotations[idx * 4 + 0];
99
+ dest.rotations[i * 4 + 1] = source.rotations[idx * 4 + 1];
100
+ dest.rotations[i * 4 + 2] = source.rotations[idx * 4 + 2];
101
+ dest.rotations[i * 4 + 3] = source.rotations[idx * 4 + 3];
102
+
103
+ // Copy Alpha
104
+ dest.alphas[i] = source.alphas[idx];
105
+
106
+ // Copy Color
107
+ dest.colors[i * 3 + 0] = source.colors[idx * 3 + 0];
108
+ dest.colors[i * 3 + 1] = source.colors[idx * 3 + 1];
109
+ dest.colors[i * 3 + 2] = source.colors[idx * 3 + 2];
110
+
111
+ // Copy SH
112
+ if (shTotalPerPoint > 0) {
113
+ const float* srcSH = &source.sh[idx * shTotalPerPoint];
114
+ float* dstSH = &dest.sh[i * shTotalPerPoint];
115
+ std::copy(srcSH, srcSH + shTotalPerPoint, dstSH);
116
+ }
117
+ }
118
+
119
+ return dest;
120
+ }
121
+
122
+ // Helper to write GLB with embedded SPZ
123
+ void writeGlbWithSpz(const std::string& filename, const std::vector<uint8_t>& spzData, int numPoints, int shDegree, const float* minPos, const float* maxPos) {
124
+ std::ofstream out(filename, std::ios::binary);
125
+
126
+ // Padding for BIN (must be 4-byte aligned)
127
+ int paddingBin = (4 - (spzData.size() % 4)) % 4;
128
+ size_t totalBinSize = spzData.size() + paddingBin;
129
+
130
+ // Build dynamic accessors and attributes based on SH degree
131
+ std::string accessors = "";
132
+ std::string attributes = "\"POSITION\":0,\"COLOR_0\":1,\"KHR_gaussian_splatting:SCALE\":2,\"KHR_gaussian_splatting:ROTATION\":3";
133
+
134
+ // Standard accessors (0-3)
135
+ accessors += "{\"componentType\":5126,\"count\":" + std::to_string(numPoints) + ",\"type\":\"VEC3\",\"min\":[" + std::to_string(minPos[0]) + "," + std::to_string(minPos[1]) + "," + std::to_string(minPos[2]) + "],\"max\":[" + std::to_string(maxPos[0]) + "," + std::to_string(maxPos[1]) + "," + std::to_string(maxPos[2]) + "]},"; // POS
136
+ accessors += "{\"componentType\":5121,\"normalized\":true,\"count\":" + std::to_string(numPoints) + ",\"type\":\"VEC4\"},"; // COLOR
137
+ accessors += "{\"componentType\":5126,\"count\":" + std::to_string(numPoints) + ",\"type\":\"VEC3\"},"; // SCALE
138
+ accessors += "{\"componentType\":5126,\"count\":" + std::to_string(numPoints) + ",\"type\":\"VEC4\"}"; // ROTATION
139
+
140
+ int accessorIdx = 4;
141
+ auto addSH = [&](int degree, int count) {
142
+ for (int i = 0; i < count; ++i) {
143
+ attributes += ",\"KHR_gaussian_splatting:SH_DEGREE_" + std::to_string(degree) + "_COEF_" + std::to_string(i) + "\":" + std::to_string(accessorIdx++);
144
+ accessors += ",{\"componentType\":5126,\"count\":" + std::to_string(numPoints) + ",\"type\":\"VEC3\"}";
145
+ }
146
+ };
147
+
148
+ if (shDegree >= 1) addSH(1, 3);
149
+ if (shDegree >= 2) addSH(2, 5);
150
+ if (shDegree >= 3) addSH(3, 7);
151
+
152
+ // 1. Prepare JSON
153
+ // We use a custom extension to indicate this GLB contains SPZ data in the buffer view.
154
+ std::string jsonStr =
155
+ "{"
156
+ "\"asset\":{\"version\":\"2.0\"},"
157
+ "\"extensionsUsed\":[\"KHR_gaussian_splatting\",\"KHR_gaussian_splatting_compression_spz_2\",\"KHR_materials_unlit\"],"
158
+ "\"extensionsRequired\":[\"KHR_gaussian_splatting\",\"KHR_gaussian_splatting_compression_spz_2\"],"
159
+ "\"buffers\":[{\"byteLength\":" + std::to_string(totalBinSize) + "}],"
160
+ "\"bufferViews\":[{\"buffer\":0,\"byteOffset\":0,\"byteLength\":" + std::to_string(spzData.size()) + "}],"
161
+ "\"accessors\":[" + accessors + "],"
162
+ "\"materials\":[{\"extensions\":{\"KHR_materials_unlit\":{}}}],"
163
+ "\"meshes\":[{\"primitives\":[{\"mode\":0,\"material\":0,\"attributes\":{" + attributes + "},\"extensions\":{\"KHR_gaussian_splatting\":{\"extensions\":{\"KHR_gaussian_splatting_compression_spz_2\":{\"bufferView\":0}}}}}]}],"
164
+ "\"nodes\":[{\"mesh\":0,\"matrix\":[1,0,0,0,0,0,-1,0,0,1,0,0,0,0,0,1]}],"
165
+ "\"scenes\":[{\"nodes\":[0]}],"
166
+ "\"scene\":0"
167
+ "}";
168
+
169
+ // Padding for JSON (must be 4-byte aligned)
170
+ int paddingJson = (4 - (jsonStr.size() % 4)) % 4;
171
+ for(int i=0; i<paddingJson; ++i) jsonStr += ' ';
172
+
173
+ // Header
174
+ uint32_t magic = 0x46546C67; // glTF
175
+ uint32_t version = 2;
176
+ uint32_t length = 12 + 8 + (uint32_t)jsonStr.size() + 8 + (uint32_t)totalBinSize;
177
+
178
+ out.write((char*)&magic, 4);
179
+ out.write((char*)&version, 4);
180
+ out.write((char*)&length, 4);
181
+
182
+ // JSON Chunk
183
+ uint32_t jsonChunkLength = (uint32_t)jsonStr.size();
184
+ uint32_t jsonChunkType = 0x4E4F534A; // JSON
185
+ out.write((char*)&jsonChunkLength, 4);
186
+ out.write((char*)&jsonChunkType, 4);
187
+ out.write(jsonStr.data(), jsonStr.size());
188
+
189
+ // BIN Chunk
190
+ uint32_t binChunkLength = (uint32_t)totalBinSize;
191
+ uint32_t binChunkType = 0x004E4942; // BIN
192
+ out.write((char*)&binChunkLength, 4);
193
+ out.write((char*)&binChunkType, 4);
194
+ out.write((char*)spzData.data(), spzData.size());
195
+
196
+ char pad = 0;
197
+ for(int i=0; i<paddingBin; ++i) out.write(&pad, 1);
198
+ }
199
+
200
+ // Helper to compute WGS84 transform
201
+ // Returns a 16-element column-major matrix
202
+ std::vector<double> computeTransform(double lat, double lon, double alt) {
203
+ double a = 6378137.0;
204
+ double e2 = 0.00669437999014;
205
+
206
+ double latRad = lat * M_PI / 180.0;
207
+ double lonRad = lon * M_PI / 180.0;
208
+
209
+ double sinLat = std::sin(latRad);
210
+ double cosLat = std::cos(latRad);
211
+ double sinLon = std::sin(lonRad);
212
+ double cosLon = std::cos(lonRad);
213
+
214
+ double N = a / std::sqrt(1.0 - e2 * sinLat * sinLat);
215
+
216
+ double x = (N + alt) * cosLat * cosLon;
217
+ double y = (N + alt) * cosLat * sinLon;
218
+ double z = (N * (1.0 - e2) + alt) * sinLat;
219
+
220
+ // Construct rotation matrix for Z-Up Input (ENU):
221
+ // Model X (East) -> East Vector
222
+ // Model Y (North) -> North Vector
223
+ // Model Z (Up) -> Up Vector
224
+
225
+ // Up vector (Normal to ellipsoid)
226
+ double ux = cosLat * cosLon;
227
+ double uy = cosLat * sinLon;
228
+ double uz = sinLat;
229
+
230
+ // East vector
231
+ double ex = -sinLon;
232
+ double ey = cosLon;
233
+ double ez = 0.0;
234
+
235
+ // South vector = -North
236
+ // North = (-sinLat cosLon, -sinLat sinLon, cosLat)
237
+ double sx = sinLat * cosLon;
238
+ double sy = sinLat * sinLon;
239
+ double sz = -cosLat;
240
+
241
+ // Column-major 4x4 matrix
242
+ return {
243
+ ex, ey, ez, 0.0, // X -> East
244
+ -sx, -sy, -sz, 0.0, // Y -> North
245
+ ux, uy, uz, 0.0, // Z -> Up
246
+ x, y, z, 1.0
247
+ };
248
+ }
249
+
250
+ void SplatTiler::process(const TileConfig& config) {
251
+ if (m_cloud.numPoints == 0) {
252
+ std::cerr << "No points loaded." << std::endl;
253
+ return;
254
+ }
255
+
256
+ fs::create_directories(config.outputDir);
257
+
258
+ // 1. Compute Bounding Box
259
+ AABB rootAABB = {
260
+ {std::numeric_limits<float>::max(), std::numeric_limits<float>::max(), std::numeric_limits<float>::max()},
261
+ {std::numeric_limits<float>::lowest(), std::numeric_limits<float>::lowest(), std::numeric_limits<float>::lowest()}
262
+ };
263
+
264
+ for (size_t i = 0; i < m_cloud.positions.size(); i += 3) {
265
+ float x = m_cloud.positions[i];
266
+ float y = m_cloud.positions[i+1];
267
+ float z = m_cloud.positions[i+2];
268
+
269
+ rootAABB.min[0] = std::min(rootAABB.min[0], x);
270
+ rootAABB.min[1] = std::min(rootAABB.min[1], y);
271
+ rootAABB.min[2] = std::min(rootAABB.min[2], z);
272
+ rootAABB.max[0] = std::max(rootAABB.max[0], x);
273
+ rootAABB.max[1] = std::max(rootAABB.max[1], y);
274
+ rootAABB.max[2] = std::max(rootAABB.max[2], z);
275
+ }
276
+
277
+ // 2. Build Octree
278
+ std::vector<int32_t> allIndices(m_cloud.numPoints);
279
+ std::iota(allIndices.begin(), allIndices.end(), 0);
280
+
281
+ std::unique_ptr<OctreeNode> root = std::make_unique<OctreeNode>();
282
+ root->aabb = rootAABB;
283
+ root->pointIndices = std::move(allIndices);
284
+ root->depth = 0;
285
+
286
+ std::queue<OctreeNode*> queue;
287
+ queue.push(root.get());
288
+
289
+ while (!queue.empty()) {
290
+ OctreeNode* node = queue.front();
291
+ queue.pop();
292
+
293
+ // Check if we need to split
294
+ if (node->pointIndices.size() > (size_t)config.maxPointsPerTile) {
295
+ // Split into 8 children (Octree) or 4 children (Quadtree if ignoreZ)
296
+ int splitCount = config.ignoreZ ? 4 : 8;
297
+ for (int i = 0; i < splitCount; ++i) {
298
+ auto child = std::make_unique<OctreeNode>();
299
+ child->depth = node->depth + 1;
300
+ child->x = (node->x << 1) | ((i >> 0) & 1);
301
+ child->y = (node->y << 1) | ((i >> 1) & 1);
302
+ child->z = (node->z << 1) | ((i >> 2) & 1);
303
+
304
+ // Calculate child AABB
305
+ for (int axis = 0; axis < 3; ++axis) {
306
+ if (config.ignoreZ && axis == 2) {
307
+ // Don't split Z axis
308
+ child->aabb.min[axis] = node->aabb.min[axis];
309
+ child->aabb.max[axis] = node->aabb.max[axis];
310
+ } else {
311
+ float center = node->aabb.center(axis);
312
+ if ((i >> axis) & 1) {
313
+ child->aabb.min[axis] = center;
314
+ child->aabb.max[axis] = node->aabb.max[axis];
315
+ } else {
316
+ child->aabb.min[axis] = node->aabb.min[axis];
317
+ child->aabb.max[axis] = center;
318
+ }
319
+ }
320
+ }
321
+ node->children.push_back(std::move(child));
322
+ }
323
+
324
+ // Distribute points
325
+ for (int32_t idx : node->pointIndices) {
326
+ float x = m_cloud.positions[idx * 3 + 0];
327
+ float y = m_cloud.positions[idx * 3 + 1];
328
+ float z = m_cloud.positions[idx * 3 + 2];
329
+
330
+ for (auto& child : node->children) {
331
+ if (child->aabb.contains(x, y, z)) {
332
+ child->pointIndices.push_back(idx);
333
+ break;
334
+ }
335
+ }
336
+ }
337
+
338
+ // Clear parent indices (we will refill them for LOD later)
339
+ node->pointIndices.clear();
340
+
341
+ // Enqueue non-empty children
342
+ for (auto& child : node->children) {
343
+ if (!child->pointIndices.empty()) {
344
+ queue.push(child.get());
345
+ }
346
+ }
347
+ }
348
+ }
349
+
350
+ // 3. Generate LOD and Export (Bottom-Up)
351
+
352
+ std::function<void(OctreeNode*)> generateLOD = [&](OctreeNode* node) {
353
+ if (node->children.empty()) {
354
+ // Leaf node: keep points as is.
355
+ node->geometricError = 0.0f; // Highest detail
356
+ } else {
357
+ // Inner node: process children first
358
+ for (auto& child : node->children) {
359
+ generateLOD(child.get());
360
+ }
361
+
362
+ // Additive LOD Generation:
363
+ // Move points from children to this node to fill up to maxPointsPerTile.
364
+ size_t totalChildPoints = 0;
365
+ for (auto& child : node->children) {
366
+ totalChildPoints += child->pointIndices.size();
367
+ }
368
+
369
+ size_t targetCount = config.maxPointsPerTile;
370
+ bool takeAll = totalChildPoints <= targetCount;
371
+
372
+ for (auto& child : node->children) {
373
+ if (child->pointIndices.empty()) continue;
374
+
375
+ if (takeAll) {
376
+ // Move all points from child to parent (Merge)
377
+ node->pointIndices.insert(node->pointIndices.end(), child->pointIndices.begin(), child->pointIndices.end());
378
+ child->pointIndices.clear();
379
+ } else {
380
+ // Subsample: Copy a fraction of points to parent (REPLACE strategy)
381
+ double fraction;
382
+ if (config.lodStride > 1) {
383
+ fraction = 1.0 / (double)config.lodStride;
384
+ } else {
385
+ fraction = (double)targetCount / (double)totalChildPoints;
386
+ }
387
+
388
+ size_t copyCount = (size_t)(child->pointIndices.size() * fraction);
389
+ if (copyCount == 0 && targetCount > 0) copyCount = 1;
390
+
391
+ // Multi-pass selection strategy to preserve different types of features
392
+ struct PointMetric {
393
+ int32_t index;
394
+ float maxScale;
395
+ float alpha;
396
+ float luminance;
397
+ float saturation;
398
+ float random;
399
+ bool selected;
400
+ };
401
+
402
+ std::vector<PointMetric> metrics;
403
+ metrics.reserve(child->pointIndices.size());
404
+
405
+ for (int32_t idx : child->pointIndices) {
406
+ float maxLogScale = std::max({m_cloud.scales[idx * 3 + 0], m_cloud.scales[idx * 3 + 1], m_cloud.scales[idx * 3 + 2]});
407
+ float maxScale = std::exp(maxLogScale);
408
+ float alpha = 1.0f / (1.0f + std::exp(-m_cloud.alphas[idx]));
409
+
410
+ float r = 0.5f + 0.28209f * m_cloud.colors[idx * 3 + 0];
411
+ float g = 0.5f + 0.28209f * m_cloud.colors[idx * 3 + 1];
412
+ float b = 0.5f + 0.28209f * m_cloud.colors[idx * 3 + 2];
413
+ float lum = std::max(0.0f, (r + g + b) / 3.0f);
414
+
415
+ float maxC = std::max({r, g, b});
416
+ float minC = std::min({r, g, b});
417
+ float sat = (maxC - minC) * alpha;
418
+
419
+ float rnd = (float)(std::hash<int32_t>{}(idx) % 10000) / 10000.0f;
420
+ metrics.push_back({idx, maxScale, alpha, lum, sat, rnd, false});
421
+ }
422
+
423
+ std::vector<int32_t> selectedIndices;
424
+ selectedIndices.reserve(copyCount);
425
+
426
+ #if 0
427
+
428
+ std::sort(metrics.begin(), metrics.end(), [](const PointMetric& a, const PointMetric& b) {
429
+ return a.random > b.random;
430
+ });
431
+ for (auto& m : metrics) {
432
+ if (selectedIndices.size() >= copyCount) break;
433
+ if (!m.selected) { m.selected = true; selectedIndices.push_back(m.index); }
434
+ }
435
+
436
+ #else
437
+
438
+ // Define quotas for different priorities
439
+ size_t targetP1 = (size_t)(copyCount * 0.10); // 15% Highlights
440
+ size_t targetP2 = (size_t)(copyCount * 0.60); // 30% Structure (Cumulative 45%)
441
+ size_t targetP3 = (size_t)(copyCount * 0.70); // 15% Saturation (Cumulative 60%)
442
+ size_t targetP4 = (size_t)(copyCount * 0.85); // 15% Uniform (Cumulative 75%)
443
+ size_t targetP5 = copyCount; // 25% Fill/Size (Cumulative 100%)
444
+
445
+ // Pass 1: Highlights (Luminance * Alpha) - Preserves white chimneys, lights
446
+ std::sort(metrics.begin(), metrics.end(), [](const PointMetric& a, const PointMetric& b) {
447
+ return (a.luminance * a.alpha) > (b.luminance * b.alpha);
448
+ });
449
+ for (auto& m : metrics) {
450
+ if (selectedIndices.size() >= targetP1) break;
451
+ if (!m.selected) { m.selected = true; selectedIndices.push_back(m.index); }
452
+ }
453
+
454
+ // Pass 2: Structure (MaxScale * Alpha) - Preserves solid walls, roofs
455
+ std::sort(metrics.begin(), metrics.end(), [](const PointMetric& a, const PointMetric& b) {
456
+ return (a.maxScale * a.alpha) > (b.maxScale * b.alpha);
457
+ });
458
+ for (auto& m : metrics) {
459
+ if (selectedIndices.size() >= targetP2) break;
460
+ if (!m.selected) { m.selected = true; selectedIndices.push_back(m.index); }
461
+ }
462
+
463
+ // Pass 3: Saturation - Preserves colorful details (blue glasses, red signs)
464
+ std::sort(metrics.begin(), metrics.end(), [](const PointMetric& a, const PointMetric& b) {
465
+ return a.saturation > b.saturation;
466
+ });
467
+ for (auto& m : metrics) {
468
+ if (selectedIndices.size() >= targetP3) break;
469
+ if (!m.selected) { m.selected = true; selectedIndices.push_back(m.index); }
470
+ }
471
+
472
+ // Pass 4: Uniform - Preserves distribution (prevents holes)
473
+ std::sort(metrics.begin(), metrics.end(), [](const PointMetric& a, const PointMetric& b) {
474
+ return a.random > b.random;
475
+ });
476
+ for (auto& m : metrics) {
477
+ if (selectedIndices.size() >= targetP4) break;
478
+ if (!m.selected) { m.selected = true; selectedIndices.push_back(m.index); }
479
+ }
480
+
481
+ // Pass 5: Fill (MaxScale) - Preserves thin wires, large faint objects
482
+ std::sort(metrics.begin(), metrics.end(), [](const PointMetric& a, const PointMetric& b) {
483
+ return a.maxScale > b.maxScale;
484
+ });
485
+ for (auto& m : metrics) {
486
+ if (selectedIndices.size() >= targetP5) break;
487
+ if (!m.selected) { m.selected = true; selectedIndices.push_back(m.index); }
488
+ }
489
+ #endif
490
+
491
+ node->pointIndices.insert(node->pointIndices.end(), selectedIndices.begin(), selectedIndices.end());
492
+ }
493
+ }
494
+
495
+ // Prune empty children (those that gave all their points to parent and have no children of their own)
496
+ node->children.erase(
497
+ std::remove_if(node->children.begin(), node->children.end(),
498
+ [](const std::unique_ptr<OctreeNode>& child) {
499
+ return child->pointIndices.empty() && child->children.empty();
500
+ }),
501
+ node->children.end());
502
+
503
+ // Calculate geometric error based on bounds size
504
+ if (node->children.empty()) {
505
+ node->geometricError = 0.0f;
506
+ } else {
507
+ // Typical 3D Tiles calculation: Geometric error halves at each depth level.
508
+ // We use the root diagonal as the baseline to ensure monotonic decrease.
509
+ float rootDx = rootAABB.size(0);
510
+ float rootDy = rootAABB.size(1);
511
+ float rootDz = rootAABB.size(2);
512
+ float rootDiag = std::sqrt(rootDx * rootDx + rootDy * rootDy + rootDz * rootDz);
513
+
514
+ // Error = RootDiagonal / 2^depth
515
+ node->geometricError = (rootDiag / std::pow(2.0f, node->depth)) * config.geometricErrorMultiplier;
516
+ }
517
+ }
518
+ };
519
+
520
+ generateLOD(root.get());
521
+
522
+ // Export Nodes (Top-Down)
523
+ std::function<void(OctreeNode*)> exportNodes = [&](OctreeNode* node) {
524
+ if (!node->pointIndices.empty()) {
525
+ spz::GaussianCloud subset = extractSubset(m_cloud, node->pointIndices);
526
+
527
+ // Calculate min/max for GLB header
528
+ float minPos[3] = {std::numeric_limits<float>::max(), std::numeric_limits<float>::max(), std::numeric_limits<float>::max()};
529
+ float maxPos[3] = {std::numeric_limits<float>::lowest(), std::numeric_limits<float>::lowest(), std::numeric_limits<float>::lowest()};
530
+
531
+ for(size_t i=0; i<subset.numPoints; ++i) {
532
+ float x = subset.positions[i*3+0];
533
+ float y = subset.positions[i*3+1];
534
+ float z = subset.positions[i*3+2];
535
+ if(x < minPos[0]) minPos[0] = x;
536
+ if(y < minPos[1]) minPos[1] = y;
537
+ if(z < minPos[2]) minPos[2] = z;
538
+ if(x > maxPos[0]) maxPos[0] = x;
539
+ if(y > maxPos[1]) maxPos[1] = y;
540
+ if(z > maxPos[2]) maxPos[2] = z;
541
+ }
542
+
543
+ // Pack options
544
+ spz::PackOptions packOpts;
545
+ packOpts.from = spz::CoordinateSystem::RUB;
546
+
547
+ std::vector<uint8_t> spzData;
548
+ if (spz::saveSpz(subset, packOpts, &spzData)) {
549
+ std::string filename = "tile_" + std::to_string(node->depth) + "_" +
550
+ std::to_string(node->x) + "_" +
551
+ std::to_string(node->y) + "_" +
552
+ std::to_string(node->z) +
553
+ (config.exportGlb ? ".glb" : ".spz");
554
+ node->filename = filename;
555
+
556
+ std::string fullPath = (fs::path(config.outputDir) / filename).string();
557
+
558
+ if (config.exportGlb) {
559
+ writeGlbWithSpz(fullPath, spzData, subset.numPoints, subset.shDegree, minPos, maxPos);
560
+ } else {
561
+ std::ofstream out(fullPath, std::ios::binary);
562
+ out.write(reinterpret_cast<const char*>(spzData.data()), spzData.size());
563
+ }
564
+ }
565
+ }
566
+
567
+ for (auto& child : node->children) {
568
+ exportNodes(child.get());
569
+ }
570
+ };
571
+
572
+ exportNodes(root.get());
573
+
574
+ // 4. Generate tileset.json
575
+ std::ofstream tilesetOut(fs::path(config.outputDir) / "tileset.json");
576
+ tilesetOut << "{" << std::endl;
577
+ tilesetOut << " \"asset\": { \"version\": \"1.1\" }," << std::endl;
578
+
579
+ float rootDx = root->aabb.size(0);
580
+ float rootDy = root->aabb.size(1);
581
+ float rootDz = root->aabb.size(2);
582
+ float rootDiagonal = std::sqrt(rootDx * rootDx + rootDy * rootDy + rootDz * rootDz);
583
+ tilesetOut << " \"geometricError\": " << rootDiagonal * config.geometricErrorMultiplier << "," << std::endl;
584
+ tilesetOut << " \"extensions\": {" << std::endl;
585
+ tilesetOut << " \"3DTILES_content_gltf\": {" << std::endl;
586
+ tilesetOut << " \"extensionsRequired\": [\"KHR_gaussian_splatting\", \"KHR_gaussian_splatting_compression_spz_2\"]," << std::endl;
587
+ tilesetOut << " \"extensionsUsed\": [\"KHR_gaussian_splatting\", \"KHR_gaussian_splatting_compression_spz_2\"]" << std::endl;
588
+ tilesetOut << " }" << std::endl;
589
+ tilesetOut << " }," << std::endl;
590
+ tilesetOut << " \"extensionsUsed\": [\"3DTILES_content_gltf\"]," << std::endl;
591
+
592
+ tilesetOut << " \"root\": {" << std::endl;
593
+
594
+ if (config.useGeoreference) {
595
+ std::vector<double> m = computeTransform(config.latitude, config.longitude, config.altitude);
596
+ tilesetOut << " \"transform\": [" << std::endl;
597
+ tilesetOut << " " << m[0] << ", " << m[1] << ", " << m[2] << ", " << m[3] << "," << std::endl;
598
+ tilesetOut << " " << m[4] << ", " << m[5] << ", " << m[6] << ", " << m[7] << "," << std::endl;
599
+ tilesetOut << " " << m[8] << ", " << m[9] << ", " << m[10] << ", " << m[11] << "," << std::endl;
600
+ tilesetOut << " " << m[12] << ", " << m[13] << ", " << m[14] << ", " << m[15] << std::endl;
601
+ tilesetOut << " ]," << std::endl;
602
+ }
603
+
604
+ std::function<void(OctreeNode*)> writeTilesetNode = [&](OctreeNode* node) {
605
+
606
+ // Bounding Volume
607
+ tilesetOut << " \"boundingVolume\": { \"box\": [";
608
+ tilesetOut << node->aabb.center(0) << ", " << node->aabb.center(1) << ", " << node->aabb.center(2) << ", ";
609
+ tilesetOut << node->aabb.size(0)/2 << ", 0, 0, ";
610
+ tilesetOut << "0, " << node->aabb.size(1)/2 << ", 0, ";
611
+ tilesetOut << "0, 0, " << node->aabb.size(2)/2;
612
+ tilesetOut << "] }," << std::endl;
613
+
614
+ tilesetOut << " \"geometricError\": " << node->geometricError << "," << std::endl;
615
+ tilesetOut << " \"refine\": \"REPLACE\"";
616
+
617
+ if (!node->filename.empty()) {
618
+ tilesetOut << "," << std::endl;
619
+ tilesetOut << " \"content\": { \"uri\": \"" << node->filename << "\" }";
620
+ }
621
+
622
+ // Children
623
+ if (!node->children.empty()) {
624
+ tilesetOut << "," << std::endl;
625
+ tilesetOut << " \"children\": [" << std::endl;
626
+ bool first = true;
627
+ for (auto& child : node->children) {
628
+ if (!first) tilesetOut << "," << std::endl;
629
+ tilesetOut << " {" << std::endl;
630
+ writeTilesetNode(child.get());
631
+ tilesetOut << " }" << std::endl;
632
+ first = false;
633
+ }
634
+ tilesetOut << std::endl << " ]" << std::endl;
635
+ }
636
+ };
637
+
638
+ writeTilesetNode(root.get());
639
+ tilesetOut << " }" << std::endl;
640
+ tilesetOut << "}" << std::endl;
641
+ }
642
+
643
+
644
+
645
+
646
+
647
+ } // namespace tile3dgs
SplatTiler/SplatTiler.h ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #pragma once
2
+
3
+ #include <string>
4
+ #include <vector>
5
+ #include <memory>
6
+ #include "spz/load-spz.h"
7
+
8
+ namespace tile3dgs {
9
+
10
+ struct TileConfig {
11
+ int maxPointsPerTile = 65536;
12
+ std::string outputDir = "output_tiles";
13
+ // If true, wraps SPZ data in a GLB container. If false, exports raw .spz files.
14
+ bool exportGlb = true;
15
+ bool ignoreZ = false;
16
+ // Subsampling rate for LOD generation (e.g., 2 means keep 1/2 points for parent)
17
+ int lodStride = 2;
18
+
19
+ // Georeference options
20
+ bool useGeoreference = false;
21
+ double latitude = 0.0;
22
+ double longitude = 0.0;
23
+ double altitude = 0.0;
24
+
25
+ // Multiplier for geometric error calculation
26
+ float geometricErrorMultiplier = 1.0f;
27
+ };
28
+
29
+ class SplatTiler {
30
+ public:
31
+ SplatTiler();
32
+ ~SplatTiler();
33
+
34
+ // Load a PLY file into memory
35
+ bool load(const std::string& plyFilename);
36
+
37
+ // Run the tiling and export process
38
+ void process(const TileConfig& config);
39
+
40
+ private:
41
+ spz::GaussianCloud m_cloud;
42
+ };
43
+
44
+ } // namespace tile3dgs
SplatTiler/main.cpp ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #include <iostream>
2
+ #include <string>
3
+ #include <vector>
4
+ #include <filesystem>
5
+
6
+ #include "SplatTiler.h"
7
+
8
+ void printUsage(const char* progName) {
9
+ std::cout << "Usage: " << progName << " [options] <input_ply>\n";
10
+ std::cout << "Options:\n";
11
+ std::cout << " -o, --output <dir> Output directory (default: output_tiles)\n";
12
+ std::cout << " -m, --max-points <num> Max points per tile (default: 65536)\n";
13
+ std::cout << " -s, --stride <num> LOD subsampling stride (default: 2)\n";
14
+ std::cout << " --no-glb Export raw .spz files instead of .glb\n";
15
+ std::cout << " --lat <val> --lon <val> --alt <val> Georeference origin (WGS84)\n";
16
+ std::cout << " --error-scale <val> Geometric error multiplier (default: 1.0)\n";
17
+ std::cout << " --ignore-z Ignore Z axis when splitting (Quadtree)\n";
18
+ std::cout << " -h, --help Show this help message\n";
19
+ }
20
+
21
+ int main(int argc, char* argv[]) {
22
+ if (argc < 2) {
23
+ printUsage(argv[0]);
24
+ return 1;
25
+ }
26
+
27
+ tile3dgs::TileConfig config;
28
+ std::string inputPly;
29
+
30
+ for (int i = 1; i < argc; ++i) {
31
+ std::string arg = argv[i];
32
+
33
+ if (arg == "-h" || arg == "--help") {
34
+ printUsage(argv[0]);
35
+ return 0;
36
+ } else if (arg == "-o" || arg == "--output") {
37
+ if (i + 1 < argc) {
38
+ config.outputDir = argv[++i];
39
+ } else {
40
+ std::cerr << "Error: Missing argument for output directory\n";
41
+ return 1;
42
+ }
43
+ } else if (arg == "-m" || arg == "--max-points") {
44
+ if (i + 1 < argc) {
45
+ try {
46
+ config.maxPointsPerTile = std::stoi(argv[++i]);
47
+ } catch (...) {
48
+ std::cerr << "Error: Invalid number for max points\n";
49
+ return 1;
50
+ }
51
+ } else {
52
+ std::cerr << "Error: Missing argument for max points\n";
53
+ return 1;
54
+ }
55
+ } else if (arg == "-s" || arg == "--stride") {
56
+ if (i + 1 < argc) {
57
+ try {
58
+ config.lodStride = std::stoi(argv[++i]);
59
+ } catch (...) {
60
+ std::cerr << "Error: Invalid number for stride\n";
61
+ return 1;
62
+ }
63
+ } else {
64
+ std::cerr << "Error: Missing argument for stride\n";
65
+ return 1;
66
+ }
67
+ } else if (arg == "--no-glb") {
68
+ config.exportGlb = false;
69
+ } else if (arg == "--lat") {
70
+ if (i + 1 < argc) {
71
+ try {
72
+ config.latitude = std::stod(argv[++i]);
73
+ config.useGeoreference = true;
74
+ } catch (...) {
75
+ std::cerr << "Error: Invalid latitude\n";
76
+ return 1;
77
+ }
78
+ } else {
79
+ std::cerr << "Error: Missing argument for latitude\n";
80
+ return 1;
81
+ }
82
+ } else if (arg == "--lon") {
83
+ if (i + 1 < argc) {
84
+ try {
85
+ config.longitude = std::stod(argv[++i]);
86
+ config.useGeoreference = true;
87
+ } catch (...) {
88
+ std::cerr << "Error: Invalid longitude\n";
89
+ return 1;
90
+ }
91
+ } else {
92
+ std::cerr << "Error: Missing argument for longitude\n";
93
+ return 1;
94
+ }
95
+ } else if (arg == "--alt") {
96
+ if (i + 1 < argc) {
97
+ try {
98
+ config.altitude = std::stod(argv[++i]);
99
+ } catch (...) {
100
+ std::cerr << "Error: Invalid altitude\n";
101
+ return 1;
102
+ }
103
+ } else {
104
+ std::cerr << "Error: Missing argument for altitude\n";
105
+ return 1;
106
+ }
107
+ } else if (arg == "--error-scale") {
108
+ if (i + 1 < argc) {
109
+ try {
110
+ config.geometricErrorMultiplier = std::stof(argv[++i]);
111
+ } catch (...) {
112
+ std::cerr << "Error: Invalid geometric error multiplier\n";
113
+ return 1;
114
+ }
115
+ } else {
116
+ std::cerr << "Error: Missing argument for error scale\n";
117
+ return 1;
118
+ }
119
+ } else if (arg == "--ignore-z") {
120
+ config.ignoreZ = true;
121
+ } else if (arg[0] == '-') {
122
+ std::cerr << "Unknown option: " << arg << "\n";
123
+ printUsage(argv[0]);
124
+ return 1;
125
+ } else {
126
+ inputPly = arg;
127
+ }
128
+ }
129
+
130
+ if (inputPly.empty()) {
131
+ std::cerr << "Error: No input PLY file specified.\n";
132
+ printUsage(argv[0]);
133
+ return 1;
134
+ }
135
+
136
+ if (!std::filesystem::exists(inputPly)) {
137
+ std::cerr << "Error: Input file does not exist: " << inputPly << "\n";
138
+ return 1;
139
+ }
140
+
141
+ std::cout << "Loading " << inputPly << "...\n";
142
+ tile3dgs::SplatTiler tiler;
143
+ if (!tiler.load(inputPly)) {
144
+ std::cerr << "Failed to load PLY file (or file is empty).\n";
145
+ return 1;
146
+ }
147
+
148
+ std::cout << "Processing tiles...\n";
149
+ std::cout << " Output Dir: " << config.outputDir << "\n";
150
+ std::cout << " Max Points: " << config.maxPointsPerTile << "\n";
151
+ std::cout << " LOD Stride: " << config.lodStride << "\n";
152
+ std::cout << " Format: " << (config.exportGlb ? "GLB (with embedded SPZ)" : "SPZ") << "\n";
153
+ if (config.useGeoreference) {
154
+ std::cout << " Georeference: " << config.latitude << ", " << config.longitude << ", " << config.altitude << "\n";
155
+ }
156
+ std::cout << " Error Scale: " << config.geometricErrorMultiplier << "\n";
157
+ if (config.ignoreZ) {
158
+ std::cout << " Ignore Z: true (Quadtree mode)\n";
159
+ }
160
+
161
+ tiler.process(config);
162
+
163
+ std::cout << "Done.\n";
164
+
165
+ return 0;
166
+ }
SplatTiler/spz/load-spz.cc ADDED
@@ -0,0 +1,936 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #include "load-spz.h"
2
+
3
+ #include <zlib.h>
4
+
5
+ #ifdef ANDROID
6
+ #include <android/log.h>
7
+ #endif
8
+
9
+ #include <algorithm>
10
+ #include <cmath>
11
+ #include <cstdio>
12
+ #include <fstream>
13
+ #include <iostream>
14
+ #include <sstream>
15
+ #include <unordered_map>
16
+ #include <vector>
17
+
18
+ namespace spz {
19
+
20
+ namespace {
21
+
22
+ #ifdef ANDROID
23
+ static constexpr char LOG_TAG[] = "SPZ";
24
+ template <class... Args>
25
+ static void SpzLog(const char *fmt, Args &&...args) {
26
+ __android_log_print(ANDROID_LOG_INFO, LOG_TAG, fmt, std::forward<Args>(args)...);
27
+ }
28
+ #else
29
+ template <class... Args>
30
+ static void SpzLog(const char *fmt, Args &&...args) {
31
+ printf(fmt, std::forward<Args>(args)...);
32
+ printf("\n");
33
+ fflush(stdout);
34
+ }
35
+ #endif // ANDROID
36
+
37
+ template <class... Args>
38
+ static void SpzLog(const char *fmt) {
39
+ SpzLog("%s", fmt);
40
+ }
41
+
42
+ // Scale factor for DC color components. To convert to RGB, we should multiply by 0.282, but it can
43
+ // be useful to represent base colors that are out of range if the higher spherical harmonics bands
44
+ // bring them back into range so we multiply by a smaller value.
45
+ constexpr float colorScale = 0.15f;
46
+ constexpr float sqrt1_2 = (float)0.707106781186547524401; // 1/sqrt(2)
47
+
48
+ int32_t degreeForDim(int32_t dim) {
49
+ if (dim < 3)
50
+ return 0;
51
+ if (dim < 8)
52
+ return 1;
53
+ if (dim < 15)
54
+ return 2;
55
+ return 3;
56
+ }
57
+
58
+ int32_t dimForDegree(int32_t degree) {
59
+ switch (degree) {
60
+ case 0:
61
+ return 0;
62
+ case 1:
63
+ return 3;
64
+ case 2:
65
+ return 8;
66
+ case 3:
67
+ return 15;
68
+ default:
69
+ SpzLog("[SPZ: ERROR] Unsupported SH degree: %d\n", degree);
70
+ return 0;
71
+ }
72
+ }
73
+
74
+ uint8_t toUint8(float x) { return static_cast<uint8_t>(std::clamp(std::round(x), 0.0f, 255.0f)); }
75
+
76
+ // Quantizes to 8 bits, the round to nearest bucket center. 0 always maps to a bucket center.
77
+ uint8_t quantizeSH(float x, int32_t bucketSize) {
78
+ int32_t q = static_cast<int>(std::round(x * 128.0f) + 128.0f);
79
+ q = (q + bucketSize / 2) / bucketSize * bucketSize;
80
+ return static_cast<uint8_t>(std::clamp(q, 0, 255));
81
+ }
82
+
83
+ float unquantizeSH(uint8_t x) { return (static_cast<float>(x) - 128.0f) / 128.0f; }
84
+
85
+ float sigmoid(float x) { return 1 / (1 + std::exp(-x)); }
86
+
87
+ float invSigmoid(float x) { return std::log(x / (1.0f - x)); }
88
+
89
+ template <typename T>
90
+ size_t countBytes(std::vector<T> vec) {
91
+ return vec.size() * sizeof(vec[0]);
92
+ }
93
+
94
+ #define CHECK(x) \
95
+ { \
96
+ if (!(x)) { \
97
+ SpzLog("[SPZ: ERROR] Check failed: %s:%d: %s", __FILE__, __LINE__, #x); \
98
+ return false; \
99
+ } \
100
+ }
101
+
102
+ #define CHECK_GE(x, y) CHECK((x) >= (y))
103
+ #define CHECK_LE(x, y) CHECK((x) <= (y))
104
+ #define CHECK_EQ(x, y) CHECK((x) == (y))
105
+
106
+ bool checkSizes(const GaussianCloud &g) {
107
+ CHECK_GE(g.numPoints, 0);
108
+ CHECK_GE(g.shDegree, 0);
109
+ CHECK_LE(g.shDegree, 3);
110
+ CHECK_EQ(g.positions.size(), g.numPoints * 3);
111
+ CHECK_EQ(g.scales.size(), g.numPoints * 3);
112
+ CHECK_EQ(g.rotations.size(), g.numPoints * 4);
113
+ CHECK_EQ(g.alphas.size(), g.numPoints);
114
+ CHECK_EQ(g.colors.size(), g.numPoints * 3);
115
+ CHECK_EQ(g.sh.size(), g.numPoints * dimForDegree(g.shDegree) * 3);
116
+ return true;
117
+ }
118
+
119
+ bool checkSizes(const PackedGaussians &packed, int32_t numPoints, int32_t shDim, bool usesFloat16) {
120
+ CHECK_EQ(packed.positions.size(), numPoints * 3 * (usesFloat16 ? 2 : 3));
121
+ CHECK_EQ(packed.scales.size(), numPoints * 3);
122
+ CHECK_EQ(packed.rotations.size(), numPoints * (packed.usesQuaternionSmallestThree ? 4 : 3));
123
+ CHECK_EQ(packed.alphas.size(), numPoints);
124
+ CHECK_EQ(packed.colors.size(), numPoints * 3);
125
+ CHECK_EQ(packed.sh.size(), numPoints * shDim * 3);
126
+ return true;
127
+ }
128
+
129
+ constexpr uint8_t FlagAntialiased = 0x1;
130
+
131
+ struct PackedGaussiansHeader {
132
+ uint32_t magic = 0x5053474e; // NGSP = Niantic gaussian splat
133
+ uint32_t version = 3;
134
+ uint32_t numPoints = 0;
135
+ uint8_t shDegree = 0;
136
+ uint8_t fractionalBits = 0;
137
+ uint8_t flags = 0;
138
+ uint8_t reserved = 0;
139
+ };
140
+
141
+ bool decompressGzippedImpl(
142
+ const uint8_t *compressed, size_t size, int32_t windowSize, std::vector<uint8_t> *out) {
143
+ std::vector<uint8_t> buffer(8192);
144
+ z_stream stream = {};
145
+ stream.next_in = const_cast<Bytef *>(compressed);
146
+ stream.avail_in = size;
147
+ if (inflateInit2(&stream, windowSize) != Z_OK) {
148
+ return false;
149
+ }
150
+ out->clear();
151
+ bool success = false;
152
+ while (true) {
153
+ stream.next_out = buffer.data();
154
+ stream.avail_out = buffer.size();
155
+ int32_t res = inflate(&stream, Z_NO_FLUSH);
156
+ if (res != Z_OK && res != Z_STREAM_END) {
157
+ break;
158
+ }
159
+ out->insert(out->end(), buffer.data(), buffer.data() + buffer.size() - stream.avail_out);
160
+ if (res == Z_STREAM_END) {
161
+ success = true;
162
+ break;
163
+ }
164
+ }
165
+ inflateEnd(&stream);
166
+ return success;
167
+ }
168
+
169
+ bool decompressGzipped(const uint8_t *compressed, size_t size, std::vector<uint8_t> *out) {
170
+ // Here 16 means enable automatic gzip header detection; consider switching this to 32 to enable
171
+ // both automated gzip and zlib header detection.
172
+ return decompressGzippedImpl(compressed, size, 16 | MAX_WBITS, out);
173
+ }
174
+
175
+ bool decompressGzipped(const uint8_t *compressed, size_t size, std::string *out) {
176
+ std::vector<uint8_t> buffer;
177
+ if (!decompressGzipped(compressed, size, &buffer)) {
178
+ return false;
179
+ }
180
+ out->assign(reinterpret_cast<const char *>(buffer.data()), buffer.size());
181
+ return true;
182
+ }
183
+
184
+ } // namespace
185
+
186
+ bool compressGzipped(const uint8_t *data, size_t size, std::vector<uint8_t> *out) {
187
+ std::vector<uint8_t> buffer(8192);
188
+ z_stream stream = {};
189
+ if (
190
+ deflateInit2(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 16 + MAX_WBITS, 9, Z_DEFAULT_STRATEGY)
191
+ != Z_OK) {
192
+ return false;
193
+ }
194
+ out->clear();
195
+ out->reserve(size / 4);
196
+ stream.next_in = const_cast<Bytef *>(reinterpret_cast<const Bytef *>(data));
197
+ stream.avail_in = size;
198
+ bool success = false;
199
+ while (true) {
200
+ stream.next_out = buffer.data();
201
+ stream.avail_out = buffer.size();
202
+ int32_t res = deflate(&stream, Z_FINISH);
203
+ if (res != Z_OK && res != Z_STREAM_END) {
204
+ break;
205
+ }
206
+ out->insert(out->end(), buffer.data(), buffer.data() + buffer.size() - stream.avail_out);
207
+ if (res == Z_STREAM_END) {
208
+ success = true;
209
+ break;
210
+ }
211
+ }
212
+ deflateEnd(&stream);
213
+ return success;
214
+ }
215
+
216
+ void packQuaternionSmallestThree(uint8_t r[4], const float rotation[4], const CoordinateConverter& c) {
217
+ // Normalize the quaternion
218
+ Quat4f q = normalized(quat4f(&rotation[0]));
219
+ q[0] *= c.flipQ[0];
220
+ q[1] *= c.flipQ[1];
221
+ q[2] *= c.flipQ[2];
222
+
223
+ // Find largest component
224
+ unsigned iLargest = 0;
225
+ for (unsigned i = 1; i < 4; ++i)
226
+ {
227
+ if (std::abs(q[i]) > std::abs(q[iLargest]))
228
+ {
229
+ iLargest = i;
230
+ }
231
+ }
232
+
233
+ // since -q represents the same rotation as q, transform the quaternion so the largest element
234
+ // is positive. This avoids having to send its sign bit.
235
+ unsigned negate = q[iLargest] < 0;
236
+
237
+ // Do compression using sign bit and 9-bit precision per element.
238
+ uint32_t comp = iLargest;
239
+ for (unsigned i = 0; i < 4; ++i)
240
+ {
241
+ if (i != iLargest)
242
+ {
243
+ uint32_t negbit = (q[i] < 0) ^ negate;
244
+ uint32_t mag =
245
+ (uint32_t)(float((1u << 9u) - 1u) * (std::fabs(q[i]) / sqrt1_2) + 0.5f);
246
+ comp = (comp << 10u) | (negbit << 9u) | mag;
247
+ }
248
+ }
249
+
250
+ // Ensure little-endianness on all platforms
251
+ r[0] = comp & 0xff;
252
+ r[1] = (comp >> 8) & 0xff;
253
+ r[2] = (comp >> 16) & 0xff;
254
+ r[3] = (comp >> 24) & 0xff;
255
+ }
256
+
257
+ PackedGaussians packGaussians(const GaussianCloud &g, const PackOptions &o) {
258
+ if (!checkSizes(g)) {
259
+ return {};
260
+ }
261
+ const int32_t numPoints = g.numPoints;
262
+ const int32_t shDim = dimForDegree(g.shDegree);
263
+ CoordinateConverter c = coordinateConverter(o.from, CoordinateSystem::RUB);
264
+
265
+ // Use 12 bits for the fractional part of coordinates (~0.25 millimeter resolution). In the future
266
+ // we can use different values on a per-splat basis and still be compatible with the decoder.
267
+ PackedGaussians packed;
268
+ packed.numPoints = g.numPoints;
269
+ packed.shDegree = g.shDegree;
270
+ packed.fractionalBits = 12;
271
+ packed.antialiased = g.antialiased;
272
+ packed.usesQuaternionSmallestThree = true;
273
+ packed.positions.resize(numPoints * 3 * 3);
274
+ packed.scales.resize(numPoints * 3);
275
+ packed.rotations.resize(numPoints * 4);
276
+ packed.alphas.resize(numPoints);
277
+ packed.colors.resize(numPoints * 3);
278
+ packed.sh.resize(numPoints * shDim * 3);
279
+
280
+ // Store coordinates as 24-bit fixed point values.
281
+ const float scale = (1 << packed.fractionalBits);
282
+ for (size_t i = 0; i < numPoints * 3; i++) {
283
+ const int32_t fixed32 =
284
+ static_cast<int32_t>(std::round(c.flipP[i % 3] * g.positions[i] * scale));
285
+ packed.positions[i * 3 + 0] = fixed32 & 0xff;
286
+ packed.positions[i * 3 + 1] = (fixed32 >> 8) & 0xff;
287
+ packed.positions[i * 3 + 2] = (fixed32 >> 16) & 0xff;
288
+ }
289
+
290
+ for (size_t i = 0; i < numPoints * 3; i++) {
291
+ packed.scales[i] = toUint8((g.scales[i] + 10.0f) * 16.0f);
292
+ }
293
+
294
+ for (size_t i = 0; i < numPoints; i++)
295
+ {
296
+ packQuaternionSmallestThree(&packed.rotations[4 * i], &g.rotations[4 * i], c);
297
+ }
298
+
299
+ for (size_t i = 0; i < numPoints; i++) {
300
+ // Apply sigmoid activation to alpha
301
+ packed.alphas[i] = toUint8(sigmoid(g.alphas[i]) * 255.0f);
302
+ }
303
+
304
+ for (size_t i = 0; i < numPoints * 3; i++) {
305
+ // Convert SH DC component to wide RGB (allowing values that are a bit above 1 and below 0).
306
+ packed.colors[i] = toUint8(g.colors[i] * (colorScale * 255.0f) + (0.5f * 255.0f));
307
+ }
308
+
309
+ if (g.shDegree > 0) {
310
+ // Spherical harmonics quantization parameters. The data format uses 8 bits per coefficient, but
311
+ // when packing, we can quantize to fewer bits for better compression.
312
+ constexpr int32_t sh1Bits = 5;
313
+ constexpr int32_t shRestBits = 4;
314
+ const int32_t shPerPoint = dimForDegree(g.shDegree) * 3;
315
+ for (size_t i = 0; i < numPoints * shPerPoint; i += shPerPoint) {
316
+ size_t j = 0, k = 0;
317
+ for (; j < 9; j += 3, k++) { // There are 9 (3 * 3) coefficients for degree 1
318
+ packed.sh[i + j + 0] = quantizeSH(c.flipSh[k] * g.sh[i + j + 0], 1 << (8 - sh1Bits));
319
+ packed.sh[i + j + 1] = quantizeSH(c.flipSh[k] * g.sh[i + j + 1], 1 << (8 - sh1Bits));
320
+ packed.sh[i + j + 2] = quantizeSH(c.flipSh[k] * g.sh[i + j + 2], 1 << (8 - sh1Bits));
321
+ }
322
+ for (; j < shPerPoint; j += 3, k++) {
323
+ packed.sh[i + j + 0] = quantizeSH(c.flipSh[k] * g.sh[i + j + 0], 1 << (8 - shRestBits));
324
+ packed.sh[i + j + 1] = quantizeSH(c.flipSh[k] * g.sh[i + j + 1], 1 << (8 - shRestBits));
325
+ packed.sh[i + j + 2] = quantizeSH(c.flipSh[k] * g.sh[i + j + 2], 1 << (8 - shRestBits));
326
+ }
327
+ }
328
+ }
329
+
330
+ return packed;
331
+ }
332
+
333
+ void unpackQuaternionFirstThree(float rotation[4], const uint8_t r[3], const CoordinateConverter& c = CoordinateConverter())
334
+ {
335
+ Vec3f xyz = times(
336
+ plus(
337
+ times(
338
+ Vec3f{ static_cast<float>(r[0]), static_cast<float>(r[1]), static_cast<float>(r[2]) },
339
+ 1.0f / 127.5f),
340
+ Vec3f{ -1, -1, -1 }),
341
+ c.flipQ);
342
+ std::copy(xyz.data(), xyz.data() + 3, &rotation[0]);
343
+ // Compute the real component - we know the quaternion is normalized and w is non-negative
344
+ rotation[3] = std::sqrt(std::max(0.0f, 1.0f - squaredNorm(xyz)));
345
+ }
346
+
347
+ void unpackQuaternionSmallestThree(float rotation[4], const uint8_t r[4], const CoordinateConverter& c = CoordinateConverter())
348
+ {
349
+ uint32_t comp =
350
+ r[0] +
351
+ (r[1] << 8) +
352
+ (r[2] << 16) +
353
+ (r[3] << 24);
354
+
355
+ constexpr uint32_t c_mask = (1u << 9u) - 1u;
356
+
357
+ const int i_largest = comp >> 30;
358
+ float sum_squares = 0;
359
+ // [unroll]
360
+ for (int i = 3; i >= 0; --i)
361
+ {
362
+ if (i != i_largest)
363
+ {
364
+ uint32_t mag = comp & c_mask;
365
+ uint32_t negbit = (comp >> 9u) & 0x1u;
366
+ comp = comp >> 10u;
367
+ rotation[i] = sqrt1_2 * ((float)mag) / float(c_mask);
368
+ if (negbit == 1)
369
+ {
370
+ rotation[i] = -rotation[i];
371
+ }
372
+ sum_squares += rotation[i] * rotation[i];
373
+ }
374
+ }
375
+ rotation[i_largest] = sqrt(1.0f - sum_squares);
376
+
377
+ for (int i = 0; i < 3; i++)
378
+ {
379
+ rotation[i] *= c.flipQ[i];
380
+ }
381
+ }
382
+
383
+ UnpackedGaussian PackedGaussian::unpack(
384
+ bool usesFloat16, bool usesQuaternionSmallestThree, int32_t fractionalBits, const CoordinateConverter &c) const {
385
+ UnpackedGaussian result;
386
+ if (usesFloat16) {
387
+ // Decode legacy float16 format. We can remove this at some point as it was never released.
388
+ const auto *halfData = reinterpret_cast<const Half *>(position.data());
389
+ for (size_t i = 0; i < 3; i++) {
390
+ result.position[i] = c.flipP[i] * halfToFloat(halfData[i]);
391
+ }
392
+ } else {
393
+ // Decode 24-bit fixed point coordinates
394
+ float scale = 1.0 / (1 << fractionalBits);
395
+ for (size_t i = 0; i < 3; i++) {
396
+ int32_t fixed32 = position[i * 3 + 0];
397
+ fixed32 |= position[i * 3 + 1] << 8;
398
+ fixed32 |= position[i * 3 + 2] << 16;
399
+ fixed32 |= (fixed32 & 0x800000) ? 0xff000000 : 0; // sign extension
400
+ result.position[i] = c.flipP[i] * static_cast<float>(fixed32) * scale;
401
+ }
402
+ }
403
+
404
+ for (size_t i = 0; i < 3; i++) {
405
+ result.scale[i] = (scale[i] / 16.0f - 10.0f);
406
+ }
407
+
408
+ if (usesQuaternionSmallestThree)
409
+ {
410
+ unpackQuaternionSmallestThree(&result.rotation[0], &rotation[0], c);
411
+ }
412
+ else
413
+ {
414
+ unpackQuaternionFirstThree(&result.rotation[0], &rotation[0], c);
415
+ }
416
+
417
+ result.alpha = invSigmoid(alpha / 255.0f);
418
+
419
+ for (size_t i = 0; i < 3; i++) {
420
+ result.color[i] = ((color[i] / 255.0f) - 0.5f) / colorScale;
421
+ }
422
+
423
+ for (size_t i = 0; i < 15; i++) {
424
+ result.shR[i] = c.flipSh[i] * unquantizeSH(shR[i]);
425
+ result.shG[i] = c.flipSh[i] * unquantizeSH(shG[i]);
426
+ result.shB[i] = c.flipSh[i] * unquantizeSH(shB[i]);
427
+ }
428
+
429
+ return result;
430
+ }
431
+
432
+ PackedGaussian PackedGaussians::at(int32_t i) const {
433
+ PackedGaussian result;
434
+ int32_t positionBytes = usesFloat16() ? 6 : 9;
435
+ int32_t start3 = i * 3;
436
+ const auto *p = &positions[i * positionBytes];
437
+ std::copy(p, p + positionBytes, result.position.data());
438
+ std::copy(&scales[start3], &scales[start3] + 3, result.scale.data());
439
+ int32_t rotationBytes = usesQuaternionSmallestThree ? 4 : 3;
440
+ const auto& r = &rotations[i * rotationBytes];
441
+ std::copy(r, r + rotationBytes, result.rotation.data());
442
+ std::copy(&colors[start3], &colors[start3] + 3, result.color.data());
443
+ result.alpha = alphas[i];
444
+
445
+ int32_t shDim = dimForDegree(shDegree);
446
+ const auto *sh = &this->sh[i * shDim * 3];
447
+ for (int32_t j = 0; j < shDim; ++j, sh += 3) {
448
+ result.shR[j] = sh[0];
449
+ result.shG[j] = sh[1];
450
+ result.shB[j] = sh[2];
451
+ }
452
+ for (int32_t j = shDim; j < 15; ++j) {
453
+ result.shR[j] = 128;
454
+ result.shG[j] = 128;
455
+ result.shB[j] = 128;
456
+ }
457
+
458
+ return result;
459
+ }
460
+
461
+ UnpackedGaussian PackedGaussians::unpack(int32_t i, const CoordinateConverter &c) const {
462
+ return at(i).unpack(usesFloat16(), usesQuaternionSmallestThree, fractionalBits, c);
463
+ }
464
+
465
+ bool PackedGaussians::usesFloat16() const { return positions.size() == numPoints * 3 * 2; }
466
+
467
+ GaussianCloud unpackGaussians(const PackedGaussians &packed, const UnpackOptions &o) {
468
+ const int32_t numPoints = packed.numPoints;
469
+ const int32_t shDim = dimForDegree(packed.shDegree);
470
+ const bool usesFloat16 = packed.usesFloat16();
471
+ const bool usesQuaternionSmallestThree = packed.usesQuaternionSmallestThree;
472
+ if (!checkSizes(packed, numPoints, shDim, usesFloat16)) {
473
+ return {};
474
+ }
475
+
476
+ GaussianCloud result;
477
+ result.numPoints = packed.numPoints;
478
+ result.shDegree = packed.shDegree;
479
+ result.antialiased = packed.antialiased;
480
+ result.positions.resize(numPoints * 3);
481
+ result.scales.resize(numPoints * 3);
482
+ result.rotations.resize(numPoints * 4);
483
+ result.alphas.resize(numPoints);
484
+ result.colors.resize(numPoints * 3);
485
+ result.sh.resize(numPoints * shDim * 3);
486
+
487
+ if (usesFloat16) {
488
+ // Decode legacy float16 format. We can remove this at some point as it was never released.
489
+ const auto *halfData = reinterpret_cast<const Half *>(packed.positions.data());
490
+ for (size_t i = 0; i < numPoints * 3; i++) {
491
+ result.positions[i] = halfToFloat(halfData[i]);
492
+ }
493
+ } else {
494
+ // Decode 24-bit fixed point coordinates
495
+ float scale = 1.0 / (1 << packed.fractionalBits);
496
+ for (size_t i = 0; i < numPoints * 3; i++) {
497
+ int32_t fixed32 = packed.positions[i * 3 + 0];
498
+ fixed32 |= packed.positions[i * 3 + 1] << 8;
499
+ fixed32 |= packed.positions[i * 3 + 2] << 16;
500
+ fixed32 |= (fixed32 & 0x800000) ? 0xff000000 : 0; // sign extension
501
+ result.positions[i] = static_cast<float>(fixed32) * scale;
502
+ }
503
+ }
504
+
505
+ for (size_t i = 0; i < numPoints * 3; i++) {
506
+ result.scales[i] = packed.scales[i] / 16.0f - 10.0f;
507
+ }
508
+
509
+ for (size_t i = 0; i < numPoints; i++) {
510
+ if (usesQuaternionSmallestThree) {
511
+ unpackQuaternionSmallestThree(&result.rotations[4 * i], &packed.rotations[4 * i]);
512
+ } else {
513
+ unpackQuaternionFirstThree(&result.rotations[4 * i], &packed.rotations[3 * i]);
514
+ }
515
+ }
516
+
517
+ for (size_t i = 0; i < numPoints; i++) {
518
+ result.alphas[i] = invSigmoid(packed.alphas[i] / 255.0f);
519
+ }
520
+
521
+ for (size_t i = 0; i < numPoints * 3; i++) {
522
+ result.colors[i] = ((packed.colors[i] / 255.0f) - 0.5f) / colorScale;
523
+ }
524
+
525
+ for (size_t i = 0; i < packed.sh.size(); i++) {
526
+ result.sh[i] = unquantizeSH(packed.sh[i]);
527
+ }
528
+
529
+ result.convertCoordinates(CoordinateSystem::RUB, o.to);
530
+ return result;
531
+ }
532
+
533
+ void serializePackedGaussians(const PackedGaussians &packed, std::ostream *out) {
534
+ PackedGaussiansHeader header;
535
+ header.numPoints = static_cast<uint32_t>(packed.numPoints);
536
+ header.shDegree = static_cast<uint8_t>(packed.shDegree);
537
+ header.fractionalBits = static_cast<uint8_t>(packed.fractionalBits);
538
+ header.flags = static_cast<uint8_t>(packed.antialiased ? FlagAntialiased : 0);
539
+ out->write(reinterpret_cast<const char *>(&header), sizeof(header));
540
+ out->write(reinterpret_cast<const char *>(packed.positions.data()), countBytes(packed.positions));
541
+ out->write(reinterpret_cast<const char *>(packed.alphas.data()), countBytes(packed.alphas));
542
+ out->write(reinterpret_cast<const char *>(packed.colors.data()), countBytes(packed.colors));
543
+ out->write(reinterpret_cast<const char *>(packed.scales.data()), countBytes(packed.scales));
544
+ out->write(reinterpret_cast<const char *>(packed.rotations.data()), countBytes(packed.rotations));
545
+ out->write(reinterpret_cast<const char *>(packed.sh.data()), countBytes(packed.sh));
546
+ }
547
+
548
+ PackedGaussians deserializePackedGaussians(std::istream &in) {
549
+ constexpr int32_t maxPointsToRead = 10000000;
550
+
551
+ PackedGaussiansHeader header;
552
+ in.read(reinterpret_cast<char *>(&header), sizeof(header));
553
+ if (!in || header.magic != PackedGaussiansHeader().magic) {
554
+ SpzLog("[SPZ ERROR] deserializePackedGaussians: header not found");
555
+ return {};
556
+ }
557
+ if (header.version < 1 || header.version > 3) {
558
+ SpzLog("[SPZ ERROR] deserializePackedGaussians: version not supported: %d", header.version);
559
+ return {};
560
+ }
561
+ if (header.numPoints > maxPointsToRead) {
562
+ SpzLog("[SPZ ERROR] deserializePackedGaussians: Too many points: %d", header.numPoints);
563
+ return {};
564
+ }
565
+ if (header.shDegree > 3) {
566
+ SpzLog("[SPZ ERROR] deserializePackedGaussians: Unsupported SH degree: %d", header.shDegree);
567
+ return {};
568
+ }
569
+ const int32_t numPoints = header.numPoints;
570
+ const int32_t shDim = dimForDegree(header.shDegree);
571
+ const bool usesFloat16 = header.version == 1;
572
+ const bool usesQuaternionSmallestThree = header.version >= 3;
573
+ PackedGaussians result;
574
+ result.numPoints = numPoints;
575
+ result.shDegree = header.shDegree;
576
+ result.fractionalBits = header.fractionalBits;
577
+ result.antialiased = (header.flags & FlagAntialiased) != 0;
578
+ result.positions.resize(numPoints * 3 * (usesFloat16 ? 2 : 3));
579
+ result.scales.resize(numPoints * 3);
580
+ result.usesQuaternionSmallestThree = usesQuaternionSmallestThree;
581
+ result.rotations.resize(numPoints * (usesQuaternionSmallestThree ? 4 : 3));
582
+ result.alphas.resize(numPoints);
583
+ result.colors.resize(numPoints * 3);
584
+ result.sh.resize(numPoints * shDim * 3);
585
+ in.read(reinterpret_cast<char *>(result.positions.data()), countBytes(result.positions));
586
+ in.read(reinterpret_cast<char *>(result.alphas.data()), countBytes(result.alphas));
587
+ in.read(reinterpret_cast<char *>(result.colors.data()), countBytes(result.colors));
588
+ in.read(reinterpret_cast<char *>(result.scales.data()), countBytes(result.scales));
589
+ in.read(reinterpret_cast<char *>(result.rotations.data()), countBytes(result.rotations));
590
+ in.read(reinterpret_cast<char *>(result.sh.data()), countBytes(result.sh));
591
+ if (!in) {
592
+ SpzLog("[SPZ ERROR] deserializePackedGaussians: read error");
593
+ return {};
594
+ }
595
+ return result;
596
+ }
597
+
598
+ bool saveSpz(const GaussianCloud &g, const PackOptions &o, std::vector<uint8_t> *out) {
599
+ std::string data;
600
+ {
601
+ PackedGaussians packed = packGaussians(g, o);
602
+ std::stringstream ss;
603
+ serializePackedGaussians(packed, &ss);
604
+ data = ss.str();
605
+ }
606
+ return compressGzipped(reinterpret_cast<const uint8_t *>(data.data()), data.size(), out);
607
+ }
608
+
609
+ PackedGaussians loadSpzPacked(const uint8_t *data, int32_t size) {
610
+ std::string decompressed;
611
+ if (!decompressGzipped(data, size, &decompressed))
612
+ return {};
613
+ std::stringstream stream(std::move(decompressed));
614
+ return deserializePackedGaussians(stream);
615
+ }
616
+
617
+ PackedGaussians loadSpzPacked(const std::vector<uint8_t> &data) {
618
+ return loadSpzPacked(data.data(), static_cast<int>(data.size()));
619
+ }
620
+
621
+ PackedGaussians loadSpzPacked(const std::string &filename) {
622
+ std::ifstream in(filename, std::ios::binary | std::ios::ate);
623
+ if (!in.good())
624
+ return {};
625
+ std::vector<uint8_t> data(in.tellg());
626
+ in.seekg(0, std::ios::beg);
627
+ in.read(reinterpret_cast<char *>(data.data()), data.size());
628
+ if (!in.good()) {
629
+ return {};
630
+ }
631
+ return loadSpzPacked(data);
632
+ }
633
+
634
+ GaussianCloud loadSpz(const std::vector<uint8_t> &data, const UnpackOptions &o) {
635
+ return unpackGaussians(loadSpzPacked(data), o);
636
+ }
637
+
638
+ GaussianCloud loadSpz(const uint8_t *data, int32_t size, const UnpackOptions &o) {
639
+ return unpackGaussians(loadSpzPacked(data, size), o);
640
+ }
641
+
642
+ bool saveSpz(const GaussianCloud &g, const PackOptions &o, const std::string &filename) {
643
+ std::vector<uint8_t> data;
644
+ if (!saveSpz(g, o, &data)) {
645
+ return false;
646
+ }
647
+ std::ofstream out(filename, std::ios::binary | std::ios::out);
648
+ out.write(reinterpret_cast<const char *>(data.data()), data.size());
649
+ out.close();
650
+ return out.good();
651
+ }
652
+
653
+ GaussianCloud loadSpz(const std::string &filename, const UnpackOptions &o) {
654
+ std::ifstream in(filename, std::ios::binary | std::ios::ate);
655
+ if (!in.good()) {
656
+ SpzLog("[SPZ ERROR] Unable to open: %s", filename.c_str());
657
+ return {};
658
+ }
659
+ std::vector<uint8_t> data(in.tellg());
660
+ in.seekg(0, std::ios::beg);
661
+ in.read(reinterpret_cast<char *>(data.data()), data.size());
662
+ in.close();
663
+ if (!in.good()) {
664
+ SpzLog("[SPZ ERROR] Unable to load data from: %s", filename.c_str());
665
+ return {};
666
+ }
667
+ return loadSpz(data, o);
668
+ }
669
+
670
+ bool getNextHeaderLine(std::ifstream &in, std::string &line) {
671
+ while (std::getline(in, line)) {
672
+ // Find the first non-whitespace character
673
+ size_t start = line.find_first_not_of(" \t\n\r\f\v");
674
+ // If line is empty or whitespace-only, skip it and continue reading.
675
+ if (std::string::npos == start) {
676
+ continue;
677
+ }
678
+ // Trim leading whitespace and check for 'comment'
679
+ std::string trimmed_line = line.substr(start);
680
+ if (trimmed_line.rfind("comment", 0) == 0) {
681
+ continue; // Skip comment line
682
+ }
683
+ // Found a valid non-comment, non-empty line
684
+ line = trimmed_line; // Update the reference string with the trimmed line
685
+ return true;
686
+ }
687
+ // Failed to read a line (EOF or error)
688
+ return false;
689
+ }
690
+
691
+ GaussianCloud loadSplatFromPly(const std::string &filename, const UnpackOptions &o) {
692
+ SpzLog("[SPZ] Loading: %s", filename.c_str());
693
+ std::ifstream in(filename, std::ios::binary);
694
+ if (!in.good()) {
695
+ SpzLog("[SPZ ERROR] Unable to open: %s", filename.c_str());
696
+ in.close();
697
+ return {};
698
+ }
699
+ std::string line;
700
+ std::getline(in, line);
701
+ if (line != "ply") {
702
+ SpzLog("[SPZ ERROR] %s: not a .ply file", filename.c_str());
703
+ in.close();
704
+ return {};
705
+ }
706
+ if (!getNextHeaderLine(in, line) || line != "format binary_little_endian 1.0") {
707
+ SpzLog("[SPZ ERROR] %s: unsupported .ply format", filename.c_str());
708
+ in.close();
709
+ return {};
710
+ }
711
+ if (!getNextHeaderLine(in, line) || line.find("element vertex ") != 0) {
712
+ SpzLog("[SPZ ERROR] %s: missing vertex count", filename.c_str());
713
+ in.close();
714
+ return {};
715
+ }
716
+ int32_t numPoints = std::stoi(line.substr(std::strlen("element vertex ")));
717
+ if (numPoints <= 0 || numPoints > 10 * 1024 * 1024) {
718
+ SpzLog("[SPZ ERROR] %s: invalid vertex count: %d", filename.c_str(), numPoints);
719
+ in.close();
720
+ return {};
721
+ }
722
+
723
+ SpzLog("[SPZ] Loading %d points", numPoints);
724
+ std::unordered_map<std::string, int> fields; // name -> index
725
+ for (int32_t i = 0;; i++) {
726
+ if (!getNextHeaderLine(in, line)) {
727
+ SpzLog("[SPZ ERROR] %s: unexpected EOF while reading header properties.", filename.c_str());
728
+ in.close();
729
+ return {};
730
+ }
731
+ if (line == "end_header")
732
+ break;
733
+
734
+ if (line.find("property float ") != 0) {
735
+ SpzLog("[SPZ ERROR] %s: unsupported property data type: %s", filename.c_str(), line.c_str());
736
+ in.close();
737
+ return {};
738
+ }
739
+ std::string name = line.substr(std::strlen("property float "));
740
+ fields[name] = i;
741
+ }
742
+
743
+ // Returns the index for a given field name, ensuring the name exists.
744
+ const auto index = [&fields](const std::string &name) {
745
+ const auto &itr = fields.find(name);
746
+ if (itr == fields.end()) {
747
+ SpzLog("[SPZ ERROR] Missing field: %s", name.c_str());
748
+ return -1;
749
+ }
750
+ return itr->second;
751
+ };
752
+
753
+ const std::vector<int> positionIdx = {index("x"), index("y"), index("z")};
754
+ const std::vector<int> scaleIdx = {index("scale_0"), index("scale_1"), index("scale_2")};
755
+ const std::vector<int> rotIdx = {index("rot_1"), index("rot_2"), index("rot_3"), index("rot_0")};
756
+ const std::vector<int> alphaIdx = {index("opacity")};
757
+ const std::vector<int> colorIdx = {index("f_dc_0"), index("f_dc_1"), index("f_dc_2")};
758
+
759
+ // Check that only valid indices were returned.
760
+ for (auto idx : positionIdx) {
761
+ if (idx < 0) {
762
+ in.close();
763
+ return {};
764
+ }
765
+ }
766
+ for (auto idx : scaleIdx) {
767
+ if (idx < 0) {
768
+ in.close();
769
+ return {};
770
+ }
771
+ }
772
+ for (auto idx : rotIdx) {
773
+ if (idx < 0) {
774
+ in.close();
775
+ return {};
776
+ }
777
+ }
778
+ for (auto idx : alphaIdx) {
779
+ if (idx < 0) {
780
+ in.close();
781
+ return {};
782
+ }
783
+ }
784
+ for (auto idx : colorIdx) {
785
+ if (idx < 0) {
786
+ in.close();
787
+ return {};
788
+ }
789
+ }
790
+
791
+ // Spherical harmonics are optional and variable in size (depending on degree)
792
+ std::vector<int> shIdx;
793
+ for (int32_t i = 0; i < 45; i++) {
794
+ const auto &itr = fields.find("f_rest_" + std::to_string(i));
795
+ if (itr == fields.end())
796
+ break;
797
+ shIdx.push_back(itr->second);
798
+ }
799
+ const int32_t shDim = static_cast<int>(shIdx.size() / 3);
800
+
801
+ std::vector<float> values(numPoints * fields.size());
802
+ in.read(reinterpret_cast<char *>(values.data()), values.size() * sizeof(float));
803
+ if (!in.good()) {
804
+ SpzLog("[SPZ ERROR] Unable to load data from: %s", filename.c_str());
805
+ in.close();
806
+ return {};
807
+ }
808
+ in.close();
809
+
810
+ GaussianCloud result;
811
+ result.numPoints = numPoints;
812
+ result.shDegree = degreeForDim(shDim);
813
+ result.positions.reserve(numPoints * 3);
814
+ result.scales.reserve(numPoints * 3);
815
+ result.rotations.reserve(numPoints * 4);
816
+ result.alphas.reserve(numPoints * 1);
817
+ result.colors.reserve(numPoints * 3);
818
+ for (size_t i = 0; i < values.size(); i += fields.size()) {
819
+ for (int32_t j = 0; j < positionIdx.size(); j++) {
820
+ result.positions.push_back(values[i + positionIdx[j]]);
821
+ }
822
+ for (int32_t j = 0; j < scaleIdx.size(); j++) {
823
+ result.scales.push_back(values[i + scaleIdx[j]]);
824
+ }
825
+ for (int32_t j = 0; j < rotIdx.size(); j++) {
826
+ result.rotations.push_back(values[i + rotIdx[j]]);
827
+ }
828
+ for (int32_t j = 0; j < alphaIdx.size(); j++) {
829
+ result.alphas.push_back(values[i + alphaIdx[j]]);
830
+ }
831
+ for (int32_t j = 0; j < colorIdx.size(); j++) {
832
+ result.colors.push_back(values[i + colorIdx[j]]);
833
+ }
834
+ // Convert from [N,C,S] to [N,S,C] (where C is color channel, S is SH coeff).
835
+ for (int32_t j = 0; j < shDim; j++) {
836
+ result.sh.push_back(values[i + shIdx[j]]);
837
+ result.sh.push_back(values[i + shIdx[j + shDim]]);
838
+ result.sh.push_back(values[i + shIdx[j + 2 * shDim]]);
839
+ }
840
+ }
841
+
842
+ result.convertCoordinates(CoordinateSystem::RDF, o.to);
843
+ return result;
844
+ }
845
+
846
+ bool saveSplatToPly(const GaussianCloud &data, const PackOptions &o, const std::string &filename) {
847
+ const int32_t N = data.numPoints;
848
+ CHECK_EQ(data.positions.size(), N * 3);
849
+ CHECK_EQ(data.scales.size(), N * 3);
850
+ CHECK_EQ(data.rotations.size(), N * 4);
851
+ CHECK_EQ(data.alphas.size(), N);
852
+ CHECK_EQ(data.colors.size(), N * 3);
853
+ const int32_t shDim = static_cast<int>(data.sh.size() / N / 3);
854
+ const int32_t D = 17 + shDim * 3;
855
+
856
+ CoordinateConverter c = coordinateConverter(o.from, CoordinateSystem::RDF);
857
+
858
+ std::vector<float> values(N * D, 0.0f);
859
+ int32_t outIdx = 0, i3 = 0, i4 = 0;
860
+ for (int32_t i = 0; i < N; i++) {
861
+ // Position (x, y, z)
862
+ values[outIdx++] = c.flipP[0] * data.positions[i3 + 0];
863
+ values[outIdx++] = c.flipP[1] * data.positions[i3 + 1];
864
+ values[outIdx++] = c.flipP[2] * data.positions[i3 + 2];
865
+ // Normals (nx, ny, nz): these are always zero, but some viewers expect them to be present
866
+ outIdx += 3;
867
+ // Color (r, g, b): DC component for spherical harmonics
868
+ values[outIdx++] = data.colors[i3 + 0];
869
+ values[outIdx++] = data.colors[i3 + 1];
870
+ values[outIdx++] = data.colors[i3 + 2];
871
+ // Spherical harmonics: Interleave so the coefficients are the fastest-changing axis and
872
+ // the channel (r, g, b) is slower-changing axis.
873
+ for (int32_t j = 0; j < shDim; j++) {
874
+ values[outIdx++] = c.flipSh[j] * data.sh[(i * shDim + j) * 3];
875
+ }
876
+ for (int32_t j = 0; j < shDim; j++) {
877
+ values[outIdx++] = c.flipSh[j] * data.sh[(i * shDim + j) * 3 + 1];
878
+ }
879
+ for (int32_t j = 0; j < shDim; j++) {
880
+ values[outIdx++] = c.flipSh[j] * data.sh[(i * shDim + j) * 3 + 2];
881
+ }
882
+ // Alpha
883
+ values[outIdx++] = data.alphas[i];
884
+ // Scale (sx, sy, sz)
885
+ values[outIdx++] = data.scales[i3 + 0];
886
+ values[outIdx++] = data.scales[i3 + 1];
887
+ values[outIdx++] = data.scales[i3 + 2];
888
+ // Rotation (qw, qx, qy, qz)
889
+ values[outIdx++] = data.rotations[i4 + 3];
890
+ values[outIdx++] = c.flipQ[0] * data.rotations[i4 + 0];
891
+ values[outIdx++] = c.flipQ[1] * data.rotations[i4 + 1];
892
+ values[outIdx++] = c.flipQ[2] * data.rotations[i4 + 2];
893
+ i3 += 3;
894
+ i4 += 4;
895
+ }
896
+ CHECK_EQ(outIdx, values.size());
897
+
898
+ std::ofstream out(filename, std::ios::binary);
899
+ if (!out.good()) {
900
+ SpzLog("[SPZ ERROR] Unable to open for writing: %s", filename.c_str());
901
+ return false;
902
+ }
903
+ out << "ply\n";
904
+ out << "format binary_little_endian 1.0\n";
905
+ out << "element vertex " << N << "\n";
906
+ out << "property float x\n";
907
+ out << "property float y\n";
908
+ out << "property float z\n";
909
+ out << "property float nx\n";
910
+ out << "property float ny\n";
911
+ out << "property float nz\n";
912
+ out << "property float f_dc_0\n";
913
+ out << "property float f_dc_1\n";
914
+ out << "property float f_dc_2\n";
915
+ for (int32_t i = 0; i < shDim * 3; i++) {
916
+ out << "property float f_rest_" << i << "\n";
917
+ }
918
+ out << "property float opacity\n";
919
+ out << "property float scale_0\n";
920
+ out << "property float scale_1\n";
921
+ out << "property float scale_2\n";
922
+ out << "property float rot_0\n";
923
+ out << "property float rot_1\n";
924
+ out << "property float rot_2\n";
925
+ out << "property float rot_3\n";
926
+ out << "end_header\n";
927
+ out.write(reinterpret_cast<char *>(values.data()), values.size() * sizeof(float));
928
+ out.close();
929
+ if (!out.good()) {
930
+ SpzLog("[SPZ ERROR] Failed to write to: %s", filename.c_str());
931
+ return false;
932
+ }
933
+ return true;
934
+ }
935
+
936
+ } // namespace spz
SplatTiler/spz/load-spz.h ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #pragma once
2
+ #include <array>
3
+ #include <cstdint>
4
+ #include <string>
5
+ #include <vector>
6
+
7
+ #include "splat-types.h"
8
+
9
+ namespace spz {
10
+
11
+ // Represents a single inflated gaussian. Each gaussian has 236 bytes. Although the data is easier
12
+ // to intepret in this format, it is not more precise than the packed format, since it was inflated.
13
+ struct UnpackedGaussian {
14
+ std::array<float, 3> position; // x, y, z
15
+ std::array<float, 4> rotation; // x, y, z, w
16
+ std::array<float, 3> scale; // std::log(scale)
17
+ std::array<float, 3> color; // rgb sh0 encoding
18
+ float alpha; // inverse logistic
19
+ std::array<float, 15> shR;
20
+ std::array<float, 15> shG;
21
+ std::array<float, 15> shB;
22
+ };
23
+
24
+ // Represents a single low precision gaussian. Each gaussian has exactly 65 bytes, even if it does
25
+ // not have full spherical harmonics.
26
+ struct PackedGaussian {
27
+ std::array<uint8_t, 9> position{};
28
+ std::array<uint8_t, 4> rotation{};
29
+ std::array<uint8_t, 3> scale{};
30
+ std::array<uint8_t, 3> color{};
31
+ uint8_t alpha = 0;
32
+ std::array<uint8_t, 15> shR{};
33
+ std::array<uint8_t, 15> shG{};
34
+ std::array<uint8_t, 15> shB{};
35
+
36
+ UnpackedGaussian unpack(
37
+ bool usesFloat16, bool usesQuaternionSmallestThree, int32_t fractionalBits, const CoordinateConverter &c) const;
38
+ };
39
+
40
+ // Represents a full splat with lower precision. Each splat has at most 64 bytes, although splats
41
+ // with fewer spherical harmonics degrees will have less. The data is stored non-interleaved.
42
+ struct PackedGaussians {
43
+ int32_t numPoints = 0; // Total number of points (gaussians)
44
+ int32_t shDegree = 0; // Degree of spherical harmonics
45
+ int32_t fractionalBits = 0; // Number of bits used for fractional part of fixed-point coords
46
+ bool antialiased = false; // Whether gaussians should be rendered with mip-splat antialiasing
47
+ bool usesQuaternionSmallestThree = true; // Whether gaussians use the smallest three method to store quaternions
48
+
49
+ std::vector<uint8_t> positions;
50
+ std::vector<uint8_t> scales;
51
+ std::vector<uint8_t> rotations;
52
+ std::vector<uint8_t> alphas;
53
+ std::vector<uint8_t> colors;
54
+ std::vector<uint8_t> sh;
55
+
56
+ bool usesFloat16() const;
57
+ PackedGaussian at(int32_t i) const;
58
+ UnpackedGaussian unpack(int32_t i, const CoordinateConverter &c) const;
59
+ };
60
+
61
+ struct PackOptions {
62
+ CoordinateSystem from = CoordinateSystem::UNSPECIFIED;
63
+ };
64
+
65
+ struct UnpackOptions {
66
+ CoordinateSystem to = CoordinateSystem::UNSPECIFIED;
67
+ };
68
+
69
+ // Saves Gaussian splat in packed format, returning a vector of bytes.
70
+ bool saveSpz(
71
+ const GaussianCloud &gaussians, const PackOptions &options, std::vector<uint8_t> *output);
72
+
73
+ // Loads Gaussian splat from a vector of bytes in packed format.
74
+ GaussianCloud loadSpz(const std::vector<uint8_t> &data, const UnpackOptions &options);
75
+
76
+ // Loads Gaussian splat from a vector of bytes in packed format.
77
+ PackedGaussians loadSpzPacked(const std::string &filename);
78
+ PackedGaussians loadSpzPacked(const uint8_t *data, int32_t size);
79
+ PackedGaussians loadSpzPacked(const std::vector<uint8_t> &data);
80
+
81
+ // Saves Gaussian splat in packed format to a file
82
+ bool saveSpz(
83
+ const GaussianCloud &gaussians, const PackOptions &options, const std::string &filename);
84
+
85
+ // Loads Gaussian splat from a file in packed format
86
+ GaussianCloud loadSpz(const std::string &filename, const UnpackOptions &o);
87
+
88
+ // Loads Gaussian splat from a byte pointer in packed format.
89
+ GaussianCloud loadSpz(const uint8_t *data, int32_t size, const UnpackOptions &options);
90
+
91
+ // Saves Gaussian splat data in .ply format
92
+ bool saveSplatToPly(
93
+ const spz::GaussianCloud &gaussians, const PackOptions &options, const std::string &filename);
94
+
95
+ // Loads Gaussian splat data in .ply format
96
+ GaussianCloud loadSplatFromPly(const std::string &filename, const UnpackOptions &options);
97
+
98
+ void serializePackedGaussians(const PackedGaussians &packed, std::ostream *out);
99
+
100
+ bool compressGzipped(const uint8_t *data, size_t size, std::vector<uint8_t> *out);
101
+ } // namespace spz
SplatTiler/spz/splat-c-types.cc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ #include "splat-c-types.h"
2
+
3
+ // Empty.
SplatTiler/spz/splat-c-types.h ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #ifndef SPZ_SPLAT_C_TYPES_H_
2
+ #define SPZ_SPLAT_C_TYPES_H_
3
+
4
+ #define _USE_MATH_DEFINES
5
+ #include <math.h>
6
+ #include <stddef.h>
7
+ #include <stdint.h>
8
+
9
+ // These types are used to bridge between the C++ API and C (to interop with Swift and C#).
10
+
11
+ typedef struct {
12
+ size_t count;
13
+ float *data;
14
+ } SpzFloatBuffer;
15
+
16
+ typedef struct {
17
+ int32_t numPoints;
18
+ int32_t shDegree;
19
+ bool antialiased;
20
+ SpzFloatBuffer positions;
21
+ SpzFloatBuffer scales;
22
+ SpzFloatBuffer rotations;
23
+ SpzFloatBuffer alphas;
24
+ SpzFloatBuffer colors;
25
+ SpzFloatBuffer sh;
26
+ } GaussianCloudData;
27
+
28
+ #endif // SPZ_SPLAT_C_TYPES_H_
SplatTiler/spz/splat-types.cc ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #include "splat-types.h"
2
+
3
+ #include <cmath>
4
+ #include <limits>
5
+
6
+ namespace spz {
7
+
8
+ float halfToFloat(Half h) {
9
+ auto sgn = ((h >> 15) & 0x1);
10
+ auto exponent = ((h >> 10) & 0x1f);
11
+ auto mantissa = h & 0x3ff;
12
+
13
+ float signMul = sgn == 1 ? -1.0 : 1.0;
14
+ if (exponent == 0) {
15
+ // Subnormal numbers (no exponent, 0 in the mantissa decimal).
16
+ return signMul * std::pow(2.0f, -14.0f) * static_cast<float>(mantissa) / 1024.0f;
17
+ }
18
+
19
+ if (exponent == 31) {
20
+ // Infinity or NaN.
21
+ return mantissa != 0 ? std::numeric_limits<float>::quiet_NaN() : signMul * std::numeric_limits<float>::infinity();
22
+ }
23
+
24
+ // non-zero exponent implies 1 in the mantissa decimal.
25
+ return signMul * std::pow(2.0f, static_cast<float>(exponent) - 15.0f)
26
+ * (1.0f + static_cast<float>(mantissa) / 1024.0f);
27
+ }
28
+
29
+ Half floatToHalf(float f) {
30
+ uint32_t f32 = *reinterpret_cast<uint32_t *>(&f);
31
+ int32_t sign = (f32 >> 31) & 0x01; // 1 bit -> 1 bit
32
+ int32_t exponent = ((f32 >> 23) & 0xff); // 8 bits -> 5 bits
33
+ int32_t mantissa = f32 & 0x7fffff; // 23 bits -> 10 bits
34
+
35
+ // Handle inf and nan from float.
36
+ if (exponent == 0xFF) {
37
+ if (mantissa == 0) {
38
+ return (sign << 15) | 0x7C00; // Inf
39
+ }
40
+
41
+ return (sign << 15) | 0x7C01; // Nan
42
+ }
43
+
44
+ // If the exponent is greater than the range of half, return +/- Inf.
45
+ int32_t centeredExp = exponent - 127;
46
+ if (centeredExp > 15) {
47
+ return (sign << 15) | 0x7C00;
48
+ }
49
+
50
+ // Normal numbers. centeredExp = [-15, 15]
51
+ if (centeredExp > -15) {
52
+ return (sign << 15) | ((centeredExp + 15) << 10) | (mantissa >> 13);
53
+ }
54
+
55
+ // Subnormal numbers.
56
+ int32_t fullMantissa = 0x800000 | mantissa;
57
+ int32_t shift = -(centeredExp + 14); // Shift is in [-1 to -113]
58
+ int32_t newMantissa = fullMantissa >> shift;
59
+ return (sign << 15) | (newMantissa >> 13);
60
+ }
61
+
62
+ float norm(const Vec3f &a) {
63
+ return std::sqrt(squaredNorm(a));
64
+ }
65
+
66
+ Vec3f normalized(const Vec3f &v) {
67
+ float n = norm(v);
68
+ return {v[0] / n, v[1] / n, v[2] / n};
69
+ }
70
+
71
+ Quat4f normalized(const Quat4f &v) {
72
+ float norm = std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2] + v[3] * v[3]);
73
+ return {v[0] / norm, v[1] / norm, v[2] / norm, v[3] / norm};
74
+ }
75
+
76
+ float norm(const Quat4f &q) {
77
+ return std::sqrt(q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]);
78
+ }
79
+
80
+ Quat4f axisAngleQuat(const Vec3f &scaledAxis) {
81
+ const float &a0 = scaledAxis[0];
82
+ const float &a1 = scaledAxis[1];
83
+ const float &a2 = scaledAxis[2];
84
+ const float thetaSquared = a0 * a0 + a1 * a1 + a2 * a2;
85
+ // For points not at the origin, the full conversion is numerically stable.
86
+ if (thetaSquared > 0.0f) {
87
+ const float theta = std::sqrt(thetaSquared);
88
+ const float halfTheta = theta * 0.5f;
89
+ const float k = std::sin(halfTheta) / theta;
90
+ return normalized(std::array<float, 4>{std::cos(halfTheta), a0 * k, a1 * k, a2 * k});
91
+ }
92
+ // If thetaSquared is 0, then we will get NaNs when dividing by theta. By approximating with a
93
+ // Taylor series, and truncating at one term, the value will be computed correctly.
94
+ const float k = 0.5f;
95
+ return normalized(Quat4f{1.0f, a0 * k, a1 * k, a2 * k});
96
+ }
97
+
98
+ } // namespace spz
SplatTiler/spz/splat-types.h ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #pragma once
2
+
3
+ #include <algorithm>
4
+ #include <array>
5
+ #include <cmath>
6
+ #include <cstdint>
7
+ #include <cstring>
8
+ #include <vector>
9
+
10
+ #include "splat-c-types.h"
11
+
12
+ namespace spz {
13
+
14
+ inline SpzFloatBuffer copyFloatBuffer(const std::vector<float> &vector) {
15
+ SpzFloatBuffer buffer = {0, nullptr};
16
+ if (!vector.empty()) {
17
+ buffer.count = vector.size();
18
+ buffer.data = new float[buffer.count];
19
+ std::memcpy(buffer.data, vector.data(), buffer.count * sizeof(float));
20
+ }
21
+ return buffer;
22
+ }
23
+
24
+ enum class CoordinateSystem {
25
+ UNSPECIFIED = 0,
26
+ LDB = 1, // Left Down Back
27
+ RDB = 2, // Right Down Back
28
+ LUB = 3, // Left Up Back
29
+ RUB = 4, // Right Up Back, Three.js coordinate system
30
+ LDF = 5, // Left Down Front
31
+ RDF = 6, // Right Down Front, PLY coordinate system
32
+ LUF = 7, // Left Up Front, GLB coordinate system
33
+ RUF = 8, // Right Up Front, Unity coordinate system
34
+ };
35
+
36
+ struct CoordinateConverter {
37
+ std::array<float, 3> flipP = {1.0f, 1.0f, 1.0f}; // x, y, z flips.
38
+ std::array<float, 3> flipQ = {1.0f, 1.0f, 1.0f}; // x, y, z flips, w is never flipped.
39
+ std::array<float, 15> flipSh = // Flips for the 15 spherical harmonics coefficients.
40
+ {1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f};
41
+ };
42
+
43
+ constexpr std::array<bool, 3> axesMatch(CoordinateSystem a, CoordinateSystem b) {
44
+ auto aNum = static_cast<int>(a) - 1;
45
+ auto bNum = static_cast<int>(b) - 1;
46
+ if (aNum < 0 || bNum < 0) {
47
+ return {true, true, true};
48
+ }
49
+ return {
50
+ ((aNum >> 0) & 1) == ((bNum >> 0) & 1),
51
+ ((aNum >> 1) & 1) == ((bNum >> 1) & 1),
52
+ ((aNum >> 2) & 1) == ((bNum >> 2) & 1)};
53
+ }
54
+
55
+ constexpr CoordinateConverter coordinateConverter(CoordinateSystem from, CoordinateSystem to) {
56
+ auto [xMatch, yMatch, zMatch] = axesMatch(from, to);
57
+ float x = xMatch ? 1.0f : -1.0f;
58
+ float y = yMatch ? 1.0f : -1.0f;
59
+ float z = zMatch ? 1.0f : -1.0f;
60
+ return CoordinateConverter{
61
+ {x, y, z},
62
+ {y * z, x * z, x * y},
63
+ {
64
+ y, // 0
65
+ z, // 1
66
+ x, // 2
67
+ x * y, // 3
68
+ y * z, // 4
69
+ 1.0f, // 5
70
+ x * z, // 6
71
+ 1.0f, // 7
72
+ y, // 8
73
+ x * y * z, // 9
74
+ y, // 10
75
+ z, // 11
76
+ x, // 12
77
+ z, // 13
78
+ x // 14
79
+ }
80
+ };
81
+ }
82
+
83
+ // A point cloud composed of Gaussians. Each gaussian is represented by:
84
+ // - xyz position
85
+ // - xyz scales (on log scale, compute exp(x) to get scale factor)
86
+ // - xyzw quaternion
87
+ // - alpha (before sigmoid activation, compute sigmoid(a) to get alpha value between 0 and 1)
88
+ // - rgb color (as SH DC component, compute 0.5 + 0.282095 * x to get color value between 0 and 1)
89
+ // - 0 to 45 spherical harmonics coefficients (see comment below)
90
+ struct GaussianCloud {
91
+ // Total number of points (gaussians) in this splat.
92
+ int32_t numPoints = 0;
93
+
94
+ // Degree of spherical harmonics for this splat.
95
+ int32_t shDegree = 0;
96
+
97
+ // Whether the gaussians should be rendered in antialiased mode (mip splatting)
98
+ bool antialiased = false;
99
+
100
+ // See block comment above for details
101
+ std::vector<float> positions;
102
+ std::vector<float> scales;
103
+ std::vector<float> rotations;
104
+ std::vector<float> alphas;
105
+ std::vector<float> colors;
106
+
107
+ // Spherical harmonics coefficients. The number of coefficients per point depends on shDegree:
108
+ // 0 -> 0
109
+ // 1 -> 9 (3 coeffs x 3 channels)
110
+ // 2 -> 24 (8 coeffs x 3 channels)
111
+ // 3 -> 45 (15 coeffs x 3 channels)
112
+ // The color channel is the inner (fastest varying) axis, and the coefficient is the outer
113
+ // (slower varying) axis, i.e. for degree 1, the order of the 9 values is:
114
+ // sh1n1_r, sh1n1_g, sh1n1_b, sh10_r, sh10_g, sh10_b, sh1p1_r, sh1p1_g, sh1p1_b
115
+ std::vector<float> sh;
116
+
117
+ // The caller is responsible for freeing the pointers in the returned GaussianCloudData
118
+ GaussianCloudData data() const {
119
+ GaussianCloudData data;
120
+ data.numPoints = numPoints;
121
+ data.shDegree = shDegree;
122
+ data.antialiased = antialiased;
123
+ data.positions = copyFloatBuffer(positions);
124
+ data.scales = copyFloatBuffer(scales);
125
+ data.rotations = copyFloatBuffer(rotations);
126
+ data.alphas = copyFloatBuffer(alphas);
127
+ data.colors = copyFloatBuffer(colors);
128
+ data.sh = copyFloatBuffer(sh);
129
+ return data;
130
+ }
131
+
132
+ // Convert between two coordinate systems, for example from RDF (ply format) to RUB (used by spz).
133
+ // This is performed in-place.
134
+ void convertCoordinates(CoordinateSystem from, CoordinateSystem to) {
135
+ if (numPoints == 0) {
136
+ // There is nothing to convert.
137
+ return;
138
+ }
139
+ CoordinateConverter c = coordinateConverter(from, to);
140
+ for (size_t i = 0; i < positions.size(); i += 3) {
141
+ positions[i + 0] *= c.flipP[0];
142
+ positions[i + 1] *= c.flipP[1];
143
+ positions[i + 2] *= c.flipP[2];
144
+ }
145
+ for (size_t i = 0; i < rotations.size(); i += 4) {
146
+ rotations[i + 0] *= c.flipQ[0];
147
+ rotations[i + 1] *= c.flipQ[1];
148
+ rotations[i + 2] *= c.flipQ[2];
149
+ // Don't modify rotations[i + 3] (w component)
150
+ }
151
+ // Rotate spherical harmonics by inverting coefficients that reference the y and z axes, for
152
+ // each RGB channel. See spherical_harmonics_kernel_impl.h for spherical harmonics formulas.
153
+ const size_t numCoeffs = sh.size() / 3;
154
+ const size_t numCoeffsPerPoint = numCoeffs / numPoints;
155
+ size_t idx = 0;
156
+ for (size_t i = 0; i < numCoeffs; i += numCoeffsPerPoint) {
157
+ for (size_t j = 0; j < numCoeffsPerPoint; ++j, idx += 3) {
158
+ auto flip = c.flipSh[j];
159
+ sh[idx + 0] *= flip;
160
+ sh[idx + 1] *= flip;
161
+ sh[idx + 2] *= flip;
162
+ }
163
+ }
164
+ }
165
+
166
+ // Rotates the GaussianCloud by 180 degrees about the x axis (converts from RUB to RDF coordinates
167
+ // and vice versa. This is performed in-place.
168
+ void rotate180DegAboutX() { convertCoordinates(CoordinateSystem::RUB, CoordinateSystem::RDF); }
169
+
170
+ float medianVolume() const {
171
+ if (numPoints == 0) {
172
+ return 0.01f;
173
+ }
174
+ // The volume of an ellipsoid is 4/3 * pi * x * y * z, where x, y, and z are the radii on each
175
+ // axis. Scales are stored on a log scale, and exp(x) * exp(y) * exp(z) = exp(x + y + z). So we
176
+ // can sort by value = (x + y + z) and compute volume = 4/3 * pi * exp(value) later.
177
+ std::vector<float> scaleSums;
178
+ for (size_t i = 0; i < scales.size(); i += 3) {
179
+ float sum = scales[i] + scales[i + 1] + scales[i + 2];
180
+ scaleSums.push_back(sum);
181
+ }
182
+ std::sort(scaleSums.begin(), scaleSums.end());
183
+ float median = scaleSums[scaleSums.size() / 2];
184
+ return (M_PI * 4 / 3) * exp(median);
185
+ }
186
+ };
187
+
188
+ // SPZ Splat math helpers, lightweight implementations of vector and quaternion math.
189
+ using Vec3f = std::array<float, 3>; // x, y, z
190
+ using Quat4f = std::array<float, 4>; // w, x, y, z
191
+ using Half = uint16_t;
192
+
193
+ // Half-precision helpers.
194
+ float halfToFloat(Half h);
195
+ Half floatToHalf(float f);
196
+
197
+ // Vector helpers.
198
+ Vec3f normalized(const Vec3f &v);
199
+ float norm(const Vec3f &a);
200
+
201
+ // Quaternion helpers.
202
+ float norm(const Quat4f &q);
203
+ Quat4f normalized(const Quat4f &v);
204
+ Quat4f axisAngleQuat(const Vec3f &scaledAxis);
205
+
206
+ // Constexpr helpers.
207
+ constexpr Vec3f vec3f(const float *data) { return {data[0], data[1], data[2]}; }
208
+
209
+ constexpr float dot(const Vec3f &a, const Vec3f &b) {
210
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
211
+ }
212
+
213
+ constexpr float squaredNorm(const Vec3f &v) { return dot(v, v); }
214
+
215
+ constexpr Quat4f quat4f(const float *data) { return {data[0], data[1], data[2], data[3]}; }
216
+
217
+ constexpr Vec3f times(const Quat4f &q, const Vec3f &p) {
218
+ auto [w, x, y, z] = q;
219
+ auto [vx, vy, vz] = p;
220
+ auto x2 = x + x;
221
+ auto y2 = y + y;
222
+ auto z2 = z + z;
223
+ auto wx2 = w * x2;
224
+ auto wy2 = w * y2;
225
+ auto wz2 = w * z2;
226
+ auto xx2 = x * x2;
227
+ auto xy2 = x * y2;
228
+ auto xz2 = x * z2;
229
+ auto yy2 = y * y2;
230
+ auto yz2 = y * z2;
231
+ auto zz2 = z * z2;
232
+ return {
233
+ vx * (1.0f - (yy2 + zz2)) + vy * (xy2 - wz2) + vz * (xz2 + wy2),
234
+ vx * (xy2 + wz2) + vy * (1.0f - (xx2 + zz2)) + vz * (yz2 - wx2),
235
+ vx * (xz2 - wy2) + vy * (yz2 + wx2) + vz * (1.0f - (xx2 + yy2))};
236
+ }
237
+
238
+ inline Quat4f times(const Quat4f &a, const Quat4f &b) {
239
+ auto [w, x, y, z] = a;
240
+ auto [qw, qx, qy, qz] = b;
241
+ return normalized(std::array<float, 4>{
242
+ w * qw - x * qx - y * qy - z * qz,
243
+ w * qx + x * qw + y * qz - z * qy,
244
+ w * qy - x * qz + y * qw + z * qx,
245
+ w * qz + x * qy - y * qx + z * qw});
246
+ }
247
+
248
+ constexpr Quat4f times(const Quat4f &a, float s) {
249
+ return {a[0] * s, a[1] * s, a[2] * s, a[3] * s};
250
+ }
251
+
252
+ constexpr Quat4f plus(const Quat4f &a, const Quat4f &b) {
253
+ return {a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]};
254
+ }
255
+
256
+ constexpr Vec3f times(const Vec3f &v, float s) { return {v[0] * s, v[1] * s, v[2] * s}; }
257
+
258
+ constexpr Vec3f plus(const Vec3f &a, const Vec3f &b) {
259
+ return {a[0] + b[0], a[1] + b[1], a[2] + b[2]};
260
+ }
261
+
262
+ constexpr Vec3f times(const Vec3f &a, const Vec3f &b) {
263
+ return {a[0] * b[0], a[1] * b[1], a[2] * b[2]};
264
+ }
265
+
266
+ } // namespace spz
client/.env.development ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ VITE_AWS_REGION=us-east-2
2
+ VITE_AWS_COGNITO_IDENTITY_POOL_ID=us-east-2:1387102d-5a87-4742-8a69-1baa70f37984
3
+ VITE_S3_BUCKET=ddy-first-bucket
4
+ VITE_CESIUM_ION_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIxNDM0OThmMS01NjBmLTQzNTYtYjQ0MC0yMWY0ZGExZjk0ZjEiLCJpZCI6MjM2MjE1LCJpYXQiOjE3MjQyNzQ4MjB9.6XHQrKm0KGDFRZSg5veN64KaxYT3ekGj2qprsbEiI7k
client/.env.example ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AWS Configuration for GDACloud Frontend
2
+ # Copy this file to .env.local and fill in your actual values
3
+
4
+ # AWS Region where your S3 bucket and Cognito Identity Pool are located
5
+ VITE_AWS_REGION=us-east-2
6
+
7
+ # Cognito Identity Pool ID for unauthenticated access
8
+ # Format: region:uuid (e.g., us-east-2:12345678-1234-1234-1234-123456789012)
9
+ # Create this in AWS Cognito console:
10
+ # - Identity Pools -> Create New Identity Pool
11
+ # - Allow unauthenticated identities
12
+ # - Attach IAM role with S3 GetObject permissions
13
+ VITE_AWS_COGNITO_IDENTITY_POOL_ID=us-east-2:YOUR_IDENTITY_POOL_ID
14
+
15
+ # S3 Bucket name containing your 3D tile assets
16
+ VITE_S3_BUCKET=ddy-first-bucket
17
+
18
+ # Cesium Ion Token (optional, for Cesium base maps)
19
+ # Get token from https://ion.cesiumjs.com
20
+ VITE_CESIUM_ION_TOKEN=YOUR_CESIUM_ION_TOKEN
21
+
22
+ # GitHub Pages deployment base path
23
+ # For repository deployment: /repository-name/
24
+ # For custom domain or local: /
25
+ BASE_PATH=/
client/.env.production ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ VITE_AWS_REGION=us-east-2
2
+ VITE_AWS_COGNITO_IDENTITY_POOL_ID=us-east-2:1387102d-5a87-4742-8a69-1baa70f37984
3
+ VITE_S3_BUCKET=ddy-first-bucket
4
+ VITE_CESIUM_ION_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIxNDM0OThmMS01NjBmLTQzNTYtYjQ0MC0yMWY0ZGExZjk0ZjEiLCJpZCI6MjM2MjE1LCJpYXQiOjE3MjQyNzQ4MjB9.6XHQrKm0KGDFRZSg5veN64KaxYT3ekGj2qprsbEiI7k
client/README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
+
14
+ ## Expanding the ESLint configuration
15
+
16
+ If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
client/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>front-end</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.jsx"></script>
12
+ </body>
13
+ </html>
client/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
client/package.json ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "front-end",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "@aws-sdk/client-s3": "^3.509.0",
14
+ "@aws-sdk/credential-provider-cognito-identity": "^3.509.0",
15
+ "@aws-sdk/s3-request-presigner": "^3.509.0",
16
+ "@aws-sdk/util-endpoints": "^3.509.0",
17
+ "@emotion/react": "^11.14.0",
18
+ "@emotion/styled": "^11.14.1",
19
+ "@mui/icons-material": "^7.3.4",
20
+ "@mui/material": "^7.3.4",
21
+ "cesium": "^1.134.1",
22
+ "react": "^19.1.1",
23
+ "react-dom": "^19.1.1",
24
+ "resium": "^1.19.0-beta.1"
25
+ },
26
+ "devDependencies": {
27
+ "@eslint/js": "^9.36.0",
28
+ "@types/react": "^19.1.16",
29
+ "@types/react-dom": "^19.1.9",
30
+ "@vitejs/plugin-react": "^5.0.4",
31
+ "eslint": "^9.36.0",
32
+ "eslint-plugin-react-hooks": "^5.2.0",
33
+ "eslint-plugin-react-refresh": "^0.4.22",
34
+ "gh-pages": "^6.3.0",
35
+ "globals": "^16.4.0",
36
+ "terser": "^5.43.1",
37
+ "vite": "^7.1.7",
38
+ "vite-plugin-cesium": "^1.2.23"
39
+ }
40
+ }
client/src/App.css ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #root {
2
+ max-width: 1280px;
3
+ margin: 0 auto;
4
+ padding: 2rem;
5
+ text-align: center;
6
+ }
7
+
8
+ .logo {
9
+ height: 6em;
10
+ padding: 1.5em;
11
+ will-change: filter;
12
+ transition: filter 300ms;
13
+ }
14
+ .logo:hover {
15
+ filter: drop-shadow(0 0 2em #646cffaa);
16
+ }
17
+ .logo.react:hover {
18
+ filter: drop-shadow(0 0 2em #61dafbaa);
19
+ }
20
+
21
+ @keyframes logo-spin {
22
+ from {
23
+ transform: rotate(0deg);
24
+ }
25
+ to {
26
+ transform: rotate(360deg);
27
+ }
28
+ }
29
+
30
+ @media (prefers-reduced-motion: no-preference) {
31
+ a:nth-of-type(2) .logo {
32
+ animation: logo-spin infinite 20s linear;
33
+ }
34
+ }
35
+
36
+ .card {
37
+ padding: 2em;
38
+ }
39
+
40
+ .read-the-docs {
41
+ color: #888;
42
+ }
client/src/App.jsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect } from "react";
2
+ import Home from "./pages/Home";
3
+ import VisitLog from "./pages/VisitLog";
4
+ import { recordVisit } from "./utils/visitorTracking";
5
+
6
+ function App() {
7
+ const visitLogPath =
8
+ import.meta.env.VITE_VISIT_LOG_PATH || "/visits-admin";
9
+ const isVisitPage = window.location.pathname === visitLogPath;
10
+
11
+ useEffect(() => {
12
+ if (!isVisitPage) {
13
+ recordVisit();
14
+ }
15
+ }, [isVisitPage]);
16
+
17
+ if (isVisitPage) {
18
+ return <VisitLog />;
19
+ }
20
+
21
+ return (
22
+ <div className="App">
23
+ <Home />
24
+ </div>
25
+ );
26
+ }
27
+
28
+ export default App;
client/src/assets/react.svg ADDED
client/src/cesiumSetup.js ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ // Ensure Cesium assets resolve correctly under GitHub Pages base path
2
+ window.CESIUM_BASE_URL = `${import.meta.env.BASE_URL}cesium/`
client/src/components/CesiumViewer.jsx ADDED
@@ -0,0 +1,501 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, {
2
+ useRef,
3
+ useEffect,
4
+ useState,
5
+ forwardRef,
6
+ useImperativeHandle,
7
+ } from "react";
8
+ import { Viewer } from "resium";
9
+ import * as Cesium from "cesium";
10
+ import { Entity } from "resium";
11
+ import { Cartesian3, Color, Matrix3, Quaternion } from "cesium";
12
+
13
+ // Cesium Ion token is safe to embed in client code (not a secret)
14
+ Cesium.Ion.defaultAccessToken =
15
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIxNDM0OThmMS01NjBmLTQzNTYtYjQ0MC0yMWY0ZGExZjk0ZjEiLCJpZCI6MjM2MjE1LCJpYXQiOjE3MjQyNzQ4MjB9.6XHQrKm0KGDFRZSg5veN64KaxYT3ekGj2qprsbEiI7k";
16
+
17
+ function offsetTilesetHeight(tileset, offsetMeters) {
18
+ const boundingSphere = tileset.boundingSphere;
19
+ const center = boundingSphere.center;
20
+
21
+ // Get normalized up direction in ECEF
22
+ const up = Cesium.Cartesian3.normalize(center, new Cesium.Cartesian3());
23
+
24
+ // Compute translation vector = up * offset
25
+ const translation = Cesium.Cartesian3.multiplyByScalar(
26
+ up,
27
+ offsetMeters,
28
+ new Cesium.Cartesian3(),
29
+ );
30
+ // Build translation matrix in ECEF
31
+ const translationMatrix = Cesium.Matrix4.fromTranslation(translation);
32
+ // Apply to existing transform
33
+ const finalMatrix = Cesium.Matrix4.multiplyTransformation(
34
+ translationMatrix,
35
+ tileset.modelMatrix,
36
+ new Cesium.Matrix4(),
37
+ );
38
+ tileset.modelMatrix = finalMatrix;
39
+ }
40
+
41
+ function unoffsetECEFPoint(x, y, z, tileset, offsetMeters) {
42
+ const center = tileset.boundingSphere.center;
43
+
44
+ // Normalized up direction (same as used during offset)
45
+ const up = Cesium.Cartesian3.normalize(center, new Cesium.Cartesian3());
46
+
47
+ // Translation applied to tileset
48
+ const offset = Cesium.Cartesian3.multiplyByScalar(
49
+ up,
50
+ offsetMeters,
51
+ new Cesium.Cartesian3()
52
+ );
53
+
54
+ // Subtract offset to revert point
55
+ const original = Cesium.Cartesian3.subtract(
56
+ new Cesium.Cartesian3(x, y, z),
57
+ offset,
58
+ new Cesium.Cartesian3()
59
+ );
60
+
61
+ return { x: original.x, y: original.y, z: original.z };
62
+ }
63
+
64
+ function undoTilesetTransformXYZ(x, y, z, tileset) {
65
+ const ecefPoint = new Cesium.Cartesian3(x, y, z);
66
+
67
+ // Compute inverse of current modelMatrix
68
+ const inverseMatrix = Cesium.Matrix4.inverse(
69
+ tileset.modelMatrix,
70
+ new Cesium.Matrix4()
71
+ );
72
+
73
+ // Transform point back
74
+ const originalPoint = Cesium.Matrix4.multiplyByPoint(
75
+ inverseMatrix,
76
+ ecefPoint,
77
+ new Cesium.Cartesian3()
78
+ );
79
+
80
+ // Return plain numbers
81
+ return {
82
+ x: originalPoint.x,
83
+ y: originalPoint.y,
84
+ z: originalPoint.z
85
+ };
86
+ }
87
+
88
+ function rotateTileset(tileset, rotationMatrix3) {
89
+ // Convert 3x3 rotation into 4x4 matrix
90
+ const rotationMatrix4 = Cesium.Matrix4.fromRotation(
91
+ rotationMatrix3,
92
+ new Cesium.Matrix4()
93
+ );
94
+
95
+ // Apply rotation *before* the existing model matrix
96
+ Cesium.Matrix4.multiply(
97
+ rotationMatrix4,
98
+ tileset.modelMatrix,
99
+ tileset.modelMatrix
100
+ );
101
+ }
102
+
103
+ function scaleTileset(tileset, scaleX, scaleY, scaleZ) {
104
+ const scaleMatrix = Cesium.Matrix4.fromScale(
105
+ new Cesium.Cartesian3(scaleX, scaleY, scaleZ),
106
+ new Cesium.Matrix4()
107
+ );
108
+
109
+ Cesium.Matrix4.multiply(
110
+ scaleMatrix,
111
+ tileset.modelMatrix,
112
+ tileset.modelMatrix
113
+ );
114
+ }
115
+
116
+
117
+
118
+ function ecefToLatLonAlt(x, y, z) {
119
+ const ecef = new Cesium.Cartesian3(x, y, z);
120
+ const carto = Cesium.Cartographic.fromCartesian(ecef, Cesium.Ellipsoid.WGS84);
121
+
122
+ return {
123
+ lat: Cesium.Math.toDegrees(carto.latitude),
124
+ lon: Cesium.Math.toDegrees(carto.longitude),
125
+ alt: carto.height,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Pure JS implementation of ray intersection (Least Squares)
131
+ * Replaces the backend /api/compute-intersection
132
+ */
133
+ function computeIntersectionLocal(measurements) {
134
+ if (measurements.length < 2) return null;
135
+
136
+ const n = measurements.length;
137
+ let sumIminusDDt = new Cesium.Matrix3(0, 0, 0, 0, 0, 0, 0, 0, 0);
138
+ let sumIminusDDtO = new Cesium.Cartesian3(0, 0, 0);
139
+
140
+ measurements.forEach((m) => {
141
+ const origin = new Cesium.Cartesian3(m.camera.x, m.camera.y, m.camera.z);
142
+ const target = new Cesium.Cartesian3(m.point.x, m.point.y, m.point.z);
143
+ const direction = Cesium.Cartesian3.normalize(
144
+ Cesium.Cartesian3.subtract(target, origin, new Cesium.Cartesian3()),
145
+ new Cesium.Cartesian3()
146
+ );
147
+
148
+ // I - d*d^T
149
+ const d = direction;
150
+ const m_ddt = [
151
+ 1 - d.x * d.x, -d.x * d.y, -d.x * d.z,
152
+ -d.y * d.x, 1 - d.y * d.y, -d.y * d.z,
153
+ -d.z * d.x, -d.z * d.y, 1 - d.z * d.z,
154
+ ];
155
+ const mat = Cesium.Matrix3.fromArray(m_ddt);
156
+
157
+ // Accumulate matrices
158
+ Cesium.Matrix3.add(sumIminusDDt, mat, sumIminusDDt);
159
+
160
+ // Accumulate (I - d*d^T) * origin
161
+ const termO = Cesium.Matrix3.multiplyByVector(mat, origin, new Cesium.Cartesian3());
162
+ Cesium.Cartesian3.add(sumIminusDDtO, termO, sumIminusDDtO);
163
+ });
164
+
165
+ const inverseSum = Cesium.Matrix3.inverse(sumIminusDDt, new Cesium.Matrix3());
166
+ if (!inverseSum) return null;
167
+
168
+ const x = Cesium.Matrix3.multiplyByVector(inverseSum, sumIminusDDtO, new Cesium.Cartesian3());
169
+
170
+ // Calculate Residuals and Variance
171
+ let sse = 0;
172
+ measurements.forEach((m) => {
173
+ const origin = new Cesium.Cartesian3(m.camera.x, m.camera.y, m.camera.z);
174
+ const target = new Cesium.Cartesian3(m.point.x, m.point.y, m.point.z);
175
+ const direction = Cesium.Cartesian3.normalize(
176
+ Cesium.Cartesian3.subtract(target, origin, new Cesium.Cartesian3()),
177
+ new Cesium.Cartesian3()
178
+ );
179
+
180
+ // Residual vector: v = (I - d*d^T) * (x - origin)
181
+ const diff = Cesium.Cartesian3.subtract(x, origin, new Cesium.Cartesian3());
182
+ const dot = Cesium.Cartesian3.dot(direction, diff);
183
+ const projection = Cesium.Cartesian3.multiplyByScalar(direction, dot, new Cesium.Cartesian3());
184
+ const residualVec = Cesium.Cartesian3.subtract(diff, projection, new Cesium.Cartesian3());
185
+
186
+ sse += Cesium.Cartesian3.magnitudeSquared(residualVec);
187
+ });
188
+
189
+ // Variance factor (Degrees of freedom: 2n - 3)
190
+ const sigma2 = n > 1 ? sse / (2 * n - 3) : 0.01;
191
+
192
+ // Covariance Matrix = sigma2 * (Sum(I - dd^T))^-1
193
+ const covMat = Cesium.Matrix3.multiplyByScalar(inverseSum, sigma2, new Cesium.Matrix3());
194
+ const covarianceArray = Cesium.Matrix3.toArray(covMat);
195
+
196
+ return {
197
+ x: x.x,
198
+ y: x.y,
199
+ z: x.z,
200
+ stddev: Math.sqrt(sigma2),
201
+ // Flattened 3x3 matrix for the component
202
+ covariance: covarianceArray,
203
+ status: "ok"
204
+ };
205
+ }
206
+
207
+ const CesiumViewer = forwardRef(
208
+ ({ tilesetUrl, points, onAddPoint, offsetHeight, selectedPoint }, ref) => {
209
+ const viewerRef = useRef(null);
210
+ const currentModeRef = useRef("default"); // keep internal mode
211
+ const [measurements, setMeasurements] = useState([]);
212
+ const tilesetRef = useRef(null);
213
+
214
+ const addMeausure = (cameraPosition, cartesian) => {
215
+ setMeasurements((prev) => {
216
+ const id = prev.length + 1; // compute id based on latest state
217
+ const newMeasurement = {
218
+ id,
219
+ camera: {
220
+ x: cameraPosition.x,
221
+ y: cameraPosition.y,
222
+ z: cameraPosition.z,
223
+ },
224
+ point: { x: cartesian.x, y: cartesian.y, z: cartesian.z },
225
+ };
226
+ console.log("Adding measure:", newMeasurement);
227
+ return [...prev, newMeasurement];
228
+ });
229
+ };
230
+ // Expose a function to parent
231
+ useImperativeHandle(ref, () => ({
232
+ setMode: (newMode) => {
233
+ currentModeRef.current = newMode;
234
+ console.log("Cesium mode changed to:", newMode);
235
+ // TODO: enable/disable measurement handlers, selection handlers, etc.
236
+ // For example:
237
+ // if (newMode === "measure") { activateMeasurement(viewerRef.current) }
238
+ // else if (newMode === "select") { activateSelection(viewerRef.current) }
239
+ },
240
+ getMode: () => currentModeRef.current,
241
+ endMeasure: async () => {
242
+ if (onAddPoint && measurements.length > 0) {
243
+ try {
244
+ const result = computeIntersectionLocal(measurements);
245
+ if (!result) throw new Error("Could not compute intersection locally");
246
+
247
+ const tileset = viewerRef.current.cesiumElement.scene.primitives.get(0);
248
+ const coord = unoffsetECEFPoint(result.x, result.y, result.z, tileset, offsetHeight);
249
+ const lla = ecefToLatLonAlt(coord.x, coord.y, coord.z);
250
+ const point = {
251
+ lat: lla.lat,
252
+ lon: lla.lon,
253
+ alt: lla.alt,
254
+ x: result.x,
255
+ y: result.y,
256
+ z: result.z,
257
+ accuracy: result.stddev,
258
+ covariance: result.covariance,
259
+ measures: [...measurements], // copy of all measures
260
+ };
261
+ console.log("Computed intersection point:", point);
262
+ onAddPoint(point);
263
+ } catch (err) {
264
+ console.error("Failed to compute intersection:", err);
265
+ }
266
+ }
267
+ currentModeRef.current = "default";
268
+ setMeasurements([]);
269
+ },
270
+ }));
271
+
272
+ // point = {x, y, z}, size in meters, color optional
273
+ /**
274
+ * Creates a Cesium Entity to visualize a 3D point and its covariance.
275
+ *
276
+ * @param {object} point - The 3D point { x, y, z }.
277
+ * @param {number[][]} covariance - The 3x3 covariance matrix.
278
+ * @param {number} [confidenceScale=1.0] - Scale factor for the radii.
279
+ * - 1.0 = 1-sigma ellipsoid
280
+ * - 2.795 = 95% confidence ellipsoid (for 3D)
281
+ * @param {Color} [color=Color.RED.withAlpha(0.5)] - The ellipsoid material.
282
+ * @param {string} [key] - React key.
283
+ */
284
+ // Define the scratch object ONCE for the eigendecomposition
285
+ const scratchEigen = {
286
+ diagonal: new Matrix3(),
287
+ unitary: new Matrix3(),
288
+ };
289
+ // --- END ADD ---
290
+ const createCovarianceEntity = (
291
+ point,
292
+ confidenceScale = 1.0,
293
+ color = Color.RED.withAlpha(0.5),
294
+ key,
295
+ selected = false,
296
+ selectedColor = Color.GREEN.withAlpha(0.7)
297
+ ) => {
298
+ // 1. Convert your 2D array into a Cesium Matrix3
299
+ // Cesium matrices are column-major, so we flatten the array
300
+ const covMatrix = Matrix3.fromArray(point.covariance.flat());
301
+ // 2. Perform Eigendecomposition
302
+ // This finds the orientation (eigenvectors) and variance (eigenvalues)
303
+ // result.unitary = Rotation matrix (eigenvectors)
304
+ // result.diagonal = Diagonal matrix of eigenvalues
305
+ let result;
306
+ try {
307
+ result = Matrix3.computeEigenDecomposition(covMatrix, scratchEigen);
308
+ } catch (e) {
309
+ console.error("Failed to compute eigendecomposition. Matrix may be singular.", e);
310
+ return null; // Don't render if math fails
311
+ }
312
+ const diagonal = result.diagonal; // Extract diagonal elements
313
+ const unitary = result.unitary;
314
+ const length = Math.sqrt(diagonal[0] + diagonal[4] + diagonal[8]) + 1.0e-12;
315
+
316
+ // 3. Get the radii (standard deviations)
317
+ // The radii are the square root of the eigenvalues (on the diagonal)
318
+ // We apply the confidence scaling factor here.
319
+ const radii = new Cartesian3(
320
+ Math.sqrt(diagonal[0]) / length * confidenceScale,
321
+ Math.sqrt(diagonal[4]) / length * confidenceScale,
322
+ Math.sqrt(diagonal[8]) / length * confidenceScale
323
+ );
324
+ // 4. Get the orientation
325
+ // Convert the rotation matrix (eigenvectors) into a Quaternion
326
+ const orientation = Quaternion.fromRotationMatrix(unitary);
327
+
328
+ // 5. Create the Entity
329
+ return (
330
+ <Entity
331
+ key={key}
332
+ position={new Cartesian3(point.x, point.y, point.z)}
333
+ orientation={orientation}
334
+ ellipsoid={{
335
+ radii: radii,
336
+ material: selected ? selectedColor : color,
337
+ outline: true,
338
+ outlineColor: Color.BLACK,
339
+ }}
340
+ // The solid center point marker
341
+ point={{
342
+ pixelSize: 8,
343
+ color: selected ? Color.RED : Color.WHITE,
344
+ outlineColor: Color.BLACK,
345
+ outlineWidth: 2,
346
+ }}
347
+ />
348
+ );
349
+ };
350
+
351
+ const createPointEntity = (point, size = 1.0, color = Color.RED, key) => {
352
+ const entities = [];
353
+ // Main point as a sphere
354
+ entities.push(
355
+ <Entity
356
+ key={key}
357
+ position={new Cartesian3(point.x, point.y, point.z)}
358
+ ellipsoid={{
359
+ radii: new Cartesian3(size, size, size), // Equal radii for a sphere
360
+ material: color,
361
+ }}
362
+ />
363
+ );
364
+ return entities;
365
+ };
366
+
367
+ const createLineEntity = (m, width = 2, color = Color.YELLOW, key) => {
368
+ const start = new Cartesian3(m.camera.x, m.camera.y, m.camera.z);
369
+ const end = new Cartesian3(m.point.x, m.point.y, m.point.z);
370
+ return (
371
+ <Entity
372
+ key={key}
373
+ polyline={{
374
+ positions: [start, end],
375
+ width: width,
376
+ material: color,
377
+ }}
378
+ />
379
+ );
380
+ };
381
+
382
+ useEffect(() => {
383
+ let tileset;
384
+ let handler;
385
+ let cancelled = false;
386
+
387
+ // Wait until viewerRef.current.cesiumElement becomes available
388
+ const interval = setInterval(() => {
389
+ const viewer = viewerRef.current?.cesiumElement;
390
+ if (!viewer) return;
391
+
392
+ clearInterval(interval);
393
+
394
+ // Load a tileset
395
+ if (tilesetRef.current) {
396
+ viewer.scene.primitives.remove(tilesetRef.current);
397
+ tilesetRef.current = null;
398
+ }
399
+
400
+ if (!tilesetUrl) {
401
+ return;
402
+ }
403
+
404
+ Cesium.Cesium3DTileset.fromUrl(tilesetUrl)
405
+ .then((tileset) => {
406
+ if (cancelled) {
407
+ return;
408
+ }
409
+ // Enable gaussian splat rendering for KHR_gaussian_splatting tiles.
410
+ tileset.enableShowGaussianSplatting = true;
411
+ tileset.showGaussianSplatting = true;
412
+ // for red small object
413
+ offsetTilesetHeight(tileset, offsetHeight);
414
+ /*const lon = -83.02337;
415
+ const lat = 40.01608;
416
+ const height = 10.0;
417
+ const R = new Cesium.Matrix3(
418
+ 0.940820, 0.223526, -0.254742,
419
+ 0.338449, -0.658695, 0.671991,
420
+ -0.017590, -0.718440, -0.695366
421
+ );
422
+
423
+ // 1. ENU placement at given lon/lat/height
424
+ const origin = Cesium.Cartesian3.fromDegrees(lon, lat, height);
425
+ const enu = Cesium.Transforms.eastNorthUpToFixedFrame(origin);
426
+
427
+ // 2. Rotation from 3x3 matrix
428
+ const rotationMatrix4 = Cesium.Matrix4.fromRotationTranslation(R);
429
+
430
+ // 3. Scaling matrix
431
+ const scaleMatrix = Cesium.Matrix4.fromScale(
432
+ new Cesium.Cartesian3(1.0, 1.0, 1.0)
433
+ );
434
+
435
+ // 4. Combine: modelMatrix = ENU * Rotation * Scale
436
+ let finalMatrix = Cesium.Matrix4.multiply(enu, rotationMatrix4, new Cesium.Matrix4());
437
+ finalMatrix = Cesium.Matrix4.multiply(finalMatrix, scaleMatrix, finalMatrix);
438
+ tileset.modelMatrix = finalMatrix;*/
439
+
440
+ viewer.scene.primitives.add(tileset);
441
+ viewer.zoomTo(tileset);
442
+ tilesetRef.current = tileset;
443
+ })
444
+ .catch((error) => {
445
+ console.error("Error loading tileset:", error);
446
+ });
447
+ // Set up mouse event handler
448
+ handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
449
+
450
+ handler.setInputAction((click) => {
451
+ if (currentModeRef.current === "measure") {
452
+ const cartesian = viewer.camera.pickEllipsoid(
453
+ click.position,
454
+ viewer.scene.globe.ellipsoid,
455
+ );
456
+ if (cartesian) {
457
+ addMeausure(viewer.camera.positionWC, cartesian);
458
+ }
459
+ } else {
460
+ }
461
+ }, Cesium.ScreenSpaceEventType.LEFT_CLICK);
462
+
463
+ handler.setInputAction((movement) => {
464
+ /*const cartesian = viewer.camera.pickEllipsoid(
465
+ movement.endPosition,
466
+ viewer.scene.globe.ellipsoid
467
+ );
468
+ if (cartesian) {
469
+ // Example: show coordinates or highlight
470
+ }*/
471
+ }, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
472
+ }, 200); // check every 200 ms until viewer is ready
473
+
474
+ // Cleanup
475
+ return () => {
476
+ cancelled = true;
477
+ clearInterval(interval);
478
+ if (handler) handler.destroy();
479
+ const viewer = viewerRef.current?.cesiumElement;
480
+ if (viewer && tileset) viewer.scene.primitives.remove(tileset);
481
+ if (viewer && tilesetRef.current) {
482
+ viewer.scene.primitives.remove(tilesetRef.current);
483
+ tilesetRef.current = null;
484
+ }
485
+ };
486
+ }, [tilesetUrl, offsetHeight]);
487
+
488
+ return (
489
+ <Viewer ref={viewerRef} full infoBox={false} selectionIndicator={false}>
490
+ {points.map((pt) =>
491
+ createCovarianceEntity(pt, 0.2, Color.BLUE.withAlpha(0.5), "point_" + pt.id, selectedPoint === pt.id),
492
+ )}
493
+ {measurements.map((m) =>
494
+ createLineEntity(m, 2, Color.YELLOW.withAlpha(0.5), "line_" + m.id),
495
+ )}
496
+ </Viewer>
497
+ );
498
+ },
499
+ );
500
+
501
+ export default CesiumViewer;
client/src/components/MeasurePanel.jsx ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from "react";
2
+ import {
3
+ Card,
4
+ CardContent,
5
+ Typography,
6
+ Button,
7
+ List,
8
+ ListItemButton,
9
+ ListItemText,
10
+ Divider,
11
+ Stack,
12
+ FormControl,
13
+ InputLabel,
14
+ Select,
15
+ MenuItem,
16
+ TextField,
17
+ Dialog,
18
+ DialogTitle,
19
+ DialogContent,
20
+ DialogActions,
21
+ } from "@mui/material";
22
+ import DownloadIcon from "@mui/icons-material/Download";
23
+ import DeleteIcon from "@mui/icons-material/Delete";
24
+ import EditIcon from "@mui/icons-material/Edit";
25
+
26
+ const DEFAULT_CUSTOM_MODEL = {
27
+ bucket: import.meta.env.VITE_S3_BUCKET || "ddy-first-bucket",
28
+ prefix: "data/3dtiles",
29
+ tileset: "custom/tileset.json",
30
+ offsetHeight: 0,
31
+ };
32
+
33
+ const DEFAULT_URL_MODEL = {
34
+ tilesetUrl: "",
35
+ offsetHeight: 0,
36
+ };
37
+
38
+ export default function MeasurePanel({
39
+ points,
40
+ onSelectPoint,
41
+ onDeletePoint,
42
+ onToggleMeasure,
43
+ measuring,
44
+ offsetHeight,
45
+ models,
46
+ selectedModelId,
47
+ customModel,
48
+ urlModel,
49
+ onModelChange,
50
+ onSaveCustomModel,
51
+ onSaveUrlModel,
52
+ onOffsetHeightChange,
53
+ onEditModel,
54
+ }) {
55
+ const [selectedIndex, setSelectedIndex] = useState(null);
56
+ const [customDialogOpen, setCustomDialogOpen] = useState(false);
57
+ const [draftCustomModel, setDraftCustomModel] = useState(
58
+ customModel || DEFAULT_CUSTOM_MODEL
59
+ );
60
+ const [urlDialogOpen, setUrlDialogOpen] = useState(false);
61
+ const [draftUrlModel, setDraftUrlModel] = useState(
62
+ urlModel || DEFAULT_URL_MODEL
63
+ );
64
+
65
+ useEffect(() => {
66
+ if (!customDialogOpen) {
67
+ setDraftCustomModel(customModel || DEFAULT_CUSTOM_MODEL);
68
+ }
69
+ }, [customDialogOpen, customModel]);
70
+
71
+ useEffect(() => {
72
+ if (!urlDialogOpen) {
73
+ setDraftUrlModel(urlModel || DEFAULT_URL_MODEL);
74
+ }
75
+ }, [urlDialogOpen, urlModel]);
76
+
77
+ const handleSelect = (index) => {
78
+ setSelectedIndex(index);
79
+ if (onSelectPoint) onSelectPoint(points[index]);
80
+ };
81
+ const handleDeletePoint = () => {
82
+ if (onDeletePoint) onDeletePoint();
83
+ };
84
+ const handleExport = () => {
85
+ const dataStr =
86
+ "data:text/json;charset=utf-8," +
87
+ encodeURIComponent(JSON.stringify(points, null, 2));
88
+ const downloadAnchorNode = document.createElement("a");
89
+ downloadAnchorNode.setAttribute("href", dataStr);
90
+ downloadAnchorNode.setAttribute("download", "measured_points.json");
91
+ document.body.appendChild(downloadAnchorNode); // required for firefox
92
+ downloadAnchorNode.click();
93
+ downloadAnchorNode.remove();
94
+ };
95
+
96
+ const openCustomDialog = () => {
97
+ setDraftCustomModel(customModel || DEFAULT_CUSTOM_MODEL);
98
+ setCustomDialogOpen(true);
99
+ };
100
+
101
+ const openUrlDialog = () => {
102
+ setDraftUrlModel(urlModel || DEFAULT_URL_MODEL);
103
+ setUrlDialogOpen(true);
104
+ };
105
+
106
+ const handleModelChange = (event) => {
107
+ const nextModelId = event.target.value;
108
+ if (onModelChange) onModelChange(nextModelId);
109
+ if (nextModelId === "custom") {
110
+ openCustomDialog();
111
+ }
112
+ if (nextModelId === "url") {
113
+ openUrlDialog();
114
+ }
115
+ };
116
+
117
+ const handleCustomFieldChange = (field) => (event) => {
118
+ setDraftCustomModel((prev) => ({
119
+ ...prev,
120
+ [field]: event.target.value,
121
+ }));
122
+ };
123
+
124
+ const handleCustomSave = () => {
125
+ const nextOffset = Number(draftCustomModel.offsetHeight);
126
+ const payload = {
127
+ ...draftCustomModel,
128
+ offsetHeight: Number.isNaN(nextOffset) ? 0 : nextOffset,
129
+ };
130
+ if (onSaveCustomModel) onSaveCustomModel(payload);
131
+ setCustomDialogOpen(false);
132
+ };
133
+
134
+ const handleUrlFieldChange = (field) => (event) => {
135
+ setDraftUrlModel((prev) => ({
136
+ ...prev,
137
+ [field]: event.target.value,
138
+ }));
139
+ };
140
+
141
+ const handleUrlSave = () => {
142
+ const nextOffset = Number(draftUrlModel.offsetHeight);
143
+ const payload = {
144
+ ...draftUrlModel,
145
+ offsetHeight: Number.isNaN(nextOffset) ? 0 : nextOffset,
146
+ };
147
+ if (onSaveUrlModel) onSaveUrlModel(payload);
148
+ setUrlDialogOpen(false);
149
+ };
150
+
151
+ const handleCustomClose = () => {
152
+ setCustomDialogOpen(false);
153
+ };
154
+
155
+ const handleUrlClose = () => {
156
+ setUrlDialogOpen(false);
157
+ };
158
+
159
+ return (
160
+ <Card
161
+ sx={{
162
+ width: 400,
163
+ position: "absolute",
164
+ top: 20,
165
+ left: 20,
166
+ bgcolor: "background.paper",
167
+ boxShadow: 5,
168
+ borderRadius: 3,
169
+ }}
170
+ >
171
+ <CardContent>
172
+ <Typography variant="h6" gutterBottom>
173
+ 3D Measurement
174
+ </Typography>
175
+
176
+ <Stack spacing={2} mt={2}>
177
+ <Stack direction="row" spacing={1}>
178
+ <FormControl size="small" fullWidth sx={{ flex: 1 }}>
179
+ <InputLabel id="model-select-label">Model</InputLabel>
180
+ <Select
181
+ labelId="model-select-label"
182
+ value={selectedModelId}
183
+ label="Model"
184
+ onChange={handleModelChange}
185
+ >
186
+ {models.map((model) => (
187
+ <MenuItem key={model.id} value={model.id}>
188
+ {model.name}
189
+ </MenuItem>
190
+ ))}
191
+ </Select>
192
+ </FormControl>
193
+ {(selectedModelId === "custom" || selectedModelId === "url" || selectedModelId === "upload") && (
194
+ <Button
195
+ variant="outlined"
196
+ startIcon={<EditIcon />}
197
+ onClick={() => {
198
+ if (selectedModelId === "custom") openCustomDialog();
199
+ if (selectedModelId === "url") openUrlDialog();
200
+ if (selectedModelId === "upload" && onEditModel) onEditModel();
201
+ }}
202
+ >
203
+ {selectedModelId === "upload" ? "Upload" : "Edit"}
204
+ </Button>
205
+ )}
206
+ </Stack>
207
+
208
+ <TextField
209
+ size="small"
210
+ label="Offset Height"
211
+ type="number"
212
+ value={offsetHeight}
213
+ onChange={(event) =>
214
+ onOffsetHeightChange && onOffsetHeightChange(event.target.value)
215
+ }
216
+ inputProps={{ step: 1 }}
217
+ />
218
+
219
+ <Stack direction="row" spacing={2}>
220
+ <Button
221
+ variant="contained"
222
+ color={measuring ? "error" : "primary"}
223
+ onClick={onToggleMeasure}
224
+ >
225
+ {measuring ? "End Measure" : "Start Measure"}
226
+ </Button>
227
+
228
+ <Button
229
+ variant="outlined"
230
+ color="primary"
231
+ startIcon={<DownloadIcon />}
232
+ onClick={() => handleExport()}
233
+ disabled={points.length === 0}
234
+ >
235
+ Export Points
236
+ </Button>
237
+ </Stack>
238
+
239
+ <Button
240
+ variant="outlined"
241
+ color="primary"
242
+ startIcon={<DeleteIcon />}
243
+ onClick={() => handleDeletePoint()}
244
+ disabled={points.length === 0}
245
+ >
246
+ Delete Point
247
+ </Button>
248
+ </Stack>
249
+
250
+ <Typography variant="subtitle1" sx={{ mb: 1, mt: 2 }}>
251
+ Measured Points
252
+ </Typography>
253
+
254
+ <List dense sx={{ maxHeight: 200, overflowY: "auto" }}>
255
+ {points.length === 0 && (
256
+ <Typography variant="body2" color="text.secondary" sx={{ px: 1 }}>
257
+ No points measured yet.
258
+ </Typography>
259
+ )}
260
+ {points.map((p, index) => (
261
+ <React.Fragment key={index}>
262
+ <ListItemButton
263
+ selected={selectedIndex === index}
264
+ onClick={() => handleSelect(index)}
265
+ sx={{
266
+ borderRadius: 2,
267
+ "&.Mui-selected": {
268
+ bgcolor: "primary.light",
269
+ color: "white",
270
+ "&:hover": { bgcolor: "primary.main" },
271
+ },
272
+ }}
273
+ >
274
+ <ListItemText
275
+ primary={`Point ${p.id}`}
276
+ secondary={`(${p.lat.toFixed(8)}, ${p.lon.toFixed(8)}, ${p.alt.toFixed(4)}) | Iz = ${p.accuracy.toFixed(3)} m`}
277
+ />
278
+ </ListItemButton>
279
+ {index < points.length - 1 && <Divider component="li" />}
280
+ </React.Fragment>
281
+ ))}
282
+ </List>
283
+ </CardContent>
284
+
285
+ <Dialog open={customDialogOpen} onClose={handleCustomClose} maxWidth="sm" fullWidth>
286
+ <DialogTitle>Custom S3 Model</DialogTitle>
287
+ <DialogContent>
288
+ <Stack spacing={2} mt={1}>
289
+ <TextField
290
+ size="small"
291
+ label="Bucket"
292
+ value={draftCustomModel.bucket}
293
+ onChange={handleCustomFieldChange("bucket")}
294
+ fullWidth
295
+ />
296
+ <TextField
297
+ size="small"
298
+ label="Prefix"
299
+ value={draftCustomModel.prefix}
300
+ onChange={handleCustomFieldChange("prefix")}
301
+ fullWidth
302
+ />
303
+ <TextField
304
+ size="small"
305
+ label="Tileset"
306
+ value={draftCustomModel.tileset}
307
+ onChange={handleCustomFieldChange("tileset")}
308
+ fullWidth
309
+ />
310
+ <TextField
311
+ size="small"
312
+ label="Offset Height"
313
+ type="number"
314
+ value={draftCustomModel.offsetHeight}
315
+ onChange={handleCustomFieldChange("offsetHeight")}
316
+ inputProps={{ step: 1 }}
317
+ fullWidth
318
+ />
319
+ </Stack>
320
+ </DialogContent>
321
+ <DialogActions>
322
+ <Button onClick={handleCustomClose}>Cancel</Button>
323
+ <Button variant="contained" onClick={handleCustomSave}>
324
+ Save
325
+ </Button>
326
+ </DialogActions>
327
+ </Dialog>
328
+
329
+ <Dialog open={urlDialogOpen} onClose={handleUrlClose} maxWidth="sm" fullWidth>
330
+ <DialogTitle>URL Model</DialogTitle>
331
+ <DialogContent>
332
+ <Stack spacing={2} mt={1}>
333
+ <TextField
334
+ size="small"
335
+ label="Tileset URL"
336
+ placeholder="http://localhost:8000/tileset.json"
337
+ value={draftUrlModel.tilesetUrl}
338
+ onChange={handleUrlFieldChange("tilesetUrl")}
339
+ fullWidth
340
+ />
341
+ <TextField
342
+ size="small"
343
+ label="Offset Height"
344
+ type="number"
345
+ value={draftUrlModel.offsetHeight}
346
+ onChange={handleUrlFieldChange("offsetHeight")}
347
+ inputProps={{ step: 1 }}
348
+ fullWidth
349
+ />
350
+ </Stack>
351
+ </DialogContent>
352
+ <DialogActions>
353
+ <Button onClick={handleUrlClose}>Cancel</Button>
354
+ <Button variant="contained" onClick={handleUrlSave}>
355
+ Save
356
+ </Button>
357
+ </DialogActions>
358
+ </Dialog>
359
+ </Card>
360
+ );
361
+ }
client/src/index.css ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
3
+ line-height: 1.5;
4
+ font-weight: 400;
5
+
6
+ color-scheme: light dark;
7
+ color: rgba(255, 255, 255, 0.87);
8
+ background-color: #242424;
9
+
10
+ font-synthesis: none;
11
+ text-rendering: optimizeLegibility;
12
+ -webkit-font-smoothing: antialiased;
13
+ -moz-osx-font-smoothing: grayscale;
14
+ }
15
+
16
+ a {
17
+ font-weight: 500;
18
+ color: #646cff;
19
+ text-decoration: inherit;
20
+ }
21
+ a:hover {
22
+ color: #535bf2;
23
+ }
24
+
25
+ body {
26
+ margin: 0;
27
+ display: flex;
28
+ place-items: center;
29
+ min-width: 320px;
30
+ min-height: 100vh;
31
+ }
32
+
33
+ h1 {
34
+ font-size: 3.2em;
35
+ line-height: 1.1;
36
+ }
37
+
38
+ button {
39
+ border-radius: 8px;
40
+ border: 1px solid transparent;
41
+ padding: 0.6em 1.2em;
42
+ font-size: 1em;
43
+ font-weight: 500;
44
+ font-family: inherit;
45
+ background-color: #1a1a1a;
46
+ cursor: pointer;
47
+ transition: border-color 0.25s;
48
+ }
49
+ button:hover {
50
+ border-color: #646cff;
51
+ }
52
+ button:focus,
53
+ button:focus-visible {
54
+ outline: 4px auto -webkit-focus-ring-color;
55
+ }
56
+
57
+ @media (prefers-color-scheme: light) {
58
+ :root {
59
+ color: #213547;
60
+ background-color: #ffffff;
61
+ }
62
+ a:hover {
63
+ color: #747bff;
64
+ }
65
+ button {
66
+ background-color: #f9f9f9;
67
+ }
68
+ }
client/src/main.jsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import './cesiumSetup'
2
+ import { StrictMode } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import './index.css'
5
+ import App from './App.jsx'
6
+
7
+ createRoot(document.getElementById('root')).render(
8
+ <StrictMode>
9
+ <App />
10
+ </StrictMode>,
11
+ )
client/src/pages/Home.jsx ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
+ import CesiumViewer from "../components/CesiumViewer";
3
+ import MeasurePanel from "../components/MeasurePanel";
4
+ import { getDefaultBucket, validateAwsConfig, AWS_REGION } from "../utils/awsConfig";
5
+ import {
6
+ Dialog,
7
+ DialogTitle,
8
+ DialogContent,
9
+ DialogActions,
10
+ Button,
11
+ TextField,
12
+ Stack,
13
+ } from "@mui/material";
14
+
15
+ // Models configured to use AWS Cognito authentication
16
+ // No credentials are stored in code
17
+ const BASE_MODELS = [
18
+ {
19
+ id: "small-object-1",
20
+ name: "Small Object 1",
21
+ tileset: "small_object_1/3dgs/tileset.json",
22
+ prefix: "data/3dtiles",
23
+ bucket: getDefaultBucket(),
24
+ offsetHeight: -130,
25
+ },
26
+ {
27
+ id: "small-object-2",
28
+ name: "Small Object 2",
29
+ tileset: "small_object_2/3dgs/tileset.json",
30
+ prefix: "data/3dtiles",
31
+ bucket: getDefaultBucket(),
32
+ offsetHeight: -130,
33
+ },
34
+ {
35
+ id: "wex-cc-3dgs",
36
+ name: "Wex CC 3DGS",
37
+ tileset: "wex_cc_3dgs/tileset.json",
38
+ prefix: "data/3dtiles",
39
+ bucket: getDefaultBucket(),
40
+ offsetHeight: -50,
41
+ },
42
+ {
43
+ id: "2dgs",
44
+ name: "2DGS",
45
+ tileset: "2dgs/tileset.json",
46
+ prefix: "data/3dtiles",
47
+ bucket: getDefaultBucket(),
48
+ offsetHeight: -200,
49
+ },
50
+ ];
51
+
52
+ function buildTilesetUrl(model) {
53
+ if (!model.bucket) return "";
54
+
55
+ const tilesetPath = (model.tileset || "tileset.json").replace(/^\/+/, "");
56
+ const prefix = (model.prefix || "").replace(/\/+$/, "");
57
+ const s3Key = prefix ? `${prefix}/${tilesetPath}` : tilesetPath;
58
+
59
+ // Return placeholder - actual signed URL will be created asynchronously
60
+ return s3Key;
61
+ }
62
+
63
+ export default function Home() {
64
+ // AWS configuration validation
65
+ useEffect(() => {
66
+ if (!validateAwsConfig()) {
67
+ console.warn(
68
+ "AWS configuration incomplete. Check your .env file for required variables."
69
+ );
70
+ }
71
+ }, []);
72
+
73
+ const [customModel, setCustomModel] = useState({
74
+ bucket: getDefaultBucket(),
75
+ prefix: "data/3dtiles",
76
+ tileset: "custom/tileset.json",
77
+ offsetHeight: 0,
78
+ });
79
+ const [uploadedModel, setUploadedModel] = useState({
80
+ tilesetUrl: "",
81
+ offsetHeight: 0,
82
+ });
83
+ const [urlModel, setUrlModel] = useState({
84
+ tilesetUrl: "",
85
+ offsetHeight: 0,
86
+ });
87
+ const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
88
+ const [uploadParams, setUploadParams] = useState({ lat: 0, lon: 0 });
89
+
90
+ const models = useMemo(
91
+ () => [
92
+ ...BASE_MODELS,
93
+ {
94
+ id: "custom",
95
+ name: "Custom S3 Model",
96
+ },
97
+ {
98
+ id: "url",
99
+ name: "URL Model",
100
+ },
101
+ {
102
+ id: "upload",
103
+ name: "Upload PLY/Splat",
104
+ },
105
+ ],
106
+ []
107
+ );
108
+
109
+ const [selectedModelId, setSelectedModelId] = useState(models[0].id);
110
+ const [previousModelId, setPreviousModelId] = useState(models[0].id);
111
+ const selectedModel = useMemo(() => {
112
+ if (selectedModelId === "custom") {
113
+ return { id: "custom", name: "Custom S3 Model", ...customModel };
114
+ }
115
+ if (selectedModelId === "url") {
116
+ return { id: "url", name: "URL Model", ...urlModel };
117
+ }
118
+ if (selectedModelId === "upload") {
119
+ return { id: "upload", name: "Uploaded Model", ...uploadedModel };
120
+ }
121
+ return (
122
+ BASE_MODELS.find((model) => model.id === selectedModelId) ||
123
+ BASE_MODELS[0]
124
+ );
125
+ }, [selectedModelId, customModel, urlModel, uploadedModel]);
126
+
127
+ const [offsetHeight, setOffsetHeight] = useState(
128
+ selectedModel.offsetHeight ?? 0
129
+ );
130
+
131
+ // Use direct unsigned S3 URL (bucket is publicly readable)
132
+ const [tilesetUrl, setTilesetUrl] = useState("");
133
+ const [isUploading, setIsUploading] = useState(false);
134
+ const fileInputRef = useRef(null);
135
+
136
+ useEffect(() => {
137
+ if (selectedModelId === "url") {
138
+ setTilesetUrl(urlModel.tilesetUrl || "");
139
+ return;
140
+ }
141
+ if (selectedModelId === "upload") {
142
+ setTilesetUrl(uploadedModel.tilesetUrl || "");
143
+ return;
144
+ }
145
+
146
+ const s3Key = buildTilesetUrl(selectedModel);
147
+ const directUrl = `https://${selectedModel.bucket}.s3.${AWS_REGION}.amazonaws.com/${s3Key}`;
148
+ setTilesetUrl(directUrl);
149
+ }, [selectedModel, selectedModelId, urlModel.tilesetUrl, uploadedModel.tilesetUrl]);
150
+
151
+ const [points, setPoints] = useState([]);
152
+ const [isMeasuring, setIsMeasuring] = useState(false);
153
+ const cesiumRef = useRef(null);
154
+ const maxPointId = useRef(0);
155
+ const [selectedPoint, setSelectedPoint] = useState(0);
156
+
157
+ const handleAddPoint = (point) => {
158
+ maxPointId.current += 1;
159
+ console.log("New 3D point added:", point);
160
+ setPoints((prev) => [...prev, { ...point, id: maxPointId.current }]);
161
+ };
162
+
163
+ const handleMeasureClick = () => {
164
+ if (!cesiumRef.current) return;
165
+ if (isMeasuring) {
166
+ cesiumRef.current.endMeasure();
167
+ setIsMeasuring(false);
168
+ } else {
169
+ cesiumRef.current.setMode("measure");
170
+ setIsMeasuring(true);
171
+ }
172
+ };
173
+ const handleSelectPoint = (point) => {
174
+ if (!cesiumRef.current) return;
175
+ setSelectedPoint(point.id);
176
+ console.log("Selected point for inspection:", point);
177
+ };
178
+ const handleDeletePoint = () => {
179
+ if (!cesiumRef.current) return;
180
+ if (selectedPoint === 0) return;
181
+ setPoints((prev) => prev.filter((pt) => pt.id !== selectedPoint));
182
+ setSelectedPoint(0);
183
+ };
184
+
185
+ const handleModelChange = (modelId) => {
186
+ if (modelId !== selectedModelId) {
187
+ setPreviousModelId(selectedModelId);
188
+ }
189
+ if (modelId === "upload") {
190
+ setUploadDialogOpen(true);
191
+ }
192
+ setSelectedModelId(modelId);
193
+ };
194
+
195
+ const handleCustomModelSave = (model) => {
196
+ const nextOffset = Number(model.offsetHeight);
197
+ const normalizedModel = {
198
+ ...model,
199
+ offsetHeight: Number.isNaN(nextOffset) ? 0 : nextOffset,
200
+ };
201
+ setCustomModel(normalizedModel);
202
+ setSelectedModelId("custom");
203
+ };
204
+
205
+ const handleUrlModelSave = (model) => {
206
+ const nextOffset = Number(model.offsetHeight);
207
+ const normalizedModel = {
208
+ ...model,
209
+ offsetHeight: Number.isNaN(nextOffset) ? 0 : nextOffset,
210
+ };
211
+ setUrlModel(normalizedModel);
212
+ setSelectedModelId("url");
213
+ };
214
+
215
+ const handleOffsetHeightChange = (value) => {
216
+ const nextValue = Number(value);
217
+ if (!Number.isNaN(nextValue)) {
218
+ setOffsetHeight(nextValue);
219
+ if (selectedModelId === "custom") {
220
+ setCustomModel((prev) => ({ ...prev, offsetHeight: nextValue }));
221
+ }
222
+ if (selectedModelId === "url") {
223
+ setUrlModel((prev) => ({ ...prev, offsetHeight: nextValue }));
224
+ }
225
+ if (selectedModelId === "upload") {
226
+ setUploadedModel((prev) => ({ ...prev, offsetHeight: nextValue }));
227
+ }
228
+ }
229
+ };
230
+
231
+ useEffect(() => {
232
+ setOffsetHeight(selectedModel.offsetHeight ?? 0);
233
+ }, [selectedModelId, selectedModel.offsetHeight]);
234
+
235
+ const handleFileUpload = async (event) => {
236
+ const file = event.target.files?.[0];
237
+ if (!file) return;
238
+
239
+ setIsUploading(true);
240
+ const formData = new FormData();
241
+ formData.append("file", file);
242
+ formData.append("lat", uploadParams.lat);
243
+ formData.append("lon", uploadParams.lon);
244
+
245
+ try {
246
+ const response = await fetch("/api/upload", {
247
+ method: "POST",
248
+ body: formData,
249
+ });
250
+
251
+ if (!response.ok) {
252
+ const err = await response.json();
253
+ throw new Error(err.detail || "Upload failed");
254
+ }
255
+
256
+ const data = await response.json();
257
+ setUploadedModel((prev) => ({
258
+ ...prev,
259
+ tilesetUrl: data.tilesetUrl,
260
+ offsetHeight: 0,
261
+ }));
262
+ setOffsetHeight(0);
263
+ } catch (error) {
264
+ console.error("Upload error:", error);
265
+ alert("Failed to upload/convert model: " + error.message);
266
+ } finally {
267
+ setIsUploading(false);
268
+ }
269
+ };
270
+
271
+ return (
272
+ <div style={{ height: "100vh", width: "100vw", position: "relative" }}>
273
+ <input
274
+ type="file"
275
+ ref={fileInputRef}
276
+ style={{ display: "none" }}
277
+ accept=".ply,.splat"
278
+ onChange={handleFileUpload}
279
+ />
280
+ {isUploading && (
281
+ <div style={{
282
+ position: "absolute", top: 0, left: 0, right: 0, bottom: 0,
283
+ backgroundColor: "rgba(0,0,0,0.7)", color: "white", zIndex: 9999,
284
+ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center"
285
+ }}>
286
+ <h2>Converting Model...</h2>
287
+ <p>Please wait while we process your file.</p>
288
+ </div>
289
+ )}
290
+ {/* Cesium Viewer */}
291
+ <CesiumViewer
292
+ ref={cesiumRef}
293
+ tilesetUrl={tilesetUrl}
294
+ points={points}
295
+ onAddPoint={handleAddPoint}
296
+ offsetHeight={offsetHeight}
297
+ selectedPoint={selectedPoint}
298
+ />
299
+
300
+ {/* MUI Overlay */}
301
+ <MeasurePanel
302
+ points={points}
303
+ measuring={isMeasuring}
304
+ offsetHeight={offsetHeight}
305
+ models={models}
306
+ selectedModelId={selectedModelId}
307
+ customModel={customModel}
308
+ urlModel={urlModel}
309
+ onModelChange={handleModelChange}
310
+ onSaveCustomModel={handleCustomModelSave}
311
+ onSaveUrlModel={handleUrlModelSave}
312
+ onOffsetHeightChange={handleOffsetHeightChange}
313
+ onToggleMeasure={handleMeasureClick}
314
+ onSelectPoint={handleSelectPoint}
315
+ onDeletePoint={handleDeletePoint}
316
+ onEditModel={() => setUploadDialogOpen(true)}
317
+ />
318
+
319
+ {/* Upload Dialog */}
320
+ <Dialog open={uploadDialogOpen} onClose={() => setUploadDialogOpen(false)}>
321
+ <DialogTitle>Upload Model</DialogTitle>
322
+ <DialogContent>
323
+ <Stack spacing={2} mt={1}>
324
+ <TextField
325
+ label="Latitude"
326
+ type="number"
327
+ value={uploadParams.lat}
328
+ onChange={(e) => setUploadParams(prev => ({ ...prev, lat: e.target.value }))}
329
+ fullWidth
330
+ />
331
+ <TextField
332
+ label="Longitude"
333
+ type="number"
334
+ value={uploadParams.lon}
335
+ onChange={(e) => setUploadParams(prev => ({ ...prev, lon: e.target.value }))}
336
+ fullWidth
337
+ />
338
+ </Stack>
339
+ </DialogContent>
340
+ <DialogActions>
341
+ <Button onClick={() => {
342
+ setUploadDialogOpen(false);
343
+ if (!uploadedModel.tilesetUrl) {
344
+ setSelectedModelId(previousModelId);
345
+ }
346
+ }}>Cancel</Button>
347
+ <Button variant="contained" onClick={() => {
348
+ setUploadDialogOpen(false);
349
+ fileInputRef.current?.click();
350
+ }}>
351
+ Select File & Upload
352
+ </Button>
353
+ </DialogActions>
354
+ </Dialog>
355
+ </div>
356
+ );
357
+ }
client/src/pages/VisitLog.css ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url("https://fonts.googleapis.com/css2?family=Fraunces:wght@500;700&family=Space+Grotesk:wght@400;500;600&display=swap");
2
+
3
+ :root {
4
+ --visit-bg: #f4f1ec;
5
+ --visit-ink: #1c1b19;
6
+ --visit-muted: #6b6357;
7
+ --visit-accent: #c9743a;
8
+ --visit-card: #fffaf3;
9
+ --visit-shadow: rgba(30, 24, 16, 0.12);
10
+ }
11
+
12
+ .visit-log-body #root {
13
+ max-width: none;
14
+ width: 100%;
15
+ padding: 0;
16
+ }
17
+
18
+ .visit-log-body {
19
+ margin: 0;
20
+ }
21
+
22
+ .visit-log-page {
23
+ min-height: 100vh;
24
+ padding: 48px 20px 80px;
25
+ background: radial-gradient(circle at top, #fff6e7 0%, var(--visit-bg) 60%);
26
+ color: var(--visit-ink);
27
+ font-family: "Space Grotesk", "Segoe UI", sans-serif;
28
+ }
29
+
30
+ .visit-log-header {
31
+ width: 100%;
32
+ margin: 0 0 32px;
33
+ display: flex;
34
+ flex-wrap: wrap;
35
+ gap: 24px;
36
+ align-items: flex-end;
37
+ justify-content: space-between;
38
+ }
39
+
40
+ .visit-log-header h1 {
41
+ font-family: "Fraunces", "Times New Roman", serif;
42
+ font-size: clamp(2rem, 4vw, 3rem);
43
+ margin: 6px 0 8px;
44
+ letter-spacing: -0.02em;
45
+ }
46
+
47
+ .visit-log-eyebrow {
48
+ text-transform: uppercase;
49
+ letter-spacing: 0.18em;
50
+ font-size: 0.7rem;
51
+ color: var(--visit-accent);
52
+ margin: 0;
53
+ }
54
+
55
+ .visit-log-subtitle {
56
+ max-width: 520px;
57
+ color: var(--visit-muted);
58
+ margin: 0;
59
+ }
60
+
61
+ .visit-log-back {
62
+ text-decoration: none;
63
+ color: var(--visit-ink);
64
+ border: 1px solid var(--visit-ink);
65
+ padding: 10px 18px;
66
+ border-radius: 999px;
67
+ font-size: 0.9rem;
68
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
69
+ }
70
+
71
+ .visit-log-back:hover {
72
+ transform: translateY(-2px);
73
+ box-shadow: 0 8px 18px var(--visit-shadow);
74
+ }
75
+
76
+ .visit-log-card {
77
+ width: 100%;
78
+ margin: 0 0 32px;
79
+ background: var(--visit-card);
80
+ padding: 24px;
81
+ border-radius: 20px;
82
+ box-shadow: 0 16px 30px var(--visit-shadow);
83
+ display: grid;
84
+ gap: 18px;
85
+ }
86
+
87
+ .visit-log-form {
88
+ display: grid;
89
+ gap: 16px;
90
+ grid-template-columns: 1fr auto;
91
+ align-items: end;
92
+ }
93
+
94
+ .visit-log-form label {
95
+ display: block;
96
+ font-size: 0.8rem;
97
+ text-transform: uppercase;
98
+ letter-spacing: 0.16em;
99
+ color: var(--visit-muted);
100
+ margin-bottom: 8px;
101
+ }
102
+
103
+ .visit-log-form input {
104
+ width: 100%;
105
+ padding: 12px 14px;
106
+ border-radius: 12px;
107
+ border: 1px solid #d7c9b3;
108
+ background: #fff;
109
+ font-size: 1rem;
110
+ }
111
+
112
+ .visit-log-form button {
113
+ padding: 12px 22px;
114
+ border-radius: 999px;
115
+ border: none;
116
+ background: var(--visit-ink);
117
+ color: #fff7ee;
118
+ font-weight: 600;
119
+ cursor: pointer;
120
+ }
121
+
122
+ .visit-log-form button:disabled {
123
+ opacity: 0.6;
124
+ cursor: not-allowed;
125
+ }
126
+
127
+ .visit-log-stats {
128
+ display: grid;
129
+ gap: 16px;
130
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
131
+ }
132
+
133
+ .visit-log-stats div {
134
+ padding: 16px;
135
+ border-radius: 16px;
136
+ background: rgba(201, 116, 58, 0.08);
137
+ }
138
+
139
+ .visit-log-stats span {
140
+ display: block;
141
+ color: var(--visit-muted);
142
+ font-size: 0.85rem;
143
+ }
144
+
145
+ .visit-log-stats strong {
146
+ font-size: 1.2rem;
147
+ }
148
+
149
+ .visit-log-error {
150
+ width: 100%;
151
+ margin: 0 0 24px;
152
+ padding: 12px 16px;
153
+ border-radius: 12px;
154
+ background: #ffe2d6;
155
+ color: #8b2c10;
156
+ }
157
+
158
+ .visit-log-grid {
159
+ width: 100%;
160
+ margin: 0;
161
+ display: grid;
162
+ gap: 18px;
163
+ }
164
+
165
+ .visit-log-entry {
166
+ background: #fff;
167
+ border-radius: 18px;
168
+ padding: 20px 22px;
169
+ box-shadow: 0 10px 22px rgba(30, 24, 16, 0.08);
170
+ border: 1px solid #f0e2cd;
171
+ }
172
+
173
+ .visit-log-entry-header {
174
+ display: flex;
175
+ flex-wrap: wrap;
176
+ gap: 12px;
177
+ justify-content: space-between;
178
+ align-items: baseline;
179
+ }
180
+
181
+ .visit-log-entry-header h2 {
182
+ font-size: 1.1rem;
183
+ margin: 0;
184
+ font-weight: 600;
185
+ }
186
+
187
+ .visit-log-entry-path {
188
+ font-size: 0.85rem;
189
+ color: var(--visit-muted);
190
+ }
191
+
192
+ .visit-log-entry-body {
193
+ margin-top: 16px;
194
+ display: grid;
195
+ gap: 16px;
196
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
197
+ }
198
+
199
+ .visit-log-entry-body span {
200
+ display: block;
201
+ font-size: 0.75rem;
202
+ text-transform: uppercase;
203
+ letter-spacing: 0.14em;
204
+ color: var(--visit-muted);
205
+ margin-bottom: 6px;
206
+ }
207
+
208
+ .visit-log-entry-body strong {
209
+ font-weight: 600;
210
+ }
211
+
212
+ .visit-log-empty {
213
+ padding: 24px;
214
+ border-radius: 16px;
215
+ background: #fff4e4;
216
+ text-align: center;
217
+ color: var(--visit-muted);
218
+ }
219
+
220
+ .visit-log-layout {
221
+ width: 100%;
222
+ margin: 0 0 32px;
223
+ display: grid;
224
+ grid-template-columns: minmax(240px, 320px) 1fr;
225
+ gap: 20px;
226
+ }
227
+
228
+ .visit-log-sidebar {
229
+ background: #fffaf3;
230
+ border-radius: 18px;
231
+ padding: 18px;
232
+ box-shadow: 0 12px 22px rgba(30, 24, 16, 0.08);
233
+ border: 1px solid #f0e2cd;
234
+ display: grid;
235
+ gap: 12px;
236
+ max-height: 520px;
237
+ overflow: hidden;
238
+ }
239
+
240
+ .visit-log-sidebar-header {
241
+ display: flex;
242
+ align-items: baseline;
243
+ justify-content: space-between;
244
+ }
245
+
246
+ .visit-log-sidebar-header h2 {
247
+ margin: 0;
248
+ font-size: 1.1rem;
249
+ }
250
+
251
+ .visit-log-sidebar-header span {
252
+ font-size: 0.85rem;
253
+ color: var(--visit-muted);
254
+ }
255
+
256
+ .visit-log-visitor-list {
257
+ overflow-y: auto;
258
+ padding-right: 6px;
259
+ display: grid;
260
+ gap: 10px;
261
+ }
262
+
263
+ .visit-log-visitor {
264
+ text-align: left;
265
+ border: 1px solid #eddac2;
266
+ border-radius: 14px;
267
+ padding: 12px;
268
+ background: #fff;
269
+ display: grid;
270
+ gap: 6px;
271
+ cursor: pointer;
272
+ transition: border 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
273
+ }
274
+
275
+ .visit-log-visitor strong {
276
+ font-size: 0.95rem;
277
+ font-weight: 600;
278
+ }
279
+
280
+ .visit-log-visitor span {
281
+ font-size: 0.8rem;
282
+ color: var(--visit-muted);
283
+ }
284
+
285
+ .visit-log-visitor:hover {
286
+ transform: translateY(-2px);
287
+ box-shadow: 0 10px 18px rgba(30, 24, 16, 0.08);
288
+ }
289
+
290
+ .visit-log-visitor--active {
291
+ border-color: var(--visit-accent);
292
+ box-shadow: 0 10px 20px rgba(201, 116, 58, 0.18);
293
+ }
294
+
295
+ .visit-log-map-panel {
296
+ background: #fff;
297
+ border-radius: 18px;
298
+ padding: 16px;
299
+ box-shadow: 0 12px 22px rgba(30, 24, 16, 0.08);
300
+ border: 1px solid #f0e2cd;
301
+ display: grid;
302
+ gap: 12px;
303
+ }
304
+
305
+ .visit-log-map {
306
+ width: 100%;
307
+ min-height: 420px;
308
+ border-radius: 14px;
309
+ overflow: hidden;
310
+ }
311
+
312
+ .visit-log-map-note {
313
+ font-size: 0.85rem;
314
+ color: var(--visit-muted);
315
+ }
316
+
317
+ @media (max-width: 720px) {
318
+ .visit-log-form {
319
+ grid-template-columns: 1fr;
320
+ }
321
+
322
+ .visit-log-layout {
323
+ grid-template-columns: 1fr;
324
+ }
325
+
326
+ .visit-log-sidebar {
327
+ max-height: none;
328
+ }
329
+ }
client/src/pages/VisitLog.jsx ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
+ import "./VisitLog.css";
3
+
4
+ const EMPTY_STATE = [];
5
+
6
+ function formatTimestamp(value) {
7
+ if (!value) return "Unknown time";
8
+ const date = new Date(value);
9
+ if (Number.isNaN(date.getTime())) return value;
10
+ return date.toLocaleString();
11
+ }
12
+
13
+ function formatLocation(location) {
14
+ if (!location || typeof location.lat !== "number") {
15
+ return "Unavailable";
16
+ }
17
+ const lat = location.lat.toFixed(4);
18
+ const lon = location.lon.toFixed(4);
19
+ const accuracy = location.accuracyMeters
20
+ ? `${Math.round(location.accuracyMeters)} m`
21
+ : "Unknown";
22
+ return `${lat}, ${lon} (+/-${accuracy})`;
23
+ }
24
+
25
+ function loadGoogleMaps(apiKey) {
26
+ if (typeof window === "undefined") {
27
+ return Promise.reject(new Error("Window not available"));
28
+ }
29
+ if (window.google && window.google.maps) {
30
+ return Promise.resolve(window.google);
31
+ }
32
+
33
+ return new Promise((resolve, reject) => {
34
+ const existing = document.querySelector("script[data-google-maps]");
35
+ if (existing) {
36
+ existing.addEventListener("load", () => resolve(window.google));
37
+ existing.addEventListener("error", () =>
38
+ reject(new Error("Failed to load Google Maps"))
39
+ );
40
+ return;
41
+ }
42
+
43
+ const script = document.createElement("script");
44
+ script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}`;
45
+ script.async = true;
46
+ script.defer = true;
47
+ script.dataset.googleMaps = "true";
48
+ script.addEventListener("load", () => resolve(window.google));
49
+ script.addEventListener("error", () =>
50
+ reject(new Error("Failed to load Google Maps"))
51
+ );
52
+ document.head.appendChild(script);
53
+ });
54
+ }
55
+
56
+ export default function VisitLog() {
57
+ const [visitorId, setVisitorId] = useState("");
58
+ const [selectedVisitorId, setSelectedVisitorId] = useState("");
59
+ const [visits, setVisits] = useState(EMPTY_STATE);
60
+ const [visitors, setVisitors] = useState(EMPTY_STATE);
61
+ const [loading, setLoading] = useState(false);
62
+ const [error, setError] = useState("");
63
+ const [mapError, setMapError] = useState("");
64
+ const mapContainerRef = useRef(null);
65
+ const mapRef = useRef(null);
66
+ const markersRef = useRef([]);
67
+ const pendingFocusRef = useRef("");
68
+ const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY;
69
+
70
+ const visitCount = visits.length;
71
+ const lastSeen = useMemo(() => {
72
+ if (!visitCount) return "No visits yet";
73
+ return formatTimestamp(visits[0].ts);
74
+ }, [visitCount, visits]);
75
+
76
+ const fetchVisits = async (targetName) => {
77
+ if (!targetName) return;
78
+ setLoading(true);
79
+ setError("");
80
+ try {
81
+ const response = await fetch(
82
+ `/api/visits?username=${encodeURIComponent(targetName)}`
83
+ );
84
+ const payload = await response.json();
85
+ if (!response.ok) {
86
+ throw new Error(payload.message || "Failed to load visits");
87
+ }
88
+ setVisits(Array.isArray(payload.visits) ? payload.visits : []);
89
+ } catch (err) {
90
+ setError(err.message || "Failed to load visits");
91
+ setVisits(EMPTY_STATE);
92
+ } finally {
93
+ setLoading(false);
94
+ }
95
+ };
96
+
97
+ const fetchVisitors = async () => {
98
+ try {
99
+ const response = await fetch("/api/visitors");
100
+ const payload = await response.json();
101
+ if (!response.ok) {
102
+ throw new Error(payload.message || "Failed to load visitors");
103
+ }
104
+ setVisitors(Array.isArray(payload.visitors) ? payload.visitors : []);
105
+ } catch (err) {
106
+ setMapError(err.message || "Failed to load visitors");
107
+ }
108
+ };
109
+
110
+ const handleSelectVisitor = (targetName) => {
111
+ if (!targetName) return;
112
+ setVisitorId(targetName);
113
+ setSelectedVisitorId(targetName);
114
+ fetchVisits(targetName);
115
+ pendingFocusRef.current = targetName;
116
+ focusMapOnVisitor(targetName);
117
+ };
118
+
119
+ const focusMapOnVisitor = (targetName) => {
120
+ if (!mapRef.current || !window.google || !targetName) {
121
+ return;
122
+ }
123
+ const match = visitors.find(
124
+ (visitor) =>
125
+ visitor.username === targetName || visitor.visitorId === targetName
126
+ );
127
+ const location = match?.location;
128
+ if (!location || typeof location.lat !== "number") {
129
+ return;
130
+ }
131
+ const position = { lat: location.lat, lng: location.lon };
132
+ mapRef.current.panTo(position);
133
+ mapRef.current.setZoom(8);
134
+ };
135
+
136
+ useEffect(() => {
137
+ const params = new URLSearchParams(window.location.search);
138
+ const fromQuery = params.get("username") || "";
139
+ if (fromQuery) {
140
+ handleSelectVisitor(fromQuery);
141
+ }
142
+ }, []);
143
+
144
+ const handleSubmit = (event) => {
145
+ event.preventDefault();
146
+ handleSelectVisitor(visitorId.trim());
147
+ };
148
+
149
+ useEffect(() => {
150
+ document.body.classList.add("visit-log-body");
151
+ return () => {
152
+ document.body.classList.remove("visit-log-body");
153
+ };
154
+ }, []);
155
+
156
+ useEffect(() => {
157
+ fetchVisitors();
158
+ }, []);
159
+
160
+ useEffect(() => {
161
+ if (!apiKey) {
162
+ setMapError("Missing Google Maps API key.");
163
+ return;
164
+ }
165
+
166
+ let cancelled = false;
167
+ loadGoogleMaps(apiKey)
168
+ .then(() => {
169
+ if (cancelled) return;
170
+ if (!mapRef.current && mapContainerRef.current) {
171
+ mapRef.current = new window.google.maps.Map(
172
+ mapContainerRef.current,
173
+ {
174
+ center: { lat: 20, lng: 0 },
175
+ zoom: 2,
176
+ mapTypeControl: false,
177
+ streetViewControl: false,
178
+ fullscreenControl: false,
179
+ }
180
+ );
181
+ }
182
+ if (pendingFocusRef.current) {
183
+ focusMapOnVisitor(pendingFocusRef.current);
184
+ }
185
+ })
186
+ .catch((err) => {
187
+ if (!cancelled) {
188
+ setMapError(err.message || "Failed to load Google Maps");
189
+ }
190
+ });
191
+
192
+ return () => {
193
+ cancelled = true;
194
+ };
195
+ }, [apiKey]);
196
+
197
+ useEffect(() => {
198
+ if (!mapRef.current || !window.google) {
199
+ return;
200
+ }
201
+
202
+ markersRef.current.forEach((marker) => marker.setMap(null));
203
+ markersRef.current = [];
204
+
205
+ const bounds = new window.google.maps.LatLngBounds();
206
+ let hasMarker = false;
207
+
208
+ visitors.forEach((visitor) => {
209
+ const location = visitor.location;
210
+ if (!location || typeof location.lat !== "number") {
211
+ return;
212
+ }
213
+ const position = { lat: location.lat, lng: location.lon };
214
+ const marker = new window.google.maps.Marker({
215
+ position,
216
+ map: mapRef.current,
217
+ title: visitor.visitorId,
218
+ });
219
+ marker.addListener("click", () => handleSelectVisitor(visitor.visitorId));
220
+ markersRef.current.push(marker);
221
+ bounds.extend(position);
222
+ hasMarker = true;
223
+ });
224
+
225
+ if (hasMarker) {
226
+ mapRef.current.fitBounds(bounds);
227
+ } else {
228
+ mapRef.current.setCenter({ lat: 20, lng: 0 });
229
+ mapRef.current.setZoom(2);
230
+ }
231
+ if (pendingFocusRef.current) {
232
+ focusMapOnVisitor(pendingFocusRef.current);
233
+ }
234
+ }, [visitors]);
235
+
236
+ useEffect(() => {
237
+ if (!selectedVisitorId) {
238
+ return;
239
+ }
240
+ focusMapOnVisitor(selectedVisitorId);
241
+ }, [selectedVisitorId, visitors]);
242
+
243
+ return (
244
+ <div className="visit-log-page">
245
+ <header className="visit-log-header">
246
+ <div>
247
+ <p className="visit-log-eyebrow">Visitor Insights</p>
248
+ <h1>Visit Timeline</h1>
249
+ <p className="visit-log-subtitle">
250
+ Track visits for a specific visitor and review model selections with
251
+ coarse location context.
252
+ </p>
253
+ </div>
254
+ <a className="visit-log-back" href="/">
255
+ Back to 3D Viewer
256
+ </a>
257
+ </header>
258
+
259
+ <section className="visit-log-card">
260
+ <form className="visit-log-form" onSubmit={handleSubmit}>
261
+ <div>
262
+ <label htmlFor="visitorId">Visitor ID</label>
263
+ <input
264
+ id="visitorId"
265
+ value={visitorId}
266
+ onChange={(event) => setVisitorId(event.target.value)}
267
+ placeholder="v_..."
268
+ />
269
+ </div>
270
+ <button type="submit" disabled={!visitorId || loading}>
271
+ {loading ? "Loading..." : "Load Visits"}
272
+ </button>
273
+ </form>
274
+
275
+ <div className="visit-log-stats">
276
+ <div>
277
+ <span>Total Visits</span>
278
+ <strong>{visitCount}</strong>
279
+ </div>
280
+ <div>
281
+ <span>Last Seen</span>
282
+ <strong>{lastSeen}</strong>
283
+ </div>
284
+ </div>
285
+ </section>
286
+
287
+ {error && <p className="visit-log-error">{error}</p>}
288
+ {mapError && <p className="visit-log-error">{mapError}</p>}
289
+
290
+ <section className="visit-log-layout">
291
+ <aside className="visit-log-sidebar">
292
+ <div className="visit-log-sidebar-header">
293
+ <h2>Visitors</h2>
294
+ <span>{visitors.length} total</span>
295
+ </div>
296
+ <div className="visit-log-visitor-list">
297
+ {visitors.length === 0 && (
298
+ <p className="visit-log-empty">
299
+ No visitors found. Make sure the visit log has data.
300
+ </p>
301
+ )}
302
+ {visitors.map((visitor) => (
303
+ <button
304
+ key={visitor.visitorId}
305
+ className={
306
+ visitor.username === selectedVisitorId
307
+ ? "visit-log-visitor visit-log-visitor--active"
308
+ : "visit-log-visitor"
309
+ }
310
+ type="button"
311
+ onClick={() => handleSelectVisitor(visitor.username || visitor.visitorId)}
312
+ >
313
+ <strong>{visitor.username || visitor.visitorId}</strong>
314
+ <span>{formatTimestamp(visitor.lastSeen)}</span>
315
+ </button>
316
+ ))}
317
+ </div>
318
+ </aside>
319
+
320
+ <div className="visit-log-map-panel">
321
+ <div className="visit-log-map" ref={mapContainerRef} />
322
+ <div className="visit-log-map-note">
323
+ Click a marker or visitor to view their history.
324
+ </div>
325
+ </div>
326
+ </section>
327
+
328
+ <section className="visit-log-grid">
329
+ {visitCount === 0 && !loading && !error && (
330
+ <div className="visit-log-empty">
331
+ No visits yet. Enter a visitor ID to view activity.
332
+ </div>
333
+ )}
334
+ {visits.map((visit, index) => (
335
+ <article className="visit-log-entry" key={`${visit.ts}-${index}`}>
336
+ <div className="visit-log-entry-header">
337
+ <h2>{formatTimestamp(visit.ts)}</h2>
338
+ <span className="visit-log-entry-path">{visit.path || "/"}</span>
339
+ </div>
340
+ <div className="visit-log-entry-body">
341
+ <div>
342
+ <span>User</span>
343
+ <strong>{visit.username || visit.visitorId || "Unknown"}</strong>
344
+ </div>
345
+ <div>
346
+ <span>Model</span>
347
+ <strong>{visit.model?.name || visit.model?.id || "Unknown"}</strong>
348
+ </div>
349
+ <div>
350
+ <span>Tileset</span>
351
+ <strong>{visit.model?.tileset || "Unknown"}</strong>
352
+ </div>
353
+ <div>
354
+ <span>Offset Height</span>
355
+ <strong>
356
+ {typeof visit.model?.offsetHeight === "number"
357
+ ? visit.model.offsetHeight
358
+ : "Unknown"}
359
+ </strong>
360
+ </div>
361
+ <div>
362
+ <span>Coarse Location</span>
363
+ <strong>{formatLocation(visit.location)}</strong>
364
+ </div>
365
+ </div>
366
+ </article>
367
+ ))}
368
+ </section>
369
+ </div>
370
+ );
371
+ }
client/src/utils/awsConfig.js ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * AWS Configuration for Cognito-based S3 access
3
+ *
4
+ * This module handles AWS credentials through Cognito Identity Provider.
5
+ * No AWS credentials are stored in code or exposed to the client.
6
+ *
7
+ * Environment Variables Required:
8
+ * - VITE_AWS_REGION: AWS region (e.g., "us-east-2")
9
+ * - VITE_AWS_COGNITO_IDENTITY_POOL_ID: Cognito Identity Pool ID (e.g., "us-east-2:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx")
10
+ * - VITE_S3_BUCKET: Default S3 bucket name (e.g., "ddy-first-bucket")
11
+ */
12
+
13
+ import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
14
+ import { fromCognitoIdentityPool } from "@aws-sdk/credential-provider-cognito-identity";
15
+ import { CognitoIdentityClient } from "@aws-sdk/client-cognito-identity";
16
+
17
+ export const AWS_REGION = "us-east-2";
18
+ // Cognito pool ID is safe to embed in client code (not a secret)
19
+ export const COGNITO_IDENTITY_POOL_ID = "us-east-2:1387102d-5a87-4742-8a69-1baa70f37984";
20
+ export const DEFAULT_BUCKET = "ddy-first-bucket";
21
+
22
+ /**
23
+ * Initialize S3 client with Cognito credentials
24
+ * Uses unauthenticated Cognito Identity pool for public S3 access
25
+ */
26
+ export function getS3Client() {
27
+ const credentialsProvider = fromCognitoIdentityPool({
28
+ client: new CognitoIdentityClient({ region: AWS_REGION }),
29
+ identityPoolId: COGNITO_IDENTITY_POOL_ID,
30
+ });
31
+
32
+ return new S3Client({
33
+ region: AWS_REGION,
34
+ credentials: credentialsProvider,
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Get S3 object as text (for JSON tilesets, metadata, etc.)
40
+ */
41
+ export async function getS3ObjectAsText(bucket, key) {
42
+ const s3Client = getS3Client();
43
+ if (!s3Client) {
44
+ throw new Error("S3 client not initialized");
45
+ }
46
+
47
+ try {
48
+ const command = new GetObjectCommand({
49
+ Bucket: bucket,
50
+ Key: key,
51
+ });
52
+
53
+ const response = await s3Client.send(command);
54
+ const text = await response.Body.transformToString();
55
+ return text;
56
+ } catch (error) {
57
+ console.error(`Failed to fetch S3 object s3://${bucket}/${key}:`, error);
58
+ throw error;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Get S3 object as JSON
64
+ */
65
+ export async function getS3ObjectAsJson(bucket, key) {
66
+ const text = await getS3ObjectAsText(bucket, key);
67
+ return JSON.parse(text);
68
+ }
69
+
70
+ /**
71
+ * Build a direct S3 URL for tile assets
72
+ * Note: This URL requires proper CORS configuration on S3 bucket
73
+ * or the bucket must be public with proper authentication
74
+ */
75
+ export function buildS3Url(bucket, key) {
76
+ return `https://${bucket}.s3.${AWS_REGION}.amazonaws.com/${key}`;
77
+ }
78
+
79
+ /**
80
+ * Rewrite URLs in a tileset JSON to use proper S3 paths
81
+ * Handles both relative and absolute URLs
82
+ */
83
+ export function rewriteTilesetUrls(tilesetJson, bucket, basePath) {
84
+ const rewrite = (obj) => {
85
+ if (!obj || typeof obj !== "object") return;
86
+
87
+ if (Array.isArray(obj)) {
88
+ obj.forEach(rewrite);
89
+ return;
90
+ }
91
+
92
+ Object.keys(obj).forEach((key) => {
93
+ const value = obj[key];
94
+
95
+ // Rewrite uri/url fields to point to S3
96
+ if ((key === "uri" || key === "url") && typeof value === "string") {
97
+ // Skip external URLs
98
+ if (value.startsWith("http://") || value.startsWith("https://")) {
99
+ return;
100
+ }
101
+
102
+ // Skip data URLs
103
+ if (value.startsWith("data:")) {
104
+ return;
105
+ }
106
+
107
+ // Build S3 URL for relative paths
108
+ const relativePath = value.startsWith("/") ? value.slice(1) : value;
109
+ const s3Key = basePath
110
+ ? `${basePath}/${relativePath}`.replace(/\/+/g, "/")
111
+ : relativePath;
112
+
113
+ obj[key] = buildS3Url(bucket, s3Key);
114
+ }
115
+
116
+ // Recursively rewrite nested objects
117
+ if (typeof value === "object") {
118
+ rewrite(value);
119
+ }
120
+ });
121
+ };
122
+
123
+ rewrite(tilesetJson);
124
+ return tilesetJson;
125
+ }
126
+
127
+ /**
128
+ * Load a tileset JSON file from S3 and rewrite its URLs
129
+ */
130
+ export async function loadTilesetFromS3(bucket, tilesetPath, basePath) {
131
+ try {
132
+ const tilesetJson = await getS3ObjectAsJson(bucket, tilesetPath);
133
+ return rewriteTilesetUrls(tilesetJson, bucket, basePath);
134
+ } catch (error) {
135
+ console.error(`Failed to load tileset from S3:`, error);
136
+ throw error;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Get default S3 bucket name
142
+ */
143
+ export function getDefaultBucket() {
144
+ return DEFAULT_BUCKET;
145
+ }
146
+
147
+ /**
148
+ * Get AWS region
149
+ */
150
+ export function getAwsRegion() {
151
+ return AWS_REGION;
152
+ }
153
+
154
+ /**
155
+ * Validate AWS configuration
156
+ */
157
+ export function validateAwsConfig() {
158
+ // Configuration is hardcoded, always valid
159
+ return true;
160
+ }
client/src/utils/visitorTracking.js ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const VISITOR_ID_KEY = "gda.visitorId";
2
+ const VISITOR_HISTORY_KEY = "gda.visitorHistory";
3
+ const VISITOR_COOKIE = "gda_vid";
4
+ const SESSION_MARK_KEY = "gda.visitRecorded";
5
+ const LOCATION_CACHE_KEY = "gda.coarseLocation";
6
+
7
+ function generateVisitorId() {
8
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
9
+ return crypto.randomUUID();
10
+ }
11
+
12
+ const random = Math.random().toString(36).slice(2);
13
+ const time = Date.now().toString(36);
14
+ return `v_${time}_${random}`;
15
+ }
16
+
17
+ function getOrCreateVisitorId() {
18
+ try {
19
+ const existing = localStorage.getItem(VISITOR_ID_KEY);
20
+ if (existing) {
21
+ return existing;
22
+ }
23
+
24
+ const id = generateVisitorId();
25
+ localStorage.setItem(VISITOR_ID_KEY, id);
26
+ return id;
27
+ } catch {
28
+ return generateVisitorId();
29
+ }
30
+ }
31
+
32
+ function markCookie(visitorId) {
33
+ if (typeof document === "undefined") {
34
+ return;
35
+ }
36
+
37
+ document.cookie = `${VISITOR_COOKIE}=${visitorId}; Max-Age=31536000; Path=/; SameSite=Lax`;
38
+ }
39
+
40
+ function shouldRecordVisit() {
41
+ try {
42
+ if (sessionStorage.getItem(SESSION_MARK_KEY)) {
43
+ return false;
44
+ }
45
+
46
+ sessionStorage.setItem(SESSION_MARK_KEY, "1");
47
+ return true;
48
+ } catch {
49
+ return true;
50
+ }
51
+ }
52
+
53
+ export function getVisitorId() {
54
+ if (typeof window === "undefined") {
55
+ return null;
56
+ }
57
+
58
+ const visitorId = getOrCreateVisitorId();
59
+ markCookie(visitorId);
60
+ return visitorId;
61
+ }
62
+
63
+ export function recordVisit() {
64
+ if (typeof window === "undefined") {
65
+ return null;
66
+ }
67
+
68
+ if (!shouldRecordVisit()) {
69
+ return getVisitorId();
70
+ }
71
+
72
+ const visitorId = getVisitorId();
73
+
74
+ const entry = {
75
+ id: visitorId,
76
+ ts: new Date().toISOString(),
77
+ path: `${window.location.pathname}${window.location.search}${window.location.hash}`,
78
+ };
79
+
80
+ try {
81
+ const raw = localStorage.getItem(VISITOR_HISTORY_KEY);
82
+ const history = raw ? JSON.parse(raw) : [];
83
+ history.push(entry);
84
+
85
+ const trimmed = history.slice(-50);
86
+ localStorage.setItem(VISITOR_HISTORY_KEY, JSON.stringify(trimmed));
87
+ } catch {
88
+ // If storage is unavailable, we silently skip persistence.
89
+ }
90
+
91
+ return visitorId;
92
+ }
93
+
94
+ export async function sendVisitToServer({
95
+ visitorId,
96
+ model,
97
+ path,
98
+ location,
99
+ } = {}) {
100
+ // Client-only tracking - visits are stored in localStorage only
101
+ // For GitHub Pages deployment, server endpoint is not available
102
+ // Implement alternative: send to a serverless service (Lambda, Firebase, etc.) if needed
103
+
104
+ if (typeof window === "undefined") {
105
+ return;
106
+ }
107
+
108
+ const resolvedVisitorId = visitorId || getVisitorId();
109
+ if (!resolvedVisitorId) {
110
+ return;
111
+ }
112
+
113
+ const payload = {
114
+ visitorId: resolvedVisitorId,
115
+ model,
116
+ location,
117
+ path:
118
+ path ||
119
+ `${window.location.pathname}${window.location.search}${window.location.hash}`,
120
+ ts: new Date().toISOString(),
121
+ };
122
+
123
+ // Store visit in localStorage for client-side analytics
124
+ try {
125
+ const raw = localStorage.getItem(VISITOR_HISTORY_KEY);
126
+ const history = raw ? JSON.parse(raw) : [];
127
+ history.push(payload);
128
+
129
+ // Keep last 100 visits
130
+ const trimmed = history.slice(-100);
131
+ localStorage.setItem(VISITOR_HISTORY_KEY, JSON.stringify(trimmed));
132
+ } catch {
133
+ // Ignore storage errors
134
+ }
135
+
136
+ // TODO: For production, consider sending to a serverless backend:
137
+ // - AWS Lambda + API Gateway
138
+ // - Firebase Cloud Functions
139
+ // - Vercel Edge Functions
140
+ // - Or use a third-party analytics service (Google Analytics, Plausible, etc.)
141
+ //
142
+ // Example with Firebase:
143
+ // const response = await fetch(
144
+ // 'https://your-firebase-project.cloudfunctions.net/recordVisit',
145
+ // {
146
+ // method: 'POST',
147
+ // headers: { 'Content-Type': 'application/json' },
148
+ // body: JSON.stringify(payload),
149
+ // }
150
+ // ).catch(() => {}); // Ignore network errors
151
+ }
152
+
153
+ export function getCoarseLocation() {
154
+ if (typeof window === "undefined") {
155
+ return Promise.resolve(null);
156
+ }
157
+
158
+ try {
159
+ const cached = sessionStorage.getItem(LOCATION_CACHE_KEY);
160
+ if (cached) {
161
+ return Promise.resolve(JSON.parse(cached));
162
+ }
163
+ } catch {
164
+ // Ignore cache errors and fall back to live lookup.
165
+ }
166
+
167
+ if (!("geolocation" in navigator)) {
168
+ return Promise.resolve(null);
169
+ }
170
+
171
+ return new Promise((resolve) => {
172
+ navigator.geolocation.getCurrentPosition(
173
+ (position) => {
174
+ const location = {
175
+ lat: position.coords.latitude,
176
+ lon: position.coords.longitude,
177
+ accuracyMeters: position.coords.accuracy,
178
+ };
179
+ try {
180
+ sessionStorage.setItem(LOCATION_CACHE_KEY, JSON.stringify(location));
181
+ } catch {
182
+ // Ignore cache errors.
183
+ }
184
+ resolve(location);
185
+ },
186
+ () => resolve(null),
187
+ {
188
+ enableHighAccuracy: false,
189
+ maximumAge: 10 * 60 * 1000,
190
+ timeout: 8000,
191
+ }
192
+ );
193
+ });
194
+ }
client/vite.config.js ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import cesium from 'vite-plugin-cesium';
4
+
5
+ // https://vite.dev/config/
6
+ export default defineConfig(({ command }) => ({
7
+ // Use absolute base for dev server, relative base for GitHub Pages build
8
+ base: command === 'serve' ? '/' : './',
9
+
10
+ plugins: [react(), cesium()],
11
+
12
+ server: {
13
+ port: 5173,
14
+ // Development server config - no proxies needed
15
+ // All S3 requests handled client-side through AWS SDK
16
+ },
17
+
18
+ publicDir: 'public',
19
+
20
+ build: {
21
+ // Optimize for production
22
+ target: 'esnext',
23
+ minify: 'terser',
24
+ sourcemap: false, // Set to true for debugging production builds
25
+ }
26
+ }))
server/main.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import asyncio
3
+ import uuid
4
+ import aiofiles
5
+ from pathlib import Path
6
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException
7
+ from fastapi.staticfiles import StaticFiles
8
+ from fastapi.responses import FileResponse
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+
11
+ app = FastAPI()
12
+
13
+ # Enable CORS
14
+ app.add_middleware(
15
+ CORSMiddleware,
16
+ allow_origins=["*"],
17
+ allow_credentials=True,
18
+ allow_methods=["*"],
19
+ allow_headers=["*"],
20
+ )
21
+
22
+ # Configuration
23
+ DATA_DIR = Path("/app/data")
24
+ UPLOAD_DIR = DATA_DIR / "uploads"
25
+ CONVERTED_DIR = DATA_DIR / "converted"
26
+ STATIC_DIR = Path("/app/static")
27
+ BINARY_PATH = "/usr/local/bin/SplatTiler"
28
+
29
+ # Ensure directories exist
30
+ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
31
+ CONVERTED_DIR.mkdir(parents=True, exist_ok=True)
32
+
33
+ @app.post("/api/upload")
34
+ async def upload_ply(
35
+ file: UploadFile = File(...),
36
+ lat: float = Form(0.0),
37
+ lon: float = Form(0.0)
38
+ ):
39
+ if not file.filename.lower().endswith(('.ply', '.splat')):
40
+ raise HTTPException(status_code=400, detail="Only .ply or .splat files are supported")
41
+
42
+ job_id = str(uuid.uuid4())
43
+ job_dir = CONVERTED_DIR / job_id
44
+ job_dir.mkdir(parents=True, exist_ok=True)
45
+
46
+ input_path = UPLOAD_DIR / f"{job_id}_{file.filename}"
47
+
48
+ try:
49
+ # Save uploaded file
50
+ async with aiofiles.open(input_path, "wb") as out_file:
51
+ while content := await file.read(1024 * 1024): # Read in 1MB chunks
52
+ await out_file.write(content)
53
+
54
+ # Run conversion
55
+ # Usage: SplatTiler -o <output_directory> -m 500000 [--lat <val> --lon <val>] <input_file>
56
+ print(f"Starting conversion for {job_id}...")
57
+
58
+ cmd = [BINARY_PATH, "-o", str(job_dir), "-m", "500000"]
59
+ if lat != 0.0 or lon != 0.0:
60
+ cmd.extend(["--lat", str(lat), "--lon", str(lon)])
61
+ cmd.append(str(input_path))
62
+
63
+ process = await asyncio.create_subprocess_exec(
64
+ *cmd,
65
+ stdout=asyncio.subprocess.PIPE,
66
+ stderr=asyncio.subprocess.PIPE
67
+ )
68
+ stdout, stderr = await process.communicate()
69
+
70
+ if process.returncode != 0:
71
+ error_msg = stderr.decode() if stderr else "Unknown error"
72
+ print(f"Conversion failed: {error_msg}")
73
+ raise HTTPException(status_code=500, detail=f"Conversion failed: {error_msg}")
74
+
75
+ print(f"Conversion successful for {job_id}")
76
+ return {
77
+ "jobId": job_id,
78
+ "tilesetUrl": f"/data/converted/{job_id}/tileset.json"
79
+ }
80
+
81
+ except Exception as e:
82
+ print(f"Error processing file: {e}")
83
+ raise HTTPException(status_code=500, detail=str(e))
84
+ finally:
85
+ # Cleanup input file to save space
86
+ if input_path.exists():
87
+ os.remove(input_path)
88
+
89
+ # Mount converted data for access by Cesium
90
+ app.mount("/data/converted", StaticFiles(directory=str(CONVERTED_DIR)), name="converted")
91
+
92
+ # Serve React App
93
+ app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="assets")
94
+
95
+ @app.get("/{full_path:path}")
96
+ async def serve_react_app(full_path: str):
97
+ file_path = STATIC_DIR / full_path
98
+ if file_path.exists() and file_path.is_file():
99
+ return FileResponse(file_path)
100
+ return FileResponse(STATIC_DIR / "index.html")
server/requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ python-multipart
4
+ aiofiles