From 76e9f68e638e512a468780abac71f5ae4f54eb8f Mon Sep 17 00:00:00 2001 From: Brian Beck Date: Thu, 13 Nov 2025 22:55:58 -0800 Subject: [PATCH] migrate to react-three-fiber --- app/InspectorControls.tsx | 50 +++ app/InteriorInstance.tsx | 91 +++++ app/Mission.tsx | 31 ++ app/ObserverControls.tsx | 95 +++++ app/SettingsProvider.tsx | 23 ++ app/SimGroup.tsx | 6 + app/Sky.tsx | 94 +++++ app/TerrainBlock.tsx | 220 +++++++++++ app/WaterBlock.tsx | 10 + app/page.tsx | 760 ++------------------------------------ app/renderObject.tsx | 19 + app/style.css | 4 +- package-lock.json | 408 +++++++++++++++++++- package.json | 2 + src/arrayUtils.ts | 7 + src/loaders.ts | 69 ++++ src/mission.ts | 62 +++- src/textureUtils.ts | 168 +++++++++ 18 files changed, 1367 insertions(+), 752 deletions(-) create mode 100644 app/InspectorControls.tsx create mode 100644 app/Mission.tsx create mode 100644 app/ObserverControls.tsx create mode 100644 app/SettingsProvider.tsx create mode 100644 app/SimGroup.tsx create mode 100644 app/renderObject.tsx create mode 100644 src/arrayUtils.ts create mode 100644 src/loaders.ts create mode 100644 src/textureUtils.ts 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 ( -
- -
- -
- { - setFogEnabled(event.target.checked); - }} + + +
+ + + + + + - -
-
-
+ + + ); } 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"}; +` + ); +}