From a087231343b3964c188de70727aec9ce2cb2ddb6 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 21 Jun 2026 19:18:29 +0200 Subject: [PATCH 1/2] fix(website): encode parametric shadow fields in the gallery scene URL (and fix selfShadow decode) --- .../GalleryWorkbench/hooks/useRouteSync.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/website/src/components/GalleryWorkbench/hooks/useRouteSync.ts b/website/src/components/GalleryWorkbench/hooks/useRouteSync.ts index e52b07af..9b55c7ba 100644 --- a/website/src/components/GalleryWorkbench/hooks/useRouteSync.ts +++ b/website/src/components/GalleryWorkbench/hooks/useRouteSync.ts @@ -42,6 +42,10 @@ interface SerializedGallerySceneOptions { shadow?: boolean; self?: boolean; reach?: number; + sp?: boolean; + sd?: number; + sst?: SceneOptionsState["shadowStyle"]; + sfa?: boolean; ground?: boolean; gc?: string; fl?: boolean; @@ -101,6 +105,10 @@ const COMPACT_KEY_BY_OPTION: Record = { shadow: "S", self: "Z", reach: "E", + sp: "D", + sd: "F", + sst: "H", + sfa: "I", ground: "g", gc: "G", fl: "L", @@ -134,6 +142,7 @@ const BOOLEAN_OPTIONS = new Set([ "ap", "c", "i", "ar", "axes", "sel", "hov", "helper", "solid", "fill", "outline", "shadow", "ground", "fl", "fm", "fj", "fc", "fiy", + "self", "sp", "sfa", ]); function getRoutePresetValue(): string { @@ -274,6 +283,10 @@ function sceneOptionsPayload( addBoolean(out, "shadow", options.castShadow, defaults.castShadow); addBoolean(out, "self", options.selfShadow, defaults.selfShadow); addNumber(out, "reach", options.shadowMaxExtend, defaults.shadowMaxExtend); + addBoolean(out, "sp", options.shadowParametric, defaults.shadowParametric); + addNumber(out, "sd", options.shadowDefinition, defaults.shadowDefinition); + addString(out, "sst", options.shadowStyle, defaults.shadowStyle); + addBoolean(out, "sfa", options.shadowFollowAnimation, defaults.shadowFollowAnimation); addBoolean(out, "ground", options.showGround, defaults.showGround); addString(out, "gc", options.groundColor, defaults.groundColor); addBoolean(out, "fl", options.fpvLook, defaults.fpvLook); @@ -377,6 +390,9 @@ function encodeCompactValue(key: SerializedGallerySceneOptionKey, value: Seriali if (key === "drag" && (value === "orbit" || value === "pan" || value === "fpv")) { return encodeEnum(value, { orbit: "o", pan: "p", fpv: "f" }); } + if (key === "sst" && (value === "vector" || value === "pixel")) { + return encodeEnum(value, { vector: "v", pixel: "p" }); + } return typeof value === "string" ? value : undefined; } @@ -444,6 +460,10 @@ function isDragMode(value: unknown): value is SceneOptionsState["dragMode"] { return value === "orbit" || value === "pan" || value === "fpv"; } +function isShadowStyle(value: unknown): value is SceneOptionsState["shadowStyle"] { + return value === "vector" || value === "pixel"; +} + function isVec3(value: unknown): value is SceneTarget { return Array.isArray(value) && value.length === 3 && @@ -493,6 +513,10 @@ function sceneOptionsFromPayload(o: SerializedGallerySceneOptions): Partial Date: Sun, 21 Jun 2026 19:31:09 +0200 Subject: [PATCH 2/2] fix(website): persist FPV camera position to the scene URL on movement settle --- .../components/VanillaScene/VanillaScene.tsx | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/website/src/components/VanillaScene/VanillaScene.tsx b/website/src/components/VanillaScene/VanillaScene.tsx index 9fc648ed..5055472c 100644 --- a/website/src/components/VanillaScene/VanillaScene.tsx +++ b/website/src/components/VanillaScene/VanillaScene.tsx @@ -391,6 +391,9 @@ export function VanillaScene({ onBuildRef.current = onBuild; const onCameraChangeRef = useRef(onCameraChange); onCameraChangeRef.current = onCameraChange; + // Debounce handle for syncing FPV position to options/URL once movement + // settles (FPV emits per-frame; we only want the resting spot). + const fpvSettleTimerRef = useRef(0); const onSelectionChangeRef = useRef(onSelectionChange); onSelectionChangeRef.current = onSelectionChange; const onHoverChangeRef = useRef(onHoverChange); @@ -952,6 +955,12 @@ export function VanillaScene({ const scene = sceneRef.current; const camera = cameraRef.current; if (!scene || !camera) return; + // FPV is authoritative over the live camera. We DO sync FPV's position back + // to options (debounced, so the scene URL captures where you walked), but + // re-applying that camera here would fight FPV's own per-frame writes — its + // target is a derived look-ahead point, not the stored eye. Skip; the + // initial pose still restores via scene creation (Effect 1) on load. + if (options.dragMode === "fpv") return; camera.update({ rotX: options.rotX, rotY: options.rotY, @@ -959,7 +968,7 @@ export function VanillaScene({ target: options.target as Vec3, }); scene.applyCamera(); - }, [options.rotX, options.rotY, options.zoom, options.target]); + }, [options.rotX, options.rotY, options.zoom, options.target, options.dragMode]); // Effect 2b — lighting + shadow updates. Runs only when the light, shadow, // textureLighting, or ground color actually change (sliders, not camera). @@ -1039,11 +1048,27 @@ export function VanillaScene({ lookSensitivity: options.fpvLookSensitivity, invertY: options.fpvInvertY, }); - // FPV is authoritative over the camera while engaged — don't echo - // its per-frame writes back into React state; that round-trip fights - // the rAF tick and causes visible jitter on mouselook and walk. - // The React side picks up the final camera state when the user - // exits FPV mode (next controls rebuild reads scene.getOptions()). + // FPV is authoritative over the camera while engaged — don't echo its + // per-frame writes back into React state; that round-trip fights the + // rAF tick and jitters mouselook/walk. But once movement SETTLES + // (~900ms idle) we sync the resting pose to options so the scene URL + // captures where you walked. We store the EYE position (getOrigin) as + // `target`: on reload FPV re-seeds its origin from camera.target, so + // the eye lands back at that spot. The camera-apply effect skips fpv + // mode, so this write never fights the live controls. + fpv.addEventListener("change", () => { + if (fpvSettleTimerRef.current) window.clearTimeout(fpvSettleTimerRef.current); + fpvSettleTimerRef.current = window.setTimeout(() => { + const st = scene.camera.state; + const eye = fpv.getOrigin(); + onCameraChangeRef.current?.({ + rotX: st.rotX ?? 90, + rotY: st.rotY ?? 0, + zoom: (st.zoom ?? 1) / LEGACY_ZOOM_COMPAT, + target: [eye[0], eye[1], eye[2]], + }); + }, 900); + }); return fpv; } const factory = options.dragMode === "pan" ? createPolyMapControls : createPolyOrbitControls; @@ -1061,11 +1086,13 @@ export function VanillaScene({ return controls; }; if (controlsRef.current) controlsRef.current.destroy(); + if (fpvSettleTimerRef.current) { window.clearTimeout(fpvSettleTimerRef.current); fpvSettleTimerRef.current = 0; } controlsRef.current = buildControls(); return () => { // Effect re-runs when deps change — destroy only on full unmount, // which is signaled by the scene Effect 1 cleanup destroying scene. // Until then, the next effect run will reuse + update controlsRef. + if (fpvSettleTimerRef.current) { window.clearTimeout(fpvSettleTimerRef.current); fpvSettleTimerRef.current = 0; } }; }, [ options.renderer,