Skip to content

Commit 0e184ee

Browse files
authored
fix(website): persist parametric shadows + FPV walk position in the scene URL (#74)
* fix(website): encode parametric shadow fields in the gallery scene URL (and fix selfShadow decode) * fix(website): persist FPV camera position to the scene URL on movement settle
1 parent 6442f2c commit 0e184ee

2 files changed

Lines changed: 62 additions & 6 deletions

File tree

website/src/components/GalleryWorkbench/hooks/useRouteSync.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ interface SerializedGallerySceneOptions {
4242
shadow?: boolean;
4343
self?: boolean;
4444
reach?: number;
45+
sp?: boolean;
46+
sd?: number;
47+
sst?: SceneOptionsState["shadowStyle"];
48+
sfa?: boolean;
4549
ground?: boolean;
4650
gc?: string;
4751
fl?: boolean;
@@ -101,6 +105,10 @@ const COMPACT_KEY_BY_OPTION: Record<SerializedGallerySceneOptionKey, string> = {
101105
shadow: "S",
102106
self: "Z",
103107
reach: "E",
108+
sp: "D",
109+
sd: "F",
110+
sst: "H",
111+
sfa: "I",
104112
ground: "g",
105113
gc: "G",
106114
fl: "L",
@@ -134,6 +142,7 @@ const BOOLEAN_OPTIONS = new Set<SerializedGallerySceneOptionKey>([
134142
"ap", "c", "i", "ar", "axes", "sel", "hov", "helper",
135143
"solid", "fill", "outline", "shadow", "ground",
136144
"fl", "fm", "fj", "fc", "fiy",
145+
"self", "sp", "sfa",
137146
]);
138147

139148
function getRoutePresetValue(): string {
@@ -274,6 +283,10 @@ function sceneOptionsPayload(
274283
addBoolean(out, "shadow", options.castShadow, defaults.castShadow);
275284
addBoolean(out, "self", options.selfShadow, defaults.selfShadow);
276285
addNumber(out, "reach", options.shadowMaxExtend, defaults.shadowMaxExtend);
286+
addBoolean(out, "sp", options.shadowParametric, defaults.shadowParametric);
287+
addNumber(out, "sd", options.shadowDefinition, defaults.shadowDefinition);
288+
addString(out, "sst", options.shadowStyle, defaults.shadowStyle);
289+
addBoolean(out, "sfa", options.shadowFollowAnimation, defaults.shadowFollowAnimation);
277290
addBoolean(out, "ground", options.showGround, defaults.showGround);
278291
addString(out, "gc", options.groundColor, defaults.groundColor);
279292
addBoolean(out, "fl", options.fpvLook, defaults.fpvLook);
@@ -377,6 +390,9 @@ function encodeCompactValue(key: SerializedGallerySceneOptionKey, value: Seriali
377390
if (key === "drag" && (value === "orbit" || value === "pan" || value === "fpv")) {
378391
return encodeEnum(value, { orbit: "o", pan: "p", fpv: "f" });
379392
}
393+
if (key === "sst" && (value === "vector" || value === "pixel")) {
394+
return encodeEnum(value, { vector: "v", pixel: "p" });
395+
}
380396
return typeof value === "string" ? value : undefined;
381397
}
382398

@@ -444,6 +460,10 @@ function isDragMode(value: unknown): value is SceneOptionsState["dragMode"] {
444460
return value === "orbit" || value === "pan" || value === "fpv";
445461
}
446462

463+
function isShadowStyle(value: unknown): value is SceneOptionsState["shadowStyle"] {
464+
return value === "vector" || value === "pixel";
465+
}
466+
447467
function isVec3(value: unknown): value is SceneTarget {
448468
return Array.isArray(value) &&
449469
value.length === 3 &&
@@ -493,6 +513,10 @@ function sceneOptionsFromPayload(o: SerializedGallerySceneOptions): Partial<Scen
493513
...(isBoolean(o.shadow) ? { castShadow: o.shadow } : null),
494514
...(isBoolean(o.self) ? { selfShadow: o.self } : null),
495515
...(isFiniteNumber(o.reach) ? { shadowMaxExtend: o.reach } : null),
516+
...(isBoolean(o.sp) ? { shadowParametric: o.sp } : null),
517+
...(isFiniteNumber(o.sd) ? { shadowDefinition: o.sd } : null),
518+
...(isShadowStyle(o.sst) ? { shadowStyle: o.sst } : null),
519+
...(isBoolean(o.sfa) ? { shadowFollowAnimation: o.sfa } : null),
496520
...(isBoolean(o.ground) ? { showGround: o.ground } : null),
497521
...(isHexColor(o.gc) ? { groundColor: o.gc.toLowerCase() } : null),
498522
...(isBoolean(o.fl) ? { fpvLook: o.fl } : null),
@@ -527,6 +551,7 @@ function decodeDottedCompactValue(key: SerializedGallerySceneOptionKey, value: s
527551
if (key === "mp" || key === "bp") return value === "e" ? "exact" : value;
528552
if (key === "mr") return decodeEnum(value, { lossless: "x", lossy: "y", disabled: "d" });
529553
if (key === "drag") return decodeEnum(value, { orbit: "o", pan: "p", fpv: "f" });
554+
if (key === "sst") return decodeEnum(value, { vector: "v", pixel: "p" });
530555
return decodeCompactNumber(value) ?? value;
531556
}
532557

@@ -604,6 +629,10 @@ function readPackedValue(
604629
const value = decodeEnum(routeValue[index] ?? "", { orbit: "o", pan: "p", fpv: "f" });
605630
return value ? { value, next: index + 1 } : undefined;
606631
}
632+
if (key === "sst") {
633+
const value = decodeEnum(routeValue[index] ?? "", { vector: "v", pixel: "p" });
634+
return value ? { value, next: index + 1 } : undefined;
635+
}
607636
return readPackedNumber(routeValue, index);
608637
}
609638

website/src/components/VanillaScene/VanillaScene.tsx

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,9 @@ export function VanillaScene({
391391
onBuildRef.current = onBuild;
392392
const onCameraChangeRef = useRef(onCameraChange);
393393
onCameraChangeRef.current = onCameraChange;
394+
// Debounce handle for syncing FPV position to options/URL once movement
395+
// settles (FPV emits per-frame; we only want the resting spot).
396+
const fpvSettleTimerRef = useRef(0);
394397
const onSelectionChangeRef = useRef(onSelectionChange);
395398
onSelectionChangeRef.current = onSelectionChange;
396399
const onHoverChangeRef = useRef(onHoverChange);
@@ -952,14 +955,20 @@ export function VanillaScene({
952955
const scene = sceneRef.current;
953956
const camera = cameraRef.current;
954957
if (!scene || !camera) return;
958+
// FPV is authoritative over the live camera. We DO sync FPV's position back
959+
// to options (debounced, so the scene URL captures where you walked), but
960+
// re-applying that camera here would fight FPV's own per-frame writes — its
961+
// target is a derived look-ahead point, not the stored eye. Skip; the
962+
// initial pose still restores via scene creation (Effect 1) on load.
963+
if (options.dragMode === "fpv") return;
955964
camera.update({
956965
rotX: options.rotX,
957966
rotY: options.rotY,
958967
zoom: options.zoom * LEGACY_ZOOM_COMPAT,
959968
target: options.target as Vec3,
960969
});
961970
scene.applyCamera();
962-
}, [options.rotX, options.rotY, options.zoom, options.target]);
971+
}, [options.rotX, options.rotY, options.zoom, options.target, options.dragMode]);
963972

964973
// Effect 2b — lighting + shadow updates. Runs only when the light, shadow,
965974
// textureLighting, or ground color actually change (sliders, not camera).
@@ -1039,11 +1048,27 @@ export function VanillaScene({
10391048
lookSensitivity: options.fpvLookSensitivity,
10401049
invertY: options.fpvInvertY,
10411050
});
1042-
// FPV is authoritative over the camera while engaged — don't echo
1043-
// its per-frame writes back into React state; that round-trip fights
1044-
// the rAF tick and causes visible jitter on mouselook and walk.
1045-
// The React side picks up the final camera state when the user
1046-
// exits FPV mode (next controls rebuild reads scene.getOptions()).
1051+
// FPV is authoritative over the camera while engaged — don't echo its
1052+
// per-frame writes back into React state; that round-trip fights the
1053+
// rAF tick and jitters mouselook/walk. But once movement SETTLES
1054+
// (~900ms idle) we sync the resting pose to options so the scene URL
1055+
// captures where you walked. We store the EYE position (getOrigin) as
1056+
// `target`: on reload FPV re-seeds its origin from camera.target, so
1057+
// the eye lands back at that spot. The camera-apply effect skips fpv
1058+
// mode, so this write never fights the live controls.
1059+
fpv.addEventListener("change", () => {
1060+
if (fpvSettleTimerRef.current) window.clearTimeout(fpvSettleTimerRef.current);
1061+
fpvSettleTimerRef.current = window.setTimeout(() => {
1062+
const st = scene.camera.state;
1063+
const eye = fpv.getOrigin();
1064+
onCameraChangeRef.current?.({
1065+
rotX: st.rotX ?? 90,
1066+
rotY: st.rotY ?? 0,
1067+
zoom: (st.zoom ?? 1) / LEGACY_ZOOM_COMPAT,
1068+
target: [eye[0], eye[1], eye[2]],
1069+
});
1070+
}, 900);
1071+
});
10471072
return fpv;
10481073
}
10491074
const factory = options.dragMode === "pan" ? createPolyMapControls : createPolyOrbitControls;
@@ -1061,11 +1086,13 @@ export function VanillaScene({
10611086
return controls;
10621087
};
10631088
if (controlsRef.current) controlsRef.current.destroy();
1089+
if (fpvSettleTimerRef.current) { window.clearTimeout(fpvSettleTimerRef.current); fpvSettleTimerRef.current = 0; }
10641090
controlsRef.current = buildControls();
10651091
return () => {
10661092
// Effect re-runs when deps change — destroy only on full unmount,
10671093
// which is signaled by the scene Effect 1 cleanup destroying scene.
10681094
// Until then, the next effect run will reuse + update controlsRef.
1095+
if (fpvSettleTimerRef.current) { window.clearTimeout(fpvSettleTimerRef.current); fpvSettleTimerRef.current = 0; }
10691096
};
10701097
}, [
10711098
options.renderer,

0 commit comments

Comments
 (0)