improve query param handling, add support for linking too coords

This commit is contained in:
Brian Beck 2025-12-16 17:31:13 -08:00
parent 2a36c18f92
commit 55c1067682
39 changed files with 939 additions and 678 deletions

View file

@ -36,8 +36,13 @@ export function useCameras() {
export function CamerasProvider({ children }: { children: ReactNode }) {
const { camera } = useThree();
const [cameraIndex, setCameraIndex] = useState(0);
const [cameraIndex, setCameraIndex] = useState(-1);
const [cameraMap, setCameraMap] = useState<Record<string, CameraEntry>>({});
const [initialViewState, setInitialViewState] = useState(() => ({
initialized: false,
position: null,
quarternion: null,
}));
const registerCamera = useCallback((camera: CameraEntry) => {
setCameraMap((prevCameraMap) => ({
@ -55,38 +60,70 @@ export function CamerasProvider({ children }: { children: ReactNode }) {
const cameraCount = Object.keys(cameraMap).length;
const nextCamera = useCallback(() => {
setCameraIndex((prev) => {
if (cameraCount === 0) {
return 0;
}
return (prev + 1) % cameraCount;
});
}, [cameraCount]);
const setCamera = useCallback(
(index: number) => {
console.log(`[CamerasProvider] setCamera(${index})`);
if (index >= 0 && index < cameraCount) {
console.log(`[CamerasProvider] setCameraIndex(${index})`);
setCameraIndex(index);
const cameraId = Object.keys(cameraMap)[index];
const cameraInfo = cameraMap[cameraId];
camera.position.copy(cameraInfo.position);
// Apply coordinate system correction for Torque3D to Three.js
const correction = new Quaternion().setFromAxisAngle(
new Vector3(0, 1, 0),
-Math.PI / 2,
);
camera.quaternion.copy(cameraInfo.rotation).multiply(correction);
console.log(`[CamerasProvider] Done updating camera.`);
}
},
[cameraCount],
[camera, cameraCount, cameraMap],
);
const nextCamera = useCallback(() => {
console.log(`[CamerasProvider] nextCamera()`, cameraCount, cameraIndex);
setCamera(cameraCount ? (cameraIndex + 1) % cameraCount : -1);
}, [cameraCount, cameraIndex, setCamera]);
useEffect(() => {
const cameraCount = Object.keys(cameraMap).length;
if (cameraIndex < cameraCount) {
const cameraId = Object.keys(cameraMap)[cameraIndex];
const cameraInfo = cameraMap[cameraId];
camera.position.copy(cameraInfo.position);
// Apply coordinate system correction for Torque3D to Three.js
const correction = new Quaternion().setFromAxisAngle(
new Vector3(0, 1, 0),
-Math.PI / 2,
);
camera.quaternion.copy(cameraInfo.rotation).multiply(correction);
const hash = window.location.hash;
if (hash.startsWith("#c")) {
const [positionString, quarternionString] = hash.slice(2).split("~");
const position = positionString.split(",").map((s) => parseFloat(s));
const quarternion = quarternionString
.split(",")
.map((s) => parseFloat(s));
setInitialViewState({
initialized: true,
position: new Vector3(...position),
quarternion: new Quaternion(...quarternion),
});
} else {
setInitialViewState({
initialized: true,
position: null,
quarternion: null,
});
}
}, [cameraIndex, cameraMap, camera]);
}, []);
useEffect(() => {
if (initialViewState.initialized && initialViewState.position) {
camera.position.copy(initialViewState.position);
if (initialViewState.quarternion) {
camera.quaternion.copy(initialViewState.quarternion);
}
}
}, [initialViewState]);
useEffect(() => {
if (!initialViewState.initialized || initialViewState.position) return;
if (cameraCount > 0 && cameraIndex === -1) {
console.log(`[CamerasProvider] setCamera(0) in useEffect`);
setCamera(0);
}
}, [cameraCount, setCamera, cameraIndex]);
const context: CamerasContextValue = useMemo(
() => ({

View file

@ -0,0 +1,60 @@
import { RefObject, useCallback, useRef, useState } from "react";
import { FaMapPin } from "react-icons/fa";
import { FaClipboardCheck } from "react-icons/fa6";
import { Camera, Quaternion, Vector3 } from "three";
function encodeViewHash({
position,
quaternion,
}: {
position: Vector3;
quaternion: Quaternion;
}) {
const trunc = (num: number) => parseFloat(num.toFixed(3));
const encodedPosition = `${trunc(position.x)},${trunc(position.y)},${trunc(position.z)}`;
const encodedQuaternion = `${trunc(quaternion.x)},${trunc(quaternion.y)},${trunc(quaternion.z)},${trunc(quaternion.w)}`;
return `#c${encodedPosition}~${encodedQuaternion}`;
}
export function CopyCoordinatesButton({
cameraRef,
}: {
cameraRef: RefObject<Camera | null>;
}) {
const [showCopied, setShowCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCopyLink = useCallback(async () => {
clearTimeout(timerRef.current);
const camera = cameraRef.current;
if (!camera) return;
const hash = encodeViewHash(camera);
// Update the URL hash
const fullPath = `${window.location.pathname}${window.location.search}${hash}`;
const fullUrl = `${window.location.origin}${fullPath}`;
window.history.replaceState(null, "", fullPath);
try {
await navigator.clipboard.writeText(fullUrl);
setShowCopied(true);
timerRef.current = setTimeout(() => {
setShowCopied(false);
}, 1300);
} catch (err) {
console.error(err);
}
}, [cameraRef]);
return (
<button
type="button"
className="IconButton CopyCoordinatesButton"
aria-label="Copy coordinates URL"
title="Copy coordinates URL"
onClick={handleCopyLink}
data-copied={showCopied ? "true" : "false"}
>
<FaMapPin className="MapPin" />
<FaClipboardCheck className="ClipboardCheck" />
</button>
);
}

View file

@ -1,10 +1,14 @@
import { useControls, useDebug, useSettings } from "./SettingsProvider";
import { MissionSelect } from "./MissionSelect";
import { RefObject } from "react";
import { Camera } from "three";
import { CopyCoordinatesButton } from "./CopyCoordinatesButton";
export function InspectorControls({
missionName,
missionType,
onChangeMission,
cameraRef,
}: {
missionName: string;
missionType: string;
@ -15,6 +19,7 @@ export function InspectorControls({
missionName: string;
missionType: string;
}) => void;
cameraRef: RefObject<Camera | null>;
}) {
const {
fogEnabled,
@ -41,6 +46,7 @@ export function InspectorControls({
missionType={missionType}
onChange={onChangeMission}
/>
<CopyCoordinatesButton cameraRef={cameraRef} />
<div className="CheckboxField">
<input
id="fogInput"

View file

@ -139,7 +139,6 @@ function useExecutedMission(
interface MissionProps {
name: string;
missionType: string;
setMissionType: (type: string) => void;
onLoadingChange?: (isLoading: boolean, progress?: number) => void;
}

View file

@ -212,7 +212,7 @@ export function MissionSelect({
if (!searchValue)
return { type: "grouped" as const, groups: defaultGroups };
const matches = matchSorter(allMissions, searchValue, {
keys: ["displayName", "missionName"],
keys: ["displayName", "missionName", "missionTypes", "groupName"],
});
return { type: "flat" as const, missions: matches };
}, [searchValue]);

View file

@ -45,8 +45,15 @@ function CameraMovement() {
const controls = new PointerLockControls(camera, gl.domElement);
controlsRef.current = controls;
return () => {
controls.dispose();
};
}, [camera, gl.domElement]);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (controls.isLocked) {
const controls = controlsRef.current;
if (!controls || controls.isLocked) {
nextCamera();
} else if (e.target === gl.domElement) {
// Only lock if clicking directly on the canvas (not on UI elements)
@ -58,9 +65,8 @@ function CameraMovement() {
return () => {
document.removeEventListener("click", handleClick);
controls.dispose();
};
}, [camera, gl, nextCamera]);
}, [nextCamera]);
// Handle number keys 1-9 for camera selection
useEffect(() => {