Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions website/src/components/GalleryWorkbench/hooks/useRouteSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -101,6 +105,10 @@ const COMPACT_KEY_BY_OPTION: Record<SerializedGallerySceneOptionKey, string> = {
shadow: "S",
self: "Z",
reach: "E",
sp: "D",
sd: "F",
sst: "H",
sfa: "I",
ground: "g",
gc: "G",
fl: "L",
Expand Down Expand Up @@ -134,6 +142,7 @@ const BOOLEAN_OPTIONS = new Set<SerializedGallerySceneOptionKey>([
"ap", "c", "i", "ar", "axes", "sel", "hov", "helper",
"solid", "fill", "outline", "shadow", "ground",
"fl", "fm", "fj", "fc", "fiy",
"self", "sp", "sfa",
]);

function getRoutePresetValue(): string {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -493,6 +513,10 @@ function sceneOptionsFromPayload(o: SerializedGallerySceneOptions): Partial<Scen
...(isBoolean(o.shadow) ? { castShadow: o.shadow } : null),
...(isBoolean(o.self) ? { selfShadow: o.self } : null),
...(isFiniteNumber(o.reach) ? { shadowMaxExtend: o.reach } : null),
...(isBoolean(o.sp) ? { shadowParametric: o.sp } : null),
...(isFiniteNumber(o.sd) ? { shadowDefinition: o.sd } : null),
...(isShadowStyle(o.sst) ? { shadowStyle: o.sst } : null),
...(isBoolean(o.sfa) ? { shadowFollowAnimation: o.sfa } : null),
...(isBoolean(o.ground) ? { showGround: o.ground } : null),
...(isHexColor(o.gc) ? { groundColor: o.gc.toLowerCase() } : null),
...(isBoolean(o.fl) ? { fpvLook: o.fl } : null),
Expand Down Expand Up @@ -527,6 +551,7 @@ function decodeDottedCompactValue(key: SerializedGallerySceneOptionKey, value: s
if (key === "mp" || key === "bp") return value === "e" ? "exact" : value;
if (key === "mr") return decodeEnum(value, { lossless: "x", lossy: "y", disabled: "d" });
if (key === "drag") return decodeEnum(value, { orbit: "o", pan: "p", fpv: "f" });
if (key === "sst") return decodeEnum(value, { vector: "v", pixel: "p" });
return decodeCompactNumber(value) ?? value;
}

Expand Down Expand Up @@ -604,6 +629,10 @@ function readPackedValue(
const value = decodeEnum(routeValue[index] ?? "", { orbit: "o", pan: "p", fpv: "f" });
return value ? { value, next: index + 1 } : undefined;
}
if (key === "sst") {
const value = decodeEnum(routeValue[index] ?? "", { vector: "v", pixel: "p" });
return value ? { value, next: index + 1 } : undefined;
}
return readPackedNumber(routeValue, index);
}

Expand Down
39 changes: 33 additions & 6 deletions website/src/components/VanillaScene/VanillaScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -952,14 +955,20 @@ 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,
zoom: options.zoom * LEGACY_ZOOM_COMPAT,
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).
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down
Loading