diff --git a/app/InspectorControls.tsx b/app/InspectorControls.tsx
new file mode 100644
index 00000000..c0a5ade0
--- /dev/null
+++ b/app/InspectorControls.tsx
@@ -0,0 +1,50 @@
+import { getResourceList } from "@/src/manifest";
+
+const excludeMissions = new Set([
+ "SkiFree",
+ "SkiFree_Daily",
+ "SkiFree_Randomizer",
+]);
+
+const missions = getResourceList()
+ .map((resourcePath) => resourcePath.match(/^missions\/(.+)\.mis$/))
+ .filter(Boolean)
+ .map((match) => match[1])
+ .filter((name) => !excludeMissions.has(name));
+
+export function InspectorControls({
+ missionName,
+ onChangeMission,
+ fogEnabled,
+ onChangeFogEnabled,
+}: {
+ missionName: string;
+ onChangeMission: (name: string) => void;
+ fogEnabled: boolean;
+ onChangeFogEnabled: (enabled: boolean) => void;
+}) {
+ return (
+
+
+
+ {
+ onChangeFogEnabled(event.target.checked);
+ }}
+ />
+
+
+
+ );
+}
diff --git a/app/InteriorInstance.tsx b/app/InteriorInstance.tsx
index e69de29b..8f212968 100644
--- a/app/InteriorInstance.tsx
+++ b/app/InteriorInstance.tsx
@@ -0,0 +1,91 @@
+import { useGLTF, useTexture } from "@react-three/drei";
+import { BASE_URL, interiorTextureToUrl, interiorToUrl } from "@/src/loaders";
+import {
+ ConsoleObject,
+ getPosition,
+ getProperty,
+ getRotation,
+ getScale,
+} from "@/src/mission";
+import { Suspense, useMemo } from "react";
+import { Material, Mesh } from "three";
+import { setupColor } from "@/src/textureUtils";
+
+const FALLBACK_URL = `${BASE_URL}/black.png`;
+
+function useInterior(interiorFile: string) {
+ const url = interiorToUrl(interiorFile);
+ return useGLTF(url);
+}
+
+function InteriorTexture({ material }: { material: Material }) {
+ let url = FALLBACK_URL;
+ try {
+ url = interiorTextureToUrl(material.name);
+ } catch (err) {
+ console.error(err);
+ }
+
+ const texture = useTexture(url, (texture) => setupColor(texture));
+
+ return ;
+}
+
+function InteriorMesh({ node }: { node: Mesh }) {
+ return (
+
+ {node.material ? (
+
+ }
+ >
+
+
+ ) : null}
+
+ );
+}
+
+export function InteriorModel({ interiorFile }: { interiorFile: string }) {
+ const { nodes } = useInterior(interiorFile);
+
+ return (
+ <>
+ {Object.entries(nodes)
+ // .filter(
+ // ([name, node]: [string, any]) => true
+ // // !node.material || !node.material.name.match(/\.\d+$/)
+ // )
+ .map(([name, node]: [string, any]) => (
+
+ ))}
+ >
+ );
+}
+
+function InteriorPlaceholder() {
+ return (
+
+
+
+
+ );
+}
+
+export function InteriorInstance({ object }: { object: ConsoleObject }) {
+ const interiorFile = getProperty(object, "interiorFile").value;
+ const [z, y, x] = useMemo(() => getPosition(object), [object]);
+ const [scaleX, scaleY, scaleZ] = useMemo(() => getScale(object), [object]);
+ const q = useMemo(() => getRotation(object, true), [object]);
+
+ return (
+
+ }>
+
+
+
+ );
+}
diff --git a/app/Mission.tsx b/app/Mission.tsx
new file mode 100644
index 00000000..4df47d59
--- /dev/null
+++ b/app/Mission.tsx
@@ -0,0 +1,31 @@
+import { loadMission } from "@/src/loaders";
+import { useQuery } from "@tanstack/react-query";
+import { renderObject } from "./renderObject";
+
+function useMission(name: string) {
+ return useQuery({
+ queryKey: ["mission", name],
+ queryFn: () => loadMission(name),
+ });
+}
+
+const DEFAULT_LIGHT_ARGS = [
+ "rgba(209, 237, 255, 1)",
+ "rgba(186, 200, 181, 1)",
+ 2,
+] as const;
+
+export function Mission({ name }: { name: string }) {
+ const { data: mission } = useMission(name);
+
+ if (!mission) {
+ return null;
+ }
+
+ return (
+ <>
+
+ {mission.objects.map((object, i) => renderObject(object, i))}
+ >
+ );
+}
diff --git a/app/ObserverControls.tsx b/app/ObserverControls.tsx
new file mode 100644
index 00000000..d5d407b7
--- /dev/null
+++ b/app/ObserverControls.tsx
@@ -0,0 +1,95 @@
+import {
+ KeyboardControls,
+ KeyboardControlsEntry,
+ Point,
+ PointerLockControls,
+} from "@react-three/drei";
+
+import { useRef } from "react";
+import { useFrame, useThree } from "@react-three/fiber";
+import { useKeyboardControls } from "@react-three/drei";
+import * as THREE from "three";
+
+enum Controls {
+ forward = "forward",
+ backward = "backward",
+ left = "left",
+ right = "right",
+ up = "up",
+ down = "down",
+}
+
+const BASE_SPEED = 100; // units per second
+
+function CameraMovement() {
+ const [subscribe, getKeys] = useKeyboardControls();
+ const { camera } = useThree();
+
+ // Scratch vectors to avoid allocations each frame
+ const forwardVec = useRef(new THREE.Vector3());
+ const sideVec = useRef(new THREE.Vector3());
+ const moveVec = useRef(new THREE.Vector3());
+
+ useFrame((_, delta) => {
+ const { forward, backward, left, right, up, down } = getKeys();
+
+ if (!forward && !backward && !left && !right && !up && !down) {
+ return;
+ }
+
+ const speed = BASE_SPEED;
+
+ // Forward/backward: take complete camera angle into account (including Y)
+ camera.getWorldDirection(forwardVec.current);
+ forwardVec.current.normalize();
+
+ // Left/right: move along XZ plane
+ sideVec.current.crossVectors(camera.up, forwardVec.current).normalize();
+
+ moveVec.current.set(0, 0, 0);
+
+ if (forward) {
+ moveVec.current.add(forwardVec.current);
+ }
+ if (backward) {
+ moveVec.current.sub(forwardVec.current);
+ }
+ if (left) {
+ moveVec.current.add(sideVec.current);
+ }
+ if (right) {
+ moveVec.current.sub(sideVec.current);
+ }
+ if (up) {
+ moveVec.current.y += 1;
+ }
+ if (down) {
+ moveVec.current.y -= 1;
+ }
+
+ if (moveVec.current.lengthSq() > 0) {
+ moveVec.current.normalize().multiplyScalar(speed * delta);
+ camera.position.add(moveVec.current);
+ }
+ });
+
+ return null;
+}
+
+const KEYBOARD_CONTROLS = [
+ { name: Controls.forward, keys: ["KeyW"] },
+ { name: Controls.backward, keys: ["KeyS"] },
+ { name: Controls.left, keys: ["KeyA"] },
+ { name: Controls.right, keys: ["KeyD"] },
+ { name: Controls.up, keys: ["Space"] },
+ { name: Controls.down, keys: ["ShiftLeft", "ShiftRight"] },
+];
+
+export function ObserverControls() {
+ return (
+
+
+
+
+ );
+}
diff --git a/app/SettingsProvider.tsx b/app/SettingsProvider.tsx
new file mode 100644
index 00000000..c5577049
--- /dev/null
+++ b/app/SettingsProvider.tsx
@@ -0,0 +1,23 @@
+import React, { useContext, useMemo } from "react";
+
+const SettingsContext = React.createContext(null);
+
+export function useSettings() {
+ return useContext(SettingsContext);
+}
+
+export function SettingsProvider({
+ children,
+ fogEnabled,
+}: {
+ children: React.ReactNode;
+ fogEnabled: boolean;
+}) {
+ const value = useMemo(() => ({ fogEnabled }), [fogEnabled]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/SimGroup.tsx b/app/SimGroup.tsx
new file mode 100644
index 00000000..a603b0a7
--- /dev/null
+++ b/app/SimGroup.tsx
@@ -0,0 +1,6 @@
+import { ConsoleObject } from "@/src/mission";
+import { renderObject } from "./renderObject";
+
+export function SimGroup({ object }: { object: ConsoleObject }) {
+ return object.children.map((child, i) => renderObject(child, i));
+}
diff --git a/app/Sky.tsx b/app/Sky.tsx
index e69de29b..bc103eea 100644
--- a/app/Sky.tsx
+++ b/app/Sky.tsx
@@ -0,0 +1,94 @@
+import { ConsoleObject, getProperty } from "@/src/mission";
+import { useSettings } from "./SettingsProvider";
+import { Suspense, useMemo } from "react";
+import { BASE_URL, getUrlForPath, loadDetailMapList } from "@/src/loaders";
+import { useQuery } from "@tanstack/react-query";
+import { useCubeTexture } from "@react-three/drei";
+import { Color } from "three";
+
+const FALLBACK_URL = `${BASE_URL}/black.png`;
+
+function useDetailMapList(name: string) {
+ return useQuery({
+ queryKey: ["detailMapList", name],
+ queryFn: () => loadDetailMapList(name),
+ });
+}
+
+export function SkyBox({ materialList }: { materialList: string }) {
+ const { data: detailMapList } = useDetailMapList(materialList);
+
+ const skyBoxFiles = useMemo(
+ () =>
+ detailMapList
+ ? [
+ getUrlForPath(detailMapList[1], FALLBACK_URL), // +x
+ getUrlForPath(detailMapList[3], FALLBACK_URL), // -x
+ getUrlForPath(detailMapList[4], FALLBACK_URL), // +y
+ getUrlForPath(detailMapList[5], FALLBACK_URL), // -y
+ getUrlForPath(detailMapList[0], FALLBACK_URL), // +z
+ getUrlForPath(detailMapList[2], FALLBACK_URL), // -z
+ ]
+ : [
+ FALLBACK_URL,
+ FALLBACK_URL,
+ FALLBACK_URL,
+ FALLBACK_URL,
+ FALLBACK_URL,
+ FALLBACK_URL,
+ ],
+ [detailMapList]
+ );
+
+ const skyBox = useCubeTexture(skyBoxFiles, { path: "" });
+
+ return ;
+}
+
+export function Sky({ object }: { object: ConsoleObject }) {
+ const { fogEnabled } = useSettings();
+
+ // Skybox textures.
+ const materialList = getProperty(object, "materialList")?.value;
+
+ // Fog parameters.
+ // TODO: There can be multiple fog volumes/layers. Render simple fog for now.
+ const fogDistance = useMemo(() => {
+ const distainceString = getProperty(object, "fogDistance")?.value;
+ if (distainceString) {
+ return parseFloat(distainceString);
+ }
+ }, [object]);
+
+ const fogColor = useMemo(() => {
+ const colorString = getProperty(object, "fogColor")?.value;
+ if (colorString) {
+ // `colorString` might specify an alpha value, but three.js doesn't
+ // support opacity on fog or scene backgrounds, so ignore it.
+ const [r, g, b] = colorString.split(" ").map((s) => parseFloat(s));
+ return new Color().setRGB(r, g, b);
+ }
+ }, [object]);
+
+ const backgroundColor = fogColor ? (
+
+ ) : null;
+
+ return (
+ <>
+ {materialList ? (
+ // If there's a skybox, its textures will need to load. Render just the
+ // fog color as the background in the meantime.
+
+
+
+ ) : (
+ // If there's no skybox, just render the fog color as the background.
+ backgroundColor
+ )}
+ {fogEnabled && fogDistance && fogColor ? (
+
+ ) : null}
+ >
+ );
+}
diff --git a/app/TerrainBlock.tsx b/app/TerrainBlock.tsx
index e69de29b..3660a7f3 100644
--- a/app/TerrainBlock.tsx
+++ b/app/TerrainBlock.tsx
@@ -0,0 +1,220 @@
+import { uint16ToFloat32 } from "@/src/arrayUtils";
+import { loadTerrain, terrainTextureToUrl } from "@/src/loaders";
+import {
+ ConsoleObject,
+ getPosition,
+ getProperty,
+ getRotation,
+ getScale,
+} from "@/src/mission";
+import { useQuery } from "@tanstack/react-query";
+import { Suspense, useCallback, useMemo } from "react";
+import { useTexture } from "@react-three/drei";
+import {
+ DataTexture,
+ RedFormat,
+ FloatType,
+ NoColorSpace,
+ NearestFilter,
+ ClampToEdgeWrapping,
+ UnsignedByteType,
+ PlaneGeometry,
+} from "three";
+import {
+ setupColor,
+ setupMask,
+ updateTerrainTextureShader,
+} from "@/src/textureUtils";
+
+function useTerrain(terrainFile: string) {
+ return useQuery({
+ queryKey: ["terrain", terrainFile],
+ queryFn: () => loadTerrain(terrainFile),
+ });
+}
+
+function BlendedTerrainTextures({
+ displacementMap,
+ visibilityMask,
+ textureNames,
+ alphaMaps,
+}: {
+ displacementMap: DataTexture;
+ visibilityMask: DataTexture;
+ textureNames: string[];
+ alphaMaps: Uint8Array[];
+}) {
+ const baseTextures = useTexture(
+ textureNames.map((name) => terrainTextureToUrl(name)),
+ (textures) => {
+ textures.forEach((tex) => setupColor(tex));
+ }
+ );
+
+ const alphaTextures = useMemo(
+ () => alphaMaps.map((data) => setupMask(data)),
+ [alphaMaps]
+ );
+
+ const onBeforeCompile = useCallback(
+ (shader) => {
+ updateTerrainTextureShader({
+ shader,
+ baseTextures,
+ alphaTextures,
+ visibilityMask,
+ });
+ },
+ [baseTextures, alphaTextures, visibilityMask]
+ );
+
+ return (
+
+ );
+}
+
+function TerrainMaterial({
+ heightMap,
+ textureNames,
+ alphaMaps,
+ emptySquares,
+}: {
+ heightMap: Uint16Array;
+ emptySquares: number[];
+ textureNames: string[];
+ alphaMaps: Uint8Array[];
+}) {
+ const displacementMap = useMemo(() => {
+ const f32HeightMap = uint16ToFloat32(heightMap);
+ const displacementMap = new DataTexture(
+ f32HeightMap,
+ 256,
+ 256,
+ RedFormat,
+ FloatType
+ );
+ displacementMap.colorSpace = NoColorSpace;
+ displacementMap.generateMipmaps = false;
+ displacementMap.needsUpdate = true;
+ return displacementMap;
+ }, [heightMap]);
+
+ const visibilityMask: DataTexture | null = useMemo(() => {
+ if (!emptySquares.length) {
+ return null;
+ }
+
+ const terrainSize = 256;
+
+ // Create a mask texture (1 = visible, 0 = invisible)
+ const maskData = new Uint8Array(terrainSize * terrainSize);
+ maskData.fill(255); // Start with everything visible
+
+ for (const squareId of emptySquares) {
+ // The squareId encodes position and count:
+ // Bits 0-7: X position (starting position)
+ // Bits 8-15: Y position
+ // Bits 16+: Count (number of consecutive horizontal squares)
+ const x = squareId & 0xff;
+ const y = (squareId >> 8) & 0xff;
+ const count = squareId >> 16;
+
+ for (let i = 0; i < count; i++) {
+ const px = x + i;
+ const py = y;
+ const index = py * terrainSize + px;
+ if (index >= 0 && index < maskData.length) {
+ maskData[index] = 0;
+ }
+ }
+ }
+
+ const visibilityMask = new DataTexture(
+ maskData,
+ terrainSize,
+ terrainSize,
+ RedFormat,
+ UnsignedByteType
+ );
+ visibilityMask.colorSpace = NoColorSpace;
+ visibilityMask.wrapS = visibilityMask.wrapT = ClampToEdgeWrapping;
+ visibilityMask.magFilter = NearestFilter;
+ visibilityMask.minFilter = NearestFilter;
+ visibilityMask.needsUpdate = true;
+
+ return visibilityMask;
+ }, [emptySquares]);
+
+ return (
+
+ }
+ >
+
+
+ );
+}
+
+export function TerrainBlock({ object }: { object: ConsoleObject }) {
+ const terrainFile: string = getProperty(object, "terrainFile").value;
+
+ const emptySquares: number[] = useMemo(() => {
+ const emptySquaresString: string | undefined = getProperty(
+ object,
+ "emptySquares"
+ )?.value;
+
+ return emptySquaresString
+ ? emptySquaresString.split(" ").map((s) => parseInt(s, 10))
+ : [];
+ }, [object]);
+
+ const position = useMemo(() => getPosition(object), [object]);
+ const scale = useMemo(() => getScale(object), [object]);
+ const q = useMemo(() => getRotation(object), [object]);
+
+ const planeGeometry = useMemo(() => {
+ const geometry = new PlaneGeometry(2048, 2048, 256, 256);
+ geometry.rotateX(-Math.PI / 2);
+ geometry.rotateY(-Math.PI / 2);
+ return geometry;
+ }, []);
+
+ const { data: terrain } = useTerrain(terrainFile);
+
+ return (
+
+ {terrain ? (
+
+ ) : null}
+
+ );
+}
diff --git a/app/WaterBlock.tsx b/app/WaterBlock.tsx
index e69de29b..5bf2b329 100644
--- a/app/WaterBlock.tsx
+++ b/app/WaterBlock.tsx
@@ -0,0 +1,10 @@
+import { ConsoleObject } from "@/src/mission";
+
+export function WaterBlock({ object }: { object: ConsoleObject }) {
+ return (
+
+
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index 1a797326..bfe120d0 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,738 +1,42 @@
"use client";
-import { useEffect, useRef, useState } from "react";
-import * as THREE from "three";
-import {
- getActualResourcePath,
- getResourceList,
- getSource,
-} from "@/src/manifest";
-import { parseTerrainBuffer } from "@/src/terrain";
-import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
-import { getTerrainFile, iterObjects, parseMissionScript } from "@/src/mission";
+import { useState } from "react";
+import { Canvas } from "@react-three/fiber";
+import { Mission } from "./Mission";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ObserverControls } from "./ObserverControls";
+import { InspectorControls } from "./InspectorControls";
+import { SettingsProvider } from "./SettingsProvider";
+import { PerspectiveCamera } from "@react-three/drei";
-const BASE_URL = "/t2-mapper";
-const RESOURCE_ROOT_URL = `${BASE_URL}/base/`;
-
-function getUrlForPath(resourcePath: string, fallbackUrl?: string) {
- resourcePath = getActualResourcePath(resourcePath);
- let sourcePath: string;
- try {
- sourcePath = getSource(resourcePath);
- } catch (err) {
- if (fallbackUrl) {
- return fallbackUrl;
- } else {
- throw err;
- }
- }
- if (!sourcePath) {
- return `${RESOURCE_ROOT_URL}${resourcePath}`;
- } else {
- return `${RESOURCE_ROOT_URL}@vl2/${sourcePath}/${resourcePath}`;
- }
-}
-
-function interiorToUrl(name: string) {
- const difUrl = getUrlForPath(`interiors/${name}`);
- return difUrl.replace(/\.dif$/i, ".gltf");
-}
-
-function terrainTextureToUrl(name: string) {
- name = name.replace(/^terrain\./, "");
- return getUrlForPath(`textures/terrain/${name}.png`, `${BASE_URL}/black.png`);
-}
-
-function interiorTextureToUrl(name: string) {
- name = name.replace(/\.\d+$/, "");
- return getUrlForPath(`textures/${name}.png`);
-}
-
-function textureToUrl(name: string) {
- try {
- return getUrlForPath(`textures/${name}.png`);
- } catch (err) {
- return `${BASE_URL}/black.png`;
- }
-}
-
-async function loadDetailMapList(name: string) {
- const url = getUrlForPath(`textures/${name}`);
- const res = await fetch(url);
- const text = await res.text();
- return text
- .split(/(?:\r\n|\n|\r)/)
- .map((line) => `textures/${line.trim().replace(/\.png$/i, "")}.png`);
-}
-
-function uint16ToFloat32(src: Uint16Array) {
- const out = new Float32Array(src.length);
- for (let i = 0; i < src.length; i++) {
- out[i] = src[i] / 65535;
- }
- return out;
-}
-
-async function loadMission(name: string) {
- const res = await fetch(getUrlForPath(`missions/${name}.mis`));
- const missionScript = await res.text();
- return parseMissionScript(missionScript);
-}
-
-async function loadTerrain(fileName: string) {
- const res = await fetch(getUrlForPath(`terrains/${fileName}`));
- const terrainBuffer = await res.arrayBuffer();
- return parseTerrainBuffer(terrainBuffer);
-}
-
-function resizeRendererToDisplaySize(renderer) {
- const canvas = renderer.domElement;
- const width = canvas.clientWidth;
- const height = canvas.clientHeight;
- const needResize = canvas.width !== width || canvas.height !== height;
- if (needResize) {
- renderer.setSize(width, height, false);
- }
- return needResize;
-}
-
-const excludeMissions = new Set([
- "SkiFree",
- "SkiFree_Daily",
- "SkiFree_Randomizer",
-]);
-
-const missions = getResourceList()
- .map((resourcePath) => resourcePath.match(/^missions\/(.+)\.mis$/))
- .filter(Boolean)
- .map((match) => match[1])
- .filter((name) => !excludeMissions.has(name));
+// three.js has its own loaders for textures and models, but we need to load other
+// stuff too, e.g. missions, terrains, and more. This client is used for those.
+const queryClient = new QueryClient();
export default function HomePage() {
- const canvasRef = useRef(null);
const [missionName, setMissionName] = useState("TWL2_WoodyMyrk");
- const [fogEnabled, setFogEnabled] = useState(true);
- const threeContext = useRef>({});
-
- useEffect(() => {
- const canvas = canvasRef.current;
- const renderer = new THREE.WebGLRenderer({
- canvas,
- antialias: true,
- });
-
- const textureLoader = new THREE.TextureLoader();
- const gltfLoader = new GLTFLoader();
-
- const scene = new THREE.Scene();
- const camera = new THREE.PerspectiveCamera(
- 75,
- canvas.clientWidth / canvas.clientHeight,
- 0.1,
- 2000
- );
-
- function setupColor(tex, repeat = [1, 1]) {
- tex.wrapS = tex.wrapT = THREE.RepeatWrapping; // Still need this for tiling to work
- tex.colorSpace = THREE.SRGBColorSpace;
- tex.repeat.set(...repeat);
- tex.anisotropy = renderer.capabilities.getMaxAnisotropy?.() ?? 16;
- tex.generateMipmaps = true;
- tex.minFilter = THREE.LinearMipmapLinearFilter;
- tex.magFilter = THREE.LinearFilter;
- return tex;
- }
-
- function setupMask(data) {
- const tex = new THREE.DataTexture(
- data,
- 256,
- 256,
- THREE.RedFormat, // 1 channel
- THREE.UnsignedByteType // 8-bit
- );
-
- // Masks should stay linear
- tex.colorSpace = THREE.NoColorSpace;
-
- // Set tiling / sampling. For NPOT sizes, disable mips or use power-of-two.
- tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
- tex.generateMipmaps = false; // if width/height are not powers of two
- tex.minFilter = THREE.LinearFilter; // avoid mips if generateMipmaps=false
- tex.magFilter = THREE.LinearFilter;
-
- tex.needsUpdate = true;
-
- return tex;
- }
-
- const skyColor = "rgba(209, 237, 255, 1)";
- const groundColor = "rgba(186, 200, 181, 1)";
- const intensity = 2;
- const light = new THREE.HemisphereLight(skyColor, groundColor, intensity);
- scene.add(light);
-
- // Free-look camera setup
- camera.position.set(0, 100, 512);
-
- const keys = {
- w: false, a: false, s: false, d: false,
- shift: false, space: false
- };
-
- const onKeyDown = (e) => {
- if (e.code === "KeyW") keys.w = true;
- if (e.code === "KeyA") keys.a = true;
- if (e.code === "KeyS") keys.s = true;
- if (e.code === "KeyD") keys.d = true;
- if (e.code === "ShiftLeft" || e.code === "ShiftRight") keys.shift = true;
- if (e.code === "Space") keys.space = true;
- };
-
- const onKeyUp = (e) => {
- if (e.code === "KeyW") keys.w = false;
- if (e.code === "KeyA") keys.a = false;
- if (e.code === "KeyS") keys.s = false;
- if (e.code === "KeyD") keys.d = false;
- if (e.code === "ShiftLeft" || e.code === "ShiftRight") keys.shift = false;
- if (e.code === "Space") keys.space = false;
- };
-
- document.addEventListener("keydown", onKeyDown);
- document.addEventListener("keyup", onKeyUp);
-
- // Mouse look controls
- let isPointerLocked = false;
- const euler = new THREE.Euler(0, 0, 0, 'YXZ');
- const PI_2 = Math.PI / 2;
-
- const onMouseMove = (e) => {
- if (!isPointerLocked) return;
-
- const movementX = e.movementX || 0;
- const movementY = e.movementY || 0;
-
- euler.setFromQuaternion(camera.quaternion);
- euler.y -= movementX * 0.002;
- euler.x -= movementY * 0.002;
- euler.x = Math.max(-PI_2, Math.min(PI_2, euler.x));
- camera.quaternion.setFromEuler(euler);
- };
-
- const onPointerLockChange = () => {
- isPointerLocked = document.pointerLockElement === canvas;
- };
-
- const onCanvasClick = () => {
- if (!isPointerLocked) {
- canvas.requestPointerLock();
- }
- };
-
- canvas.addEventListener('click', onCanvasClick);
- document.addEventListener('pointerlockchange', onPointerLockChange);
- document.addEventListener('mousemove', onMouseMove);
-
- let moveSpeed = 2;
-
- const onWheel = (e: WheelEvent) => {
- e.preventDefault();
-
- // Adjust speed based on wheel direction
- const delta = e.deltaY > 0 ? .75 : 1.25;
- moveSpeed = Math.max(0.025, Math.min(4, moveSpeed * delta));
-
- // Log the new speed for user feedback
- console.log(`Movement speed: ${moveSpeed.toFixed(3)}`);
- };
-
- canvas.addEventListener('wheel', onWheel, { passive: false });
-
- const animate = (t) => {
- // Free-look movement
- const forward = new THREE.Vector3();
- camera.getWorldDirection(forward);
-
- const right = new THREE.Vector3();
- right.crossVectors(forward, camera.up).normalize();
-
- let move = new THREE.Vector3();
- if (keys.w) move.add(forward);
- if (keys.s) move.sub(forward);
- if (keys.a) move.sub(right);
- if (keys.d) move.add(right);
- if (keys.space) move.add(camera.up);
- if (keys.shift) move.sub(camera.up);
-
- if (move.lengthSq() > 0) {
- move.normalize().multiplyScalar(moveSpeed);
- camera.position.add(move);
- }
-
- if (resizeRendererToDisplaySize(renderer)) {
- const canvas = renderer.domElement;
- camera.aspect = canvas.clientWidth / canvas.clientHeight;
- camera.updateProjectionMatrix();
- }
-
- renderer.render(scene, camera);
- };
- renderer.setAnimationLoop(animate);
-
- threeContext.current = {
- scene,
- renderer,
- camera,
- setupColor,
- setupMask,
- textureLoader,
- gltfLoader,
- };
-
- return () => {
- document.removeEventListener("keydown", onKeyDown);
- document.removeEventListener("keyup", onKeyUp);
- document.removeEventListener('pointerlockchange', onPointerLockChange);
- document.removeEventListener('mousemove', onMouseMove);
- canvas.removeEventListener('click', onCanvasClick);
- canvas.removeEventListener('wheel', onWheel);
- renderer.setAnimationLoop(null);
- renderer.dispose();
- };
- }, []);
-
- useEffect(() => {
- const {
- scene,
- camera,
- setupColor,
- setupMask,
- textureLoader,
- gltfLoader,
- } = threeContext.current;
-
- let cancel = false;
- let root: THREE.Group;
-
- async function loadMap() {
- const mission = await loadMission(missionName);
- const terrainFile = getTerrainFile(mission);
- const terrain = await loadTerrain(terrainFile);
-
- const layerCount = terrain.textureNames.length;
-
- const baseTextures = terrain.textureNames.map((name) => {
- return setupColor(textureLoader.load(terrainTextureToUrl(name)));
- });
-
- const alphaTextures = terrain.alphaMaps.map((data) => setupMask(data));
-
- const planeSize = 2048;
- const geom = new THREE.PlaneGeometry(planeSize, planeSize, 256, 256);
- geom.rotateX(-Math.PI / 2);
- geom.rotateY(-Math.PI / 2);
-
- // Find TerrainBlock properties for empty squares
- let emptySquares: number[] | null = null;
- for (const obj of iterObjects(mission.objects)) {
- if (obj.className === "TerrainBlock") {
- const emptySquaresStr = obj.properties.find((p: any) => p.target.name === "emptySquares")?.value;
- if (emptySquaresStr) {
- emptySquares = emptySquaresStr.split(" ").map((s: string) => parseInt(s))
- }
- break;
- }
- }
-
- const f32HeightMap = uint16ToFloat32(terrain.heightMap);
-
- // Create a visibility mask for empty squares
- let visibilityMask: THREE.DataTexture | null = null;
- if (emptySquares) {
- const terrainSize = 256;
-
- // Create a mask texture (1 = visible, 0 = invisible)
- const maskData = new Uint8Array(terrainSize * terrainSize);
- maskData.fill(255); // Start with everything visible
-
- for (const squareId of emptySquares) {
- // The squareId encodes position and count:
- // Bits 0-7: X position (starting position)
- // Bits 8-15: Y position
- // Bits 16+: Count (number of consecutive horizontal squares)
- const x = (squareId & 0xFF);
- const y = (squareId >> 8) & 0xFF;
- const count = (squareId >> 16);
-
- for (let i = 0; i < count; i++) {
- const px = x + i;
- const py = y;
- const index = py * terrainSize + px;
- if (index >= 0 && index < maskData.length) {
- maskData[index] = 0;
- }
- }
- }
-
- visibilityMask = new THREE.DataTexture(
- maskData,
- terrainSize,
- terrainSize,
- THREE.RedFormat,
- THREE.UnsignedByteType
- );
- visibilityMask.colorSpace = THREE.NoColorSpace;
- visibilityMask.wrapS = visibilityMask.wrapT = THREE.ClampToEdgeWrapping;
- visibilityMask.magFilter = THREE.NearestFilter;
- visibilityMask.minFilter = THREE.NearestFilter;
- visibilityMask.needsUpdate = true;
- }
-
- const heightMap = new THREE.DataTexture(
- f32HeightMap,
- 256,
- 256,
- THREE.RedFormat,
- THREE.FloatType
- );
- heightMap.colorSpace = THREE.NoColorSpace;
- heightMap.generateMipmaps = false;
- heightMap.needsUpdate = true;
-
- // Start with a standard material; assign map to trigger USE_MAP/vMapUv
- const mat = new THREE.MeshStandardMaterial({
- // map: base0,
- displacementMap: heightMap,
- map: heightMap,
- displacementScale: 2048,
- depthWrite: true,
- // displacementBias: -128,
- });
-
- // Inject our 4-layer blend before lighting
- mat.onBeforeCompile = (shader) => {
- // uniforms for 4 albedo maps + 3 alpha masks
- baseTextures.forEach((tex, i) => {
- shader.uniforms[`albedo${i}`] = { value: tex };
- });
- alphaTextures.forEach((tex, i) => {
- if (i > 0) {
- shader.uniforms[`mask${i}`] = { value: tex };
- }
- });
-
- // Add visibility mask uniform if we have empty squares
- if (visibilityMask) {
- shader.uniforms.visibilityMask = { value: visibilityMask };
- }
-
- // Add per-texture tiling uniforms
- baseTextures.forEach((tex, i) => {
- shader.uniforms[`tiling${i}`] = {
- value: Math.min(
- 512,
- { 0: 16, 1: 16, 2: 32, 3: 32, 4: 32, 5: 32 }[i]
- ),
- };
- });
-
- // Declare our uniforms at the top of the fragment shader
- shader.fragmentShader =
- `
-uniform sampler2D albedo0;
-uniform sampler2D albedo1;
-uniform sampler2D albedo2;
-uniform sampler2D albedo3;
-uniform sampler2D albedo4;
-uniform sampler2D albedo5;
-uniform sampler2D mask1;
-uniform sampler2D mask2;
-uniform sampler2D mask3;
-uniform sampler2D mask4;
-uniform sampler2D mask5;
-uniform float tiling0;
-uniform float tiling1;
-uniform float tiling2;
-uniform float tiling3;
-uniform float tiling4;
-uniform float tiling5;
-${visibilityMask ? 'uniform sampler2D visibilityMask;' : ''}
-` + shader.fragmentShader;
-
- if (visibilityMask) {
- const clippingPlaceholder = '#include ';
- shader.fragmentShader = shader.fragmentShader.replace(
- clippingPlaceholder,
- `${clippingPlaceholder}
- // Early discard for invisible areas (before fog/lighting)
- float visibility = texture2D(visibilityMask, vMapUv).r;
- if (visibility < 0.5) {
- discard;
- }
- `
- );
- }
-
- // Replace the default map sampling block with our layered blend.
- // We rely on vMapUv provided by USE_MAP.
- shader.fragmentShader = shader.fragmentShader.replace(
- "#include ",
- `
- // Sample base albedo layers (sRGB textures auto-decoded to linear)
- vec2 baseUv = vMapUv;
- vec3 c0 = texture2D(albedo0, baseUv * vec2(tiling0)).rgb;
- ${
- layerCount > 1
- ? `vec3 c1 = texture2D(albedo1, baseUv * vec2(tiling1)).rgb;`
- : ""
- }
- ${
- layerCount > 2
- ? `vec3 c2 = texture2D(albedo2, baseUv * vec2(tiling2)).rgb;`
- : ""
- }
- ${
- layerCount > 3
- ? `vec3 c3 = texture2D(albedo3, baseUv * vec2(tiling3)).rgb;`
- : ""
- }
- ${
- layerCount > 4
- ? `vec3 c4 = texture2D(albedo4, baseUv * vec2(tiling4)).rgb;`
- : ""
- }
- ${
- layerCount > 5
- ? `vec3 c5 = texture2D(albedo5, baseUv * vec2(tiling5)).rgb;`
- : ""
- }
-
- // Sample linear masks (use R channel)
- float a1 = texture2D(mask1, baseUv).r;
- ${layerCount > 1 ? `float a2 = texture2D(mask2, baseUv).r;` : ""}
- ${layerCount > 2 ? `float a3 = texture2D(mask3, baseUv).r;` : ""}
- ${layerCount > 3 ? `float a4 = texture2D(mask4, baseUv).r;` : ""}
- ${layerCount > 4 ? `float a5 = texture2D(mask5, baseUv).r;` : ""}
-
- // Bottom-up compositing: each mask tells how much the higher layer replaces lower
- ${layerCount > 1 ? `vec3 blended = mix(c0, c1, clamp(a1, 0.0, 1.0));` : ""}
- ${layerCount > 2 ? `blended = mix(blended, c2, clamp(a2, 0.0, 1.0));` : ""}
- ${layerCount > 3 ? `blended = mix(blended, c3, clamp(a3, 0.0, 1.0));` : ""}
- ${layerCount > 4 ? `blended = mix(blended, c4, clamp(a4, 0.0, 1.0));` : ""}
- ${layerCount > 5 ? `blended = mix(blended, c5, clamp(a5, 0.0, 1.0));` : ""}
-
- // Assign to diffuseColor before lighting
- diffuseColor.rgb = ${layerCount > 1 ? "blended" : "c0"};
-`
- );
- };
-
- root = new THREE.Group();
-
- const terrainMesh = new THREE.Mesh(geom, mat);
- root.add(terrainMesh);
-
- for (const obj of iterObjects(mission.objects)) {
- const getProperty = (name) => {
- const property = obj.properties.find((p) => p.target.name === name);
- // console.log({ name, property });
- return property;
- };
-
- const getPosition = () => {
- const position = getProperty("position")?.value ?? "0 0 0";
- const [x, z, y] = position.split(" ").map((s) => parseFloat(s));
- return [x, y, z];
- };
-
- const getScale = () => {
- const scale = getProperty("scale")?.value ?? "1 1 1";
- const [scaleX, scaleZ, scaleY] = scale
- .split(" ")
- .map((s) => parseFloat(s));
- return [scaleX, scaleY, scaleZ];
- };
-
- const getRotation = (isInterior = false) => {
- const rotation = getProperty("rotation")?.value ?? "1 0 0 0";
- const [ax, az, ay, angle] = rotation
- .split(" ")
- .map((s) => parseFloat(s));
-
- if (isInterior) {
- // For interiors: Apply coordinate system transformation
- // 1. Convert rotation axis from source coords (ax, az, ay) to Three.js coords
- // 2. Apply -90 Y rotation to align coordinate systems
- const sourceRotation = new THREE.Quaternion().setFromAxisAngle(
- new THREE.Vector3(az, ay, ax),
- -angle * (Math.PI / 180)
- );
- const coordSystemFix = new THREE.Quaternion().setFromAxisAngle(
- new THREE.Vector3(0, 1, 0),
- Math.PI / 2
- );
- return coordSystemFix.multiply(sourceRotation);
- } else {
- // For other objects (terrain, etc)
- return new THREE.Quaternion().setFromAxisAngle(
- new THREE.Vector3(ax, ay, -az),
- angle * (Math.PI / 180)
- );
- }
- };
-
- switch (obj.className) {
- case "TerrainBlock": {
- const [x, y, z] = getPosition();
- camera.position.set(x - 512, y + 256, z - 512);
- const [scaleX, scaleY, scaleZ] = getScale();
- const q = getRotation();
- terrainMesh.position.set(x, y, z);
- terrainMesh.scale.set(scaleX, scaleY, scaleZ);
- terrainMesh.quaternion.copy(q);
- break;
- }
- case "Sky": {
- const materialList = getProperty("materialList")?.value;
- if (materialList) {
- const detailMapList = await loadDetailMapList(materialList);
- const skyLoader = new THREE.CubeTextureLoader();
- const fallbackUrl = `${BASE_URL}/black.png`;
- const texture = skyLoader.load([
- getUrlForPath(detailMapList[1], fallbackUrl), // +x
- getUrlForPath(detailMapList[3], fallbackUrl), // -x
- getUrlForPath(detailMapList[4], fallbackUrl), // +y
- getUrlForPath(detailMapList[5], fallbackUrl), // -y
- getUrlForPath(detailMapList[0], fallbackUrl), // +z
- getUrlForPath(detailMapList[2], fallbackUrl), // -z
- ]);
- scene.background = texture;
- }
- const fogDistance = getProperty("fogDistance")?.value;
- const fogColor = getProperty("fogColor")?.value;
- if (fogDistance && fogColor) {
- const distance = parseFloat(fogDistance);
- const [r, g, b] = fogColor.split(" ").map((s) => parseFloat(s));
- const color = new THREE.Color().setRGB(r, g, b);
- const fog = new THREE.Fog(color, 0, distance * 2);
- if (fogEnabled) {
- scene.fog = fog;
- } else {
- scene._fog = fog;
- }
- }
- break;
- }
- case "InteriorInstance": {
- const [z, y, x] = getPosition();
- const [scaleX, scaleY, scaleZ] = getScale();
- const q = getRotation(true);
- const interiorFile = getProperty("interiorFile").value;
- gltfLoader.load(interiorToUrl(interiorFile), (gltf) => {
- gltf.scene.traverse((o) => {
- if (o.material?.name) {
- const name = o.material.name;
- try {
- const tex = textureLoader.load(interiorTextureToUrl(name));
- o.material.map = setupColor(tex);
- } catch (err) {
- console.error(err);
- }
- o.material.needsUpdate = true;
- }
- });
- const interior = gltf.scene;
- interior.position.set(x - 1024, y, z - 1024);
- interior.scale.set(-scaleX, scaleY, -scaleZ);
- interior.quaternion.copy(q);
- root.add(interior);
- });
- break;
- }
- case "WaterBlock": {
- const [z, y, x] = getPosition();
- const [scaleZ, scaleY, scaleX] = getScale();
- const q = getRotation(true);
-
- const surfaceTexture =
- getProperty("surfaceTexture")?.value ?? "liquidTiles/BlueWater";
-
- const geometry = new THREE.BoxGeometry(scaleZ, scaleY, scaleX);
- const material = new THREE.MeshStandardMaterial({
- map: setupColor(
- textureLoader.load(textureToUrl(surfaceTexture)),
- [8, 8]
- ),
- // transparent: true,
- opacity: 0.8,
- });
- const water = new THREE.Mesh(geometry, material);
-
- water.position.set(
- x - 1024 + scaleX / 2,
- y + scaleY / 2,
- z - 1024 + scaleZ / 2
- );
- water.quaternion.copy(q);
-
- root.add(water);
- break;
- }
- }
- }
-
- if (cancel) {
- return;
- }
-
- scene.add(root);
- }
-
- loadMap();
-
- return () => {
- cancel = true;
- root.removeFromParent();
- };
- }, [missionName]);
-
- useEffect(() => {
- const { scene } = threeContext.current;
- if (fogEnabled) {
- scene.fog = scene._fog ?? null;
- scene._fog = null;
- scene.needsUpdate = true;
- } else {
- scene._fog = scene.fog;
- scene.fog = null;
- scene.needsUpdate = true;
- }
- }, [fogEnabled]);
+ const [fogEnabled, setFogEnabled] = useState(false);
return (
-
-
-
-
-
-
-
+
+
+
);
}
diff --git a/app/renderObject.tsx b/app/renderObject.tsx
new file mode 100644
index 00000000..bb3c4d29
--- /dev/null
+++ b/app/renderObject.tsx
@@ -0,0 +1,19 @@
+import { ConsoleObject } from "@/src/mission";
+import { TerrainBlock } from "./TerrainBlock";
+import { WaterBlock } from "./WaterBlock";
+import { SimGroup } from "./SimGroup";
+import { InteriorInstance } from "./InteriorInstance";
+import { Sky } from "./Sky";
+
+const componentMap = {
+ SimGroup,
+ TerrainBlock,
+ WaterBlock,
+ InteriorInstance,
+ Sky,
+};
+
+export function renderObject(object: ConsoleObject, key: string | number) {
+ const Component = componentMap[object.className];
+ return Component ? : null;
+}
diff --git a/app/style.css b/app/style.css
index b2badbf9..2a53f0a4 100644
--- a/app/style.css
+++ b/app/style.css
@@ -2,6 +2,7 @@ html,
body {
margin: 0;
padding: 0;
+ background: black;
}
html {
@@ -10,8 +11,7 @@ html {
font-size: 100%;
}
-#canvas {
- display: block;
+main {
width: 100vw;
height: 100vh;
}
diff --git a/package-lock.json b/package-lock.json
index 9804fbb3..41f97bc9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,9 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
+ "@react-three/drei": "^10.7.6",
"@react-three/fiber": "^9.3.0",
+ "@tanstack/react-query": "^5.90.8",
"next": "^15.5.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
@@ -40,7 +42,6 @@
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
- "dev": true,
"license": "Apache-2.0"
},
"node_modules/@emnapi/runtime": {
@@ -954,6 +955,24 @@
"node": ">=12"
}
},
+ "node_modules/@mediapipe/tasks-vision": {
+ "version": "0.10.17",
+ "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz",
+ "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@monogrid/gainmap-js": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.1.0.tgz",
+ "integrity": "sha512-Obb0/gEd/HReTlg8ttaYk+0m62gQJmCblMOjHSMHRrBP2zdfKMHLCRbh/6ex9fSUJMKdjjIEiohwkbGD3wj2Nw==",
+ "license": "MIT",
+ "dependencies": {
+ "promise-worker-transferable": "^1.0.4"
+ },
+ "peerDependencies": {
+ "three": ">= 0.159.0"
+ }
+ },
"node_modules/@next/env": {
"version": "15.5.2",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.2.tgz",
@@ -1101,6 +1120,46 @@
"node": ">=20.8"
}
},
+ "node_modules/@react-three/drei": {
+ "version": "10.7.6",
+ "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.6.tgz",
+ "integrity": "sha512-ZSFwRlRaa4zjtB7yHO6Q9xQGuyDCzE7whXBhum92JslcMRC3aouivp0rAzszcVymIoJx6PXmibyP+xr+zKdwLg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.26.0",
+ "@mediapipe/tasks-vision": "0.10.17",
+ "@monogrid/gainmap-js": "^3.0.6",
+ "@use-gesture/react": "^10.3.1",
+ "camera-controls": "^3.1.0",
+ "cross-env": "^7.0.3",
+ "detect-gpu": "^5.0.56",
+ "glsl-noise": "^0.0.0",
+ "hls.js": "^1.5.17",
+ "maath": "^0.10.8",
+ "meshline": "^3.3.1",
+ "stats-gl": "^2.2.8",
+ "stats.js": "^0.17.0",
+ "suspend-react": "^0.1.3",
+ "three-mesh-bvh": "^0.8.3",
+ "three-stdlib": "^2.35.6",
+ "troika-three-text": "^0.52.4",
+ "tunnel-rat": "^0.1.2",
+ "use-sync-external-store": "^1.4.0",
+ "utility-types": "^3.11.0",
+ "zustand": "^5.0.1"
+ },
+ "peerDependencies": {
+ "@react-three/fiber": "^9.0.0",
+ "react": "^19",
+ "react-dom": "^19",
+ "three": ">=0.159"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@react-three/fiber": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.3.0.tgz",
@@ -1166,11 +1225,42 @@
"tslib": "^2.8.0"
}
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.90.8",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.8.tgz",
+ "integrity": "sha512-4E0RP/0GJCxSNiRF2kAqE/LQkTJVlL/QNU7gIJSptaseV9HP6kOuA+N11y4bZKZxa3QopK3ZuewwutHx6DqDXQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.90.8",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.8.tgz",
+ "integrity": "sha512-/3b9QGzkf4rE5/miL6tyhldQRlLXzMHcySOm/2Tm2OLEFE9P1ImkH0+OviDBSvyAvtAOJocar5xhd7vxdLi3aQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.90.8"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
- "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/draco3d": {
+ "version": "1.4.10",
+ "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz",
+ "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==",
"license": "MIT"
},
"node_modules/@types/node": {
@@ -1183,6 +1273,12 @@
"undici-types": "~7.10.0"
}
},
+ "node_modules/@types/offscreencanvas": {
+ "version": "2019.7.3",
+ "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
+ "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
+ "license": "MIT"
+ },
"node_modules/@types/react": {
"version": "19.1.12",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
@@ -1206,15 +1302,14 @@
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
- "dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
- "dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
@@ -1241,11 +1336,28 @@
"integrity": "sha512-GPe4AsfOSpqWd3xA/0gwoKod13ChcfV67trvxaW2krUbgb9gxQjnCx8zGshzMl8LSHZlNH5gQ8LNScsDuc7nGQ==",
"license": "MIT"
},
+ "node_modules/@use-gesture/core": {
+ "version": "10.3.1",
+ "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
+ "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
+ "license": "MIT"
+ },
+ "node_modules/@use-gesture/react": {
+ "version": "10.3.1",
+ "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
+ "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@use-gesture/core": "10.3.1"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0"
+ }
+ },
"node_modules/@webgpu/types": {
"version": "0.1.64",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.64.tgz",
"integrity": "sha512-84kRIAGV46LJTlJZWxShiOrNL30A+9KokD7RB3dRCIqODFjodS5tCD5yyiZ8kIReGVZSDfA3XkkwyyOIF6K62A==",
- "dev": true,
"license": "BSD-3-Clause"
},
"node_modules/ansi-regex": {
@@ -1294,6 +1406,15 @@
],
"license": "MIT"
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
@@ -1324,6 +1445,19 @@
"ieee754": "^1.2.1"
}
},
+ "node_modules/camera-controls": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.0.tgz",
+ "integrity": "sha512-w5oULNpijgTRH0ARFJJ0R5ct1nUM3R3WP7/b8A6j9uTGpRfnsypc/RBMPQV8JQDPayUe37p/TZZY1PcUr4czOQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.11.0",
+ "npm": ">=10.8.2"
+ },
+ "peerDependencies": {
+ "three": ">=0.126.1"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001741",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz",
@@ -1411,11 +1545,28 @@
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
+ "node_modules/cross-env": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
+ "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.1"
+ },
+ "bin": {
+ "cross-env": "src/bin/cross-env.js",
+ "cross-env-shell": "src/bin/cross-env-shell.js"
+ },
+ "engines": {
+ "node": ">=10.14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -1432,6 +1583,15 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
+ "node_modules/detect-gpu": {
+ "version": "5.0.70",
+ "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz",
+ "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==",
+ "license": "MIT",
+ "dependencies": {
+ "webgl-constants": "^1.1.1"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@@ -1442,6 +1602,12 @@
"node": ">=8"
}
},
+ "node_modules/draco3d": {
+ "version": "1.5.7",
+ "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
+ "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==",
+ "license": "Apache-2.0"
+ },
"node_modules/duplexer2": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
@@ -1511,7 +1677,6 @@
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
- "dev": true,
"license": "MIT"
},
"node_modules/foreground-child": {
@@ -1597,12 +1762,24 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/glsl-noise": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz",
+ "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==",
+ "license": "MIT"
+ },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
+ "node_modules/hls.js": {
+ "version": "1.6.14",
+ "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.14.tgz",
+ "integrity": "sha512-CSpT2aXsv71HST8C5ETeVo+6YybqCpHBiYrCRQSn3U5QUZuLTSsvtq/bj+zuvjLVADeKxoebzo16OkH8m1+65Q==",
+ "license": "Apache-2.0"
+ },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -1623,6 +1800,12 @@
],
"license": "BSD-3-Clause"
},
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -1646,6 +1829,12 @@
"node": ">=8"
}
},
+ "node_modules/is-promise": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
+ "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
+ "license": "MIT"
+ },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@ -1656,7 +1845,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true,
"license": "ISC"
},
"node_modules/its-fine": {
@@ -1708,6 +1896,15 @@
"graceful-fs": "^4.1.6"
}
},
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
"node_modules/lru-cache": {
"version": "11.2.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.1.tgz",
@@ -1718,11 +1915,29 @@
"node": "20 || >=22"
}
},
+ "node_modules/maath": {
+ "version": "0.10.8",
+ "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz",
+ "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/three": ">=0.134.0",
+ "three": ">=0.134.0"
+ }
+ },
+ "node_modules/meshline": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz",
+ "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
"node_modules/meshoptimizer": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
"integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
- "dev": true,
"license": "MIT"
},
"node_modules/minimatch": {
@@ -1838,7 +2053,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -1913,12 +2127,28 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/potpack": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz",
+ "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
+ "license": "ISC"
+ },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
+ "node_modules/promise-worker-transferable": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
+ "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "is-promise": "^2.1.0",
+ "lie": "^3.0.2"
+ }
+ },
"node_modules/react": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
@@ -1993,6 +2223,15 @@
"util-deprecate": "~1.0.1"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -2095,7 +2334,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -2108,7 +2346,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -2156,6 +2393,32 @@
"node": ">=0.10.0"
}
},
+ "node_modules/stats-gl": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz",
+ "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/three": "*",
+ "three": "^0.170.0"
+ },
+ "peerDependencies": {
+ "@types/three": "*",
+ "three": "*"
+ }
+ },
+ "node_modules/stats-gl/node_modules/three": {
+ "version": "0.170.0",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
+ "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
+ "license": "MIT"
+ },
+ "node_modules/stats.js": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz",
+ "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==",
+ "license": "MIT"
+ },
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
@@ -2308,6 +2571,68 @@
"license": "MIT",
"peer": true
},
+ "node_modules/three-mesh-bvh": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz",
+ "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "three": ">= 0.159.0"
+ }
+ },
+ "node_modules/three-stdlib": {
+ "version": "2.36.1",
+ "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz",
+ "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/draco3d": "^1.4.0",
+ "@types/offscreencanvas": "^2019.6.4",
+ "@types/webxr": "^0.5.2",
+ "draco3d": "^1.4.1",
+ "fflate": "^0.6.9",
+ "potpack": "^1.0.1"
+ },
+ "peerDependencies": {
+ "three": ">=0.128.0"
+ }
+ },
+ "node_modules/three-stdlib/node_modules/fflate": {
+ "version": "0.6.10",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz",
+ "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==",
+ "license": "MIT"
+ },
+ "node_modules/troika-three-text": {
+ "version": "0.52.4",
+ "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
+ "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==",
+ "license": "MIT",
+ "dependencies": {
+ "bidi-js": "^1.0.2",
+ "troika-three-utils": "^0.52.4",
+ "troika-worker-utils": "^0.52.0",
+ "webgl-sdf-generator": "1.1.1"
+ },
+ "peerDependencies": {
+ "three": ">=0.125.0"
+ }
+ },
+ "node_modules/troika-three-utils": {
+ "version": "0.52.4",
+ "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz",
+ "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "three": ">=0.125.0"
+ }
+ },
+ "node_modules/troika-worker-utils": {
+ "version": "0.52.0",
+ "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz",
+ "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==",
+ "license": "MIT"
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -2334,6 +2659,43 @@
"fsevents": "~2.3.3"
}
},
+ "node_modules/tunnel-rat": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz",
+ "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==",
+ "license": "MIT",
+ "dependencies": {
+ "zustand": "^4.3.2"
+ }
+ },
+ "node_modules/tunnel-rat/node_modules/zustand": {
+ "version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/typescript": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
@@ -2382,7 +2744,6 @@
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
"license": "MIT",
- "peer": true,
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
@@ -2393,11 +2754,30 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
+ "node_modules/utility-types": {
+ "version": "3.11.0",
+ "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz",
+ "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/webgl-constants": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz",
+ "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg=="
+ },
+ "node_modules/webgl-sdf-generator": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz",
+ "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==",
+ "license": "MIT"
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
diff --git a/package.json b/package.json
index 3b9a77c7..dc5c7fa8 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,9 @@
"start": "next dev"
},
"dependencies": {
+ "@react-three/drei": "^10.7.6",
"@react-three/fiber": "^9.3.0",
+ "@tanstack/react-query": "^5.90.8",
"next": "^15.5.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
diff --git a/src/arrayUtils.ts b/src/arrayUtils.ts
new file mode 100644
index 00000000..a8e1c713
--- /dev/null
+++ b/src/arrayUtils.ts
@@ -0,0 +1,7 @@
+export function uint16ToFloat32(src: Uint16Array) {
+ const out = new Float32Array(src.length);
+ for (let i = 0; i < src.length; i++) {
+ out[i] = src[i] / 65535;
+ }
+ return out;
+}
diff --git a/src/loaders.ts b/src/loaders.ts
new file mode 100644
index 00000000..eb299f10
--- /dev/null
+++ b/src/loaders.ts
@@ -0,0 +1,69 @@
+import { getActualResourcePath, getSource } from "./manifest";
+import { parseMissionScript } from "./mission";
+import { parseTerrainBuffer } from "./terrain";
+
+export const BASE_URL = "/t2-mapper";
+export const RESOURCE_ROOT_URL = `${BASE_URL}/base/`;
+
+export function getUrlForPath(resourcePath: string, fallbackUrl?: string) {
+ resourcePath = getActualResourcePath(resourcePath);
+ let sourcePath: string;
+ try {
+ sourcePath = getSource(resourcePath);
+ } catch (err) {
+ if (fallbackUrl) {
+ return fallbackUrl;
+ } else {
+ throw err;
+ }
+ }
+ if (!sourcePath) {
+ return `${RESOURCE_ROOT_URL}${resourcePath}`;
+ } else {
+ return `${RESOURCE_ROOT_URL}@vl2/${sourcePath}/${resourcePath}`;
+ }
+}
+
+export function interiorToUrl(name: string) {
+ const difUrl = getUrlForPath(`interiors/${name}`);
+ return difUrl.replace(/\.dif$/i, ".gltf");
+}
+
+export function terrainTextureToUrl(name: string) {
+ name = name.replace(/^terrain\./, "");
+ return getUrlForPath(`textures/terrain/${name}.png`, `${BASE_URL}/black.png`);
+}
+
+export function interiorTextureToUrl(name: string) {
+ name = name.replace(/\.\d+$/, "");
+ return getUrlForPath(`textures/${name}.png`);
+}
+
+export function textureToUrl(name: string) {
+ try {
+ return getUrlForPath(`textures/${name}.png`);
+ } catch (err) {
+ return `${BASE_URL}/black.png`;
+ }
+}
+
+export async function loadDetailMapList(name: string) {
+ const url = getUrlForPath(`textures/${name}`);
+ const res = await fetch(url);
+ const text = await res.text();
+ return text
+ .split(/(?:\r\n|\n|\r)/)
+ .map((line) => `textures/${line.trim().replace(/\.png$/i, "")}.png`);
+}
+
+export async function loadMission(name: string) {
+ const res = await fetch(getUrlForPath(`missions/${name}.mis`));
+ const missionScript = await res.text();
+ return parseMissionScript(missionScript);
+}
+
+export async function loadTerrain(fileName: string) {
+ const res = await fetch(getUrlForPath(`terrains/${fileName}`));
+ const terrainBuffer = await res.arrayBuffer();
+ return parseTerrainBuffer(terrainBuffer);
+}
diff --git a/src/mission.ts b/src/mission.ts
index 92ddeae9..b0dfc1ae 100644
--- a/src/mission.ts
+++ b/src/mission.ts
@@ -1,4 +1,5 @@
import parser from "@/generated/mission.cjs";
+import { Quaternion, Vector3 } from "three";
const definitionComment = /^ (DisplayName|MissionTypes) = (.+)$/;
const sectionBeginComment = /^--- ([A-Z ]+) BEGIN ---$/;
@@ -175,7 +176,8 @@ export function parseMissionScript(script) {
};
}
-type Mission = ReturnType;
+export type Mission = ReturnType;
+export type ConsoleObject = Mission["objects"][number];
export function* iterObjects(objectList) {
for (const obj of objectList) {
@@ -186,18 +188,62 @@ export function* iterObjects(objectList) {
}
}
-export function getTerrainFile(mission: Mission) {
- let terrainBlock;
+export function getTerrainBlock(mission: Mission): ConsoleObject {
for (const obj of iterObjects(mission.objects)) {
if (obj.className === "TerrainBlock") {
- terrainBlock = obj;
- break;
+ return obj;
}
}
- if (!terrainBlock) {
- throw new Error("Error!");
- }
+ throw new Error("No TerrainBlock found!");
+}
+
+export function getTerrainFile(mission: Mission) {
+ const terrainBlock = getTerrainBlock(mission);
return terrainBlock.properties.find(
(prop) => prop.target.name === "terrainFile"
).value;
}
+
+export function getProperty(obj: ConsoleObject, name: string) {
+ const property = obj.properties.find((p) => p.target.name === name);
+ // console.log({ name, property });
+ return property;
+}
+
+export function getPosition(obj: ConsoleObject): [number, number, number] {
+ const position = getProperty(obj, "position")?.value ?? "0 0 0";
+ const [x, z, y] = position.split(" ").map((s) => parseFloat(s));
+ return [x, y, z];
+}
+
+export function getScale(obj: ConsoleObject): [number, number, number] {
+ const scale = getProperty(obj, "scale")?.value ?? "1 1 1";
+ const [scaleX, scaleZ, scaleY] = scale.split(" ").map((s) => parseFloat(s));
+ return [scaleX, scaleY, scaleZ];
+}
+
+export function getRotation(obj: ConsoleObject, isInterior = false) {
+ const rotation = getProperty(obj, "rotation")?.value ?? "1 0 0 0";
+ const [ax, az, ay, angle] = rotation.split(" ").map((s) => parseFloat(s));
+
+ if (isInterior) {
+ // For interiors: Apply coordinate system transformation
+ // 1. Convert rotation axis from source coords (ax, az, ay) to Three.js coords
+ // 2. Apply -90 Y rotation to align coordinate systems
+ const sourceRotation = new Quaternion().setFromAxisAngle(
+ new Vector3(az, ay, ax),
+ -angle * (Math.PI / 180)
+ );
+ const coordSystemFix = new Quaternion().setFromAxisAngle(
+ new Vector3(0, 1, 0),
+ Math.PI / 2
+ );
+ return coordSystemFix.multiply(sourceRotation);
+ } else {
+ // For other objects (terrain, etc)
+ return new Quaternion().setFromAxisAngle(
+ new Vector3(ax, ay, -az),
+ angle * (Math.PI / 180)
+ );
+ }
+}
diff --git a/src/textureUtils.ts b/src/textureUtils.ts
new file mode 100644
index 00000000..a91b10f4
--- /dev/null
+++ b/src/textureUtils.ts
@@ -0,0 +1,168 @@
+import {
+ DataTexture,
+ LinearFilter,
+ LinearMipmapLinearFilter,
+ NoColorSpace,
+ RedFormat,
+ RepeatWrapping,
+ SRGBColorSpace,
+ UnsignedByteType,
+} from "three";
+
+export function setupColor(tex, repeat = [1, 1]) {
+ tex.wrapS = tex.wrapT = RepeatWrapping;
+ tex.colorSpace = SRGBColorSpace;
+ tex.repeat.set(...repeat);
+ tex.anisotropy = 16;
+ tex.generateMipmaps = true;
+ tex.minFilter = LinearMipmapLinearFilter;
+ tex.magFilter = LinearFilter;
+
+ tex.needsUpdate = true;
+
+ return tex;
+}
+
+export function setupMask(data) {
+ const tex = new DataTexture(
+ data,
+ 256,
+ 256,
+ RedFormat, // 1 channel
+ UnsignedByteType // 8-bit
+ );
+
+ // Masks should stay linear
+ tex.colorSpace = NoColorSpace;
+
+ // Set tiling / sampling. For NPOT sizes, disable mips or use power-of-two.
+ tex.wrapS = tex.wrapT = RepeatWrapping;
+ tex.generateMipmaps = false; // if width/height are not powers of two
+ tex.minFilter = LinearFilter; // avoid mips if generateMipmaps=false
+ tex.magFilter = LinearFilter;
+
+ tex.needsUpdate = true;
+
+ return tex;
+}
+
+export function updateTerrainTextureShader({
+ shader,
+ baseTextures,
+ alphaTextures,
+ visibilityMask,
+}) {
+ const layerCount = baseTextures.length;
+
+ baseTextures.forEach((tex, i) => {
+ shader.uniforms[`albedo${i}`] = { value: tex };
+ });
+
+ alphaTextures.forEach((tex, i) => {
+ if (i > 0) {
+ shader.uniforms[`mask${i}`] = { value: tex };
+ }
+ });
+
+ // Add visibility mask uniform if we have empty squares
+ if (visibilityMask) {
+ shader.uniforms.visibilityMask = { value: visibilityMask };
+ }
+
+ // Add per-texture tiling uniforms
+ baseTextures.forEach((tex, i) => {
+ shader.uniforms[`tiling${i}`] = {
+ value: Math.min(512, { 0: 16, 1: 16, 2: 32, 3: 32, 4: 32, 5: 32 }[i]),
+ };
+ });
+
+ // Declare our uniforms at the top of the fragment shader
+ shader.fragmentShader =
+ `
+uniform sampler2D albedo0;
+uniform sampler2D albedo1;
+uniform sampler2D albedo2;
+uniform sampler2D albedo3;
+uniform sampler2D albedo4;
+uniform sampler2D albedo5;
+uniform sampler2D mask1;
+uniform sampler2D mask2;
+uniform sampler2D mask3;
+uniform sampler2D mask4;
+uniform sampler2D mask5;
+uniform float tiling0;
+uniform float tiling1;
+uniform float tiling2;
+uniform float tiling3;
+uniform float tiling4;
+uniform float tiling5;
+${visibilityMask ? "uniform sampler2D visibilityMask;" : ""}
+` + shader.fragmentShader;
+
+ if (visibilityMask) {
+ const clippingPlaceholder = "#include ";
+ shader.fragmentShader = shader.fragmentShader.replace(
+ clippingPlaceholder,
+ `${clippingPlaceholder}
+ // Early discard for invisible areas (before fog/lighting)
+ float visibility = texture2D(visibilityMask, vMapUv).r;
+ if (visibility < 0.5) {
+ discard;
+ }
+ `
+ );
+ }
+
+ // Replace the default map sampling block with our layered blend.
+ // We rely on vMapUv provided by USE_MAP.
+ shader.fragmentShader = shader.fragmentShader.replace(
+ "#include ",
+ `
+ // Sample base albedo layers (sRGB textures auto-decoded to linear)
+ vec2 baseUv = vMapUv;
+ vec3 c0 = texture2D(albedo0, baseUv * vec2(tiling0)).rgb;
+ ${
+ layerCount > 1
+ ? `vec3 c1 = texture2D(albedo1, baseUv * vec2(tiling1)).rgb;`
+ : ""
+ }
+ ${
+ layerCount > 2
+ ? `vec3 c2 = texture2D(albedo2, baseUv * vec2(tiling2)).rgb;`
+ : ""
+ }
+ ${
+ layerCount > 3
+ ? `vec3 c3 = texture2D(albedo3, baseUv * vec2(tiling3)).rgb;`
+ : ""
+ }
+ ${
+ layerCount > 4
+ ? `vec3 c4 = texture2D(albedo4, baseUv * vec2(tiling4)).rgb;`
+ : ""
+ }
+ ${
+ layerCount > 5
+ ? `vec3 c5 = texture2D(albedo5, baseUv * vec2(tiling5)).rgb;`
+ : ""
+ }
+
+ // Sample linear masks (use R channel)
+ float a1 = texture2D(mask1, baseUv).r;
+ ${layerCount > 1 ? `float a2 = texture2D(mask2, baseUv).r;` : ""}
+ ${layerCount > 2 ? `float a3 = texture2D(mask3, baseUv).r;` : ""}
+ ${layerCount > 3 ? `float a4 = texture2D(mask4, baseUv).r;` : ""}
+ ${layerCount > 4 ? `float a5 = texture2D(mask5, baseUv).r;` : ""}
+
+ // Bottom-up compositing: each mask tells how much the higher layer replaces lower
+ ${layerCount > 1 ? `vec3 blended = mix(c0, c1, clamp(a1, 0.0, 1.0));` : ""}
+ ${layerCount > 2 ? `blended = mix(blended, c2, clamp(a2, 0.0, 1.0));` : ""}
+ ${layerCount > 3 ? `blended = mix(blended, c3, clamp(a3, 0.0, 1.0));` : ""}
+ ${layerCount > 4 ? `blended = mix(blended, c4, clamp(a4, 0.0, 1.0));` : ""}
+ ${layerCount > 5 ? `blended = mix(blended, c5, clamp(a5, 0.0, 1.0));` : ""}
+
+ // Assign to diffuseColor before lighting
+ diffuseColor.rgb = ${layerCount > 1 ? "blended" : "c0"};
+`
+ );
+}