Spaces:
Running
Running
initial file uploading
Browse files- .dockerignore +10 -0
- .gitignore +35 -0
- Dockerfile +59 -0
- SplatTiler/CMakeLists.txt +25 -0
- SplatTiler/SplatTiler.cpp +647 -0
- SplatTiler/SplatTiler.h +44 -0
- SplatTiler/main.cpp +166 -0
- SplatTiler/spz/load-spz.cc +936 -0
- SplatTiler/spz/load-spz.h +101 -0
- SplatTiler/spz/splat-c-types.cc +3 -0
- SplatTiler/spz/splat-c-types.h +28 -0
- SplatTiler/spz/splat-types.cc +98 -0
- SplatTiler/spz/splat-types.h +266 -0
- client/.env.development +4 -0
- client/.env.example +25 -0
- client/.env.production +4 -0
- client/README.md +16 -0
- client/index.html +13 -0
- client/package-lock.json +0 -0
- client/package.json +40 -0
- client/src/App.css +42 -0
- client/src/App.jsx +28 -0
- client/src/assets/react.svg +1 -0
- client/src/cesiumSetup.js +2 -0
- client/src/components/CesiumViewer.jsx +501 -0
- client/src/components/MeasurePanel.jsx +361 -0
- client/src/index.css +68 -0
- client/src/main.jsx +11 -0
- client/src/pages/Home.jsx +357 -0
- client/src/pages/VisitLog.css +329 -0
- client/src/pages/VisitLog.jsx +371 -0
- client/src/utils/awsConfig.js +160 -0
- client/src/utils/visitorTracking.js +194 -0
- client/vite.config.js +26 -0
- server/main.py +100 -0
- server/requirements.txt +4 -0
.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
|