From fb8b5762d7a704c744603ab147d83f85a4805aba Mon Sep 17 00:00:00 2001 From: Vicente Cartas Espinel Date: Fri, 14 Aug 2026 17:45:55 -0700 Subject: [PATCH 01/10] Add camera presets to the Sandbox Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tools/sandbox/src/components/footer.tsx | 110 +++- .../sandbox/src/components/reflectorZone.tsx | 2 +- .../sandbox/src/components/renderingZone.tsx | 80 ++- packages/tools/sandbox/src/globalState.ts | 17 +- .../sandbox/src/img/icon-camera-preset.svg | 1 + packages/tools/sandbox/src/main.ts | 6 +- packages/tools/sandbox/src/sandbox.tsx | 2 + packages/tools/sandbox/src/scss/footer.scss | 8 + .../tools/cameraPresetInspectorService.tsx | 96 ++++ .../sandbox/src/tools/cameraPresetManager.ts | 543 ++++++++++++++++++ .../sandbox/test/interaction.sandbox.test.ts | 216 ++++++- .../test/unit/cameraPresetManager.test.ts | 326 +++++++++++ .../tools/sandbox/test/unit/footer.test.tsx | 87 +++ 13 files changed, 1434 insertions(+), 60 deletions(-) create mode 100644 packages/tools/sandbox/src/img/icon-camera-preset.svg create mode 100644 packages/tools/sandbox/src/tools/cameraPresetInspectorService.tsx create mode 100644 packages/tools/sandbox/src/tools/cameraPresetManager.ts create mode 100644 packages/tools/sandbox/test/unit/cameraPresetManager.test.ts create mode 100644 packages/tools/sandbox/test/unit/footer.test.tsx diff --git a/packages/tools/sandbox/src/components/footer.tsx b/packages/tools/sandbox/src/components/footer.tsx index 100c03343cf4..11c5ae8b0e23 100644 --- a/packages/tools/sandbox/src/components/footer.tsx +++ b/packages/tools/sandbox/src/components/footer.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { type GlobalState } from "../globalState"; +import { type GlobalState, type SandboxSceneLoadKind } from "../globalState"; import { FooterButton } from "./footerButton"; import { DropUpButton } from "./dropUpButton"; import { EnvironmentTools } from "../tools/environmentTools"; @@ -8,6 +8,9 @@ import { AnimationBar } from "./animationBar"; import { type Nullable } from "core/types"; import { type KHR_materials_variants } from "loaders/glTF/2.0/Extensions/KHR_materials_variants"; import { type Mesh } from "core/Meshes/mesh"; +import { type Camera } from "core/Cameras/camera"; +import { type Observer } from "core/Misc/observable"; +import { DefaultCameraPresetOption } from "../tools/cameraPresetManager"; import "../scss/footer.scss"; import babylonIdentity from "../img/babylon-identity.svg"; @@ -15,6 +18,7 @@ import iconEdit from "../img/icon-edit.svg"; import iconOpen from "../img/icon-open.svg"; import iconIBL from "../img/icon-ibl.svg"; import iconCameras from "../img/icon-cameras.svg"; +import iconCameraPreset from "../img/icon-camera-preset.svg"; import iconVariants from "../img/icon-variants.svg"; interface IFooterProps { @@ -27,20 +31,41 @@ interface IFooterState {} * Footer */ export class Footer extends React.Component { - private _cameraNames: string[] = []; + private _cameras: Camera[] = []; + private _sceneHadCameras = false; + private _sceneLoadKind: SandboxSceneLoadKind = "scene"; + private readonly _onSceneLoadedObserver: Nullable>; + private readonly _onCameraChangedObserver: Nullable>; + private readonly _onCameraPresetChangedObserver: Nullable>; public constructor(props: IFooterProps) { super(props); - props.globalState.onSceneLoaded.add(() => { - this._updateCameraNames(); + this._onSceneLoadedObserver = props.globalState.onSceneLoaded.add((info) => { + this._sceneHadCameras = info.scene.cameras.length > 0; + this._sceneLoadKind = info.loadKind; + this._updateCameras(info.scene); this.forceUpdate(); }); - if (props.globalState.currentScene) { - this._updateCameraNames(); + this._onCameraChangedObserver = props.globalState.onCameraChanged.add(() => { + this._updateCameras(); + this.forceUpdate(); + }); + this._onCameraPresetChangedObserver = props.globalState.cameraPresetManager.onChanged.add(() => { this.forceUpdate(); + }); + if (props.globalState.currentScene) { + this._sceneHadCameras = props.globalState.currentSceneHadCameras; + this._sceneLoadKind = props.globalState.currentSceneLoadKind; + this._updateCameras(props.globalState.currentScene); } } + override componentWillUnmount() { + this._onSceneLoadedObserver?.remove(); + this._onCameraChangedObserver?.remove(); + this._onCameraPresetChangedObserver?.remove(); + } + showInspector() { if (this.props.globalState.currentScene) { if (this.props.globalState.isDebugLayerEnabled) { @@ -52,22 +77,42 @@ export class Footer extends React.Component { } switchCamera(index: number) { - const camera = this.props.globalState.currentScene.cameras[index]; + const scene = this.props.globalState.currentScene; + const camera = this._cameras[index]; - if (camera) { - if (this.props.globalState.currentScene.activeCamera) { - this.props.globalState.currentScene.activeCamera.detachControl(); + if (scene && camera) { + const activeCamera = this.props.globalState.cameraPresetManager.deactivatePreset(scene, camera); + if (activeCamera) { + this.props.globalState.onCameraChanged.notifyObservers(activeCamera); } - this.props.globalState.currentScene.activeCamera = camera; - camera.attachControl(); } } - private _updateCameraNames(): void { - if (!!this.props.globalState.currentScene && this.props.globalState.currentScene.cameras.length > 0) { - this._cameraNames = this.props.globalState.currentScene.cameras.map((c) => c.name); - this._cameraNames.push("default camera"); + switchCameraPreset(index: number) { + const scene = this.props.globalState.currentScene; + if (!scene) { + return; + } + + if (index === 0) { + const camera = this.props.globalState.cameraPresetManager.deactivatePreset(scene); + if (camera) { + this.props.globalState.onCameraChanged.notifyObservers(camera); + } + return; } + + const preset = this.props.globalState.cameraPresetManager.presets[index - 1]; + if (preset && this.props.globalState.currentSceneLoadKind === "scene") { + const camera = this.props.globalState.cameraPresetManager.activatePreset(preset.id, scene); + if (camera) { + this.props.globalState.onCameraChanged.notifyObservers(camera); + } + } + } + + private _updateCameras(scene = this.props.globalState.currentScene): void { + this._cameras = scene ? scene.cameras.filter((camera) => !this.props.globalState.cameraPresetManager.isPresetCamera(camera)) : []; } private _getVariantsExtension(): Nullable { @@ -114,13 +159,21 @@ export class Footer extends React.Component { } } - const hasCameras = this._cameraNames.length > 1; + const cameraNames = this._cameras.map((camera) => camera.name); + const cameraPresets = this.props.globalState.cameraPresetManager.presets; + const cameraPresetNames = [DefaultCameraPresetOption, ...cameraPresets.map((preset) => preset.name)]; + // A scene that arrived with one embedded camera historically showed this control; a camera-less scene with one generated camera did not. + const hasCameras = cameraNames.length > 1 || (cameraNames.length === 1 && this._sceneHadCameras); + const hasCameraPresets = !!this.props.globalState.currentScene && cameraPresets.length > 0 && this._sceneLoadKind === "scene"; // Determine footer class based on which controls are present let footerClass = "footer"; - if (hasCameras && hasVariants) { + const optionalControlCount = Number(hasCameras) + Number(hasCameraPresets) + Number(hasVariants); + if (optionalControlCount === 3) { + footerClass += " longest"; + } else if (optionalControlCount === 2) { footerClass += " longer"; - } else if (hasCameras || hasVariants) { + } else if (optionalControlCount === 1) { footerClass += " long"; } @@ -162,12 +215,27 @@ export class Footer extends React.Component { globalState={this.props.globalState} icon={iconCameras} label="Select camera" - options={this._cameraNames} + options={cameraNames} activeEntry={() => this.props.globalState.currentScene?.activeCamera?.name || ""} onOptionPicked={(option, index) => this.switchCamera(index)} - enabled={this._cameraNames.length > 1} + enabled={hasCameras} searchPlaceholder="Search camera" /> + { + const activeCamera = this.props.globalState.currentScene?.activeCamera; + return activeCamera && this.props.globalState.cameraPresetManager.isPresetCamera(activeCamera) + ? (this.props.globalState.cameraPresetManager.activePreset?.name ?? DefaultCameraPresetOption) + : DefaultCameraPresetOption; + }} + onOptionPicked={(option, index) => this.switchCameraPreset(index)} + enabled={hasCameraPresets} + searchPlaceholder="Search camera preset" + /> { private _scene: Scene; private _canvas: HTMLCanvasElement; private _restoreInspector = false; + private readonly _texturePreviewScenes = new WeakSet(); public constructor(props: IRenderingZoneProps) { super(props); @@ -210,30 +220,30 @@ export class RenderingZone extends React.Component { }); } - prepareCamera() { - let camera = this._scene.activeCamera as ArcRotateCamera; + prepareCamera(): Camera { + let camera = this._scene.activeCamera; // Attach camera to canvas inputs if (!camera) { this._scene.createDefaultCamera(true); - camera = this._scene.activeCamera! as ArcRotateCamera; + const defaultCamera = this._scene.activeCamera! as ArcRotateCamera; if (this._currentPluginName === "gltf" || this._currentPluginName === "obj" || this._currentPluginName === "fbx") { // glTF assets use a +Z forward convention while the default camera faces +Z. Rotate the camera to look at the front of the asset. // We do this same for obj as it matches other viewers, but obj does not specify a forward convention. // The FBX loader applies the same right-handed-to-left-handed flip as glTF, so its assets share the +Z forward convention. - camera.alpha += Math.PI; + defaultCamera.alpha += Math.PI; } // Enable camera's behaviors - camera.useFramingBehavior = true; + defaultCamera.useFramingBehavior = true; - const framingBehavior = camera.getBehaviorByName("Framing") as FramingBehavior; + const framingBehavior = defaultCamera.getBehaviorByName("Framing") as FramingBehavior; framingBehavior.framingTime = 0; framingBehavior.elevationReturnTime = -1; if (this._scene.meshes.length) { - camera.lowerRadiusLimit = null; + defaultCamera.lowerRadiusLimit = null; const worldExtends = this._scene.getWorldExtends(function (mesh) { return mesh.isVisible && mesh.isEnabled(); @@ -242,20 +252,22 @@ export class RenderingZone extends React.Component { } if (this.props.globalState.autoRotate) { - camera.useAutoRotationBehavior = true; + defaultCamera.useAutoRotationBehavior = true; } - camera.pinchPrecision = 200 / camera.radius; - camera.upperRadiusLimit = 5 * camera.radius; + defaultCamera.pinchPrecision = 200 / defaultCamera.radius; + defaultCamera.upperRadiusLimit = 5 * defaultCamera.radius; - camera.wheelDeltaPercentage = 0.01; - camera.pinchDeltaPercentage = 0.01; + defaultCamera.wheelDeltaPercentage = 0.01; + defaultCamera.pinchDeltaPercentage = 0.01; if (this.props.globalState.cameraPosition) { - camera.lowerRadiusLimit = null; - camera.setPosition(this.props.globalState.cameraPosition); - camera.lowerRadiusLimit = camera.radius; + defaultCamera.lowerRadiusLimit = null; + defaultCamera.setPosition(this.props.globalState.cameraPosition); + defaultCamera.lowerRadiusLimit = defaultCamera.radius; } + + camera = defaultCamera; } camera.attachControl(); @@ -274,13 +286,10 @@ export class RenderingZone extends React.Component { // this._canvas.style.opacity = "1"; const camera = this._scene.activeCamera! as ArcRotateCamera; if (camera.keysUp) { - camera.keysUp.push(90); // Z - camera.keysUp.push(87); // W - camera.keysDown.push(83); // S - camera.keysLeft.push(65); // A - camera.keysLeft.push(81); // Q - camera.keysRight.push(69); // E - camera.keysRight.push(68); // D + AddMissingKeys(camera.keysUp, [90, 87]); // Z, W + AddMissingKeys(camera.keysDown, [83]); // S + AddMissingKeys(camera.keysLeft, [65, 81]); // A, Q + AddMissingKeys(camera.keysRight, [69, 68]); // E, D } } } @@ -314,6 +323,7 @@ export class RenderingZone extends React.Component { } onSceneLoaded(filename: string) { + const loadKind = this._texturePreviewScenes.has(this._scene) ? "texture" : "scene"; this._scene.skipFrustumClipping = true; if (this.props.globalState.toneMapping !== undefined) { @@ -324,7 +334,7 @@ export class RenderingZone extends React.Component { this._scene.imageProcessingConfiguration.toneMappingType = ImageProcessingConfiguration.TONEMAPPING_KHR_PBR_NEUTRAL; } - this.props.globalState.onSceneLoaded.notifyObservers({ scene: this._scene, filename: filename }); + this.props.globalState.onSceneLoaded.notifyObservers({ scene: this._scene, filename: filename, loadKind }); // The FBX loader creates animation groups but does not auto-play them the way the glTF loader does. // Treat FBX assets like glTF in the Sandbox by playing the first animation group (looped) on load. @@ -332,7 +342,13 @@ export class RenderingZone extends React.Component { this._scene.animationGroups[0].start(true); } - const camera = this.prepareCamera(); + this.prepareCamera(); + if (loadKind === "scene") { + this.props.globalState.cameraPresetManager.applyActivePreset(this._scene); + } + if (this._scene.activeCamera) { + this.props.globalState.onCameraChanged.notifyObservers(this._scene.activeCamera); + } this.prepareLighting(); this.handleErrors(); @@ -345,11 +361,14 @@ export class RenderingZone extends React.Component { this._engine.runRenderLoop(() => { // NOTE: this logic to adjust camera parameters based on radius is copied in viewer.ts. // Please keep them in sync. - // Adapt the camera sensibility based on the distance to the object - camera.panningSensibility = 5000 / camera.radius; - // Update the camera speed based on the camera's distance from the target. - // TODO: This makes mouse wheel zooming behave well, but makes mouse based rotation a bit worse. - camera.speed = camera.radius * 0.2; + const activeCamera = this._scene.activeCamera; + if (activeCamera instanceof ArcRotateCamera && activeCamera.radius > 0) { + // Adapt the camera sensibility based on the distance to the object + activeCamera.panningSensibility = 5000 / activeCamera.radius; + // Update the camera speed based on the camera's distance from the target. + // TODO: This makes mouse wheel zooming behave well, but makes mouse based rotation a bit worse. + activeCamera.speed = activeCamera.radius * 0.2; + } this._scene.render(); }); }); @@ -359,6 +378,7 @@ export class RenderingZone extends React.Component { loadTextureAsset(url: string): Scene { const scene = new Scene(this._engine); + this._texturePreviewScenes.add(scene); const prevousUseOpenGLOrientationForUV = useOpenGLOrientationForUV; setOpenGLOrientationForUV(true); diff --git a/packages/tools/sandbox/src/globalState.ts b/packages/tools/sandbox/src/globalState.ts index 795a3f15a56a..5194fae036e5 100644 --- a/packages/tools/sandbox/src/globalState.ts +++ b/packages/tools/sandbox/src/globalState.ts @@ -1,13 +1,19 @@ -import { type FilesInput, type IDisposable, type Nullable, type Scene, type Vector3 } from "core/index"; +import { type Camera, type FilesInput, type IDisposable, type Nullable, type Scene, type Vector3 } from "core/index"; import { Observable } from "core/Misc/observable"; +import { CameraPresetManager } from "./tools/cameraPresetManager"; +import { MakeCameraPresetInspectorServiceDefinition } from "./tools/cameraPresetInspectorService"; -type InspectorV2Module = typeof import("inspector/legacy/legacy") & typeof import("inspector/index"); +export type InspectorV2Module = typeof import("inspector/legacy/legacy") & typeof import("inspector/index"); +export type SandboxSceneLoadKind = "scene" | "texture"; export class GlobalState { private _inspectorToken: Nullable = null; public currentScene: Scene; - public onSceneLoaded = new Observable<{ scene: Scene; filename: string }>(); + public currentSceneLoadKind: SandboxSceneLoadKind = "scene"; + public currentSceneHadCameras = false; + public onSceneLoaded = new Observable<{ scene: Scene; filename: string; loadKind: SandboxSceneLoadKind }>(); + public onCameraChanged = new Observable(); public onError = new Observable<{ scene?: Scene; message?: string }>(); public onEnvironmentChanged = new Observable(); public onRequestClickInterceptor = new Observable(); @@ -17,6 +23,7 @@ export class GlobalState { public filesInput: FilesInput; public isDebugLayerEnabled = false; + public readonly cameraPresetManager = new CameraPresetManager(); public commerceMode = false; @@ -43,7 +50,9 @@ export class GlobalState { if (this.currentScene) { const inspectorV2Module: InspectorV2Module | undefined = (globalThis).INSPECTOR; if (inspectorV2Module?.ShowInspector) { - this._inspectorToken = inspectorV2Module.ShowInspector(this.currentScene); + this._inspectorToken = inspectorV2Module.ShowInspector(this.currentScene, { + serviceDefinitions: [MakeCameraPresetInspectorServiceDefinition(this, inspectorV2Module)], + }); } else { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.currentScene.debugLayer.show(); diff --git a/packages/tools/sandbox/src/img/icon-camera-preset.svg b/packages/tools/sandbox/src/img/icon-camera-preset.svg new file mode 100644 index 000000000000..38724223b937 --- /dev/null +++ b/packages/tools/sandbox/src/img/icon-camera-preset.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/tools/sandbox/src/main.ts b/packages/tools/sandbox/src/main.ts index bace64aa6e34..436d9cc8e049 100644 --- a/packages/tools/sandbox/src/main.ts +++ b/packages/tools/sandbox/src/main.ts @@ -24,11 +24,11 @@ import { Sandbox } from "./sandbox"; const HostElement = document.getElementById("host-element") as HTMLElement; if (import.meta.env.DEV) { - // Dev mode — register the Inspector v2 debug layer (production gets it from the CDN bundle), - // then show immediately. The inspector index attaches Scene.debugLayer as a side effect. + // Dev mode — register the Inspector v2 debug layer and global module (production gets both from the CDN bundle), + // then show immediately. // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { - await import("inspector/index"); + await import("inspector/legacy/legacy"); Sandbox.Show(HostElement, { version: "dev", bundles: [] }); })(); } else { diff --git a/packages/tools/sandbox/src/sandbox.tsx b/packages/tools/sandbox/src/sandbox.tsx index 0d9df4077cea..c5e94530733d 100644 --- a/packages/tools/sandbox/src/sandbox.tsx +++ b/packages/tools/sandbox/src/sandbox.tsx @@ -148,6 +148,8 @@ export class Sandbox extends React.Component< this._updateDocumentTitle(info.filename); this._globalState.currentScene = info.scene; + this._globalState.currentSceneLoadKind = info.loadKind; + this._globalState.currentSceneHadCameras = info.scene.cameras.length > 0; if ( this._globalState.currentScene.meshes.length === 0 && this._globalState.currentScene.clearColor.r === 1 && diff --git a/packages/tools/sandbox/src/scss/footer.scss b/packages/tools/sandbox/src/scss/footer.scss index 644cfb8a5050..4063bee8c135 100644 --- a/packages/tools/sandbox/src/scss/footer.scss +++ b/packages/tools/sandbox/src/scss/footer.scss @@ -18,6 +18,10 @@ grid-template-columns: 201px 1fr 350px; } + &.longest { + grid-template-columns: 201px 1fr 420px; + } + .footerLeft { display: grid; grid-column: 1; @@ -218,6 +222,10 @@ grid-template-columns: 0px 1fr 250px; } + &.longest { + grid-template-columns: 0px 1fr 300px; + } + .dropup { .dropup-content { width: 100px; diff --git a/packages/tools/sandbox/src/tools/cameraPresetInspectorService.tsx b/packages/tools/sandbox/src/tools/cameraPresetInspectorService.tsx new file mode 100644 index 000000000000..3f0838fac789 --- /dev/null +++ b/packages/tools/sandbox/src/tools/cameraPresetInspectorService.tsx @@ -0,0 +1,96 @@ +import { type Camera } from "core/Cameras/camera"; +import { Logger } from "core/Misc/logger"; +import { type FunctionComponent } from "react"; +import { type IPropertiesService, type ISelectionService, type WeaklyTypedServiceDefinition } from "inspector/index"; +import { type GlobalState, type InspectorV2Module } from "../globalState"; + +const CameraPresetNames = new WeakMap(); +const CameraPresetSectionOrder = Number.MAX_VALUE / 2; +const CameraMetadataSectionOrder = Number.MAX_VALUE; + +export function IsCamera(entity: unknown): entity is Camera { + const candidate = entity as Partial | null; + if (!candidate || typeof candidate.getScene !== "function") { + return false; + } + + const scene = candidate.getScene(); + return !!scene && scene.cameras.includes(entity as Camera); +} + +interface ICameraPresetEditorProps { + camera: Camera; + globalState: GlobalState; + inspectorModule: InspectorV2Module; +} + +const CameraPresetEditor: FunctionComponent = (props) => { + const { camera, globalState, inspectorModule } = props; + + return ( + <> + CameraPresetNames.set(camera, value)} /> + { + try { + globalState.cameraPresetManager.saveCamera(camera, CameraPresetNames.get(camera) ?? ""); + } catch (error) { + Logger.Warn(`Unable to save Sandbox camera preset: ${error instanceof Error ? error.message : String(error)}`); + } + }} + /> + + ); +}; + +export function MakeCameraPresetInspectorServiceDefinition(globalState: GlobalState, inspectorModule: InspectorV2Module): WeaklyTypedServiceDefinition { + return { + friendlyName: "Sandbox Camera Preset Properties", + consumes: [inspectorModule.PropertiesServiceIdentity, inspectorModule.SelectionServiceIdentity], + factory: (propertiesService: IPropertiesService, selectionService: ISelectionService) => { + const presetSectionRegistration = propertiesService.addSection({ + identity: "Save Camera Preset", + order: CameraPresetSectionOrder, + }); + let metadataSectionRegistration: ReturnType | undefined; + const updateMetadataSectionRegistration = () => { + if (IsCamera(selectionService.selectedEntity)) { + metadataSectionRegistration ??= propertiesService.addSection({ + identity: "Metadata", + order: CameraMetadataSectionOrder, + }); + } else if (metadataSectionRegistration) { + metadataSectionRegistration.dispose(); + metadataSectionRegistration = undefined; + } + }; + const selectionObserver = selectionService.onSelectedEntityChanged.add(updateMetadataSectionRegistration); + updateMetadataSectionRegistration(); + const cameraPresetSection: FunctionComponent<{ context: Camera }> = (props) => { + const { context } = props; + return ; + }; + const contentRegistration = propertiesService.addSectionContent({ + key: "Sandbox Camera Preset Properties", + predicate: IsCamera, + content: [ + { + section: "Save Camera Preset", + component: cameraPresetSection, + }, + ], + }); + + return { + dispose: () => { + selectionObserver.remove(); + contentRegistration.dispose(); + metadataSectionRegistration?.dispose(); + presetSectionRegistration.dispose(); + }, + }; + }, + }; +} diff --git a/packages/tools/sandbox/src/tools/cameraPresetManager.ts b/packages/tools/sandbox/src/tools/cameraPresetManager.ts new file mode 100644 index 000000000000..c395a685aaa7 --- /dev/null +++ b/packages/tools/sandbox/src/tools/cameraPresetManager.ts @@ -0,0 +1,543 @@ +import { ArcRotateCamera } from "core/Cameras/arcRotateCamera"; +import { Camera } from "core/Cameras/camera"; +import { type AutoRotationBehavior } from "core/Behaviors/Cameras/autoRotationBehavior"; +import { type BouncingBehavior } from "core/Behaviors/Cameras/bouncingBehavior"; +import { type FramingBehavior } from "core/Behaviors/Cameras/framingBehavior"; +import { DataStorage } from "core/Misc/dataStorage"; +import { Logger } from "core/Misc/logger"; +import { Observable } from "core/Misc/observable"; +import { type Scene } from "core/scene"; + +export const CameraPresetStorageKey = "Babylon/Sandbox/cameraPresets"; +export const DefaultCameraPresetOption = "Default camera"; + +const CameraPresetStorageVersion = 1; + +interface IAutoRotationBehaviorState { + idleRotationSpeed: number; + idleRotationWaitTime: number; + idleRotationSpinupTime: number; + zoomStopsAnimation: boolean; + targetAlpha: number | null; +} + +interface IBouncingBehaviorState { + transitionDuration: number; + lowerRadiusTransitionRange: number; + upperRadiusTransitionRange: number; + autoTransitionRange: boolean; +} + +interface IFramingBehaviorState { + mode: number; + radiusScale: number; + positionScale: number; + defaultElevation: number; + elevationReturnTime: number; + elevationReturnWaitTime: number; + zoomStopsAnimation: boolean; + framingTime: number; + autoCorrectCameraLimitsAndSensibility: boolean; +} + +export interface ICameraPresetBehaviorState { + version: 1; + useAutoRotationBehavior?: boolean; + useBouncingBehavior?: boolean; + useFramingBehavior?: boolean; + autoRotation?: IAutoRotationBehaviorState; + bouncing?: IBouncingBehaviorState; + framing?: IFramingBehaviorState; +} + +export interface ICameraPreset { + id: string; + name: string; + cameraType: string; + cameraData: Record; + behaviors?: ICameraPresetBehaviorState; +} + +export interface ICameraPresetState { + version: 1; + activePresetId: string | null; + presets: ICameraPreset[]; +} + +export interface ICameraPresetStorageBackend { + read(): unknown; + write(state: ICameraPresetState): void; +} + +const DefaultCameraPresetState: ICameraPresetState = { + version: CameraPresetStorageVersion, + activePresetId: null, + presets: [], +}; + +const DataStorageBackend: ICameraPresetStorageBackend = { + read: () => DataStorage.ReadJson(CameraPresetStorageKey, null), + write: (state) => DataStorage.WriteJson(CameraPresetStorageKey, state), +}; + +function IsRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function IsFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function ParseAutoRotationBehaviorState(value: unknown): IAutoRotationBehaviorState | undefined { + if ( + !IsRecord(value) || + !IsFiniteNumber(value.idleRotationSpeed) || + !IsFiniteNumber(value.idleRotationWaitTime) || + !IsFiniteNumber(value.idleRotationSpinupTime) || + typeof value.zoomStopsAnimation !== "boolean" || + (value.targetAlpha !== null && !IsFiniteNumber(value.targetAlpha)) + ) { + return undefined; + } + + return { + idleRotationSpeed: value.idleRotationSpeed, + idleRotationWaitTime: value.idleRotationWaitTime, + idleRotationSpinupTime: value.idleRotationSpinupTime, + zoomStopsAnimation: value.zoomStopsAnimation, + targetAlpha: value.targetAlpha, + }; +} + +function ParseBouncingBehaviorState(value: unknown): IBouncingBehaviorState | undefined { + if ( + !IsRecord(value) || + !IsFiniteNumber(value.transitionDuration) || + !IsFiniteNumber(value.lowerRadiusTransitionRange) || + !IsFiniteNumber(value.upperRadiusTransitionRange) || + typeof value.autoTransitionRange !== "boolean" + ) { + return undefined; + } + + return { + transitionDuration: value.transitionDuration, + lowerRadiusTransitionRange: value.lowerRadiusTransitionRange, + upperRadiusTransitionRange: value.upperRadiusTransitionRange, + autoTransitionRange: value.autoTransitionRange, + }; +} + +function ParseFramingBehaviorState(value: unknown): IFramingBehaviorState | undefined { + if ( + !IsRecord(value) || + !IsFiniteNumber(value.mode) || + !IsFiniteNumber(value.radiusScale) || + !IsFiniteNumber(value.positionScale) || + !IsFiniteNumber(value.defaultElevation) || + !IsFiniteNumber(value.elevationReturnTime) || + !IsFiniteNumber(value.elevationReturnWaitTime) || + typeof value.zoomStopsAnimation !== "boolean" || + !IsFiniteNumber(value.framingTime) || + typeof value.autoCorrectCameraLimitsAndSensibility !== "boolean" + ) { + return undefined; + } + + return { + mode: value.mode, + radiusScale: value.radiusScale, + positionScale: value.positionScale, + defaultElevation: value.defaultElevation, + elevationReturnTime: value.elevationReturnTime, + elevationReturnWaitTime: value.elevationReturnWaitTime, + zoomStopsAnimation: value.zoomStopsAnimation, + framingTime: value.framingTime, + autoCorrectCameraLimitsAndSensibility: value.autoCorrectCameraLimitsAndSensibility, + }; +} + +function ParseBehaviorState(value: unknown): ICameraPresetBehaviorState | undefined { + if (!IsRecord(value)) { + return undefined; + } + + const behaviorState: ICameraPresetBehaviorState = { version: 1 }; + for (const property of ["useAutoRotationBehavior", "useBouncingBehavior", "useFramingBehavior"] as const) { + if (typeof value[property] === "boolean") { + behaviorState[property] = value[property]; + } + } + + if (value.version === 1) { + behaviorState.autoRotation = ParseAutoRotationBehaviorState(value.autoRotation); + behaviorState.bouncing = ParseBouncingBehaviorState(value.bouncing); + behaviorState.framing = ParseFramingBehaviorState(value.framing); + } + + return Object.values(behaviorState).some((property) => property !== 1 && property !== undefined) ? behaviorState : undefined; +} + +function CreateCameraPresetId(): string { + if (globalThis.crypto?.randomUUID) { + return globalThis.crypto.randomUUID(); + } + + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} + +export function GetUniqueCameraPresetName(existingNames: readonly string[], requestedName: string): string { + const normalizedNames = new Set(existingNames.map((name) => name.trim().toLocaleLowerCase())); + const trimmedName = requestedName.trim(); + + if (!trimmedName) { + let index = 1; + while (normalizedNames.has(`preset ${index}`)) { + index++; + } + return `Preset ${index}`; + } + + if (!normalizedNames.has(trimmedName.toLocaleLowerCase())) { + return trimmedName; + } + + const suffixMatch = /^(.*)\s+(\d+)$/.exec(trimmedName); + const baseName = suffixMatch?.[1].trim() || trimmedName; + let suffix = suffixMatch ? Number(suffixMatch[2]) + 1 : 2; + if (!Number.isSafeInteger(suffix)) { + suffix = 2; + } + + while (normalizedNames.has(`${baseName} ${suffix}`.toLocaleLowerCase())) { + suffix++; + } + + return `${baseName} ${suffix}`; +} + +export function ParseCameraPresetState(value: unknown): ICameraPresetState { + if (!IsRecord(value) || value.version !== CameraPresetStorageVersion || !Array.isArray(value.presets)) { + return { ...DefaultCameraPresetState, presets: [] }; + } + + const ids = new Set(); + const names = [DefaultCameraPresetOption]; + const presets: ICameraPreset[] = []; + + for (const candidate of value.presets) { + if (!IsRecord(candidate) || typeof candidate.id !== "string" || typeof candidate.name !== "string" || typeof candidate.cameraType !== "string") { + continue; + } + + const id = candidate.id.trim(); + const requestedName = candidate.name.trim(); + const cameraType = candidate.cameraType.trim(); + if (!id || !requestedName || !cameraType || ids.has(id) || !IsRecord(candidate.cameraData) || candidate.cameraData.type !== cameraType) { + continue; + } + + const name = GetUniqueCameraPresetName(names, requestedName); + ids.add(id); + names.push(name); + presets.push({ + id, + name, + cameraType, + cameraData: candidate.cameraData, + behaviors: ParseBehaviorState(candidate.behaviors), + }); + } + + const activePresetId = typeof value.activePresetId === "string" && ids.has(value.activePresetId) ? value.activePresetId : null; + return { + version: CameraPresetStorageVersion, + activePresetId, + presets, + }; +} + +function GetBehaviorState(camera: Camera): ICameraPresetBehaviorState | undefined { + if (!(camera instanceof ArcRotateCamera)) { + return undefined; + } + + const behaviorState: ICameraPresetBehaviorState = { + version: 1, + useAutoRotationBehavior: camera.useAutoRotationBehavior, + useBouncingBehavior: camera.useBouncingBehavior, + useFramingBehavior: camera.useFramingBehavior, + }; + + const autoRotationBehavior = camera.getBehaviorByName("AutoRotation") as AutoRotationBehavior | null; + if (autoRotationBehavior) { + behaviorState.autoRotation = { + idleRotationSpeed: autoRotationBehavior.idleRotationSpeed, + idleRotationWaitTime: autoRotationBehavior.idleRotationWaitTime, + idleRotationSpinupTime: autoRotationBehavior.idleRotationSpinupTime, + zoomStopsAnimation: autoRotationBehavior.zoomStopsAnimation, + targetAlpha: autoRotationBehavior.targetAlpha, + }; + } + + const bouncingBehavior = camera.getBehaviorByName("Bouncing") as BouncingBehavior | null; + if (bouncingBehavior) { + behaviorState.bouncing = { + transitionDuration: bouncingBehavior.transitionDuration, + lowerRadiusTransitionRange: bouncingBehavior.lowerRadiusTransitionRange, + upperRadiusTransitionRange: bouncingBehavior.upperRadiusTransitionRange, + autoTransitionRange: bouncingBehavior.autoTransitionRange, + }; + } + + const framingBehavior = camera.getBehaviorByName("Framing") as FramingBehavior | null; + if (framingBehavior) { + behaviorState.framing = { + mode: framingBehavior.mode, + radiusScale: framingBehavior.radiusScale, + positionScale: framingBehavior.positionScale, + defaultElevation: framingBehavior.defaultElevation, + elevationReturnTime: framingBehavior.elevationReturnTime, + elevationReturnWaitTime: framingBehavior.elevationReturnWaitTime, + zoomStopsAnimation: framingBehavior.zoomStopsAnimation, + framingTime: framingBehavior.framingTime, + autoCorrectCameraLimitsAndSensibility: framingBehavior.autoCorrectCameraLimitsAndSensibility, + }; + } + + return behaviorState; +} + +function ApplyBehaviorState(camera: Camera, behaviorState: ICameraPresetBehaviorState | undefined): void { + if (!behaviorState || !(camera instanceof ArcRotateCamera)) { + return; + } + + for (const property of ["useAutoRotationBehavior", "useBouncingBehavior", "useFramingBehavior"] as const) { + if (typeof behaviorState[property] === "boolean") { + camera[property] = behaviorState[property]; + } + } + + const autoRotationBehavior = camera.getBehaviorByName("AutoRotation") as AutoRotationBehavior | null; + if (autoRotationBehavior && behaviorState.autoRotation) { + Object.assign(autoRotationBehavior, behaviorState.autoRotation); + } + + const bouncingBehavior = camera.getBehaviorByName("Bouncing") as BouncingBehavior | null; + if (bouncingBehavior && behaviorState.bouncing) { + const { autoTransitionRange, ...bouncingProperties } = behaviorState.bouncing; + Object.assign(bouncingBehavior, bouncingProperties); + bouncingBehavior.autoTransitionRange = autoTransitionRange; + } + + const framingBehavior = camera.getBehaviorByName("Framing") as FramingBehavior | null; + if (framingBehavior && behaviorState.framing) { + Object.assign(framingBehavior, behaviorState.framing); + } +} + +function DisposeCameras(cameras: Iterable): void { + for (const camera of cameras) { + if (!camera.isDisposed()) { + camera.dispose(); + } + } +} + +export class CameraPresetManager { + public readonly onChanged = new Observable(); + + private _state: ICameraPresetState; + private readonly _presetCameras = new WeakMap>(); + private readonly _presetCameraSet = new WeakSet(); + private readonly _sceneCameras = new WeakMap(); + + public constructor( + private readonly _storage: ICameraPresetStorageBackend = DataStorageBackend, + private readonly _createId: () => string = CreateCameraPresetId + ) { + try { + this._state = ParseCameraPresetState(this._storage.read()); + } catch (error) { + Logger.Warn(`Unable to read Sandbox camera presets: ${error instanceof Error ? error.message : String(error)}`); + this._state = { ...DefaultCameraPresetState, presets: [] }; + } + } + + public get presets(): readonly ICameraPreset[] { + return this._state.presets; + } + + public get activePresetId(): string | null { + return this._state.activePresetId; + } + + public get activePreset(): ICameraPreset | undefined { + return this._state.presets.find((preset) => preset.id === this._state.activePresetId); + } + + public saveCamera(camera: Camera, requestedName: string): ICameraPreset { + const name = GetUniqueCameraPresetName([DefaultCameraPresetOption, ...this._state.presets.map((preset) => preset.name)], requestedName); + let id = this._createId(); + while (this._state.presets.some((preset) => preset.id === id)) { + id = this._createId(); + } + + const cameraData = JSON.parse(JSON.stringify(camera.serialize())) as Record; + const preset: ICameraPreset = { + id, + name, + cameraType: camera.getClassName(), + cameraData, + behaviors: GetBehaviorState(camera), + }; + + this._updateState({ + ...this._state, + presets: [...this._state.presets, preset], + }); + return preset; + } + + public activatePreset(presetId: string, scene: Scene): Camera | null { + const preset = this._state.presets.find((candidate) => candidate.id === presetId); + if (!preset) { + return null; + } + + const camera = this._applyPreset(preset, scene); + if (camera) { + this._updateState({ + ...this._state, + activePresetId: preset.id, + }); + } + return camera; + } + + public applyActivePreset(scene: Scene): Camera | null { + const preset = this.activePreset; + return preset ? this._applyPreset(preset, scene) : null; + } + + public deactivatePreset(scene: Scene, sceneCamera?: Camera): Camera | null { + let camera = sceneCamera ?? this._sceneCameras.get(scene); + if (!camera || camera.isDisposed() || this.isPresetCamera(camera) || !scene.cameras.includes(camera)) { + camera = scene.cameras.find((candidate) => !this.isPresetCamera(candidate)); + } + + if (!camera) { + return null; + } + + const inputElement = scene.getEngine().getInputElement(); + if (inputElement && scene.activeCamera !== camera) { + scene.activeCamera?.detachControl(); + } + scene.activeCamera = camera; + if (inputElement) { + camera.attachControl(); + } + + this._sceneCameras.set(scene, camera); + this.releasePresetCamera(scene); + if (this._state.activePresetId !== null) { + this._updateState({ + ...this._state, + activePresetId: null, + }); + } + return camera; + } + + public isPresetCamera(camera: Camera): boolean { + return this._presetCameraSet.has(camera); + } + + public releasePresetCamera(scene: Scene): void { + const cameras = this._presetCameras.get(scene); + if (cameras && (!scene.activeCamera || !cameras.has(scene.activeCamera))) { + this._presetCameras.delete(scene); + for (const camera of cameras) { + this._presetCameraSet.delete(camera); + } + DisposeCameras(cameras); + } + } + + private _applyPreset(preset: ICameraPreset, scene: Scene): Camera | null { + const existingCameras = new Set(scene.cameras); + const previousCamera = scene.activeCamera; + if (previousCamera && !this.isPresetCamera(previousCamera)) { + this._sceneCameras.set(scene, previousCamera); + } + const inputElement = scene.getEngine().getInputElement(); + const cameraData: Record = { + ...preset.cameraData, + id: `SandboxCameraPreset/${preset.id}`, + name: preset.name, + }; + delete cameraData.parentId; + delete cameraData.parentInstanceIndex; + delete cameraData.lockedTargetId; + + let camera: Camera | undefined; + let previousCameraDetached = false; + try { + camera = Camera.Parse(cameraData, scene); + if (camera.getClassName() !== preset.cameraType) { + DisposeCameras(scene.cameras.filter((createdCamera) => !existingCameras.has(createdCamera))); + Logger.Warn(`Unable to apply Sandbox camera preset "${preset.name}": camera type "${preset.cameraType}" is not available.`); + return null; + } + + ApplyBehaviorState(camera, preset.behaviors); + + if (inputElement) { + camera.attachControl(); + if (previousCamera) { + previousCameraDetached = true; + previousCamera.detachControl(); + } + } + scene.activeCamera = camera; + } catch (error) { + if (scene.activeCamera && !existingCameras.has(scene.activeCamera)) { + scene.activeCamera = previousCamera; + } + DisposeCameras(scene.cameras.filter((createdCamera) => !existingCameras.has(createdCamera))); + if (inputElement && previousCameraDetached && previousCamera) { + previousCamera.attachControl(); + } + Logger.Warn(`Unable to apply Sandbox camera preset "${preset.name}": ${error instanceof Error ? error.message : String(error)}`); + return null; + } + + const createdCameras = new Set(scene.cameras.filter((sceneCamera) => !existingCameras.has(sceneCamera))); + createdCameras.add(camera); + const previousPresetCameras = this._presetCameras.get(scene); + if (previousPresetCameras) { + for (const previousPresetCamera of previousPresetCameras) { + this._presetCameraSet.delete(previousPresetCamera); + } + DisposeCameras(previousPresetCameras); + } + + this._presetCameras.set(scene, createdCameras); + for (const createdCamera of createdCameras) { + this._presetCameraSet.add(createdCamera); + } + return camera; + } + + private _updateState(state: ICameraPresetState): void { + this._state = state; + try { + this._storage.write(state); + } catch (error) { + Logger.Warn(`Unable to persist Sandbox camera presets: ${error instanceof Error ? error.message : String(error)}`); + } + this.onChanged.notifyObservers(); + } +} diff --git a/packages/tools/sandbox/test/interaction.sandbox.test.ts b/packages/tools/sandbox/test/interaction.sandbox.test.ts index 8b4dfeed329d..4b5708f0fbaa 100644 --- a/packages/tools/sandbox/test/interaction.sandbox.test.ts +++ b/packages/tools/sandbox/test/interaction.sandbox.test.ts @@ -11,6 +11,10 @@ test.beforeAll(async () => { const snapshot = process.env.SNAPSHOT ? "?snapshot=" + process.env.SNAPSHOT : ""; const cdnPort = ":" + (process.env.CDN_PORT || 1337); const url = (process.env.SANDBOX_BASE_URL || getGlobalConfig().baseUrl.replace(cdnPort, process.env.SANDBOX_PORT || ":1339")) + snapshot; +const cameraPresetStorageKey = "Babylon/Sandbox/cameraPresets"; +const boxModelUrl = url + (snapshot ? "&" : "?") + "assetUrl=https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/main/2.0/Box/glTF-Binary/Box.glb"; +const embeddedCameraModelUrl = url + (snapshot ? "&" : "?") + "assetUrl=https://assets.babylonjs.com/meshes/Box/Box_extras.gltf"; +const textureAssetUrl = url + (snapshot ? "&" : "?") + "assetUrl=https://assets.babylonjs.com/textures/grass.png"; /** * Wait for the sandbox app to be fully rendered with CSS applied. @@ -108,7 +112,7 @@ test("loading a model using query parameters", async ({ page }) => { }); test("inspector is opened when clicking on the button", async ({ page }) => { - await page.goto(url + (snapshot ? "&" : "?") + "assetUrl=https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/main/2.0/Box/glTF-Binary/Box.glb", { + await page.goto(boxModelUrl, { waitUntil: "load", }); await page.setViewportSize({ @@ -128,3 +132,213 @@ test("inspector is opened when clicking on the button", async ({ page }) => { // check snapshot of the page await expect(page).toHaveScreenshot({ maxDiffPixels: 3000 }); }); + +test("camera presets can be saved, selected, and restored", async ({ page }) => { + test.setTimeout(90000); + + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + await page.addInitScript((storageKey) => { + const initializedKey = `${storageKey}/testInitialized`; + if (!sessionStorage.getItem(initializedKey)) { + localStorage.removeItem(storageKey); + sessionStorage.setItem(initializedKey, "true"); + } + }, cameraPresetStorageKey); + + await page.goto(boxModelUrl, { waitUntil: "load" }); + await page.setViewportSize({ width: 1920, height: 1080 }); + await waitForSandboxReady(page); + await page.waitForSelector("#babylonjsLoadingDiv", { state: "detached" }); + await page.waitForLoadState("networkidle"); + + const footer = page.locator("#footer"); + const presetSelector = footer.getByTitle("Select camera preset"); + await expect(presetSelector).toHaveCount(0); + await expect(footer.getByTitle("Select camera")).toHaveCount(0); + + await page.goto(embeddedCameraModelUrl, { waitUntil: "load" }); + await waitForSandboxReady(page); + await page.waitForSelector("#babylonjsLoadingDiv", { state: "detached" }); + await page.waitForLoadState("networkidle"); + const embeddedCameraFooter = page.locator("#footer"); + await expect(embeddedCameraFooter.getByTitle("Select camera")).toBeVisible(); + await embeddedCameraFooter.getByTitle("Select camera").click(); + await expect(embeddedCameraFooter.locator(".dropup-content-line")).toHaveText(["Camera", "default camera"]); + await embeddedCameraFooter.getByTitle("default camera", { exact: true }).click(); + const activeEmbeddedSceneCameraName = await page.evaluate(() => { + return (globalThis as typeof globalThis & { BABYLON: { EngineStore: { LastCreatedScene?: { activeCamera?: { name: string } } } } }).BABYLON.EngineStore.LastCreatedScene + ?.activeCamera?.name; + }); + expect(activeEmbeddedSceneCameraName).toBe("default camera"); + await page.keyboard.press("Space"); + await expect(embeddedCameraFooter).toHaveCount(0); + await page.keyboard.press("Space"); + await expect(page.locator("#footer").getByTitle("Select camera")).toBeVisible(); + + await page.goto(boxModelUrl, { waitUntil: "load" }); + await waitForSandboxReady(page); + await page.waitForSelector("#babylonjsLoadingDiv", { state: "detached" }); + await page.waitForLoadState("networkidle"); + await expect(page.locator("#footer").getByTitle("Select camera")).toHaveCount(0); + + await page.getByTitle("Display inspector").click(); + const inspector = page.locator("#babylon-inspector-container"); + await expect(inspector).toBeVisible(); + + const nodes = page.getByRole("treeitem", { name: "Nodes", exact: true }); + await nodes.focus(); + await nodes.press("ArrowRight"); + await page.evaluate(() => { + const runtime = globalThis as typeof globalThis & { + BABYLON: { EngineStore: { LastCreatedScene?: { activeCamera?: { inspectableCustomProperties?: unknown[] } } } }; + }; + const camera = runtime.BABYLON.EngineStore.LastCreatedScene?.activeCamera; + if (camera) { + camera.inspectableCustomProperties = []; + } + }); + await page.getByRole("treeitem", { name: /^default camera/ }).click(); + + const presetHeader = inspector.getByRole("button", { name: "Save Camera Preset", exact: true }); + await expect(presetHeader).toBeVisible(); + await expect(presetHeader).toHaveAttribute("aria-expanded", "true"); + const sectionHeaders = await inspector.locator("button[aria-expanded]").allTextContents(); + expect(sectionHeaders).toContain("Custom"); + expect(sectionHeaders.indexOf("Custom")).toBeLessThan(sectionHeaders.indexOf("Save Camera Preset")); + expect(sectionHeaders.indexOf("Save Camera Preset")).toBe(sectionHeaders.indexOf("Metadata") - 1); + + await page.getByRole("treeitem", { name: "hdrSkyBox", exact: true }).click(); + const meshSectionHeaders = await inspector.locator("button[aria-expanded]").allTextContents(); + expect(meshSectionHeaders).not.toContain("Save Camera Preset"); + if (meshSectionHeaders.includes("Custom")) { + expect(meshSectionHeaders.indexOf("Metadata")).toBeLessThan(meshSectionHeaders.indexOf("Custom")); + } + + await page.getByRole("treeitem", { name: /^default camera/ }).click(); + + const presetSection = presetHeader.locator("xpath=../.."); + const presetNameInput = presetSection.getByRole("textbox"); + const savePresetButton = presetSection.getByRole("button", { name: "Save", exact: true }); + + await savePresetButton.click(); + await expect(footer.getByTitle("Select camera preset")).toBeVisible(); + + await presetNameInput.fill(" City "); + await savePresetButton.click(); + await presetNameInput.fill("city"); + await savePresetButton.click(); + + const savedState = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey) ?? "null"), cameraPresetStorageKey); + expect(savedState.activePresetId).toBeNull(); + expect(savedState.presets.map((preset: { name: string }) => preset.name)).toEqual(["Preset 1", "City", "city 2"]); + + await footer.getByTitle("Display inspector").click({ force: true }); + await expect(inspector).toHaveCount(0); + + await footer.getByTitle("Select camera preset").click(); + await expect(footer.locator(".dropup-content-line")).toHaveText(["Default camera", "Preset 1", "City", "city 2"]); + await footer.getByTitle("Preset 1", { exact: true }).click(); + + const selectedState = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey) ?? "null"), cameraPresetStorageKey); + expect(selectedState.activePresetId).toBe(selectedState.presets[0].id); + + await page.reload({ waitUntil: "load" }); + await waitForSandboxReady(page); + await page.waitForSelector("#babylonjsLoadingDiv", { state: "detached" }); + await page.waitForLoadState("networkidle"); + await expect(page.locator("#footer").getByTitle("Select camera preset")).toBeVisible(); + + await page.getByTitle("Display inspector").click(); + await expect(inspector).toBeVisible(); + const reloadedNodes = page.getByRole("treeitem", { name: "Nodes", exact: true }); + await reloadedNodes.focus(); + await reloadedNodes.press("ArrowRight"); + + const restoredCamera = page.getByRole("treeitem", { name: /^Preset 1/ }); + await expect(restoredCamera).toBeVisible(); + await restoredCamera.click(); + await expect(restoredCamera.getByRole("button", { name: "Activate and Attach Controls" })).toHaveAttribute("aria-pressed", "true"); + + const navigationKeys = await page.evaluate(() => { + const camera = (globalThis as typeof globalThis & { debugNode?: Record }).debugNode; + return { + up: camera?.keysUp ?? [], + down: camera?.keysDown ?? [], + left: camera?.keysLeft ?? [], + right: camera?.keysRight ?? [], + }; + }); + expect(navigationKeys.up.filter((key) => key === 90)).toHaveLength(1); + expect(navigationKeys.up.filter((key) => key === 87)).toHaveLength(1); + expect(navigationKeys.down.filter((key) => key === 83)).toHaveLength(1); + expect(navigationKeys.left.filter((key) => key === 65)).toHaveLength(1); + expect(navigationKeys.left.filter((key) => key === 81)).toHaveLength(1); + expect(navigationKeys.right.filter((key) => key === 69)).toHaveLength(1); + expect(navigationKeys.right.filter((key) => key === 68)).toHaveLength(1); + + await footer.getByTitle("Display inspector").click({ force: true }); + await footer.getByTitle("Select camera preset").click(); + await footer.getByTitle("Default camera", { exact: true }).click(); + + const defaultState = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey) ?? "null"), cameraPresetStorageKey); + expect(defaultState.activePresetId).toBeNull(); + + await page.reload({ waitUntil: "load" }); + await waitForSandboxReady(page); + await page.waitForSelector("#babylonjsLoadingDiv", { state: "detached" }); + await page.waitForLoadState("networkidle"); + const reloadedDefaultState = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey) ?? "null"), cameraPresetStorageKey); + expect(reloadedDefaultState.activePresetId).toBeNull(); + + await page.getByTitle("Display inspector").click(); + await expect(inspector).toBeVisible(); + const defaultNodes = page.getByRole("treeitem", { name: "Nodes", exact: true }); + await defaultNodes.focus(); + await defaultNodes.press("ArrowRight"); + const reloadedDefaultCamera = page.getByRole("treeitem", { name: /^default camera/ }); + await expect(reloadedDefaultCamera).toBeVisible(); + await expect(reloadedDefaultCamera.getByRole("button", { name: "Activate and Attach Controls" })).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByRole("treeitem", { name: /^Preset 1/ })).toHaveCount(0); + + await footer.getByTitle("Display inspector").click({ force: true }); + await footer.getByTitle("Select camera preset").click(); + await footer.getByTitle("Preset 1", { exact: true }).click(); + const reactivatedState = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey) ?? "null"), cameraPresetStorageKey); + expect(reactivatedState.activePresetId).toBe(reactivatedState.presets[0].id); + + await page.goto(textureAssetUrl, { waitUntil: "load" }); + await waitForSandboxReady(page); + await page.waitForSelector("#babylonjsLoadingDiv", { state: "detached" }); + await page.waitForLoadState("networkidle"); + const textureFooter = page.locator("#footer"); + await expect(textureFooter.getByTitle("Select camera preset")).toHaveCount(0); + const textureState = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey) ?? "null"), cameraPresetStorageKey); + expect(textureState.activePresetId).toBe(textureState.presets[0].id); + + await expect(inspector).toBeVisible(); + const textureNodes = page.getByRole("treeitem", { name: "Nodes", exact: true }); + await textureNodes.focus(); + await textureNodes.press("ArrowRight"); + const textureCamera = page.getByRole("treeitem", { name: /^default camera/ }); + await expect(textureCamera).toBeVisible(); + await expect(textureCamera.getByRole("button", { name: "Activate and Attach Controls" })).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByRole("treeitem", { name: /^Preset 1/ })).toHaveCount(0); + + await page.goto(boxModelUrl, { waitUntil: "load" }); + await waitForSandboxReady(page); + await page.waitForSelector("#babylonjsLoadingDiv", { state: "detached" }); + await page.waitForLoadState("networkidle"); + await expect(page.locator("#footer").getByTitle("Select camera preset")).toBeVisible(); + const finalState = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey) ?? "null"), cameraPresetStorageKey); + expect(finalState.activePresetId).toBe(finalState.presets[0].id); + await page.getByTitle("Display inspector").click(); + await expect(inspector).toBeVisible(); + const finalNodes = page.getByRole("treeitem", { name: "Nodes", exact: true }); + await finalNodes.focus(); + await finalNodes.press("ArrowRight"); + const reappliedCamera = page.getByRole("treeitem", { name: /^Preset 1/ }); + await expect(reappliedCamera).toBeVisible(); + await expect(reappliedCamera.getByRole("button", { name: "Activate and Attach Controls" })).toHaveAttribute("aria-pressed", "true"); + expect(pageErrors).toHaveLength(0); +}); diff --git a/packages/tools/sandbox/test/unit/cameraPresetManager.test.ts b/packages/tools/sandbox/test/unit/cameraPresetManager.test.ts new file mode 100644 index 000000000000..6713336d307d --- /dev/null +++ b/packages/tools/sandbox/test/unit/cameraPresetManager.test.ts @@ -0,0 +1,326 @@ +import { ArcRotateCamera } from "core/Cameras/arcRotateCamera"; +import { Camera } from "core/Cameras/camera"; +import { UniversalCamera } from "core/Cameras/universalCamera"; +import { type AutoRotationBehavior } from "core/Behaviors/Cameras/autoRotationBehavior"; +import { type BouncingBehavior } from "core/Behaviors/Cameras/bouncingBehavior"; +import { type FramingBehavior } from "core/Behaviors/Cameras/framingBehavior"; +import { NullEngine } from "core/Engines/nullEngine"; +import { Vector3 } from "core/Maths/math.vector"; +import { Scene } from "core/scene"; +import { + CameraPresetManager, + GetUniqueCameraPresetName, + ParseCameraPresetState, + type ICameraPresetState, + type ICameraPresetStorageBackend, +} from "../../src/tools/cameraPresetManager"; +import { IsCamera } from "../../src/tools/cameraPresetInspectorService"; + +class MemoryCameraPresetStorage implements ICameraPresetStorageBackend { + public value: unknown = null; + + public read(): unknown { + return this.value; + } + + public write(state: ICameraPresetState): void { + this.value = JSON.parse(JSON.stringify(state)); + } +} + +describe("camera preset naming", () => { + it("uses the first available generic name for a blank name", () => { + expect(GetUniqueCameraPresetName(["Preset 1", "preset 3"], " ")).toBe("Preset 2"); + }); + + it("compares names case-insensitively and increments numeric suffixes", () => { + const names = ["City", "City 2", "city 3"]; + expect(GetUniqueCameraPresetName(names, " CITY ")).toBe("CITY 4"); + expect(GetUniqueCameraPresetName(names, "City 2")).toBe("City 4"); + }); +}); + +describe("camera preset storage", () => { + it("rejects unsupported schemas, filters malformed presets, and normalizes conflicting names", () => { + expect(ParseCameraPresetState({ version: 2, activePresetId: "old", presets: [] })).toEqual({ version: 1, activePresetId: null, presets: [] }); + + const state = ParseCameraPresetState({ + version: 1, + activePresetId: "missing", + presets: [ + { + id: "valid", + name: "Street", + cameraType: "ArcRotateCamera", + cameraData: { type: "ArcRotateCamera", alpha: 1 }, + behaviors: { version: 1, useFramingBehavior: true, unexpected: true }, + }, + { id: "invalid", name: "Invalid", cameraType: "FreeCamera", cameraData: { type: "ArcRotateCamera" } }, + { id: "duplicate-name", name: "street", cameraType: "ArcRotateCamera", cameraData: { type: "ArcRotateCamera" } }, + ], + }); + + expect(state).toEqual({ + version: 1, + activePresetId: null, + presets: [ + { + id: "valid", + name: "Street", + cameraType: "ArcRotateCamera", + cameraData: { type: "ArcRotateCamera", alpha: 1 }, + behaviors: { version: 1, useFramingBehavior: true }, + }, + { + id: "duplicate-name", + name: "street 2", + cameraType: "ArcRotateCamera", + cameraData: { type: "ArcRotateCamera" }, + behaviors: undefined, + }, + ], + }); + }); + + it("renames the reserved default option without invalidating the active preset", () => { + const state = ParseCameraPresetState({ + version: 1, + activePresetId: "reserved", + presets: [ + { + id: "reserved", + name: " Default camera ", + cameraType: "ArcRotateCamera", + cameraData: { type: "ArcRotateCamera" }, + }, + ], + }); + + expect(state.activePresetId).toBe("reserved"); + expect(state.presets.map((preset) => preset.name)).toEqual(["Default camera 2"]); + }); +}); + +describe("camera preset Inspector predicate", () => { + it("handles entities whose scene is unavailable", () => { + expect(IsCamera({ getScene: () => null })).toBe(false); + expect(IsCamera({ getScene: () => ({ cameras: [] }) })).toBe(false); + }); +}); + +describe("CameraPresetManager", () => { + it("leaves the scene camera unchanged when persisted camera data cannot be applied", () => { + const engine = new NullEngine(); + const scene = new Scene(engine); + const defaultCamera = new ArcRotateCamera("default camera", 0, 0, 1, Vector3.Zero(), scene); + scene.activeCamera = defaultCamera; + + const storage = new MemoryCameraPresetStorage(); + storage.value = { + version: 1, + activePresetId: "unavailable", + presets: [ + { + id: "unavailable", + name: "Unavailable", + cameraType: "ArcRotateCamera", + cameraData: { type: "ArcRotateCamera", name: "Unavailable" }, + }, + ], + }; + + const manager = new CameraPresetManager(storage); + const originalParse = Camera.Parse; + Camera.Parse = (_cameraData, targetScene) => { + new UniversalCamera("partial camera", Vector3.Zero(), targetScene); + throw new Error("Unable to parse camera"); + }; + + let result; + try { + result = manager.applyActivePreset(scene); + } finally { + Camera.Parse = originalParse; + } + + expect(result).toBeNull(); + expect(scene.activeCamera).toBe(defaultCamera); + expect(scene.cameras).toEqual([defaultCamera]); + + scene.dispose(); + engine.dispose(); + }); + + it("recreates a saved camera subtype instead of applying partial data to the active camera", () => { + const engine = new NullEngine(); + const scene = new Scene(engine); + const defaultCamera = new ArcRotateCamera("default camera", 0, 0, 1, Vector3.Zero(), scene); + const universalCamera = new UniversalCamera("walkthrough", new Vector3(10, 20, 30), scene); + universalCamera.speed = 7; + universalCamera.keysUp = [73]; + scene.activeCamera = defaultCamera; + + const storage = new MemoryCameraPresetStorage(); + const manager = new CameraPresetManager(storage, () => "universal"); + const preset = manager.saveCamera(universalCamera, "Walkthrough"); + universalCamera.dispose(); + + const appliedCamera = manager.activatePreset(preset.id, scene); + expect(appliedCamera).toBeInstanceOf(UniversalCamera); + expect(appliedCamera).not.toBe(defaultCamera); + expect(appliedCamera?.position.asArray()).toEqual([10, 20, 30]); + expect((appliedCamera as UniversalCamera).speed).toBe(7); + expect((appliedCamera as UniversalCamera).keysUp).toEqual([73]); + expect(scene.activeCamera).toBe(appliedCamera); + + scene.dispose(); + engine.dispose(); + }); + + it("round-trips camera properties and behavior state without leaking replacement cameras", () => { + const engine = new NullEngine(); + const scene = new Scene(engine); + const sourceCamera = new ArcRotateCamera("source", 0.75, Math.PI * 0.75, 42, new Vector3(4, 5, 6), scene); + sourceCamera.wheelDeltaPercentage = 0.025; + sourceCamera.minZ = 0.5; + sourceCamera.maxZ = 5000; + sourceCamera.useAutoRotationBehavior = true; + sourceCamera.useBouncingBehavior = true; + sourceCamera.useFramingBehavior = true; + const sourceAutoRotationBehavior = sourceCamera.getBehaviorByName("AutoRotation") as AutoRotationBehavior; + sourceAutoRotationBehavior.idleRotationSpeed = 0.1; + sourceAutoRotationBehavior.idleRotationWaitTime = 3000; + sourceAutoRotationBehavior.idleRotationSpinupTime = 4000; + sourceAutoRotationBehavior.zoomStopsAnimation = true; + sourceAutoRotationBehavior.targetAlpha = 1.5; + const sourceBouncingBehavior = sourceCamera.getBehaviorByName("Bouncing") as BouncingBehavior; + sourceBouncingBehavior.transitionDuration = 600; + sourceBouncingBehavior.lowerRadiusTransitionRange = 3; + sourceBouncingBehavior.upperRadiusTransitionRange = -4; + sourceBouncingBehavior.autoTransitionRange = false; + const sourceFramingBehavior = sourceCamera.getBehaviorByName("Framing") as FramingBehavior; + sourceFramingBehavior.mode = 1; + sourceFramingBehavior.radiusScale = 1.2; + sourceFramingBehavior.positionScale = 0.4; + sourceFramingBehavior.defaultElevation = 0.25; + sourceFramingBehavior.elevationReturnTime = -1; + sourceFramingBehavior.elevationReturnWaitTime = 0; + sourceFramingBehavior.zoomStopsAnimation = true; + sourceFramingBehavior.framingTime = 0; + sourceFramingBehavior.autoCorrectCameraLimitsAndSensibility = false; + scene.activeCamera = sourceCamera; + + const storage = new MemoryCameraPresetStorage(); + let id = 0; + const manager = new CameraPresetManager(storage, () => `preset-${++id}`); + const firstPreset = manager.saveCamera(sourceCamera, "City"); + + sourceCamera.alpha = 2; + sourceCamera.radius = 10; + sourceCamera.useAutoRotationBehavior = false; + + const appliedCamera = manager.activatePreset(firstPreset.id, scene) as ArcRotateCamera; + expect(appliedCamera).not.toBe(sourceCamera); + expect(appliedCamera.getClassName()).toBe("ArcRotateCamera"); + expect(appliedCamera.alpha).toBeCloseTo(0.75); + expect(appliedCamera.beta).toBeCloseTo(Math.PI * 0.75); + expect(appliedCamera.beta).toBeGreaterThan(Math.PI / 2); + expect(appliedCamera.radius).toBeCloseTo(42); + expect(appliedCamera.target.asArray()).toEqual([4, 5, 6]); + expect(appliedCamera.wheelDeltaPercentage).toBeCloseTo(0.025); + expect(appliedCamera.minZ).toBeCloseTo(0.5); + expect(appliedCamera.maxZ).toBeCloseTo(5000); + expect(appliedCamera.useAutoRotationBehavior).toBe(true); + expect(appliedCamera.useBouncingBehavior).toBe(true); + expect(appliedCamera.useFramingBehavior).toBe(true); + const appliedAutoRotationBehavior = appliedCamera.getBehaviorByName("AutoRotation") as AutoRotationBehavior; + expect(appliedAutoRotationBehavior.idleRotationSpeed).toBeCloseTo(0.1); + expect(appliedAutoRotationBehavior.idleRotationWaitTime).toBe(3000); + expect(appliedAutoRotationBehavior.idleRotationSpinupTime).toBe(4000); + expect(appliedAutoRotationBehavior.zoomStopsAnimation).toBe(true); + expect(appliedAutoRotationBehavior.targetAlpha).toBeCloseTo(1.5); + const appliedBouncingBehavior = appliedCamera.getBehaviorByName("Bouncing") as BouncingBehavior; + expect(appliedBouncingBehavior.transitionDuration).toBe(600); + expect(appliedBouncingBehavior.lowerRadiusTransitionRange).toBeCloseTo(3); + expect(appliedBouncingBehavior.upperRadiusTransitionRange).toBeCloseTo(-4); + expect(appliedBouncingBehavior.autoTransitionRange).toBe(false); + const appliedFramingBehavior = appliedCamera.getBehaviorByName("Framing") as FramingBehavior; + expect(appliedFramingBehavior.framingTime).toBe(0); + expect(appliedFramingBehavior.elevationReturnTime).toBe(-1); + expect(appliedFramingBehavior.elevationReturnWaitTime).toBe(0); + expect(appliedFramingBehavior.defaultElevation).toBeCloseTo(0.25); + expect(appliedFramingBehavior.autoCorrectCameraLimitsAndSensibility).toBe(false); + scene.render(); + expect(appliedCamera.beta).toBeCloseTo(Math.PI * 0.75); + expect(scene.cameras).toHaveLength(2); + expect(manager.isPresetCamera(appliedCamera)).toBe(true); + expect(manager.isPresetCamera(sourceCamera)).toBe(false); + + const secondPreset = manager.saveCamera(appliedCamera, "City"); + expect(secondPreset.name).toBe("City 2"); + const secondAppliedCamera = manager.activatePreset(secondPreset.id, scene); + expect(scene.cameras).toHaveLength(2); + expect(manager.activePresetId).toBe(secondPreset.id); + expect(secondAppliedCamera).toBe(scene.activeCamera); + + scene.activeCamera = sourceCamera; + manager.releasePresetCamera(scene); + expect(scene.cameras).toEqual([sourceCamera]); + + const restoredManager = new CameraPresetManager(storage); + expect(restoredManager.activePresetId).toBe(secondPreset.id); + expect(restoredManager.presets.map((preset) => preset.name)).toEqual(["City", "City 2"]); + + const nextScene = new Scene(engine); + const nextSceneDefaultCamera = new ArcRotateCamera("default camera", 0, 0, 1, Vector3.Zero(), nextScene); + nextScene.activeCamera = nextSceneDefaultCamera; + const restoredCamera = restoredManager.applyActivePreset(nextScene) as ArcRotateCamera; + expect(restoredCamera).not.toBe(nextSceneDefaultCamera); + expect(restoredCamera.alpha).toBeCloseTo(0.75); + expect(restoredCamera.beta).toBeCloseTo(Math.PI * 0.75); + expect(restoredCamera.radius).toBeCloseTo(42); + expect((restoredCamera.getBehaviorByName("Framing") as FramingBehavior).elevationReturnTime).toBe(-1); + expect(nextScene.activeCamera).toBe(restoredCamera); + + nextScene.dispose(); + scene.dispose(); + engine.dispose(); + }); + + it("restores a scene camera and persists the default selection when a preset is deactivated", () => { + const engine = new NullEngine(); + const scene = new Scene(engine); + const defaultCamera = new ArcRotateCamera("default camera", 0, 1, 5, Vector3.Zero(), scene); + const embeddedCamera = new UniversalCamera("embedded camera", new Vector3(1, 2, 3), scene); + scene.activeCamera = defaultCamera; + + const storage = new MemoryCameraPresetStorage(); + let id = 0; + const manager = new CameraPresetManager(storage, () => `preset-${++id}`); + const preset = manager.saveCamera(defaultCamera, "Overview"); + expect(manager.saveCamera(defaultCamera, "Default camera").name).toBe("Default camera 2"); + const presetCamera = manager.activatePreset(preset.id, scene); + + expect(presetCamera).toBe(scene.activeCamera); + expect(manager.activePresetId).toBe(preset.id); + expect(manager.deactivatePreset(scene)).toBe(defaultCamera); + expect(scene.activeCamera).toBe(defaultCamera); + expect(scene.cameras).toEqual([defaultCamera, embeddedCamera]); + expect(manager.activePresetId).toBeNull(); + expect((storage.value as ICameraPresetState).activePresetId).toBeNull(); + + manager.activatePreset(preset.id, scene); + expect(manager.deactivatePreset(scene, embeddedCamera)).toBe(embeddedCamera); + expect(scene.activeCamera).toBe(embeddedCamera); + expect(scene.cameras).toEqual([defaultCamera, embeddedCamera]); + expect(manager.activePresetId).toBeNull(); + + const restoredManager = new CameraPresetManager(storage); + expect(restoredManager.activePresetId).toBeNull(); + expect(restoredManager.applyActivePreset(scene)).toBeNull(); + expect(scene.activeCamera).toBe(embeddedCamera); + + scene.dispose(); + engine.dispose(); + }); +}); diff --git a/packages/tools/sandbox/test/unit/footer.test.tsx b/packages/tools/sandbox/test/unit/footer.test.tsx new file mode 100644 index 000000000000..7a4826cae5f2 --- /dev/null +++ b/packages/tools/sandbox/test/unit/footer.test.tsx @@ -0,0 +1,87 @@ +import * as React from "react"; +import { UniversalCamera } from "core/Cameras/universalCamera"; +import { NullEngine } from "core/Engines/nullEngine"; +import { Vector3 } from "core/Maths/math.vector"; +import { Observable } from "core/Misc/observable"; +import { Scene } from "core/scene"; +import { Footer } from "../../src/components/footer"; +import { type GlobalState, type SandboxSceneLoadKind } from "../../src/globalState"; + +interface IControlProps { + children?: React.ReactNode; + enabled?: boolean; + label?: string; + options?: string[]; +} + +function FindControl(node: React.ReactNode, label: string): React.ReactElement | undefined { + if (!React.isValidElement(node)) { + return undefined; + } + if (node.props.label === label) { + return node; + } + + for (const child of React.Children.toArray(node.props.children)) { + const match = FindControl(child, label); + if (match) { + return match; + } + } + + return undefined; +} + +describe("Sandbox Footer camera controls", () => { + it("uses scene payload cameras, hides texture presets, and removes observers on unmount", () => { + const onSceneLoaded = new Observable<{ scene: Scene; filename: string; loadKind: SandboxSceneLoadKind }>(); + const onCameraChanged = new Observable(); + const onPresetChanged = new Observable(); + const globalState = { + currentScene: undefined, + currentSceneHadCameras: false, + currentSceneLoadKind: "scene", + glTFLoaderExtensions: {}, + onSceneLoaded, + onCameraChanged, + cameraPresetManager: { + isPresetCamera: () => false, + onChanged: onPresetChanged, + presets: [{ name: "Saved view" }], + }, + } as unknown as GlobalState; + const initialObserverCounts = [onSceneLoaded.observers.length, onCameraChanged.observers.length, onPresetChanged.observers.length]; + const footer = new Footer({ globalState }); + footer.forceUpdate = vi.fn(); + + expect([onSceneLoaded.observers.length, onCameraChanged.observers.length, onPresetChanged.observers.length]).toEqual(initialObserverCounts.map((count) => count + 1)); + expect(FindControl(footer.render(), "Select camera preset")?.props.enabled).toBe(false); + + const engine = new NullEngine(); + const embeddedCameraScene = new Scene(engine); + new UniversalCamera("Embedded camera", Vector3.Zero(), embeddedCameraScene); + globalState.currentScene = embeddedCameraScene; + onSceneLoaded.notifyObservers({ scene: embeddedCameraScene, filename: "embedded.gltf", loadKind: "scene" }); + + let renderedFooter = footer.render(); + expect(FindControl(renderedFooter, "Select camera")?.props).toMatchObject({ enabled: true, options: ["Embedded camera"] }); + expect(FindControl(renderedFooter, "Select camera preset")?.props.enabled).toBe(true); + + const textureScene = new Scene(engine); + onSceneLoaded.notifyObservers({ scene: textureScene, filename: "texture.png", loadKind: "texture" }); + globalState.currentScene = textureScene; + const generatedCamera = new UniversalCamera("default camera", Vector3.Zero(), textureScene); + onCameraChanged.notifyObservers(generatedCamera); + + renderedFooter = footer.render(); + expect(FindControl(renderedFooter, "Select camera")?.props.enabled).toBe(false); + expect(FindControl(renderedFooter, "Select camera preset")?.props.enabled).toBe(false); + + footer.componentWillUnmount(); + expect([onSceneLoaded.observers.length, onCameraChanged.observers.length, onPresetChanged.observers.length]).toEqual(initialObserverCounts); + + embeddedCameraScene.dispose(); + textureScene.dispose(); + engine.dispose(); + }); +}); From adc03bf0c1dde1ae4e721ce2ad80b25e032a133a Mon Sep 17 00:00:00 2001 From: Vicente Cartas Espinel Date: Fri, 14 Aug 2026 23:32:02 -0700 Subject: [PATCH 02/10] Code review fixes (automated by code-review skill) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tools/sandbox/src/components/footer.tsx | 6 +- .../sandbox/src/components/renderingZone.tsx | 2 +- packages/tools/sandbox/src/globalState.ts | 8 +- packages/tools/sandbox/src/sandbox.tsx | 2 + .../tools/cameraPresetInspectorService.tsx | 11 +- .../sandbox/src/tools/cameraPresetManager.ts | 139 ++++++++++++------ .../sandbox/test/interaction.sandbox.test.ts | 56 +++++-- .../test/unit/cameraPresetManager.test.ts | 102 ++++++++++++- 8 files changed, 255 insertions(+), 71 deletions(-) diff --git a/packages/tools/sandbox/src/components/footer.tsx b/packages/tools/sandbox/src/components/footer.tsx index 11c5ae8b0e23..65b8d3ea5d15 100644 --- a/packages/tools/sandbox/src/components/footer.tsx +++ b/packages/tools/sandbox/src/components/footer.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { type GlobalState, type SandboxSceneLoadKind } from "../globalState"; +import { type GlobalState, type SandboxSceneLoadedInfo, type SandboxSceneLoadKind } from "../globalState"; import { FooterButton } from "./footerButton"; import { DropUpButton } from "./dropUpButton"; import { EnvironmentTools } from "../tools/environmentTools"; @@ -34,7 +34,7 @@ export class Footer extends React.Component { private _cameras: Camera[] = []; private _sceneHadCameras = false; private _sceneLoadKind: SandboxSceneLoadKind = "scene"; - private readonly _onSceneLoadedObserver: Nullable>; + private readonly _onSceneLoadedObserver: Nullable>; private readonly _onCameraChangedObserver: Nullable>; private readonly _onCameraPresetChangedObserver: Nullable>; @@ -103,7 +103,7 @@ export class Footer extends React.Component { } const preset = this.props.globalState.cameraPresetManager.presets[index - 1]; - if (preset && this.props.globalState.currentSceneLoadKind === "scene") { + if (preset && this._sceneLoadKind === "scene") { const camera = this.props.globalState.cameraPresetManager.activatePreset(preset.id, scene); if (camera) { this.props.globalState.onCameraChanged.notifyObservers(camera); diff --git a/packages/tools/sandbox/src/components/renderingZone.tsx b/packages/tools/sandbox/src/components/renderingZone.tsx index bf730f46fc5c..00ac179c0258 100644 --- a/packages/tools/sandbox/src/components/renderingZone.tsx +++ b/packages/tools/sandbox/src/components/renderingZone.tsx @@ -343,7 +343,7 @@ export class RenderingZone extends React.Component { } this.prepareCamera(); - if (loadKind === "scene") { + if (loadKind === "scene" && !this.props.globalState.cameraPresetOverrideFromUrl) { this.props.globalState.cameraPresetManager.applyActivePreset(this._scene); } if (this._scene.activeCamera) { diff --git a/packages/tools/sandbox/src/globalState.ts b/packages/tools/sandbox/src/globalState.ts index 5194fae036e5..3f5a67901d74 100644 --- a/packages/tools/sandbox/src/globalState.ts +++ b/packages/tools/sandbox/src/globalState.ts @@ -5,6 +5,7 @@ import { MakeCameraPresetInspectorServiceDefinition } from "./tools/cameraPreset export type InspectorV2Module = typeof import("inspector/legacy/legacy") & typeof import("inspector/index"); export type SandboxSceneLoadKind = "scene" | "texture"; +export type SandboxSceneLoadedInfo = { scene: Scene; filename: string; loadKind: SandboxSceneLoadKind }; export class GlobalState { private _inspectorToken: Nullable = null; @@ -12,7 +13,7 @@ export class GlobalState { public currentScene: Scene; public currentSceneLoadKind: SandboxSceneLoadKind = "scene"; public currentSceneHadCameras = false; - public onSceneLoaded = new Observable<{ scene: Scene; filename: string; loadKind: SandboxSceneLoadKind }>(); + public onSceneLoaded = new Observable(); public onCameraChanged = new Observable(); public onError = new Observable<{ scene?: Scene; message?: string }>(); public onEnvironmentChanged = new Observable(); @@ -23,12 +24,15 @@ export class GlobalState { public filesInput: FilesInput; public isDebugLayerEnabled = false; - public readonly cameraPresetManager = new CameraPresetManager(); + public readonly cameraPresetManager = new CameraPresetManager(undefined, undefined, (message, scene) => { + this.onError.notifyObservers({ scene, message }); + }); public commerceMode = false; public assetUrl?: string; public autoRotate = false; + public cameraPresetOverrideFromUrl = false; public cameraPosition?: Vector3; public skybox = true; public toneMapping?: number; diff --git a/packages/tools/sandbox/src/sandbox.tsx b/packages/tools/sandbox/src/sandbox.tsx index c5e94530733d..b197b92d873c 100644 --- a/packages/tools/sandbox/src/sandbox.tsx +++ b/packages/tools/sandbox/src/sandbox.tsx @@ -331,10 +331,12 @@ export class Sandbox extends React.Component< break; } case "camera": { + this._globalState.cameraPresetOverrideFromUrl = true; this._camera = +value; break; } case "cameraposition": { + this._globalState.cameraPresetOverrideFromUrl = true; this._globalState.cameraPosition = Vector3.FromArray( value.split(",").map(function (component) { return +component; diff --git a/packages/tools/sandbox/src/tools/cameraPresetInspectorService.tsx b/packages/tools/sandbox/src/tools/cameraPresetInspectorService.tsx index 3f0838fac789..5c4e42643179 100644 --- a/packages/tools/sandbox/src/tools/cameraPresetInspectorService.tsx +++ b/packages/tools/sandbox/src/tools/cameraPresetInspectorService.tsx @@ -1,5 +1,4 @@ import { type Camera } from "core/Cameras/camera"; -import { Logger } from "core/Misc/logger"; import { type FunctionComponent } from "react"; import { type IPropertiesService, type ISelectionService, type WeaklyTypedServiceDefinition } from "inspector/index"; import { type GlobalState, type InspectorV2Module } from "../globalState"; @@ -33,13 +32,7 @@ const CameraPresetEditor: FunctionComponent = (props) { - try { - globalState.cameraPresetManager.saveCamera(camera, CameraPresetNames.get(camera) ?? ""); - } catch (error) { - Logger.Warn(`Unable to save Sandbox camera preset: ${error instanceof Error ? error.message : String(error)}`); - } - }} + onClick={() => globalState.cameraPresetManager.saveCamera(camera, CameraPresetNames.get(camera) ?? "")} /> ); @@ -55,6 +48,8 @@ export function MakeCameraPresetInspectorServiceDefinition(globalState: GlobalSt order: CameraPresetSectionOrder, }); let metadataSectionRegistration: ReturnType | undefined; + // The Inspector never registers "Metadata", so it is an implicit section that sorts above every explicitly ordered one. + // Claim it while a camera is selected to keep the preset section above it, and release it so other entities keep their usual layout. const updateMetadataSectionRegistration = () => { if (IsCamera(selectionService.selectedEntity)) { metadataSectionRegistration ??= propertiesService.addSection({ diff --git a/packages/tools/sandbox/src/tools/cameraPresetManager.ts b/packages/tools/sandbox/src/tools/cameraPresetManager.ts index c395a685aaa7..1e56aa88f4db 100644 --- a/packages/tools/sandbox/src/tools/cameraPresetManager.ts +++ b/packages/tools/sandbox/src/tools/cameraPresetManager.ts @@ -175,7 +175,8 @@ function ParseBehaviorState(value: unknown): ICameraPresetBehaviorState | undefi behaviorState.framing = ParseFramingBehaviorState(value.framing); } - return Object.values(behaviorState).some((property) => property !== 1 && property !== undefined) ? behaviorState : undefined; + const hasBehaviorContent = Object.entries(behaviorState).some(([property, propertyValue]) => property !== "version" && propertyValue !== undefined); + return hasBehaviorContent ? behaviorState : undefined; } function CreateCameraPresetId(): string { @@ -326,9 +327,7 @@ function ApplyBehaviorState(camera: Camera, behaviorState: ICameraPresetBehavior const bouncingBehavior = camera.getBehaviorByName("Bouncing") as BouncingBehavior | null; if (bouncingBehavior && behaviorState.bouncing) { - const { autoTransitionRange, ...bouncingProperties } = behaviorState.bouncing; - Object.assign(bouncingBehavior, bouncingProperties); - bouncingBehavior.autoTransitionRange = autoTransitionRange; + Object.assign(bouncingBehavior, behaviorState.bouncing); } const framingBehavior = camera.getBehaviorByName("Framing") as FramingBehavior | null; @@ -355,7 +354,8 @@ export class CameraPresetManager { public constructor( private readonly _storage: ICameraPresetStorageBackend = DataStorageBackend, - private readonly _createId: () => string = CreateCameraPresetId + private readonly _createId: () => string = CreateCameraPresetId, + private readonly _onError?: (message: string, scene?: Scene) => void ) { try { this._state = ParseCameraPresetState(this._storage.read()); @@ -377,27 +377,36 @@ export class CameraPresetManager { return this._state.presets.find((preset) => preset.id === this._state.activePresetId); } - public saveCamera(camera: Camera, requestedName: string): ICameraPreset { - const name = GetUniqueCameraPresetName([DefaultCameraPresetOption, ...this._state.presets.map((preset) => preset.name)], requestedName); - let id = this._createId(); - while (this._state.presets.some((preset) => preset.id === id)) { - id = this._createId(); - } - - const cameraData = JSON.parse(JSON.stringify(camera.serialize())) as Record; - const preset: ICameraPreset = { - id, - name, - cameraType: camera.getClassName(), - cameraData, - behaviors: GetBehaviorState(camera), - }; + public saveCamera(camera: Camera, requestedName: string): ICameraPreset | null { + try { + const name = GetUniqueCameraPresetName([DefaultCameraPresetOption, ...this._state.presets.map((preset) => preset.name)], requestedName); + let id = this._createId(); + while (this._state.presets.some((preset) => preset.id === id)) { + id = this._createId(); + } - this._updateState({ - ...this._state, - presets: [...this._state.presets, preset], - }); - return preset; + const cameraData = JSON.parse(JSON.stringify(camera.serialize())) as Record; + const preset: ICameraPreset = { + id, + name, + cameraType: camera.getClassName(), + cameraData, + behaviors: GetBehaviorState(camera), + }; + + return this._updateState( + { + ...this._state, + presets: [...this._state.presets, preset], + }, + camera.getScene() + ) + ? preset + : null; + } catch (error) { + this._reportError(`Unable to save Sandbox camera preset: ${error instanceof Error ? error.message : String(error)}`, camera.getScene()); + return null; + } } public activatePreset(presetId: string, scene: Scene): Camera | null { @@ -406,12 +415,25 @@ export class CameraPresetManager { return null; } + const previousState = this._state; + const nextState: ICameraPresetState = { + ...this._state, + activePresetId: preset.id, + }; + if (preset.id !== this._state.activePresetId && !this._persistState(nextState, scene)) { + return null; + } + const camera = this._applyPreset(preset, scene); - if (camera) { - this._updateState({ - ...this._state, - activePresetId: preset.id, - }); + if (!camera) { + if (preset.id !== previousState.activePresetId) { + this._restorePersistedState(previousState, scene); + } + return null; + } + + if (preset.id !== previousState.activePresetId) { + this._commitState(nextState); } return camera; } @@ -431,6 +453,19 @@ export class CameraPresetManager { return null; } + if ( + this._state.activePresetId !== null && + !this._updateState( + { + ...this._state, + activePresetId: null, + }, + scene + ) + ) { + return scene.activeCamera; + } + const inputElement = scene.getEngine().getInputElement(); if (inputElement && scene.activeCamera !== camera) { scene.activeCamera?.detachControl(); @@ -442,12 +477,6 @@ export class CameraPresetManager { this._sceneCameras.set(scene, camera); this.releasePresetCamera(scene); - if (this._state.activePresetId !== null) { - this._updateState({ - ...this._state, - activePresetId: null, - }); - } return camera; } @@ -488,7 +517,7 @@ export class CameraPresetManager { camera = Camera.Parse(cameraData, scene); if (camera.getClassName() !== preset.cameraType) { DisposeCameras(scene.cameras.filter((createdCamera) => !existingCameras.has(createdCamera))); - Logger.Warn(`Unable to apply Sandbox camera preset "${preset.name}": camera type "${preset.cameraType}" is not available.`); + this._reportError(`Unable to apply Sandbox camera preset "${preset.name}": camera type "${preset.cameraType}" is not available.`, scene); return null; } @@ -510,7 +539,7 @@ export class CameraPresetManager { if (inputElement && previousCameraDetached && previousCamera) { previousCamera.attachControl(); } - Logger.Warn(`Unable to apply Sandbox camera preset "${preset.name}": ${error instanceof Error ? error.message : String(error)}`); + this._reportError(`Unable to apply Sandbox camera preset "${preset.name}": ${error instanceof Error ? error.message : String(error)}`, scene); return null; } @@ -531,13 +560,41 @@ export class CameraPresetManager { return camera; } - private _updateState(state: ICameraPresetState): void { - this._state = state; + private _updateState(state: ICameraPresetState, scene?: Scene): boolean { + if (!this._persistState(state, scene)) { + return false; + } + + this._commitState(state); + return true; + } + + private _persistState(state: ICameraPresetState, scene?: Scene): boolean { try { this._storage.write(state); } catch (error) { - Logger.Warn(`Unable to persist Sandbox camera presets: ${error instanceof Error ? error.message : String(error)}`); + this._reportError(`Unable to persist Sandbox camera presets: ${error instanceof Error ? error.message : String(error)}`, scene); + return false; } + + return true; + } + + private _restorePersistedState(state: ICameraPresetState, scene: Scene): void { + try { + this._storage.write(state); + } catch (error) { + this._reportError(`Unable to restore Sandbox camera preset preference: ${error instanceof Error ? error.message : String(error)}`, scene); + } + } + + private _commitState(state: ICameraPresetState): void { + this._state = state; this.onChanged.notifyObservers(); } + + private _reportError(message: string, scene?: Scene): void { + Logger.Warn(message); + this._onError?.(message, scene); + } } diff --git a/packages/tools/sandbox/test/interaction.sandbox.test.ts b/packages/tools/sandbox/test/interaction.sandbox.test.ts index 4b5708f0fbaa..a904dd066eb4 100644 --- a/packages/tools/sandbox/test/interaction.sandbox.test.ts +++ b/packages/tools/sandbox/test/interaction.sandbox.test.ts @@ -138,6 +138,8 @@ test("camera presets can be saved, selected, and restored", async ({ page }) => const pageErrors: string[] = []; page.on("pageerror", (error) => pageErrors.push(error.message)); + // This init script runs on every navigation, so the session flag limits the reset to the first one and lets + // presets saved later in the test survive the reloads that verify persistence. await page.addInitScript((storageKey) => { const initializedKey = `${storageKey}/testInitialized`; if (!sessionStorage.getItem(initializedKey)) { @@ -166,11 +168,9 @@ test("camera presets can be saved, selected, and restored", async ({ page }) => await embeddedCameraFooter.getByTitle("Select camera").click(); await expect(embeddedCameraFooter.locator(".dropup-content-line")).toHaveText(["Camera", "default camera"]); await embeddedCameraFooter.getByTitle("default camera", { exact: true }).click(); - const activeEmbeddedSceneCameraName = await page.evaluate(() => { - return (globalThis as typeof globalThis & { BABYLON: { EngineStore: { LastCreatedScene?: { activeCamera?: { name: string } } } } }).BABYLON.EngineStore.LastCreatedScene - ?.activeCamera?.name; - }); - expect(activeEmbeddedSceneCameraName).toBe("default camera"); + await embeddedCameraFooter.getByTitle("Select camera").click(); + await expect(embeddedCameraFooter.getByTitle("default camera", { exact: true }).locator(":scope > div")).toHaveCSS("opacity", "1"); + await embeddedCameraFooter.getByTitle("default camera", { exact: true }).click(); await page.keyboard.press("Space"); await expect(embeddedCameraFooter).toHaveCount(0); await page.keyboard.press("Space"); @@ -189,15 +189,14 @@ test("camera presets can be saved, selected, and restored", async ({ page }) => const nodes = page.getByRole("treeitem", { name: "Nodes", exact: true }); await nodes.focus(); await nodes.press("ArrowRight"); + await page.getByRole("treeitem", { name: /^default camera/ }).click(); await page.evaluate(() => { - const runtime = globalThis as typeof globalThis & { - BABYLON: { EngineStore: { LastCreatedScene?: { activeCamera?: { inspectableCustomProperties?: unknown[] } } } }; - }; - const camera = runtime.BABYLON.EngineStore.LastCreatedScene?.activeCamera; + const camera = (globalThis as typeof globalThis & { debugNode?: { inspectableCustomProperties?: unknown[] } }).debugNode; if (camera) { camera.inspectableCustomProperties = []; } }); + await page.getByRole("treeitem", { name: "hdrSkyBox", exact: true }).click(); await page.getByRole("treeitem", { name: /^default camera/ }).click(); const presetHeader = inspector.getByRole("button", { name: "Save Camera Preset", exact: true }); @@ -307,6 +306,45 @@ test("camera presets can be saved, selected, and restored", async ({ page }) => const reactivatedState = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey) ?? "null"), cameraPresetStorageKey); expect(reactivatedState.activePresetId).toBe(reactivatedState.presets[0].id); + await page.goto(embeddedCameraModelUrl + "&camera=0", { waitUntil: "load" }); + await waitForSandboxReady(page); + await page.waitForSelector("#babylonjsLoadingDiv", { state: "detached" }); + await page.waitForLoadState("networkidle"); + const cameraOverrideFooter = page.locator("#footer"); + await cameraOverrideFooter.getByTitle("Select camera", { exact: true }).click(); + await expect(cameraOverrideFooter.locator(".dropup-content-line")).toHaveText(["Camera"]); + await expect(cameraOverrideFooter.getByTitle("Camera", { exact: true }).locator(":scope > div")).toHaveCSS("opacity", "1"); + await page.locator(".clickInterceptor").click(); + const cameraOverrideState = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey) ?? "null"), cameraPresetStorageKey); + expect(cameraOverrideState.activePresetId).toBe(cameraOverrideState.presets[0].id); + + await page.goto(boxModelUrl + "&cameraPosition=10,20,30", { waitUntil: "load" }); + await waitForSandboxReady(page); + await page.waitForSelector("#babylonjsLoadingDiv", { state: "detached" }); + await page.waitForLoadState("networkidle"); + if (!(await inspector.isVisible())) { + await page.getByTitle("Display inspector").click(); + } + const cameraPositionOverrideNodes = page.getByRole("treeitem", { name: "Nodes", exact: true }); + await cameraPositionOverrideNodes.focus(); + await cameraPositionOverrideNodes.press("ArrowRight"); + const cameraPositionOverride = page.getByRole("treeitem", { name: /^default camera/ }); + await cameraPositionOverride.click(); + await expect(cameraPositionOverride.getByRole("button", { name: "Activate and Attach Controls" })).toHaveAttribute("aria-pressed", "true"); + const urlCameraPositionOverride = await page.evaluate(() => { + const activeCamera = (globalThis as typeof globalThis & { debugNode?: { name: string; position: { asArray(): number[] } } }).debugNode; + return { name: activeCamera?.name, position: activeCamera?.position.asArray() }; + }); + expect(urlCameraPositionOverride.name).toBe("default camera"); + const position = urlCameraPositionOverride.position; + expect(position).toBeDefined(); + const [positionX, positionY, positionZ] = position!; + expect(positionY / positionX).toBeCloseTo(2); + expect(positionZ / positionX).toBeCloseTo(3); + await expect(page.getByRole("treeitem", { name: /^Preset 1/ })).toHaveCount(0); + const cameraPositionOverrideState = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey) ?? "null"), cameraPresetStorageKey); + expect(cameraPositionOverrideState.activePresetId).toBe(cameraPositionOverrideState.presets[0].id); + await page.goto(textureAssetUrl, { waitUntil: "load" }); await waitForSandboxReady(page); await page.waitForSelector("#babylonjsLoadingDiv", { state: "detached" }); diff --git a/packages/tools/sandbox/test/unit/cameraPresetManager.test.ts b/packages/tools/sandbox/test/unit/cameraPresetManager.test.ts index 6713336d307d..66dc4491241f 100644 --- a/packages/tools/sandbox/test/unit/cameraPresetManager.test.ts +++ b/packages/tools/sandbox/test/unit/cameraPresetManager.test.ts @@ -28,6 +28,12 @@ class MemoryCameraPresetStorage implements ICameraPresetStorageBackend { } } +class FailingCameraPresetStorage extends MemoryCameraPresetStorage { + public override write(_state: ICameraPresetState): void { + throw new Error("Storage unavailable"); + } +} + describe("camera preset naming", () => { it("uses the first available generic name for a blank name", () => { expect(GetUniqueCameraPresetName(["Preset 1", "preset 3"], " ")).toBe("Preset 2"); @@ -38,9 +44,20 @@ describe("camera preset naming", () => { expect(GetUniqueCameraPresetName(names, " CITY ")).toBe("CITY 4"); expect(GetUniqueCameraPresetName(names, "City 2")).toBe("City 4"); }); + + it("restarts numbering when the requested suffix cannot be incremented safely", () => { + expect(GetUniqueCameraPresetName(["City 99999999999999999999"], "City 99999999999999999999")).toBe("City 2"); + }); }); describe("camera preset storage", () => { + it("falls back to an empty state for payloads that are not a preset collection", () => { + const emptyState = { version: 1, activePresetId: null, presets: [] }; + expect(ParseCameraPresetState(null)).toEqual(emptyState); + expect(ParseCameraPresetState("not a state")).toEqual(emptyState); + expect(ParseCameraPresetState({ version: 1, activePresetId: "orphan", presets: "not an array" })).toEqual(emptyState); + }); + it("rejects unsupported schemas, filters malformed presets, and normalizes conflicting names", () => { expect(ParseCameraPresetState({ version: 2, activePresetId: "old", presets: [] })).toEqual({ version: 1, activePresetId: null, presets: [] }); @@ -109,11 +126,52 @@ describe("camera preset Inspector predicate", () => { }); describe("CameraPresetManager", () => { - it("leaves the scene camera unchanged when persisted camera data cannot be applied", () => { + it("keeps storage read failures warning-only during startup", () => { + const errors: string[] = []; + const manager = new CameraPresetManager( + { + read: () => { + throw new Error("Unreadable storage"); + }, + write: () => {}, + }, + undefined, + (message) => errors.push(message) + ); + + expect(manager.presets).toEqual([]); + expect(manager.activePresetId).toBeNull(); + expect(errors).toEqual([]); + }); + + it("does not commit a saved preset when persistence fails and reports the error once", () => { + const engine = new NullEngine(); + const scene = new Scene(engine); + const camera = new ArcRotateCamera("camera", 0, 0, 1, Vector3.Zero(), scene); + const errors: string[] = []; + const manager = new CameraPresetManager( + new FailingCameraPresetStorage(), + () => "failed-preset", + (message) => errors.push(message) + ); + let changeCount = 0; + manager.onChanged.add(() => changeCount++); + + expect(manager.saveCamera(camera, "Failed preset")).toBeNull(); + expect(manager.presets).toEqual([]); + expect(changeCount).toBe(0); + expect(errors).toEqual(["Unable to persist Sandbox camera presets: Storage unavailable"]); + + scene.dispose(); + engine.dispose(); + }); + + it("leaves the scene camera unchanged and reports once when persisted camera data cannot be applied", () => { const engine = new NullEngine(); const scene = new Scene(engine); const defaultCamera = new ArcRotateCamera("default camera", 0, 0, 1, Vector3.Zero(), scene); scene.activeCamera = defaultCamera; + const errors: string[] = []; const storage = new MemoryCameraPresetStorage(); storage.value = { @@ -129,7 +187,7 @@ describe("CameraPresetManager", () => { ], }; - const manager = new CameraPresetManager(storage); + const manager = new CameraPresetManager(storage, undefined, (message) => errors.push(message)); const originalParse = Camera.Parse; Camera.Parse = (_cameraData, targetScene) => { new UniversalCamera("partial camera", Vector3.Zero(), targetScene); @@ -146,6 +204,36 @@ describe("CameraPresetManager", () => { expect(result).toBeNull(); expect(scene.activeCamera).toBe(defaultCamera); expect(scene.cameras).toEqual([defaultCamera]); + expect(errors).toEqual(['Unable to apply Sandbox camera preset "Unavailable": Unable to parse camera']); + + scene.dispose(); + engine.dispose(); + }); + + it("discards the substituted camera when the saved camera type is not registered", () => { + const engine = new NullEngine(); + const scene = new Scene(engine); + const defaultCamera = new ArcRotateCamera("default camera", 0, 0, 1, Vector3.Zero(), scene); + scene.activeCamera = defaultCamera; + + const storage = new MemoryCameraPresetStorage(); + storage.value = { + version: 1, + activePresetId: "unregistered", + presets: [ + { + id: "unregistered", + name: "Unregistered", + cameraType: "NotRegisteredCamera", + cameraData: { type: "NotRegisteredCamera", name: "Unregistered", position: [1, 2, 3] }, + }, + ], + }; + + const manager = new CameraPresetManager(storage); + expect(manager.applyActivePreset(scene)).toBeNull(); + expect(scene.activeCamera).toBe(defaultCamera); + expect(scene.cameras).toEqual([defaultCamera]); scene.dispose(); engine.dispose(); @@ -162,7 +250,7 @@ describe("CameraPresetManager", () => { const storage = new MemoryCameraPresetStorage(); const manager = new CameraPresetManager(storage, () => "universal"); - const preset = manager.saveCamera(universalCamera, "Walkthrough"); + const preset = manager.saveCamera(universalCamera, "Walkthrough")!; universalCamera.dispose(); const appliedCamera = manager.activatePreset(preset.id, scene); @@ -213,7 +301,7 @@ describe("CameraPresetManager", () => { const storage = new MemoryCameraPresetStorage(); let id = 0; const manager = new CameraPresetManager(storage, () => `preset-${++id}`); - const firstPreset = manager.saveCamera(sourceCamera, "City"); + const firstPreset = manager.saveCamera(sourceCamera, "City")!; sourceCamera.alpha = 2; sourceCamera.radius = 10; @@ -256,7 +344,7 @@ describe("CameraPresetManager", () => { expect(manager.isPresetCamera(appliedCamera)).toBe(true); expect(manager.isPresetCamera(sourceCamera)).toBe(false); - const secondPreset = manager.saveCamera(appliedCamera, "City"); + const secondPreset = manager.saveCamera(appliedCamera, "City")!; expect(secondPreset.name).toBe("City 2"); const secondAppliedCamera = manager.activatePreset(secondPreset.id, scene); expect(scene.cameras).toHaveLength(2); @@ -297,8 +385,8 @@ describe("CameraPresetManager", () => { const storage = new MemoryCameraPresetStorage(); let id = 0; const manager = new CameraPresetManager(storage, () => `preset-${++id}`); - const preset = manager.saveCamera(defaultCamera, "Overview"); - expect(manager.saveCamera(defaultCamera, "Default camera").name).toBe("Default camera 2"); + const preset = manager.saveCamera(defaultCamera, "Overview")!; + expect(manager.saveCamera(defaultCamera, "Default camera")!.name).toBe("Default camera 2"); const presetCamera = manager.activatePreset(preset.id, scene); expect(presetCamera).toBe(scene.activeCamera); From cb1ce32c5f3424cde3101d4ad054c1cb6d870783 Mon Sep 17 00:00:00 2001 From: Vicente Cartas Espinel Date: Sat, 15 Aug 2026 02:06:17 -0700 Subject: [PATCH 03/10] Address camera preset review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sandbox/src/components/renderingZone.tsx | 4 +- packages/tools/sandbox/src/globalState.ts | 14 +++++++ .../sandbox/src/tools/cameraPresetManager.ts | 7 ++-- .../test/unit/cameraPresetManager.test.ts | 41 +++++++++++++++++++ .../sandbox/test/unit/globalState.test.ts | 27 ++++++++++++ 5 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 packages/tools/sandbox/test/unit/globalState.test.ts diff --git a/packages/tools/sandbox/src/components/renderingZone.tsx b/packages/tools/sandbox/src/components/renderingZone.tsx index 00ac179c0258..04761950e575 100644 --- a/packages/tools/sandbox/src/components/renderingZone.tsx +++ b/packages/tools/sandbox/src/components/renderingZone.tsx @@ -343,9 +343,7 @@ export class RenderingZone extends React.Component { } this.prepareCamera(); - if (loadKind === "scene" && !this.props.globalState.cameraPresetOverrideFromUrl) { - this.props.globalState.cameraPresetManager.applyActivePreset(this._scene); - } + this.props.globalState.applyActiveCameraPresetForLoad(this._scene, loadKind); if (this._scene.activeCamera) { this.props.globalState.onCameraChanged.notifyObservers(this._scene.activeCamera); } diff --git a/packages/tools/sandbox/src/globalState.ts b/packages/tools/sandbox/src/globalState.ts index 3f5a67901d74..39b63a25b940 100644 --- a/packages/tools/sandbox/src/globalState.ts +++ b/packages/tools/sandbox/src/globalState.ts @@ -48,6 +48,20 @@ export class GlobalState { constructor(private readonly _versionInfo: { version: string; bundles: string[] }) {} + public consumeCameraPresetOverrideFromUrl(): boolean { + const overrideFromUrl = this.cameraPresetOverrideFromUrl; + this.cameraPresetOverrideFromUrl = false; + return overrideFromUrl; + } + + public applyActiveCameraPresetForLoad(scene: Scene, loadKind: SandboxSceneLoadKind): Camera | null { + if (loadKind !== "scene" || this.consumeCameraPresetOverrideFromUrl()) { + return null; + } + + return this.cameraPresetManager.applyActivePreset(scene); + } + public showDebugLayer() { if (!this.isDebugLayerEnabled) { this.isDebugLayerEnabled = true; diff --git a/packages/tools/sandbox/src/tools/cameraPresetManager.ts b/packages/tools/sandbox/src/tools/cameraPresetManager.ts index 1e56aa88f4db..c016de24541e 100644 --- a/packages/tools/sandbox/src/tools/cameraPresetManager.ts +++ b/packages/tools/sandbox/src/tools/cameraPresetManager.ts @@ -188,7 +188,7 @@ function CreateCameraPresetId(): string { } export function GetUniqueCameraPresetName(existingNames: readonly string[], requestedName: string): string { - const normalizedNames = new Set(existingNames.map((name) => name.trim().toLocaleLowerCase())); + const normalizedNames = new Set(existingNames.map((name) => name.trim().toLowerCase())); const trimmedName = requestedName.trim(); if (!trimmedName) { @@ -199,7 +199,7 @@ export function GetUniqueCameraPresetName(existingNames: readonly string[], requ return `Preset ${index}`; } - if (!normalizedNames.has(trimmedName.toLocaleLowerCase())) { + if (!normalizedNames.has(trimmedName.toLowerCase())) { return trimmedName; } @@ -210,7 +210,7 @@ export function GetUniqueCameraPresetName(existingNames: readonly string[], requ suffix = 2; } - while (normalizedNames.has(`${baseName} ${suffix}`.toLocaleLowerCase())) { + while (normalizedNames.has(`${baseName} ${suffix}`.toLowerCase())) { suffix++; } @@ -507,6 +507,7 @@ export class CameraPresetManager { id: `SandboxCameraPreset/${preset.id}`, name: preset.name, }; + delete cameraData.uniqueId; delete cameraData.parentId; delete cameraData.parentInstanceIndex; delete cameraData.lockedTargetId; diff --git a/packages/tools/sandbox/test/unit/cameraPresetManager.test.ts b/packages/tools/sandbox/test/unit/cameraPresetManager.test.ts index 66dc4491241f..80084d4cc22b 100644 --- a/packages/tools/sandbox/test/unit/cameraPresetManager.test.ts +++ b/packages/tools/sandbox/test/unit/cameraPresetManager.test.ts @@ -6,7 +6,9 @@ import { type BouncingBehavior } from "core/Behaviors/Cameras/bouncingBehavior"; import { type FramingBehavior } from "core/Behaviors/Cameras/framingBehavior"; import { NullEngine } from "core/Engines/nullEngine"; import { Vector3 } from "core/Maths/math.vector"; +import { SerializationHelper } from "core/Misc/decorators.serialization"; import { Scene } from "core/scene"; +import { describe, expect, it, vi } from "vitest"; import { CameraPresetManager, GetUniqueCameraPresetName, @@ -45,6 +47,16 @@ describe("camera preset naming", () => { expect(GetUniqueCameraPresetName(names, "City 2")).toBe("City 4"); }); + it("normalizes names without locale-sensitive casing", () => { + const toLocaleLowerCase = vi.spyOn(String.prototype, "toLocaleLowerCase"); + try { + expect(GetUniqueCameraPresetName(["CITY"], "city")).toBe("city 2"); + expect(toLocaleLowerCase).not.toHaveBeenCalled(); + } finally { + toLocaleLowerCase.mockRestore(); + } + }); + it("restarts numbering when the requested suffix cannot be incremented safely", () => { expect(GetUniqueCameraPresetName(["City 99999999999999999999"], "City 99999999999999999999")).toBe("City 2"); }); @@ -265,6 +277,35 @@ describe("CameraPresetManager", () => { engine.dispose(); }); + it("assigns a fresh scene uniqueId when applying serialized camera data", () => { + const engine = new NullEngine(); + const scene = new Scene(engine); + const sourceCamera = new ArcRotateCamera("source", 0, 1, 5, Vector3.Zero(), scene); + scene.activeCamera = sourceCamera; + + const manager = new CameraPresetManager(new MemoryCameraPresetStorage(), () => "collision"); + const preset = manager.saveCamera(sourceCamera, "Collision")!; + expect(preset.cameraData.uniqueId).toBe(sourceCamera.uniqueId); + + const getUniqueId = vi.spyOn(scene, "getUniqueId"); + const previousAllowLoadingUniqueId = SerializationHelper.AllowLoadingUniqueId; + let appliedCamera; + try { + SerializationHelper.AllowLoadingUniqueId = true; + appliedCamera = manager.activatePreset(preset.id, scene); + } finally { + SerializationHelper.AllowLoadingUniqueId = previousAllowLoadingUniqueId; + } + + expect(appliedCamera).not.toBeNull(); + expect(appliedCamera?.uniqueId).not.toBe(sourceCamera.uniqueId); + expect(getUniqueId.mock.results.map((result) => result.value)).toContain(appliedCamera?.uniqueId); + expect(new Set(scene.cameras.map((camera) => camera.uniqueId)).size).toBe(scene.cameras.length); + + scene.dispose(); + engine.dispose(); + }); + it("round-trips camera properties and behavior state without leaking replacement cameras", () => { const engine = new NullEngine(); const scene = new Scene(engine); diff --git a/packages/tools/sandbox/test/unit/globalState.test.ts b/packages/tools/sandbox/test/unit/globalState.test.ts new file mode 100644 index 000000000000..4cb96a00bb1f --- /dev/null +++ b/packages/tools/sandbox/test/unit/globalState.test.ts @@ -0,0 +1,27 @@ +import { type Scene } from "core/scene"; +import { describe, expect, it, vi } from "vitest"; +import { GlobalState } from "../../src/globalState"; + +describe("GlobalState camera preset URL override", () => { + it("preserves the override through texture previews and consumes it for only one model load", () => { + const globalState = new GlobalState({ version: "test", bundles: [] }); + const applyActivePreset = vi.spyOn(globalState.cameraPresetManager, "applyActivePreset").mockReturnValue(null); + const textureScene = {} as Scene; + const urlModelScene = {} as Scene; + const ordinaryModelScene = {} as Scene; + + globalState.cameraPresetOverrideFromUrl = true; + + expect(globalState.applyActiveCameraPresetForLoad(textureScene, "texture")).toBeNull(); + expect(globalState.cameraPresetOverrideFromUrl).toBe(true); + expect(applyActivePreset).not.toHaveBeenCalled(); + + expect(globalState.applyActiveCameraPresetForLoad(urlModelScene, "scene")).toBeNull(); + expect(globalState.cameraPresetOverrideFromUrl).toBe(false); + expect(applyActivePreset).not.toHaveBeenCalled(); + + expect(globalState.applyActiveCameraPresetForLoad(ordinaryModelScene, "scene")).toBeNull(); + expect(applyActivePreset).toHaveBeenCalledOnce(); + expect(applyActivePreset).toHaveBeenCalledWith(ordinaryModelScene); + }); +}); From ef930cbdfe30e5c4b4dfb5b94f9aad2e8254d1e7 Mon Sep 17 00:00:00 2001 From: Vicente Cartas Espinel Date: Sat, 15 Aug 2026 18:00:58 -0700 Subject: [PATCH 04/10] Stabilize Sandbox camera preset interaction tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sandbox/test/interaction.sandbox.test.ts | 673 ++++++++++++------ 1 file changed, 438 insertions(+), 235 deletions(-) diff --git a/packages/tools/sandbox/test/interaction.sandbox.test.ts b/packages/tools/sandbox/test/interaction.sandbox.test.ts index a904dd066eb4..e6acbb2e4d35 100644 --- a/packages/tools/sandbox/test/interaction.sandbox.test.ts +++ b/packages/tools/sandbox/test/interaction.sandbox.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, type Locator, type Page } from "@playwright/test"; import { readFileSync } from "fs"; import { getGlobalConfig } from "@tools/test-tools"; @@ -12,9 +12,81 @@ const snapshot = process.env.SNAPSHOT ? "?snapshot=" + process.env.SNAPSHOT : "" const cdnPort = ":" + (process.env.CDN_PORT || 1337); const url = (process.env.SANDBOX_BASE_URL || getGlobalConfig().baseUrl.replace(cdnPort, process.env.SANDBOX_PORT || ":1339")) + snapshot; const cameraPresetStorageKey = "Babylon/Sandbox/cameraPresets"; +const inspectorTeachingMomentStoragePrefix = "Babylon/Inspector/TeachingMoments/"; const boxModelUrl = url + (snapshot ? "&" : "?") + "assetUrl=https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/main/2.0/Box/glTF-Binary/Box.glb"; const embeddedCameraModelUrl = url + (snapshot ? "&" : "?") + "assetUrl=https://assets.babylonjs.com/meshes/Box/Box_extras.gltf"; const textureAssetUrl = url + (snapshot ? "&" : "?") + "assetUrl=https://assets.babylonjs.com/textures/grass.png"; +const cameraPresetId = "test-overview"; + +interface IStoredCameraPresetState { + version: number; + activePresetId: string | null; + presets: { id: string; name: string; cameraType: string; cameraData: Record }[]; +} + +interface ISceneCameraState { + name: string | undefined; + id: string | undefined; + position: number[] | undefined; + keysUp: number[] | undefined; + keysDown: number[] | undefined; + keysLeft: number[] | undefined; + keysRight: number[] | undefined; + useAutoRotationBehavior: boolean | undefined; + useFramingBehavior: boolean | undefined; + idleRotationWaitTime: number | undefined; + cameraNames: string[]; + cameraIds: string[]; +} + +function createCameraPresetState(activePresetId: string | null): Record { + return { + version: 1, + activePresetId, + presets: [ + { + id: cameraPresetId, + name: "Overview", + cameraType: "ArcRotateCamera", + cameraData: { + name: "Overview", + id: "overview-source", + type: "ArcRotateCamera", + position: [8, 6, 8], + target: [0, 0, 0], + alpha: 0.75, + beta: 1.1, + radius: 12, + minZ: 0.1, + maxZ: 1000, + inputsmgr: { + ArcRotateCameraKeyboardMoveInput: { + keysUp: [90, 87], + keysDown: [83], + keysLeft: [65, 81], + keysRight: [69, 68], + }, + ArcRotateCameraMouseWheelInput: {}, + ArcRotateCameraPointersInput: {}, + }, + }, + behaviors: { + version: 1, + useAutoRotationBehavior: true, + useBouncingBehavior: false, + useFramingBehavior: false, + autoRotation: { + idleRotationSpeed: 0, + idleRotationWaitTime: 1234, + idleRotationSpinupTime: 567, + zoomStopsAnimation: true, + targetAlpha: null, + }, + }, + }, + ], + }; +} /** * Wait for the sandbox app to be fully rendered with CSS applied. @@ -29,6 +101,154 @@ async function waitForSandboxReady(page: import("@playwright/test").Page) { await page.evaluate(() => document.fonts.ready); } +async function suppressInspectorTeachingMoments(page: Page): Promise { + await page.addInitScript((storagePrefix) => { + const storageGetItem = Storage.prototype.getItem; + const teachingMomentReads: string[] = []; + (globalThis as typeof globalThis & { __sandboxInspectorTeachingMomentReads?: string[] }).__sandboxInspectorTeachingMomentReads = teachingMomentReads; + Storage.prototype.getItem = function (key: string): string | null { + if (this === localStorage && key.startsWith(storagePrefix)) { + teachingMomentReads.push(key); + return "true"; + } + return storageGetItem.call(this, key); + }; + }, inspectorTeachingMomentStoragePrefix); +} + +async function clearCameraPresetStorage(page: Page): Promise { + await suppressInspectorTeachingMoments(page); + await page.addInitScript((storageKey) => { + const initializedKey = `${storageKey}/testInitialized`; + if (!sessionStorage.getItem(initializedKey)) { + localStorage.removeItem(storageKey); + sessionStorage.setItem(initializedKey, "true"); + } + }, cameraPresetStorageKey); +} + +async function seedCameraPresetStorage(page: Page, activePresetId: string | null): Promise { + await suppressInspectorTeachingMoments(page); + await page.addInitScript( + ({ storageKey, state }) => { + if (localStorage.getItem(storageKey) === null) { + localStorage.setItem(storageKey, JSON.stringify(state)); + } + }, + { storageKey: cameraPresetStorageKey, state: createCameraPresetState(activePresetId) } + ); +} + +function getAssetFileName(targetUrl: string): string { + const assetUrl = new URL(targetUrl).searchParams.get("assetUrl"); + if (!assetUrl) { + throw new Error(`Expected an assetUrl query parameter in ${targetUrl}`); + } + + return decodeURIComponent(new URL(assetUrl).pathname.split("/").pop()!); +} + +async function waitForLoadedAsset(page: Page, expectedFileName: string): Promise { + await waitForSandboxReady(page); + const escapedFileName = expectedFileName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + await expect(page).toHaveTitle(new RegExp(`${escapedFileName}$`)); + await expect(page.locator("#canvasZone")).toHaveClass(/checkerboard/); + await expect(page.locator("#babylonjsLoadingDiv")).toHaveCount(0); +} + +async function loadSandboxAsset(page: Page, targetUrl: string): Promise { + await page.goto(targetUrl, { waitUntil: "load" }); + await waitForLoadedAsset(page, getAssetFileName(targetUrl)); +} + +async function reloadSandboxAsset(page: Page): Promise { + const expectedFileName = getAssetFileName(page.url()); + await page.reload({ waitUntil: "load" }); + await waitForLoadedAsset(page, expectedFileName); +} + +async function openInspector(page: Page): Promise { + const inspector = page.locator("#babylon-inspector-container"); + if (!(await inspector.isVisible())) { + await page.getByTitle("Display inspector").click(); + } + await expect(inspector).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => (globalThis as typeof globalThis & { __sandboxInspectorTeachingMomentReads?: string[] }).__sandboxInspectorTeachingMomentReads?.length ?? 0) + ) + .toBeGreaterThan(0); + await expect(page.getByRole("button", { name: "dismiss", exact: true })).toHaveCount(0); + return inspector; +} + +async function closeInspector(page: Page): Promise { + await page.locator("#footer").getByTitle("Display inspector").click({ force: true }); + await expect(page.locator("#babylon-inspector-container")).toHaveCount(0); +} + +async function expandInspectorNodes(page: Page): Promise { + const nodes = page.getByRole("treeitem", { name: "Nodes", exact: true }); + await expect(nodes).toBeVisible(); + await nodes.focus(); + await nodes.press("ArrowRight"); +} + +async function readCameraPresetState(page: Page): Promise { + return page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey) ?? "null") as IStoredCameraPresetState, cameraPresetStorageKey); +} + +async function readSceneCameraState(page: Page): Promise { + return page.evaluate(async () => { + type CameraState = { + name: string; + id: string; + position?: { asArray(): number[] }; + keysUp?: number[]; + keysDown?: number[]; + keysLeft?: number[]; + keysRight?: number[]; + useAutoRotationBehavior?: boolean; + useFramingBehavior?: boolean; + getBehaviorByName?(name: string): { idleRotationWaitTime?: number } | null; + }; + type SceneState = { activeCamera?: CameraState; cameras: CameraState[] }; + type EngineStoreState = { LastCreatedScene?: SceneState }; + const globalEngineStore = (globalThis as typeof globalThis & { BABYLON?: { EngineStore?: EngineStoreState } }).BABYLON?.EngineStore; + const engineStoreModuleUrl = performance + .getEntriesByType("resource") + .map((entry) => entry.name) + .find((resourceUrl) => /\/Engines\/engineStore\.js(?:\?|$)/.test(resourceUrl)); + const engineStoreModule = engineStoreModuleUrl ? ((await import(engineStoreModuleUrl)) as { EngineStore?: EngineStoreState }) : undefined; + const scene = (globalEngineStore ?? engineStoreModule?.EngineStore)?.LastCreatedScene; + if (!scene) { + throw new Error("Expected the Sandbox EngineStore.LastCreatedScene to be available"); + } + + const activeCamera = scene.activeCamera; + return { + name: activeCamera?.name, + id: activeCamera?.id, + position: activeCamera?.position?.asArray(), + keysUp: activeCamera?.keysUp, + keysDown: activeCamera?.keysDown, + keysLeft: activeCamera?.keysLeft, + keysRight: activeCamera?.keysRight, + useAutoRotationBehavior: activeCamera?.useAutoRotationBehavior, + useFramingBehavior: activeCamera?.useFramingBehavior, + idleRotationWaitTime: (activeCamera?.getBehaviorByName?.("AutoRotation") as { idleRotationWaitTime?: number } | null | undefined)?.idleRotationWaitTime, + cameraNames: scene.cameras.map((camera) => camera.name), + cameraIds: scene.cameras.map((camera) => camera.id), + }; + }); +} + +function trackPageErrors(page: Page): string[] { + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + return pageErrors; +} + test("Sandbox is loaded (Desktop)", async ({ page }) => { await page.goto(url, { waitUntil: "load", @@ -133,250 +353,233 @@ test("inspector is opened when clicking on the button", async ({ page }) => { await expect(page).toHaveScreenshot({ maxDiffPixels: 3000 }); }); -test("camera presets can be saved, selected, and restored", async ({ page }) => { - test.setTimeout(90000); - - const pageErrors: string[] = []; - page.on("pageerror", (error) => pageErrors.push(error.message)); - // This init script runs on every navigation, so the session flag limits the reset to the first one and lets - // presets saved later in the test survive the reloads that verify persistence. - await page.addInitScript((storageKey) => { - const initializedKey = `${storageKey}/testInitialized`; - if (!sessionStorage.getItem(initializedKey)) { - localStorage.removeItem(storageKey); - sessionStorage.setItem(initializedKey, "true"); - } - }, cameraPresetStorageKey); - - await page.goto(boxModelUrl, { waitUntil: "load" }); - await page.setViewportSize({ width: 1920, height: 1080 }); - await waitForSandboxReady(page); - await page.waitForSelector("#babylonjsLoadingDiv", { state: "detached" }); - await page.waitForLoadState("networkidle"); - - const footer = page.locator("#footer"); - const presetSelector = footer.getByTitle("Select camera preset"); - await expect(presetSelector).toHaveCount(0); - await expect(footer.getByTitle("Select camera")).toHaveCount(0); - - await page.goto(embeddedCameraModelUrl, { waitUntil: "load" }); - await waitForSandboxReady(page); - await page.waitForSelector("#babylonjsLoadingDiv", { state: "detached" }); - await page.waitForLoadState("networkidle"); - const embeddedCameraFooter = page.locator("#footer"); - await expect(embeddedCameraFooter.getByTitle("Select camera")).toBeVisible(); - await embeddedCameraFooter.getByTitle("Select camera").click(); - await expect(embeddedCameraFooter.locator(".dropup-content-line")).toHaveText(["Camera", "default camera"]); - await embeddedCameraFooter.getByTitle("default camera", { exact: true }).click(); - await embeddedCameraFooter.getByTitle("Select camera").click(); - await expect(embeddedCameraFooter.getByTitle("default camera", { exact: true }).locator(":scope > div")).toHaveCSS("opacity", "1"); - await embeddedCameraFooter.getByTitle("default camera", { exact: true }).click(); - await page.keyboard.press("Space"); - await expect(embeddedCameraFooter).toHaveCount(0); - await page.keyboard.press("Space"); - await expect(page.locator("#footer").getByTitle("Select camera")).toBeVisible(); - - await page.goto(boxModelUrl, { waitUntil: "load" }); - await waitForSandboxReady(page); - await page.waitForSelector("#babylonjsLoadingDiv", { state: "detached" }); - await page.waitForLoadState("networkidle"); - await expect(page.locator("#footer").getByTitle("Select camera")).toHaveCount(0); +test.describe("camera presets", () => { + test.describe.configure({ timeout: 90000 }); + test.use({ viewport: { width: 1280, height: 720 } }); + + test("camera preset controls follow model and footer lifecycle", async ({ page }) => { + const pageErrors = trackPageErrors(page); + await clearCameraPresetStorage(page); + + await loadSandboxAsset(page, embeddedCameraModelUrl); + const footer = page.locator("#footer"); + await expect(footer.getByTitle("Select camera preset")).toHaveCount(0); + const cameraSelector = footer.getByTitle("Select camera"); + await expect(cameraSelector).toHaveCount(1); + await expect(cameraSelector).toBeVisible(); + + await page.keyboard.press("Space"); + await expect(footer).toHaveCount(0); + await page.keyboard.press("Space"); + await expect(page.locator("#footer").getByTitle("Select camera")).toHaveCount(1); + + await loadSandboxAsset(page, boxModelUrl); + await expect(footer.getByTitle("Select camera preset")).toHaveCount(0); + await expect(footer.getByTitle("Select camera")).toHaveCount(0); + expect(pageErrors).toHaveLength(0); + }); - await page.getByTitle("Display inspector").click(); - const inspector = page.locator("#babylon-inspector-container"); - await expect(inspector).toBeVisible(); + test("embedded camera selection keeps the default camera active", async ({ page }) => { + const pageErrors = trackPageErrors(page); + await clearCameraPresetStorage(page); + await loadSandboxAsset(page, embeddedCameraModelUrl); + + const footer = page.locator("#footer"); + const cameraSelector = footer.getByTitle("Select camera"); + await cameraSelector.click(); + await expect(footer.locator(".dropup-content-line")).toHaveText(["Camera", "default camera"]); + await footer.getByTitle("default camera", { exact: true }).click(); + await cameraSelector.click(); + await expect(footer.getByTitle("default camera", { exact: true }).locator(":scope > div")).toHaveCSS("opacity", "1"); + expect(pageErrors).toHaveLength(0); + }); - const nodes = page.getByRole("treeitem", { name: "Nodes", exact: true }); - await nodes.focus(); - await nodes.press("ArrowRight"); - await page.getByRole("treeitem", { name: /^default camera/ }).click(); - await page.evaluate(() => { - const camera = (globalThis as typeof globalThis & { debugNode?: { inspectableCustomProperties?: unknown[] } }).debugNode; - if (camera) { - camera.inspectableCustomProperties = []; + test("camera preset Inspector section follows camera selection and section order", async ({ page }) => { + const pageErrors = trackPageErrors(page); + await clearCameraPresetStorage(page); + await loadSandboxAsset(page, boxModelUrl); + + const inspector = await openInspector(page); + await expandInspectorNodes(page); + await page.getByRole("treeitem", { name: /^default camera/ }).click(); + await page.evaluate(() => { + const camera = (globalThis as typeof globalThis & { debugNode?: { inspectableCustomProperties?: unknown[] } }).debugNode; + if (camera) { + camera.inspectableCustomProperties = []; + } + }); + await page.getByRole("treeitem", { name: "hdrSkyBox", exact: true }).click(); + await page.getByRole("treeitem", { name: /^default camera/ }).click(); + + const presetHeader = inspector.getByRole("button", { name: "Save Camera Preset", exact: true }); + const customHeader = inspector.getByRole("button", { name: "Custom", exact: true }); + const metadataHeader = inspector.getByRole("button", { name: "Metadata", exact: true }); + await expect(presetHeader).toBeVisible(); + await expect(presetHeader).toHaveAttribute("aria-expanded", "true"); + await expect(customHeader).toBeVisible(); + await expect(metadataHeader).toBeVisible(); + const sectionHeaders = await inspector.locator("button[aria-expanded]").allTextContents(); + expect(sectionHeaders).toContain("Custom"); + expect(sectionHeaders.indexOf("Custom")).toBeLessThan(sectionHeaders.indexOf("Save Camera Preset")); + expect(sectionHeaders.indexOf("Save Camera Preset")).toBe(sectionHeaders.indexOf("Metadata") - 1); + + await page.getByRole("treeitem", { name: "hdrSkyBox", exact: true }).click(); + await expect(inspector.getByText("Receive Shadows", { exact: true })).toBeVisible(); + await expect(metadataHeader).toBeVisible(); + await expect(presetHeader).toHaveCount(0); + const meshSectionHeaders = await inspector.locator("button[aria-expanded]").allTextContents(); + expect(meshSectionHeaders).not.toContain("Save Camera Preset"); + if (meshSectionHeaders.includes("Custom")) { + expect(meshSectionHeaders.indexOf("Metadata")).toBeLessThan(meshSectionHeaders.indexOf("Custom")); } + expect(pageErrors).toHaveLength(0); }); - await page.getByRole("treeitem", { name: "hdrSkyBox", exact: true }).click(); - await page.getByRole("treeitem", { name: /^default camera/ }).click(); - - const presetHeader = inspector.getByRole("button", { name: "Save Camera Preset", exact: true }); - await expect(presetHeader).toBeVisible(); - await expect(presetHeader).toHaveAttribute("aria-expanded", "true"); - const sectionHeaders = await inspector.locator("button[aria-expanded]").allTextContents(); - expect(sectionHeaders).toContain("Custom"); - expect(sectionHeaders.indexOf("Custom")).toBeLessThan(sectionHeaders.indexOf("Save Camera Preset")); - expect(sectionHeaders.indexOf("Save Camera Preset")).toBe(sectionHeaders.indexOf("Metadata") - 1); - - await page.getByRole("treeitem", { name: "hdrSkyBox", exact: true }).click(); - const meshSectionHeaders = await inspector.locator("button[aria-expanded]").allTextContents(); - expect(meshSectionHeaders).not.toContain("Save Camera Preset"); - if (meshSectionHeaders.includes("Custom")) { - expect(meshSectionHeaders.indexOf("Metadata")).toBeLessThan(meshSectionHeaders.indexOf("Custom")); - } - - await page.getByRole("treeitem", { name: /^default camera/ }).click(); - - const presetSection = presetHeader.locator("xpath=../.."); - const presetNameInput = presetSection.getByRole("textbox"); - const savePresetButton = presetSection.getByRole("button", { name: "Save", exact: true }); - - await savePresetButton.click(); - await expect(footer.getByTitle("Select camera preset")).toBeVisible(); - - await presetNameInput.fill(" City "); - await savePresetButton.click(); - await presetNameInput.fill("city"); - await savePresetButton.click(); - - const savedState = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey) ?? "null"), cameraPresetStorageKey); - expect(savedState.activePresetId).toBeNull(); - expect(savedState.presets.map((preset: { name: string }) => preset.name)).toEqual(["Preset 1", "City", "city 2"]); - - await footer.getByTitle("Display inspector").click({ force: true }); - await expect(inspector).toHaveCount(0); - await footer.getByTitle("Select camera preset").click(); - await expect(footer.locator(".dropup-content-line")).toHaveText(["Default camera", "Preset 1", "City", "city 2"]); - await footer.getByTitle("Preset 1", { exact: true }).click(); - - const selectedState = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey) ?? "null"), cameraPresetStorageKey); - expect(selectedState.activePresetId).toBe(selectedState.presets[0].id); - - await page.reload({ waitUntil: "load" }); - await waitForSandboxReady(page); - await page.waitForSelector("#babylonjsLoadingDiv", { state: "detached" }); - await page.waitForLoadState("networkidle"); - await expect(page.locator("#footer").getByTitle("Select camera preset")).toBeVisible(); - - await page.getByTitle("Display inspector").click(); - await expect(inspector).toBeVisible(); - const reloadedNodes = page.getByRole("treeitem", { name: "Nodes", exact: true }); - await reloadedNodes.focus(); - await reloadedNodes.press("ArrowRight"); - - const restoredCamera = page.getByRole("treeitem", { name: /^Preset 1/ }); - await expect(restoredCamera).toBeVisible(); - await restoredCamera.click(); - await expect(restoredCamera.getByRole("button", { name: "Activate and Attach Controls" })).toHaveAttribute("aria-pressed", "true"); - - const navigationKeys = await page.evaluate(() => { - const camera = (globalThis as typeof globalThis & { debugNode?: Record }).debugNode; - return { - up: camera?.keysUp ?? [], - down: camera?.keysDown ?? [], - left: camera?.keysLeft ?? [], - right: camera?.keysRight ?? [], - }; + test("camera preset saved through Inspector activates and survives reload", async ({ page }) => { + const pageErrors = trackPageErrors(page); + await clearCameraPresetStorage(page); + await loadSandboxAsset(page, boxModelUrl); + + const originalCameraState = await readSceneCameraState(page); + const footer = page.locator("#footer"); + const inspector = await openInspector(page); + await expandInspectorNodes(page); + await page.getByRole("treeitem", { name: /^default camera/ }).click(); + + const presetHeader = inspector.getByRole("button", { name: "Save Camera Preset", exact: true }); + await expect(presetHeader).toBeVisible(); + const presetSection = presetHeader.locator("xpath=../.."); + const savePresetButton = presetSection.getByRole("button", { name: "Save", exact: true }); + await savePresetButton.click(); + await expect(footer.getByTitle("Select camera preset")).toBeVisible(); + + const savedState = await readCameraPresetState(page); + expect(savedState.activePresetId).toBeNull(); + expect(savedState.presets).toHaveLength(1); + expect(savedState.presets[0]).toMatchObject({ name: "Preset 1", cameraType: "ArcRotateCamera" }); + expect(savedState.presets[0].cameraData).toMatchObject({ name: originalCameraState.name, id: originalCameraState.id }); + + await closeInspector(page); + await footer.getByTitle("Select camera preset").click(); + await expect(footer.locator(".dropup-content-line")).toHaveText(["Default camera", "Preset 1"]); + await footer.getByTitle("Preset 1", { exact: true }).click(); + expect((await readCameraPresetState(page)).activePresetId).toBe(savedState.presets[0].id); + + await reloadSandboxAsset(page); + expect((await readCameraPresetState(page)).activePresetId).toBe(savedState.presets[0].id); + const restoredCameraState = await readSceneCameraState(page); + expect(restoredCameraState).toMatchObject({ + name: "Preset 1", + id: `SandboxCameraPreset/${savedState.presets[0].id}`, + keysUp: originalCameraState.keysUp, + keysDown: originalCameraState.keysDown, + keysLeft: originalCameraState.keysLeft, + keysRight: originalCameraState.keysRight, + useAutoRotationBehavior: originalCameraState.useAutoRotationBehavior, + useFramingBehavior: originalCameraState.useFramingBehavior, + }); + expect(pageErrors).toHaveLength(0); }); - expect(navigationKeys.up.filter((key) => key === 90)).toHaveLength(1); - expect(navigationKeys.up.filter((key) => key === 87)).toHaveLength(1); - expect(navigationKeys.down.filter((key) => key === 83)).toHaveLength(1); - expect(navigationKeys.left.filter((key) => key === 65)).toHaveLength(1); - expect(navigationKeys.left.filter((key) => key === 81)).toHaveLength(1); - expect(navigationKeys.right.filter((key) => key === 69)).toHaveLength(1); - expect(navigationKeys.right.filter((key) => key === 68)).toHaveLength(1); - await footer.getByTitle("Display inspector").click({ force: true }); - await footer.getByTitle("Select camera preset").click(); - await footer.getByTitle("Default camera", { exact: true }).click(); + test("camera preset activation survives reload with serialized inputs and behaviors", async ({ page }) => { + const pageErrors = trackPageErrors(page); + await seedCameraPresetStorage(page, null); + await loadSandboxAsset(page, boxModelUrl); + + const footer = page.locator("#footer"); + await footer.getByTitle("Select camera preset").click(); + await footer.getByTitle("Overview", { exact: true }).click(); + expect((await readCameraPresetState(page)).activePresetId).toBe(cameraPresetId); + expect(await readSceneCameraState(page)).toMatchObject({ name: "Overview", id: `SandboxCameraPreset/${cameraPresetId}` }); + + await reloadSandboxAsset(page); + await footer.getByTitle("Select camera preset").click(); + await expect(footer.getByTitle("Overview", { exact: true }).locator(":scope > div")).toHaveCSS("opacity", "1"); + const restoredCameraState = await readSceneCameraState(page); + expect(restoredCameraState).toEqual({ + name: "Overview", + id: `SandboxCameraPreset/${cameraPresetId}`, + position: expect.any(Array), + keysUp: [90, 87], + keysDown: [83], + keysLeft: [65, 81], + keysRight: [69, 68], + useAutoRotationBehavior: true, + useFramingBehavior: false, + idleRotationWaitTime: 1234, + cameraNames: ["default camera", "Overview"], + cameraIds: ["default camera", `SandboxCameraPreset/${cameraPresetId}`], + }); + expect(pageErrors).toHaveLength(0); + }); - const defaultState = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey) ?? "null"), cameraPresetStorageKey); - expect(defaultState.activePresetId).toBeNull(); + test("default camera selection survives reload and removes preset cameras", async ({ page }) => { + const pageErrors = trackPageErrors(page); + await seedCameraPresetStorage(page, cameraPresetId); + await loadSandboxAsset(page, boxModelUrl); + + const footer = page.locator("#footer"); + expect(await readSceneCameraState(page)).toMatchObject({ name: "Overview", id: `SandboxCameraPreset/${cameraPresetId}` }); + + await footer.getByTitle("Select camera preset").click(); + await footer.getByTitle("Default camera", { exact: true }).click(); + expect((await readCameraPresetState(page)).activePresetId).toBeNull(); + expect(await readSceneCameraState(page)).toMatchObject({ name: "default camera", cameraNames: ["default camera"], cameraIds: ["default camera"] }); + + await reloadSandboxAsset(page); + await footer.getByTitle("Select camera preset").click(); + await expect(footer.getByTitle("Default camera", { exact: true }).locator(":scope > div")).toHaveCSS("opacity", "1"); + expect((await readCameraPresetState(page)).activePresetId).toBeNull(); + expect(await readSceneCameraState(page)).toMatchObject({ name: "default camera", cameraNames: ["default camera"], cameraIds: ["default camera"] }); + expect(pageErrors).toHaveLength(0); + }); - await page.reload({ waitUntil: "load" }); - await waitForSandboxReady(page); - await page.waitForSelector("#babylonjsLoadingDiv", { state: "detached" }); - await page.waitForLoadState("networkidle"); - const reloadedDefaultState = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey) ?? "null"), cameraPresetStorageKey); - expect(reloadedDefaultState.activePresetId).toBeNull(); + test("camera query override preserves the stored active camera preset", async ({ page }) => { + const pageErrors = trackPageErrors(page); + await seedCameraPresetStorage(page, cameraPresetId); + + await loadSandboxAsset(page, embeddedCameraModelUrl + "&camera=0"); + const footer = page.locator("#footer"); + await footer.getByTitle("Select camera", { exact: true }).click(); + await expect(footer.locator(".dropup-content-line")).toHaveText(["Camera"]); + await expect(footer.getByTitle("Camera", { exact: true }).locator(":scope > div")).toHaveCSS("opacity", "1"); + expect((await readCameraPresetState(page)).activePresetId).toBe(cameraPresetId); + expect(await readSceneCameraState(page)).toMatchObject({ name: "Camera", cameraNames: ["Camera"], cameraIds: ["Camera"] }); + expect(pageErrors).toHaveLength(0); + }); - await page.getByTitle("Display inspector").click(); - await expect(inspector).toBeVisible(); - const defaultNodes = page.getByRole("treeitem", { name: "Nodes", exact: true }); - await defaultNodes.focus(); - await defaultNodes.press("ArrowRight"); - const reloadedDefaultCamera = page.getByRole("treeitem", { name: /^default camera/ }); - await expect(reloadedDefaultCamera).toBeVisible(); - await expect(reloadedDefaultCamera.getByRole("button", { name: "Activate and Attach Controls" })).toHaveAttribute("aria-pressed", "true"); - await expect(page.getByRole("treeitem", { name: /^Preset 1/ })).toHaveCount(0); - - await footer.getByTitle("Display inspector").click({ force: true }); - await footer.getByTitle("Select camera preset").click(); - await footer.getByTitle("Preset 1", { exact: true }).click(); - const reactivatedState = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey) ?? "null"), cameraPresetStorageKey); - expect(reactivatedState.activePresetId).toBe(reactivatedState.presets[0].id); - - await page.goto(embeddedCameraModelUrl + "&camera=0", { waitUntil: "load" }); - await waitForSandboxReady(page); - await page.waitForSelector("#babylonjsLoadingDiv", { state: "detached" }); - await page.waitForLoadState("networkidle"); - const cameraOverrideFooter = page.locator("#footer"); - await cameraOverrideFooter.getByTitle("Select camera", { exact: true }).click(); - await expect(cameraOverrideFooter.locator(".dropup-content-line")).toHaveText(["Camera"]); - await expect(cameraOverrideFooter.getByTitle("Camera", { exact: true }).locator(":scope > div")).toHaveCSS("opacity", "1"); - await page.locator(".clickInterceptor").click(); - const cameraOverrideState = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey) ?? "null"), cameraPresetStorageKey); - expect(cameraOverrideState.activePresetId).toBe(cameraOverrideState.presets[0].id); - - await page.goto(boxModelUrl + "&cameraPosition=10,20,30", { waitUntil: "load" }); - await waitForSandboxReady(page); - await page.waitForSelector("#babylonjsLoadingDiv", { state: "detached" }); - await page.waitForLoadState("networkidle"); - if (!(await inspector.isVisible())) { - await page.getByTitle("Display inspector").click(); - } - const cameraPositionOverrideNodes = page.getByRole("treeitem", { name: "Nodes", exact: true }); - await cameraPositionOverrideNodes.focus(); - await cameraPositionOverrideNodes.press("ArrowRight"); - const cameraPositionOverride = page.getByRole("treeitem", { name: /^default camera/ }); - await cameraPositionOverride.click(); - await expect(cameraPositionOverride.getByRole("button", { name: "Activate and Attach Controls" })).toHaveAttribute("aria-pressed", "true"); - const urlCameraPositionOverride = await page.evaluate(() => { - const activeCamera = (globalThis as typeof globalThis & { debugNode?: { name: string; position: { asArray(): number[] } } }).debugNode; - return { name: activeCamera?.name, position: activeCamera?.position.asArray() }; + test("camera position override preserves the stored active camera preset", async ({ page }) => { + const pageErrors = trackPageErrors(page); + await seedCameraPresetStorage(page, cameraPresetId); + + await loadSandboxAsset(page, boxModelUrl + "&cameraPosition=10,20,30"); + const urlCameraPositionOverride = await readSceneCameraState(page); + expect(urlCameraPositionOverride.name).toBe("default camera"); + const [positionX, positionY, positionZ] = urlCameraPositionOverride.position!; + expect(positionY / positionX).toBeCloseTo(2); + expect(positionZ / positionX).toBeCloseTo(3); + expect(urlCameraPositionOverride.cameraNames).toEqual(["default camera"]); + expect(urlCameraPositionOverride.cameraIds).toEqual(["default camera"]); + expect((await readCameraPresetState(page)).activePresetId).toBe(cameraPresetId); + expect(pageErrors).toHaveLength(0); }); - expect(urlCameraPositionOverride.name).toBe("default camera"); - const position = urlCameraPositionOverride.position; - expect(position).toBeDefined(); - const [positionX, positionY, positionZ] = position!; - expect(positionY / positionX).toBeCloseTo(2); - expect(positionZ / positionX).toBeCloseTo(3); - await expect(page.getByRole("treeitem", { name: /^Preset 1/ })).toHaveCount(0); - const cameraPositionOverrideState = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey) ?? "null"), cameraPresetStorageKey); - expect(cameraPositionOverrideState.activePresetId).toBe(cameraPositionOverrideState.presets[0].id); - - await page.goto(textureAssetUrl, { waitUntil: "load" }); - await waitForSandboxReady(page); - await page.waitForSelector("#babylonjsLoadingDiv", { state: "detached" }); - await page.waitForLoadState("networkidle"); - const textureFooter = page.locator("#footer"); - await expect(textureFooter.getByTitle("Select camera preset")).toHaveCount(0); - const textureState = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey) ?? "null"), cameraPresetStorageKey); - expect(textureState.activePresetId).toBe(textureState.presets[0].id); - await expect(inspector).toBeVisible(); - const textureNodes = page.getByRole("treeitem", { name: "Nodes", exact: true }); - await textureNodes.focus(); - await textureNodes.press("ArrowRight"); - const textureCamera = page.getByRole("treeitem", { name: /^default camera/ }); - await expect(textureCamera).toBeVisible(); - await expect(textureCamera.getByRole("button", { name: "Activate and Attach Controls" })).toHaveAttribute("aria-pressed", "true"); - await expect(page.getByRole("treeitem", { name: /^Preset 1/ })).toHaveCount(0); - - await page.goto(boxModelUrl, { waitUntil: "load" }); - await waitForSandboxReady(page); - await page.waitForSelector("#babylonjsLoadingDiv", { state: "detached" }); - await page.waitForLoadState("networkidle"); - await expect(page.locator("#footer").getByTitle("Select camera preset")).toBeVisible(); - const finalState = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey) ?? "null"), cameraPresetStorageKey); - expect(finalState.activePresetId).toBe(finalState.presets[0].id); - await page.getByTitle("Display inspector").click(); - await expect(inspector).toBeVisible(); - const finalNodes = page.getByRole("treeitem", { name: "Nodes", exact: true }); - await finalNodes.focus(); - await finalNodes.press("ArrowRight"); - const reappliedCamera = page.getByRole("treeitem", { name: /^Preset 1/ }); - await expect(reappliedCamera).toBeVisible(); - await expect(reappliedCamera.getByRole("button", { name: "Activate and Attach Controls" })).toHaveAttribute("aria-pressed", "true"); - expect(pageErrors).toHaveLength(0); + test("camera presets pause for texture previews and reapply to the next model", async ({ page }) => { + const pageErrors = trackPageErrors(page); + await seedCameraPresetStorage(page, cameraPresetId); + + await loadSandboxAsset(page, textureAssetUrl); + const footer = page.locator("#footer"); + await expect(footer.getByTitle("Select camera preset")).toHaveCount(0); + expect((await readCameraPresetState(page)).activePresetId).toBe(cameraPresetId); + expect(await readSceneCameraState(page)).toMatchObject({ name: "default camera", cameraNames: ["default camera"], cameraIds: ["default camera"] }); + + await loadSandboxAsset(page, boxModelUrl); + await expect(footer.getByTitle("Select camera preset")).toBeVisible(); + await footer.getByTitle("Select camera preset").click(); + await expect(footer.getByTitle("Overview", { exact: true }).locator(":scope > div")).toHaveCSS("opacity", "1"); + expect((await readCameraPresetState(page)).activePresetId).toBe(cameraPresetId); + expect(await readSceneCameraState(page)).toMatchObject({ name: "Overview", id: `SandboxCameraPreset/${cameraPresetId}` }); + expect(pageErrors).toHaveLength(0); + }); }); From cfc8b801fb437102d935260a95cdcddaaa1e3ed3 Mon Sep 17 00:00:00 2001 From: Vicente Cartas Espinel Date: Mon, 17 Aug 2026 15:13:25 -0700 Subject: [PATCH 05/10] Add camera limit URL parameters to the Sandbox Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sandbox/src/components/renderingZone.tsx | 2 +- packages/tools/sandbox/src/globalState.ts | 38 ++++-- packages/tools/sandbox/src/sandbox.tsx | 21 ++- .../sandbox/src/tools/cameraUrlConfig.ts | 16 +++ .../sandbox/test/interaction.sandbox.test.ts | 96 +++++++++++++ .../sandbox/test/unit/globalState.test.ts | 128 ++++++++++++++++-- 6 files changed, 278 insertions(+), 23 deletions(-) create mode 100644 packages/tools/sandbox/src/tools/cameraUrlConfig.ts diff --git a/packages/tools/sandbox/src/components/renderingZone.tsx b/packages/tools/sandbox/src/components/renderingZone.tsx index 04761950e575..640aef482032 100644 --- a/packages/tools/sandbox/src/components/renderingZone.tsx +++ b/packages/tools/sandbox/src/components/renderingZone.tsx @@ -343,7 +343,7 @@ export class RenderingZone extends React.Component { } this.prepareCamera(); - this.props.globalState.applyActiveCameraPresetForLoad(this._scene, loadKind); + this.props.globalState.applyCameraConfigurationForLoad(this._scene, loadKind); if (this._scene.activeCamera) { this.props.globalState.onCameraChanged.notifyObservers(this._scene.activeCamera); } diff --git a/packages/tools/sandbox/src/globalState.ts b/packages/tools/sandbox/src/globalState.ts index 39b63a25b940..9f559bc00e4e 100644 --- a/packages/tools/sandbox/src/globalState.ts +++ b/packages/tools/sandbox/src/globalState.ts @@ -1,4 +1,5 @@ import { type Camera, type FilesInput, type IDisposable, type Nullable, type Scene, type Vector3 } from "core/index"; +import { ArcRotateCamera } from "core/Cameras/arcRotateCamera"; import { Observable } from "core/Misc/observable"; import { CameraPresetManager } from "./tools/cameraPresetManager"; import { MakeCameraPresetInspectorServiceDefinition } from "./tools/cameraPresetInspectorService"; @@ -6,9 +7,12 @@ import { MakeCameraPresetInspectorServiceDefinition } from "./tools/cameraPreset export type InspectorV2Module = typeof import("inspector/legacy/legacy") & typeof import("inspector/index"); export type SandboxSceneLoadKind = "scene" | "texture"; export type SandboxSceneLoadedInfo = { scene: Scene; filename: string; loadKind: SandboxSceneLoadKind }; +export type SandboxCameraUrlNumericOverrides = { minZ: number; lowerRadiusLimit?: number } | { minZ?: number; lowerRadiusLimit: number }; export class GlobalState { private _inspectorToken: Nullable = null; + private _cameraUrlNumericOverrides: { minZ?: number; lowerRadiusLimit?: number } = {}; + private _suppressCameraPresetForNextModelLoad = false; public currentScene: Scene; public currentSceneLoadKind: SandboxSceneLoadKind = "scene"; @@ -32,7 +36,6 @@ export class GlobalState { public assetUrl?: string; public autoRotate = false; - public cameraPresetOverrideFromUrl = false; public cameraPosition?: Vector3; public skybox = true; public toneMapping?: number; @@ -48,18 +51,37 @@ export class GlobalState { constructor(private readonly _versionInfo: { version: string; bundles: string[] }) {} - public consumeCameraPresetOverrideFromUrl(): boolean { - const overrideFromUrl = this.cameraPresetOverrideFromUrl; - this.cameraPresetOverrideFromUrl = false; - return overrideFromUrl; + public setCameraUrlNumericOverrides(overrides: SandboxCameraUrlNumericOverrides): void { + this._cameraUrlNumericOverrides = { ...this._cameraUrlNumericOverrides, ...overrides }; } - public applyActiveCameraPresetForLoad(scene: Scene, loadKind: SandboxSceneLoadKind): Camera | null { - if (loadKind !== "scene" || this.consumeCameraPresetOverrideFromUrl()) { + public suppressCameraPresetForNextModelLoad(): void { + this._suppressCameraPresetForNextModelLoad = true; + } + + private _consumeCameraPresetSuppression(): boolean { + const suppressCameraPreset = this._suppressCameraPresetForNextModelLoad; + this._suppressCameraPresetForNextModelLoad = false; + return suppressCameraPreset; + } + + public applyCameraConfigurationForLoad(scene: Scene, loadKind: SandboxSceneLoadKind): Camera | null { + if (loadKind !== "scene") { return null; } - return this.cameraPresetManager.applyActivePreset(scene); + const presetCamera = this._consumeCameraPresetSuppression() ? null : this.cameraPresetManager.applyActivePreset(scene); + const activeCamera = scene.activeCamera; + if (activeCamera) { + if (this._cameraUrlNumericOverrides.minZ !== undefined) { + activeCamera.minZ = this._cameraUrlNumericOverrides.minZ; + } + if (this._cameraUrlNumericOverrides.lowerRadiusLimit !== undefined && activeCamera instanceof ArcRotateCamera) { + activeCamera.lowerRadiusLimit = this._cameraUrlNumericOverrides.lowerRadiusLimit; + } + } + + return presetCamera; } public showDebugLayer() { diff --git a/packages/tools/sandbox/src/sandbox.tsx b/packages/tools/sandbox/src/sandbox.tsx index b197b92d873c..4229eb816cfa 100644 --- a/packages/tools/sandbox/src/sandbox.tsx +++ b/packages/tools/sandbox/src/sandbox.tsx @@ -7,6 +7,7 @@ import { Footer } from "./components/footer"; import { WelcomeDialog } from "./components/welcomeDialog"; import { LocalStorageHelper } from "./tools/localStorageHelper"; import { EnvironmentTools } from "./tools/environmentTools"; +import { ParseCameraUrlValue } from "./tools/cameraUrlConfig"; import { Vector3 } from "core/Maths/math.vector"; import { Deferred } from "core/Misc/deferred"; import { type Scene } from "core/scene"; @@ -331,12 +332,28 @@ export class Sandbox extends React.Component< break; } case "camera": { - this._globalState.cameraPresetOverrideFromUrl = true; + this._globalState.suppressCameraPresetForNextModelLoad(); this._camera = +value; break; } + case "cameraminz": { + const minZ = ParseCameraUrlValue("cameraMinZ", value); + if (minZ !== undefined) { + this._globalState.setCameraUrlNumericOverrides({ minZ }); + this._globalState.suppressCameraPresetForNextModelLoad(); + } + break; + } + case "cameralowerradiuslimit": { + const lowerRadiusLimit = ParseCameraUrlValue("cameraLowerRadiusLimit", value); + if (lowerRadiusLimit !== undefined) { + this._globalState.setCameraUrlNumericOverrides({ lowerRadiusLimit }); + this._globalState.suppressCameraPresetForNextModelLoad(); + } + break; + } case "cameraposition": { - this._globalState.cameraPresetOverrideFromUrl = true; + this._globalState.suppressCameraPresetForNextModelLoad(); this._globalState.cameraPosition = Vector3.FromArray( value.split(",").map(function (component) { return +component; diff --git a/packages/tools/sandbox/src/tools/cameraUrlConfig.ts b/packages/tools/sandbox/src/tools/cameraUrlConfig.ts new file mode 100644 index 000000000000..aed53bad5314 --- /dev/null +++ b/packages/tools/sandbox/src/tools/cameraUrlConfig.ts @@ -0,0 +1,16 @@ +import { Logger } from "core/Misc/logger"; + +export type CameraNumericUrlParameter = "cameraMinZ" | "cameraLowerRadiusLimit"; + +export function ParseCameraUrlValue(parameterName: CameraNumericUrlParameter, value: string | undefined): number | undefined { + const parsedValue = value === undefined || value.trim() === "" ? Number.NaN : Number(value); + const isValid = Number.isFinite(parsedValue) && (parameterName === "cameraMinZ" ? parsedValue > 0 : parsedValue >= 0); + + if (!isValid) { + const expectedRange = parameterName === "cameraMinZ" ? "a positive finite number" : "a non-negative finite number"; + Logger.Warn(`Ignoring invalid ${parameterName} URL parameter value "${value ?? ""}"; expected ${expectedRange}.`); + return undefined; + } + + return parsedValue; +} diff --git a/packages/tools/sandbox/test/interaction.sandbox.test.ts b/packages/tools/sandbox/test/interaction.sandbox.test.ts index e6acbb2e4d35..1177dad24425 100644 --- a/packages/tools/sandbox/test/interaction.sandbox.test.ts +++ b/packages/tools/sandbox/test/interaction.sandbox.test.ts @@ -39,6 +39,11 @@ interface ISceneCameraState { cameraIds: string[]; } +interface ICameraNumericState { + minZ: number | undefined; + lowerRadiusLimit: number | null | undefined; +} + function createCameraPresetState(activePresetId: string | null): Record { return { version: 1, @@ -243,6 +248,26 @@ async function readSceneCameraState(page: Page): Promise { }); } +async function readActiveCameraNumericState(page: Page): Promise { + return page.evaluate(async () => { + type CameraState = { minZ?: number; lowerRadiusLimit?: number | null }; + type SceneState = { activeCamera?: CameraState }; + type EngineStoreState = { LastCreatedScene?: SceneState }; + const globalEngineStore = (globalThis as typeof globalThis & { BABYLON?: { EngineStore?: EngineStoreState } }).BABYLON?.EngineStore; + const engineStoreModuleUrl = performance + .getEntriesByType("resource") + .map((entry) => entry.name) + .find((resourceUrl) => /\/Engines\/engineStore\.js(?:\?|$)/.test(resourceUrl)); + const engineStoreModule = engineStoreModuleUrl ? ((await import(engineStoreModuleUrl)) as { EngineStore?: EngineStoreState }) : undefined; + const activeCamera = (globalEngineStore ?? engineStoreModule?.EngineStore)?.LastCreatedScene?.activeCamera; + if (!activeCamera) { + throw new Error("Expected the Sandbox active camera to be available"); + } + + return { minZ: activeCamera.minZ, lowerRadiusLimit: activeCamera.lowerRadiusLimit }; + }); +} + function trackPageErrors(page: Page): string[] { const pageErrors: string[] = []; page.on("pageerror", (error) => pageErrors.push(error.message)); @@ -564,6 +589,77 @@ test.describe("camera presets", () => { expect(pageErrors).toHaveLength(0); }); + test("numeric camera URL overrides persist when the stored preset applies after an asset reload", async ({ page }) => { + const pageErrors = trackPageErrors(page); + await seedCameraPresetStorage(page, cameraPresetId); + + await loadSandboxAsset(page, boxModelUrl + "&cameraMinZ=0.01&cameraLowerRadiusLimit=0"); + + expect(await readSceneCameraState(page)).toMatchObject({ + name: "default camera", + id: "default camera", + cameraNames: ["default camera"], + cameraIds: ["default camera"], + }); + expect(await readActiveCameraNumericState(page)).toEqual({ minZ: 0.01, lowerRadiusLimit: 0 }); + expect((await readCameraPresetState(page)).activePresetId).toBe(cameraPresetId); + + await page.keyboard.press("r"); + await expect.poll(async () => (await readSceneCameraState(page)).name).toBe("Overview"); + expect(await readSceneCameraState(page)).toMatchObject({ + name: "Overview", + id: `SandboxCameraPreset/${cameraPresetId}`, + cameraNames: ["default camera", "Overview"], + cameraIds: ["default camera", `SandboxCameraPreset/${cameraPresetId}`], + }); + expect(await readActiveCameraNumericState(page)).toEqual({ minZ: 0.01, lowerRadiusLimit: 0 }); + expect((await readCameraPresetState(page)).activePresetId).toBe(cameraPresetId); + expect(pageErrors).toHaveLength(0); + }); + + test("lower radius URL override wins over cameraPosition framing", async ({ page }) => { + const pageErrors = trackPageErrors(page); + await clearCameraPresetStorage(page); + + await loadSandboxAsset(page, boxModelUrl + "&cameraPosition=10,20,30&cameraLowerRadiusLimit=0"); + + const cameraState = await readSceneCameraState(page); + const [positionX, positionY, positionZ] = cameraState.position!; + expect(positionY / positionX).toBeCloseTo(2); + expect(positionZ / positionX).toBeCloseTo(3); + expect((await readActiveCameraNumericState(page)).lowerRadiusLimit).toBe(0); + expect(pageErrors).toHaveLength(0); + }); + + test("invalid numeric camera URL values do not suppress the active preset", async ({ page }) => { + const pageErrors = trackPageErrors(page); + await seedCameraPresetStorage(page, cameraPresetId); + + await loadSandboxAsset(page, boxModelUrl + "&cameraMinZ=0&cameraLowerRadiusLimit=-1"); + + expect(await readSceneCameraState(page)).toMatchObject({ + name: "Overview", + id: `SandboxCameraPreset/${cameraPresetId}`, + cameraNames: ["default camera", "Overview"], + cameraIds: ["default camera", `SandboxCameraPreset/${cameraPresetId}`], + }); + expect(await readActiveCameraNumericState(page)).toEqual({ minZ: 0.1, lowerRadiusLimit: null }); + expect((await readCameraPresetState(page)).activePresetId).toBe(cameraPresetId); + expect(pageErrors).toHaveLength(0); + }); + + test("numeric camera URL overrides target the selected embedded camera", async ({ page }) => { + const pageErrors = trackPageErrors(page); + await seedCameraPresetStorage(page, cameraPresetId); + + await loadSandboxAsset(page, embeddedCameraModelUrl + "&camera=0&cameraMinZ=0.02&cameraLowerRadiusLimit=0"); + + expect(await readSceneCameraState(page)).toMatchObject({ name: "Camera", cameraNames: ["Camera"], cameraIds: ["Camera"] }); + expect(await readActiveCameraNumericState(page)).toEqual({ minZ: 0.02, lowerRadiusLimit: undefined }); + expect((await readCameraPresetState(page)).activePresetId).toBe(cameraPresetId); + expect(pageErrors).toHaveLength(0); + }); + test("camera presets pause for texture previews and reapply to the next model", async ({ page }) => { const pageErrors = trackPageErrors(page); await seedCameraPresetStorage(page, cameraPresetId); diff --git a/packages/tools/sandbox/test/unit/globalState.test.ts b/packages/tools/sandbox/test/unit/globalState.test.ts index 4cb96a00bb1f..f5b5c204f614 100644 --- a/packages/tools/sandbox/test/unit/globalState.test.ts +++ b/packages/tools/sandbox/test/unit/globalState.test.ts @@ -1,27 +1,131 @@ -import { type Scene } from "core/scene"; -import { describe, expect, it, vi } from "vitest"; +import { ArcRotateCamera } from "core/Cameras/arcRotateCamera"; +import { FreeCamera } from "core/Cameras/freeCamera"; +import { NullEngine } from "core/Engines/nullEngine"; +import { Vector3 } from "core/Maths/math.vector"; +import { Logger } from "core/Misc/logger"; +import { Scene } from "core/scene"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { GlobalState } from "../../src/globalState"; +import { ParseCameraUrlValue, type CameraNumericUrlParameter } from "../../src/tools/cameraUrlConfig"; + +afterEach(() => { + vi.restoreAllMocks(); +}); describe("GlobalState camera preset URL override", () => { - it("preserves the override through texture previews and consumes it for only one model load", () => { + it("preserves numeric overrides through textures and applies them after the preset decision on every model load", () => { + const globalState = new GlobalState({ version: "test", bundles: [] }); + const engine = new NullEngine(); + const textureScene = new Scene(engine); + const urlModelScene = new Scene(engine); + const urlCamera = new ArcRotateCamera("url camera", 0, 0, 10, Vector3.Zero(), urlModelScene); + const reloadedModelScene = new Scene(engine); + const defaultReloadedCamera = new ArcRotateCamera("reloaded camera", 0, 0, 10, Vector3.Zero(), reloadedModelScene); + const presetCamera = new ArcRotateCamera("preset camera", 0, 0, 10, Vector3.Zero(), reloadedModelScene); + const applyActivePreset = vi.spyOn(globalState.cameraPresetManager, "applyActivePreset").mockImplementation((scene) => { + scene.activeCamera = presetCamera; + return presetCamera; + }); + + urlModelScene.activeCamera = urlCamera; + urlCamera.minZ = 1; + urlCamera.lowerRadiusLimit = 5; + reloadedModelScene.activeCamera = defaultReloadedCamera; + presetCamera.minZ = 2; + presetCamera.lowerRadiusLimit = 6; + globalState.setCameraUrlNumericOverrides({ minZ: 0.01 }); + globalState.setCameraUrlNumericOverrides({ lowerRadiusLimit: 0 }); + globalState.suppressCameraPresetForNextModelLoad(); + + expect(globalState.applyCameraConfigurationForLoad(textureScene, "texture")).toBeNull(); + expect(applyActivePreset).not.toHaveBeenCalled(); + + expect(globalState.applyCameraConfigurationForLoad(urlModelScene, "scene")).toBeNull(); + expect(urlCamera.minZ).toBe(0.01); + expect(urlCamera.lowerRadiusLimit).toBe(0); + expect(applyActivePreset).not.toHaveBeenCalled(); + + expect(globalState.applyCameraConfigurationForLoad(reloadedModelScene, "scene")).toBe(presetCamera); + expect(applyActivePreset).toHaveBeenCalledOnce(); + expect(applyActivePreset).toHaveBeenCalledWith(reloadedModelScene); + expect(reloadedModelScene.activeCamera).toBe(presetCamera); + expect(presetCamera.minZ).toBe(0.01); + expect(presetCamera.lowerRadiusLimit).toBe(0); + + engine.dispose(); + }); + + it("applies minZ to a non-ArcRotate active camera and ignores lowerRadiusLimit", () => { + const globalState = new GlobalState({ version: "test", bundles: [] }); + const engine = new NullEngine(); + const scene = new Scene(engine); + const camera = new FreeCamera("embedded camera", Vector3.Zero(), scene) as FreeCamera & { lowerRadiusLimit?: number }; + + scene.activeCamera = camera; + camera.minZ = 1; + camera.lowerRadiusLimit = 7; + globalState.setCameraUrlNumericOverrides({ minZ: 0.25, lowerRadiusLimit: 0 }); + globalState.suppressCameraPresetForNextModelLoad(); + + globalState.applyCameraConfigurationForLoad(scene, "scene"); + + expect(camera.minZ).toBe(0.25); + expect(camera.lowerRadiusLimit).toBe(7); + + engine.dispose(); + }); + + it("keeps existing camera and cameraPosition URL suppression when no numeric values are present", () => { const globalState = new GlobalState({ version: "test", bundles: [] }); const applyActivePreset = vi.spyOn(globalState.cameraPresetManager, "applyActivePreset").mockReturnValue(null); - const textureScene = {} as Scene; const urlModelScene = {} as Scene; const ordinaryModelScene = {} as Scene; - globalState.cameraPresetOverrideFromUrl = true; - - expect(globalState.applyActiveCameraPresetForLoad(textureScene, "texture")).toBeNull(); - expect(globalState.cameraPresetOverrideFromUrl).toBe(true); - expect(applyActivePreset).not.toHaveBeenCalled(); + globalState.suppressCameraPresetForNextModelLoad(); - expect(globalState.applyActiveCameraPresetForLoad(urlModelScene, "scene")).toBeNull(); - expect(globalState.cameraPresetOverrideFromUrl).toBe(false); + expect(globalState.applyCameraConfigurationForLoad(urlModelScene, "scene")).toBeNull(); expect(applyActivePreset).not.toHaveBeenCalled(); - expect(globalState.applyActiveCameraPresetForLoad(ordinaryModelScene, "scene")).toBeNull(); + expect(globalState.applyCameraConfigurationForLoad(ordinaryModelScene, "scene")).toBeNull(); expect(applyActivePreset).toHaveBeenCalledOnce(); expect(applyActivePreset).toHaveBeenCalledWith(ordinaryModelScene); }); }); + +describe("ParseCameraUrlValue", () => { + it.each([ + ["cameraMinZ", undefined], + ["cameraMinZ", ""], + ["cameraMinZ", " "], + ["cameraMinZ", "NaN"], + ["cameraMinZ", "Infinity"], + ["cameraMinZ", "-Infinity"], + ["cameraMinZ", "0"], + ["cameraMinZ", "-1"], + ["cameraLowerRadiusLimit", undefined], + ["cameraLowerRadiusLimit", ""], + ["cameraLowerRadiusLimit", "NaN"], + ["cameraLowerRadiusLimit", "Infinity"], + ["cameraLowerRadiusLimit", "-Infinity"], + ["cameraLowerRadiusLimit", "-0.01"], + ] as const)("rejects invalid %s value %s and logs a warning", (parameterName, value) => { + const warn = vi.spyOn(Logger, "Warn").mockImplementation(() => {}); + + expect(ParseCameraUrlValue(parameterName, value)).toBeUndefined(); + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls[0][0]).toContain(parameterName); + expect(warn.mock.calls[0][0]).toContain(`"${value ?? ""}"`); + }); + + it.each([ + ["cameraMinZ", "0.01", 0.01], + ["cameraMinZ", "100", 100], + ["cameraLowerRadiusLimit", "0", 0], + ["cameraLowerRadiusLimit", "12.5", 12.5], + ] satisfies [CameraNumericUrlParameter, string, number][])("accepts valid %s value %s", (parameterName, value, expected) => { + const warn = vi.spyOn(Logger, "Warn").mockImplementation(() => {}); + + expect(ParseCameraUrlValue(parameterName, value)).toBe(expected); + expect(warn).not.toHaveBeenCalled(); + }); +}); From fba5d003a67d705372b7620932a7db37dc670b54 Mon Sep 17 00:00:00 2001 From: Vicente Cartas Espinel Date: Mon, 17 Aug 2026 15:49:21 -0700 Subject: [PATCH 06/10] retrigger CI From e02579f8d36ae09a0ea17b55fdd712a6fef53c1f Mon Sep 17 00:00:00 2001 From: Vicente Cartas Espinel Date: Mon, 17 Aug 2026 17:04:28 -0700 Subject: [PATCH 07/10] Narrow Sandbox Inspector preset dependency Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/tools/sandbox/src/globalState.ts | 2 +- .../src/tools/cameraPresetInspectorService.tsx | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/tools/sandbox/src/globalState.ts b/packages/tools/sandbox/src/globalState.ts index 9f559bc00e4e..438a6b00135b 100644 --- a/packages/tools/sandbox/src/globalState.ts +++ b/packages/tools/sandbox/src/globalState.ts @@ -91,7 +91,7 @@ export class GlobalState { const inspectorV2Module: InspectorV2Module | undefined = (globalThis).INSPECTOR; if (inspectorV2Module?.ShowInspector) { this._inspectorToken = inspectorV2Module.ShowInspector(this.currentScene, { - serviceDefinitions: [MakeCameraPresetInspectorServiceDefinition(this, inspectorV2Module)], + serviceDefinitions: [MakeCameraPresetInspectorServiceDefinition(this.cameraPresetManager, inspectorV2Module)], }); } else { // eslint-disable-next-line @typescript-eslint/no-floating-promises diff --git a/packages/tools/sandbox/src/tools/cameraPresetInspectorService.tsx b/packages/tools/sandbox/src/tools/cameraPresetInspectorService.tsx index 5c4e42643179..f1eeea06f4e3 100644 --- a/packages/tools/sandbox/src/tools/cameraPresetInspectorService.tsx +++ b/packages/tools/sandbox/src/tools/cameraPresetInspectorService.tsx @@ -1,7 +1,8 @@ import { type Camera } from "core/Cameras/camera"; import { type FunctionComponent } from "react"; import { type IPropertiesService, type ISelectionService, type WeaklyTypedServiceDefinition } from "inspector/index"; -import { type GlobalState, type InspectorV2Module } from "../globalState"; +import { type InspectorV2Module } from "../globalState"; +import { type CameraPresetManager } from "./cameraPresetManager"; const CameraPresetNames = new WeakMap(); const CameraPresetSectionOrder = Number.MAX_VALUE / 2; @@ -19,12 +20,12 @@ export function IsCamera(entity: unknown): entity is Camera { interface ICameraPresetEditorProps { camera: Camera; - globalState: GlobalState; + cameraPresetManager: CameraPresetManager; inspectorModule: InspectorV2Module; } const CameraPresetEditor: FunctionComponent = (props) => { - const { camera, globalState, inspectorModule } = props; + const { camera, cameraPresetManager, inspectorModule } = props; return ( <> @@ -32,13 +33,13 @@ const CameraPresetEditor: FunctionComponent = (props) globalState.cameraPresetManager.saveCamera(camera, CameraPresetNames.get(camera) ?? "")} + onClick={() => cameraPresetManager.saveCamera(camera, CameraPresetNames.get(camera) ?? "")} /> ); }; -export function MakeCameraPresetInspectorServiceDefinition(globalState: GlobalState, inspectorModule: InspectorV2Module): WeaklyTypedServiceDefinition { +export function MakeCameraPresetInspectorServiceDefinition(cameraPresetManager: CameraPresetManager, inspectorModule: InspectorV2Module): WeaklyTypedServiceDefinition { return { friendlyName: "Sandbox Camera Preset Properties", consumes: [inspectorModule.PropertiesServiceIdentity, inspectorModule.SelectionServiceIdentity], @@ -65,7 +66,7 @@ export function MakeCameraPresetInspectorServiceDefinition(globalState: GlobalSt updateMetadataSectionRegistration(); const cameraPresetSection: FunctionComponent<{ context: Camera }> = (props) => { const { context } = props; - return ; + return ; }; const contentRegistration = propertiesService.addSectionContent({ key: "Sandbox Camera Preset Properties", From 23b5fd1985ad31349f8e7f5ad493415f1a55f92a Mon Sep 17 00:00:00 2001 From: Vicente Cartas Espinel Date: Tue, 18 Aug 2026 12:39:10 -0700 Subject: [PATCH 08/10] Harden Sandbox Inspector version compatibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sandbox/src/components/renderingZone.tsx | 4 +- packages/tools/sandbox/src/globalState.ts | 13 +- .../tools/cameraPresetInspectorService.tsx | 241 ++++++++++++++---- .../sandbox/src/tools/cameraPresetManager.ts | 6 +- .../sandbox/src/tools/dataStorageJson.ts | 46 ++++ .../sandbox/test/interaction.sandbox.test.ts | 85 +++++- .../unit/cameraPresetInspectorService.test.ts | 138 ++++++++++ .../sandbox/test/unit/dataStorageJson.test.ts | 80 ++++++ .../sandbox/test/unit/globalState.test.ts | 22 ++ 9 files changed, 579 insertions(+), 56 deletions(-) create mode 100644 packages/tools/sandbox/src/tools/dataStorageJson.ts create mode 100644 packages/tools/sandbox/test/unit/cameraPresetInspectorService.test.ts create mode 100644 packages/tools/sandbox/test/unit/dataStorageJson.test.ts diff --git a/packages/tools/sandbox/src/components/renderingZone.tsx b/packages/tools/sandbox/src/components/renderingZone.tsx index 640aef482032..10aaa9f71a6c 100644 --- a/packages/tools/sandbox/src/components/renderingZone.tsx +++ b/packages/tools/sandbox/src/components/renderingZone.tsx @@ -29,7 +29,7 @@ import { type AbstractEngine } from "core/Engines/abstractEngine"; import { setOpenGLOrientationForUV, useOpenGLOrientationForUV } from "core/Compat/compatibilityOptions"; import { ImageProcessingConfiguration } from "core/Materials/imageProcessingConfiguration"; import { LoadProjectFileAsync } from "shared-ui-components/projects/projectFile"; -import { DataStorage } from "core/Misc/dataStorage"; +import { ReadJsonRecordFromDataStorage } from "../tools/dataStorageJson"; function GetFileExtension(str: string): string { return str.split(".").pop() || ""; @@ -504,7 +504,7 @@ export class RenderingZone extends React.Component { // registered when the inspector is first opened. This read ensures persisted // settings are applied even on the initial page load before the inspector opens. // Key format mirrors SettingsStore: Babylon//. - const inspectorLoaderOptions = DataStorage.ReadJson>("Babylon/Inspector/glTFLoaderOptions", {}); + const inspectorLoaderOptions = ReadJsonRecordFromDataStorage("Babylon/Inspector/glTFLoaderOptions"); for (const [key, value] of Object.entries(inspectorLoaderOptions)) { if (value !== null) { (loader as unknown as Record)[key] = value; diff --git a/packages/tools/sandbox/src/globalState.ts b/packages/tools/sandbox/src/globalState.ts index 438a6b00135b..c53967e0d24e 100644 --- a/packages/tools/sandbox/src/globalState.ts +++ b/packages/tools/sandbox/src/globalState.ts @@ -2,7 +2,7 @@ import { type Camera, type FilesInput, type IDisposable, type Nullable, type Sce import { ArcRotateCamera } from "core/Cameras/arcRotateCamera"; import { Observable } from "core/Misc/observable"; import { CameraPresetManager } from "./tools/cameraPresetManager"; -import { MakeCameraPresetInspectorServiceDefinition } from "./tools/cameraPresetInspectorService"; +import { TryMakeCameraPresetInspectorServiceDefinition } from "./tools/cameraPresetInspectorService"; export type InspectorV2Module = typeof import("inspector/legacy/legacy") & typeof import("inspector/index"); export type SandboxSceneLoadKind = "scene" | "texture"; @@ -88,11 +88,12 @@ export class GlobalState { if (!this.isDebugLayerEnabled) { this.isDebugLayerEnabled = true; if (this.currentScene) { - const inspectorV2Module: InspectorV2Module | undefined = (globalThis).INSPECTOR; - if (inspectorV2Module?.ShowInspector) { - this._inspectorToken = inspectorV2Module.ShowInspector(this.currentScene, { - serviceDefinitions: [MakeCameraPresetInspectorServiceDefinition(this.cameraPresetManager, inspectorV2Module)], - }); + const inspectorV2Module = (globalThis as typeof globalThis & { INSPECTOR?: Partial }).INSPECTOR; + if (typeof inspectorV2Module?.ShowInspector === "function") { + const cameraPresetServiceDefinition = TryMakeCameraPresetInspectorServiceDefinition(this.cameraPresetManager, inspectorV2Module); + this._inspectorToken = cameraPresetServiceDefinition + ? inspectorV2Module.ShowInspector(this.currentScene, { serviceDefinitions: [cameraPresetServiceDefinition] }) + : inspectorV2Module.ShowInspector(this.currentScene); } else { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.currentScene.debugLayer.show(); diff --git a/packages/tools/sandbox/src/tools/cameraPresetInspectorService.tsx b/packages/tools/sandbox/src/tools/cameraPresetInspectorService.tsx index f1eeea06f4e3..2a7dab111a6d 100644 --- a/packages/tools/sandbox/src/tools/cameraPresetInspectorService.tsx +++ b/packages/tools/sandbox/src/tools/cameraPresetInspectorService.tsx @@ -1,12 +1,70 @@ import { type Camera } from "core/Cameras/camera"; +import { Logger } from "core/Misc/logger"; import { type FunctionComponent } from "react"; -import { type IPropertiesService, type ISelectionService, type WeaklyTypedServiceDefinition } from "inspector/index"; +import { type IPropertiesService, type WeaklyTypedServiceDefinition } from "inspector/index"; import { type InspectorV2Module } from "../globalState"; import { type CameraPresetManager } from "./cameraPresetManager"; const CameraPresetNames = new WeakMap(); const CameraPresetSectionOrder = Number.MAX_VALUE / 2; const CameraMetadataSectionOrder = Number.MAX_VALUE; +const NoopDisposable = { dispose: () => {} }; + +interface IDisposableRegistration { + dispose(): void; +} + +interface IRemovableRegistration { + remove(): void; +} + +interface ICompatiblePropertiesService { + addSection: IPropertiesService["addSection"]; + addSectionContent: IPropertiesService["addSectionContent"]; +} + +interface ICompatibleSelectionService { + selectedEntity: unknown; + onSelectedEntityChanged: { + add(callback: () => void): unknown; + }; +} + +function IsObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function IsDisposableRegistration(value: unknown): value is IDisposableRegistration { + return IsObject(value) && typeof value.dispose === "function"; +} + +function IsRemovableRegistration(value: unknown): value is IRemovableRegistration { + return IsObject(value) && typeof value.remove === "function"; +} + +function IsCompatiblePropertiesService(value: unknown): value is ICompatiblePropertiesService { + return IsObject(value) && typeof value.addSection === "function" && typeof value.addSectionContent === "function"; +} + +function IsCompatibleSelectionService(value: unknown): value is ICompatibleSelectionService { + return IsObject(value) && "selectedEntity" in value && IsObject(value.onSelectedEntityChanged) && typeof value.onSelectedEntityChanged.add === "function"; +} + +function DisposeRegistration(registration: IDisposableRegistration | undefined): void { + try { + registration?.dispose(); + } catch { + // Compatibility cleanup must not prevent the Inspector from opening. + } +} + +function RemoveRegistration(registration: IRemovableRegistration | undefined): void { + try { + registration?.remove(); + } catch { + // Compatibility cleanup must not prevent the Inspector from opening. + } +} export function IsCamera(entity: unknown): entity is Camera { const candidate = entity as Partial | null; @@ -18,6 +76,20 @@ export function IsCamera(entity: unknown): entity is Camera { return !!scene && scene.cameras.includes(entity as Camera); } +function IsCameraPresetInspectorModule(inspectorModule: unknown): inspectorModule is InspectorV2Module { + if (!IsObject(inspectorModule)) { + return false; + } + + return ( + typeof inspectorModule.ShowInspector === "function" && + typeof inspectorModule.PropertiesServiceIdentity === "symbol" && + typeof inspectorModule.SelectionServiceIdentity === "symbol" && + typeof inspectorModule.TextInputPropertyLine === "function" && + typeof inspectorModule.ButtonLine === "function" + ); +} + interface ICameraPresetEditorProps { camera: Camera; cameraPresetManager: CameraPresetManager; @@ -39,54 +111,137 @@ const CameraPresetEditor: FunctionComponent = (props) ); }; -export function MakeCameraPresetInspectorServiceDefinition(cameraPresetManager: CameraPresetManager, inspectorModule: InspectorV2Module): WeaklyTypedServiceDefinition { +function MakeCameraPresetInspectorServiceDefinition(cameraPresetManager: CameraPresetManager, inspectorModule: InspectorV2Module): WeaklyTypedServiceDefinition { + let compatibilityWarningLogged = false; + const warnOfIncompatibility = (reason: unknown) => { + if (!compatibilityWarningLogged) { + compatibilityWarningLogged = true; + const detail = reason instanceof Error ? reason.message : String(reason); + Logger.Warn(`Unable to add Sandbox camera preset controls to the Inspector: ${detail}`); + } + }; + return { friendlyName: "Sandbox Camera Preset Properties", consumes: [inspectorModule.PropertiesServiceIdentity, inspectorModule.SelectionServiceIdentity], - factory: (propertiesService: IPropertiesService, selectionService: ISelectionService) => { - const presetSectionRegistration = propertiesService.addSection({ - identity: "Save Camera Preset", - order: CameraPresetSectionOrder, - }); - let metadataSectionRegistration: ReturnType | undefined; - // The Inspector never registers "Metadata", so it is an implicit section that sorts above every explicitly ordered one. - // Claim it while a camera is selected to keep the preset section above it, and release it so other entities keep their usual layout. - const updateMetadataSectionRegistration = () => { - if (IsCamera(selectionService.selectedEntity)) { - metadataSectionRegistration ??= propertiesService.addSection({ - identity: "Metadata", - order: CameraMetadataSectionOrder, - }); - } else if (metadataSectionRegistration) { - metadataSectionRegistration.dispose(); - metadataSectionRegistration = undefined; + factory: (propertiesService: unknown, selectionService: unknown) => { + let presetSectionRegistration: IDisposableRegistration | undefined; + let metadataSectionRegistration: IDisposableRegistration | undefined; + let selectionObserver: IRemovableRegistration | undefined; + let contentRegistration: IDisposableRegistration | undefined; + let isDisposed = false; + + const dispose = () => { + if (isDisposed) { + return; } + + isDisposed = true; + const observer = selectionObserver; + const content = contentRegistration; + const metadataSection = metadataSectionRegistration; + const presetSection = presetSectionRegistration; + selectionObserver = undefined; + contentRegistration = undefined; + metadataSectionRegistration = undefined; + presetSectionRegistration = undefined; + RemoveRegistration(observer); + DisposeRegistration(content); + DisposeRegistration(metadataSection); + DisposeRegistration(presetSection); }; - const selectionObserver = selectionService.onSelectedEntityChanged.add(updateMetadataSectionRegistration); - updateMetadataSectionRegistration(); - const cameraPresetSection: FunctionComponent<{ context: Camera }> = (props) => { - const { context } = props; - return ; - }; - const contentRegistration = propertiesService.addSectionContent({ - key: "Sandbox Camera Preset Properties", - predicate: IsCamera, - content: [ - { - section: "Save Camera Preset", - component: cameraPresetSection, - }, - ], - }); - - return { - dispose: () => { - selectionObserver.remove(); - contentRegistration.dispose(); - metadataSectionRegistration?.dispose(); - presetSectionRegistration.dispose(); - }, + + const fail = (reason: unknown) => { + dispose(); + warnOfIncompatibility(reason); + return NoopDisposable; }; + + try { + if (!IsCompatiblePropertiesService(propertiesService) || !IsCompatibleSelectionService(selectionService)) { + return fail("the loaded Inspector services do not expose the required APIs"); + } + + const presetSection = propertiesService.addSection({ + identity: "Save Camera Preset", + order: CameraPresetSectionOrder, + }); + if (!IsDisposableRegistration(presetSection)) { + return fail("addSection did not return a disposable registration"); + } + presetSectionRegistration = presetSection; + + // The Inspector never registers "Metadata", so it is an implicit section that sorts above every explicitly ordered one. + // Claim it while a camera is selected to keep the preset section above it, and release it so other entities keep their usual layout. + const updateMetadataSectionRegistration = () => { + if (isDisposed) { + return; + } + + try { + if (IsCamera(selectionService.selectedEntity)) { + if (!metadataSectionRegistration) { + const metadataSection = propertiesService.addSection({ + identity: "Metadata", + order: CameraMetadataSectionOrder, + }); + if (!IsDisposableRegistration(metadataSection)) { + fail("addSection did not return a disposable metadata registration"); + return; + } + metadataSectionRegistration = metadataSection; + } + } else if (metadataSectionRegistration) { + const metadataSection = metadataSectionRegistration; + metadataSectionRegistration = undefined; + metadataSection.dispose(); + } + } catch (error) { + fail(error); + } + }; + + const observer = selectionService.onSelectedEntityChanged.add(updateMetadataSectionRegistration); + if (!IsRemovableRegistration(observer)) { + return fail("the selection observable did not return a removable observer"); + } + if (isDisposed) { + RemoveRegistration(observer); + return NoopDisposable; + } + selectionObserver = observer; + updateMetadataSectionRegistration(); + if (isDisposed) { + return NoopDisposable; + } + + const cameraPresetSection: FunctionComponent<{ context: Camera }> = (props) => { + const { context } = props; + return ; + }; + const content = propertiesService.addSectionContent({ + key: "Sandbox Camera Preset Properties", + predicate: IsCamera, + content: [ + { + section: "Save Camera Preset", + component: cameraPresetSection, + }, + ], + }); + if (!IsDisposableRegistration(content)) { + return fail("addSectionContent did not return a disposable registration"); + } + contentRegistration = content; + + return { dispose }; + } catch (error) { + return fail(error); + } }, }; } + +export function TryMakeCameraPresetInspectorServiceDefinition(cameraPresetManager: CameraPresetManager, inspectorModule: unknown): WeaklyTypedServiceDefinition | undefined { + return IsCameraPresetInspectorModule(inspectorModule) ? MakeCameraPresetInspectorServiceDefinition(cameraPresetManager, inspectorModule) : undefined; +} diff --git a/packages/tools/sandbox/src/tools/cameraPresetManager.ts b/packages/tools/sandbox/src/tools/cameraPresetManager.ts index c016de24541e..0675da457f13 100644 --- a/packages/tools/sandbox/src/tools/cameraPresetManager.ts +++ b/packages/tools/sandbox/src/tools/cameraPresetManager.ts @@ -3,10 +3,10 @@ import { Camera } from "core/Cameras/camera"; import { type AutoRotationBehavior } from "core/Behaviors/Cameras/autoRotationBehavior"; import { type BouncingBehavior } from "core/Behaviors/Cameras/bouncingBehavior"; import { type FramingBehavior } from "core/Behaviors/Cameras/framingBehavior"; -import { DataStorage } from "core/Misc/dataStorage"; import { Logger } from "core/Misc/logger"; import { Observable } from "core/Misc/observable"; import { type Scene } from "core/scene"; +import { ReadJsonFromDataStorage, WriteJsonToDataStorage } from "./dataStorageJson"; export const CameraPresetStorageKey = "Babylon/Sandbox/cameraPresets"; export const DefaultCameraPresetOption = "Default camera"; @@ -76,8 +76,8 @@ const DefaultCameraPresetState: ICameraPresetState = { }; const DataStorageBackend: ICameraPresetStorageBackend = { - read: () => DataStorage.ReadJson(CameraPresetStorageKey, null), - write: (state) => DataStorage.WriteJson(CameraPresetStorageKey, state), + read: () => ReadJsonFromDataStorage(CameraPresetStorageKey, null), + write: (state) => WriteJsonToDataStorage(CameraPresetStorageKey, state), }; function IsRecord(value: unknown): value is Record { diff --git a/packages/tools/sandbox/src/tools/dataStorageJson.ts b/packages/tools/sandbox/src/tools/dataStorageJson.ts new file mode 100644 index 000000000000..b293a59997ef --- /dev/null +++ b/packages/tools/sandbox/src/tools/dataStorageJson.ts @@ -0,0 +1,46 @@ +import { DataStorage } from "core/Misc/dataStorage"; +import { Logger } from "core/Misc/logger"; + +const MissingStorageValue = "__BabylonSandboxMissingStorageValue__"; +const AlternateMissingStorageValue = "__BabylonSandboxAlternateMissingStorageValue__"; + +export interface IDataStorageStringAdapter { + readString(key: string, defaultValue: string): string; + writeString(key: string, value: string): void; +} + +const DataStorageStringAdapter: IDataStorageStringAdapter = { + readString: (key, defaultValue) => DataStorage.ReadString(key, defaultValue), + writeString: (key, value) => DataStorage.WriteString(key, value), +}; + +export function ReadJsonFromDataStorage(key: string, defaultValue: T, storage: IDataStorageStringAdapter = DataStorageStringAdapter): T { + let storedValue = storage.readString(key, MissingStorageValue); + if (storedValue === MissingStorageValue) { + storedValue = storage.readString(key, AlternateMissingStorageValue); + if (storedValue === AlternateMissingStorageValue) { + return defaultValue; + } + } + + try { + return JSON.parse(storedValue) as T; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + Logger.Warn(`Failed to parse JSON from storage for key "${key}". Returning default value. ${detail}`); + return defaultValue; + } +} + +export function ReadJsonRecordFromDataStorage( + key: string, + defaultValue: Record = {}, + storage: IDataStorageStringAdapter = DataStorageStringAdapter +): Record { + const storedValue = ReadJsonFromDataStorage(key, defaultValue, storage); + return typeof storedValue === "object" && storedValue !== null && !Array.isArray(storedValue) ? (storedValue as Record) : defaultValue; +} + +export function WriteJsonToDataStorage(key: string, value: T, storage: IDataStorageStringAdapter = DataStorageStringAdapter): void { + storage.writeString(key, JSON.stringify(value)); +} diff --git a/packages/tools/sandbox/test/interaction.sandbox.test.ts b/packages/tools/sandbox/test/interaction.sandbox.test.ts index 1177dad24425..d924a65442ca 100644 --- a/packages/tools/sandbox/test/interaction.sandbox.test.ts +++ b/packages/tools/sandbox/test/interaction.sandbox.test.ts @@ -10,10 +10,12 @@ test.beforeAll(async () => { // if running in the CI we need to use the babylon snapshot when loading the tools const snapshot = process.env.SNAPSHOT ? "?snapshot=" + process.env.SNAPSHOT : ""; const cdnPort = ":" + (process.env.CDN_PORT || 1337); -const url = (process.env.SANDBOX_BASE_URL || getGlobalConfig().baseUrl.replace(cdnPort, process.env.SANDBOX_PORT || ":1339")) + snapshot; +const sandboxBaseUrl = process.env.SANDBOX_BASE_URL || getGlobalConfig().baseUrl.replace(cdnPort, process.env.SANDBOX_PORT || ":1339"); +const url = sandboxBaseUrl + snapshot; const cameraPresetStorageKey = "Babylon/Sandbox/cameraPresets"; const inspectorTeachingMomentStoragePrefix = "Babylon/Inspector/TeachingMoments/"; -const boxModelUrl = url + (snapshot ? "&" : "?") + "assetUrl=https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/main/2.0/Box/glTF-Binary/Box.glb"; +const boxAssetUrl = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/main/2.0/Box/glTF-Binary/Box.glb"; +const boxModelUrl = url + (snapshot ? "&" : "?") + `assetUrl=${boxAssetUrl}`; const embeddedCameraModelUrl = url + (snapshot ? "&" : "?") + "assetUrl=https://assets.babylonjs.com/meshes/Box/Box_extras.gltf"; const textureAssetUrl = url + (snapshot ? "&" : "?") + "assetUrl=https://assets.babylonjs.com/textures/grass.png"; const cameraPresetId = "test-overview"; @@ -44,6 +46,12 @@ interface ICameraNumericState { lowerRadiusLimit: number | null | undefined; } +interface ISandboxRuntimeInfo { + engineVersion: string | undefined; + scriptBaseUrl: string | undefined; + isViteDevelopment: boolean; +} + function createCameraPresetState(activePresetId: string | null): Record { return { version: 1, @@ -153,6 +161,13 @@ function getAssetFileName(targetUrl: string): string { return decodeURIComponent(new URL(assetUrl).pathname.split("/").pop()!); } +function getVersionedBoxModelUrl(version: string): string { + const versionedUrl = new URL(sandboxBaseUrl); + versionedUrl.search = ""; + versionedUrl.hash = ""; + return `${versionedUrl.toString()}?version=${version}&assetUrl=${boxAssetUrl}`; +} + async function waitForLoadedAsset(page: Page, expectedFileName: string): Promise { await waitForSandboxReady(page); const escapedFileName = expectedFileName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -172,6 +187,23 @@ async function reloadSandboxAsset(page: Page): Promise { await waitForLoadedAsset(page, expectedFileName); } +async function readSandboxRuntimeInfo(page: Page): Promise { + return page.evaluate(() => { + type BabylonRuntime = { + Engine?: { Version?: string }; + Tools?: { ScriptBaseUrl?: string }; + }; + const babylon = (globalThis as typeof globalThis & { BABYLON?: BabylonRuntime }).BABYLON; + const isViteDevelopment = Array.from(document.scripts).some((script) => script.type === "module" && script.src !== "" && new URL(script.src).pathname === "/src/main.ts"); + + return { + engineVersion: babylon?.Engine?.Version, + scriptBaseUrl: babylon?.Tools?.ScriptBaseUrl, + isViteDevelopment, + }; + }); +} + async function openInspector(page: Page): Promise { const inspector = page.locator("#babylon-inspector-container"); if (!(await inspector.isVisible())) { @@ -679,3 +711,52 @@ test.describe("camera presets", () => { expect(pageErrors).toHaveLength(0); }); }); + +test.describe("historical Inspector camera preset compatibility", () => { + test.describe.configure({ timeout: 90000 }); + test.use({ viewport: { width: 1280, height: 720 } }); + + for (const version of ["8.40.1", "8.51.0"]) { + test(`saves and activates a camera preset with runtime ${version}`, async ({ page }) => { + const pageErrors = trackPageErrors(page); + await clearCameraPresetStorage(page); + const versionedBoxModelUrl = getVersionedBoxModelUrl(version); + + expect(new URL(versionedBoxModelUrl).searchParams.has("snapshot")).toBe(false); + await loadSandboxAsset(page, versionedBoxModelUrl); + const runtimeInfo = await readSandboxRuntimeInfo(page); + test.skip( + runtimeInfo.isViteDevelopment, + `Historical runtime ${version} requires the production Sandbox bootstrap; Vite development loaded local runtime ${runtimeInfo.engineVersion ?? "unknown"}.` + ); + expect(runtimeInfo.engineVersion, "the production Sandbox bootstrap must load the requested Babylon.js runtime").toBe(version); + expect(runtimeInfo.scriptBaseUrl, "the requested runtime must configure its versioned script base URL").toBe(`https://cdn.babylonjs.com/v${version}`); + + await page.getByTitle("Display inspector").click(); + await expandInspectorNodes(page); + await page.getByRole("treeitem", { name: /^default camera/ }).click(); + + const presetHeader = page.getByRole("button", { name: "Save Camera Preset", exact: true }); + await expect(presetHeader).toBeVisible(); + const presetSection = presetHeader.locator("xpath=../.."); + await presetSection.getByRole("button", { name: "Save", exact: true }).click(); + + const footer = page.locator("#footer"); + const cameraPresetSelector = footer.getByTitle("Select camera preset"); + await expect(cameraPresetSelector).toBeVisible(); + const savedState = await readCameraPresetState(page); + expect(savedState.activePresetId).toBeNull(); + expect(savedState.presets).toHaveLength(1); + expect(savedState.presets[0]).toMatchObject({ name: "Preset 1", cameraType: "ArcRotateCamera" }); + + await cameraPresetSelector.click({ force: true }); + await footer.getByTitle("Preset 1", { exact: true }).click({ force: true }); + expect((await readCameraPresetState(page)).activePresetId).toBe(savedState.presets[0].id); + expect(await readSceneCameraState(page)).toMatchObject({ + name: "Preset 1", + id: `SandboxCameraPreset/${savedState.presets[0].id}`, + }); + expect(pageErrors).toHaveLength(0); + }); + } +}); diff --git a/packages/tools/sandbox/test/unit/cameraPresetInspectorService.test.ts b/packages/tools/sandbox/test/unit/cameraPresetInspectorService.test.ts new file mode 100644 index 000000000000..4c7c93a50666 --- /dev/null +++ b/packages/tools/sandbox/test/unit/cameraPresetInspectorService.test.ts @@ -0,0 +1,138 @@ +import { Logger } from "core/Misc/logger"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { type InspectorV2Module } from "../../src/globalState"; +import { type CameraPresetManager } from "../../src/tools/cameraPresetManager"; +import { TryMakeCameraPresetInspectorServiceDefinition } from "../../src/tools/cameraPresetInspectorService"; + +type DisposableService = { dispose(): void }; + +function CreateInspectorModule(): InspectorV2Module { + return { + ShowInspector: vi.fn(), + PropertiesServiceIdentity: Symbol("PropertiesService"), + SelectionServiceIdentity: Symbol("SelectionService"), + TextInputPropertyLine: () => null, + ButtonLine: () => null, + } as unknown as InspectorV2Module; +} + +function CreateServiceFactory() { + const definition = TryMakeCameraPresetInspectorServiceDefinition({} as CameraPresetManager, CreateInspectorModule()); + if (!definition) { + throw new Error("Expected a compatible Inspector service definition"); + } + return definition.factory as (propertiesService: unknown, selectionService: unknown) => DisposableService; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Sandbox camera preset Inspector compatibility", () => { + it("builds a contribution for a complete Inspector module", () => { + const inspectorModule = CreateInspectorModule(); + + const definition = TryMakeCameraPresetInspectorServiceDefinition({} as CameraPresetManager, inspectorModule); + + expect(definition).toMatchObject({ + friendlyName: "Sandbox Camera Preset Properties", + consumes: [inspectorModule.PropertiesServiceIdentity, inspectorModule.SelectionServiceIdentity], + }); + }); + + it.each(["ShowInspector", "PropertiesServiceIdentity", "SelectionServiceIdentity", "TextInputPropertyLine", "ButtonLine"] as const)( + "does not build a contribution without %s", + (property) => { + const inspectorModule = CreateInspectorModule() as unknown as Record; + inspectorModule[property] = undefined; + + expect(TryMakeCameraPresetInspectorServiceDefinition({} as CameraPresetManager, inspectorModule)).toBeUndefined(); + } + ); + + it("returns a safe no-op service for incompatible consumed services and warns once", () => { + const warn = vi.spyOn(Logger, "Warn").mockImplementation(() => {}); + const factory = CreateServiceFactory(); + const instances: DisposableService[] = []; + + expect(() => { + instances.push(factory({}, {})); + instances.push(factory({ addSection: vi.fn(), addSectionContent: vi.fn() }, { selectedEntity: null, onSelectedEntityChanged: { add: undefined } })); + }).not.toThrow(); + expect(() => instances.forEach((instance) => instance.dispose())).not.toThrow(); + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls[0][0]).toContain("loaded Inspector services"); + }); + + it("does not read the selected entity while rejecting an incompatible selection service", () => { + vi.spyOn(Logger, "Warn").mockImplementation(() => {}); + const readSelectedEntity = vi.fn(); + const factory = CreateServiceFactory(); + const selectionService = { + get selectedEntity() { + readSelectedEntity(); + return null; + }, + onSelectedEntityChanged: { add: undefined }, + }; + + factory({ addSection: vi.fn(), addSectionContent: vi.fn() }, selectionService); + + expect(readSelectedEntity).not.toHaveBeenCalled(); + }); + + it("disposes partial registrations when a later registration fails", () => { + const warn = vi.spyOn(Logger, "Warn").mockImplementation(() => {}); + const presetSectionDispose = vi.fn(); + const observerRemove = vi.fn(); + const factory = CreateServiceFactory(); + const propertiesService = { + addSection: vi.fn(() => ({ dispose: presetSectionDispose })), + addSectionContent: vi.fn(() => { + throw new Error("Unsupported section content"); + }), + }; + const selectionService = { + selectedEntity: null, + onSelectedEntityChanged: { add: vi.fn(() => ({ remove: observerRemove })) }, + }; + + let instance: DisposableService | undefined; + expect(() => { + instance = factory(propertiesService, selectionService); + }).not.toThrow(); + + expect(observerRemove).toHaveBeenCalledOnce(); + expect(presetSectionDispose).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls[0][0]).toContain("Unsupported section content"); + expect(() => instance?.dispose()).not.toThrow(); + }); + + it("removes an observer returned after a synchronous selection callback fails", () => { + vi.spyOn(Logger, "Warn").mockImplementation(() => {}); + const presetSectionDispose = vi.fn(); + const observerRemove = vi.fn(); + const factory = CreateServiceFactory(); + const camera = { getScene: () => ({ cameras: [camera] }) }; + const propertiesService = { + addSection: vi.fn().mockReturnValueOnce({ dispose: presetSectionDispose }).mockReturnValueOnce(undefined), + addSectionContent: vi.fn(), + }; + const selectionService = { + selectedEntity: camera, + onSelectedEntityChanged: { + add: vi.fn((callback: () => void) => { + callback(); + return { remove: observerRemove }; + }), + }, + }; + + const instance = factory(propertiesService, selectionService); + + expect(observerRemove).toHaveBeenCalledOnce(); + expect(presetSectionDispose).toHaveBeenCalledOnce(); + expect(() => instance.dispose()).not.toThrow(); + }); +}); diff --git a/packages/tools/sandbox/test/unit/dataStorageJson.test.ts b/packages/tools/sandbox/test/unit/dataStorageJson.test.ts new file mode 100644 index 000000000000..0f54a05b1b4c --- /dev/null +++ b/packages/tools/sandbox/test/unit/dataStorageJson.test.ts @@ -0,0 +1,80 @@ +import { Logger } from "core/Misc/logger"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ReadJsonFromDataStorage, ReadJsonRecordFromDataStorage, WriteJsonToDataStorage, type IDataStorageStringAdapter } from "../../src/tools/dataStorageJson"; + +class MemoryStringStorage implements IDataStorageStringAdapter { + public readonly values = new Map(); + + public readString(key: string, defaultValue: string): string { + return this.values.get(key) ?? defaultValue; + } + + public writeString(key: string, value: string): void { + this.values.set(key, value); + } +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Sandbox JSON data storage", () => { + it("returns the provided default when the key is missing", () => { + const storage = new MemoryStringStorage(); + const defaultValue = { enabled: true }; + + expect(ReadJsonFromDataStorage("missing", defaultValue, storage)).toBe(defaultValue); + }); + + it("reads and writes valid JSON", () => { + const storage = new MemoryStringStorage(); + + WriteJsonToDataStorage("settings", { enabled: true, count: 2 }, storage); + + expect(storage.values.get("settings")).toBe('{"enabled":true,"count":2}'); + expect(ReadJsonFromDataStorage("settings", {}, storage)).toEqual({ enabled: true, count: 2 }); + }); + + it("warns and returns the default for malformed JSON", () => { + const storage = new MemoryStringStorage(); + const defaultValue = { enabled: false }; + const warn = vi.spyOn(Logger, "Warn").mockImplementation(() => {}); + storage.values.set("settings", "not-json"); + + expect(ReadJsonFromDataStorage("settings", defaultValue, storage)).toBe(defaultValue); + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls[0][0]).toContain('key "settings"'); + expect(warn.mock.calls[0]).toHaveLength(1); + }); + + it.each(["null", "[]", '"value"', "42", "true"])("returns the provided record default for non-record JSON %s", (storedValue) => { + const storage = new MemoryStringStorage(); + const defaultValue = { enabled: false }; + storage.values.set("settings", storedValue); + + expect(ReadJsonRecordFromDataStorage("settings", defaultValue, storage)).toBe(defaultValue); + }); + + it("reads a JSON record", () => { + const storage = new MemoryStringStorage(); + storage.values.set("settings", '{"enabled":true}'); + + expect(ReadJsonRecordFromDataStorage("settings", {}, storage)).toEqual({ enabled: true }); + }); + + it("preserves storage read and write errors", () => { + const readError = new Error("Read unavailable"); + const writeError = new Error("Write unavailable"); + const storage: IDataStorageStringAdapter = { + readString: () => { + throw readError; + }, + writeString: () => { + throw writeError; + }, + }; + + expect(() => ReadJsonFromDataStorage("settings", null, storage)).toThrow(readError); + expect(() => WriteJsonToDataStorage("settings", {}, storage)).toThrow(writeError); + }); +}); diff --git a/packages/tools/sandbox/test/unit/globalState.test.ts b/packages/tools/sandbox/test/unit/globalState.test.ts index f5b5c204f614..d3d004a144e7 100644 --- a/packages/tools/sandbox/test/unit/globalState.test.ts +++ b/packages/tools/sandbox/test/unit/globalState.test.ts @@ -10,6 +10,28 @@ import { ParseCameraUrlValue, type CameraNumericUrlParameter } from "../../src/t afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("GlobalState Inspector compatibility", () => { + it("opens a partial Inspector module without the Sandbox contribution", () => { + const inspectorToken = { dispose: vi.fn() }; + const showInspector = vi.fn(() => inspectorToken); + vi.stubGlobal("INSPECTOR", { ShowInspector: showInspector }); + const globalState = new GlobalState({ version: "test", bundles: [] }); + const engine = new NullEngine(); + const scene = new Scene(engine); + globalState.currentScene = scene; + + globalState.showDebugLayer(); + + expect(showInspector).toHaveBeenCalledOnce(); + expect(showInspector).toHaveBeenCalledWith(scene); + + globalState.hideDebugLayer(); + expect(inspectorToken.dispose).toHaveBeenCalledOnce(); + engine.dispose(); + }); }); describe("GlobalState camera preset URL override", () => { From 7d6ef37a1162257bdc5c815a83879bd60d9d80b2 Mon Sep 17 00:00:00 2001 From: Vicente Cartas Espinel Date: Tue, 18 Aug 2026 15:22:19 -0700 Subject: [PATCH 09/10] Improve Sandbox camera preset menu sizing --- .../sandbox/src/components/dropUpButton.tsx | 4 +- .../tools/sandbox/src/components/footer.tsx | 1 + packages/tools/sandbox/src/scss/footer.scss | 21 +++++++- .../tools/cameraPresetInspectorService.tsx | 14 ++++-- .../sandbox/src/tools/cameraPresetManager.ts | 31 ++++++++---- .../unit/cameraPresetInspectorService.test.ts | 48 +++++++++++++++++-- .../test/unit/cameraPresetManager.test.ts | 41 ++++++++++++++++ .../tools/sandbox/test/unit/footer.test.tsx | 42 ++++++++++++++-- 8 files changed, 181 insertions(+), 21 deletions(-) diff --git a/packages/tools/sandbox/src/components/dropUpButton.tsx b/packages/tools/sandbox/src/components/dropUpButton.tsx index 5fb53ade56c2..199f2364ab10 100644 --- a/packages/tools/sandbox/src/components/dropUpButton.tsx +++ b/packages/tools/sandbox/src/components/dropUpButton.tsx @@ -17,6 +17,7 @@ interface IDropUpButtonProps { selectedOption?: string; onOptionPicked: (option: string, index: number) => void; searchPlaceholder?: string; + dynamicWidth?: boolean; } export class DropUpButton extends React.Component { @@ -76,7 +77,7 @@ export class DropUpButton extends React.Component )} {this.state.isOpen && ( -
+
{this.props.options .map((o, i) => ({ option: o, index: i })) @@ -87,6 +88,7 @@ export class DropUpButton extends React.Component this.clickOption(o, i)} className="dropup-content-line">
{ onOptionPicked={(option, index) => this.switchCameraPreset(index)} enabled={hasCameraPresets} searchPlaceholder="Search camera preset" + dynamicWidth={true} /> (); const CameraPresetSectionOrder = Number.MAX_VALUE / 2; @@ -96,12 +96,20 @@ interface ICameraPresetEditorProps { inspectorModule: InspectorV2Module; } +type CameraPresetTextInputPropertyLineProps = ComponentProps & { maxLength?: number }; + const CameraPresetEditor: FunctionComponent = (props) => { const { camera, cameraPresetManager, inspectorModule } = props; + const textInputPropertyLine = inspectorModule.TextInputPropertyLine as FunctionComponent; return ( <> - CameraPresetNames.set(camera, value)} /> + {createElement(textInputPropertyLine, { + label: "Name", + value: CameraPresetNames.get(camera) ?? "", + maxLength: CameraPresetNameMaxLength, + onChange: (value) => CameraPresetNames.set(camera, value), + })} name.trim().toLowerCase())); - const trimmedName = requestedName.trim(); + const normalizedNames = new Set(existingNames.map((name) => NormalizeCameraPresetName(name).toLowerCase())); + const normalizedName = NormalizeCameraPresetName(requestedName); - if (!trimmedName) { + if (!normalizedName) { let index = 1; while (normalizedNames.has(`preset ${index}`)) { index++; @@ -199,22 +210,24 @@ export function GetUniqueCameraPresetName(existingNames: readonly string[], requ return `Preset ${index}`; } - if (!normalizedNames.has(trimmedName.toLowerCase())) { - return trimmedName; + if (!normalizedNames.has(normalizedName.toLowerCase())) { + return normalizedName; } - const suffixMatch = /^(.*)\s+(\d+)$/.exec(trimmedName); - const baseName = suffixMatch?.[1].trim() || trimmedName; + const suffixMatch = /^(.*)\s+(\d+)$/.exec(normalizedName); + const baseName = suffixMatch?.[1].trim() || normalizedName; let suffix = suffixMatch ? Number(suffixMatch[2]) + 1 : 2; if (!Number.isSafeInteger(suffix)) { suffix = 2; } - while (normalizedNames.has(`${baseName} ${suffix}`.toLowerCase())) { + let uniqueName = FormatCameraPresetNameWithSuffix(baseName, suffix); + while (normalizedNames.has(uniqueName.toLowerCase())) { suffix++; + uniqueName = FormatCameraPresetNameWithSuffix(baseName, suffix); } - return `${baseName} ${suffix}`; + return uniqueName; } export function ParseCameraPresetState(value: unknown): ICameraPresetState { diff --git a/packages/tools/sandbox/test/unit/cameraPresetInspectorService.test.ts b/packages/tools/sandbox/test/unit/cameraPresetInspectorService.test.ts index 4c7c93a50666..b2e71dfaea6c 100644 --- a/packages/tools/sandbox/test/unit/cameraPresetInspectorService.test.ts +++ b/packages/tools/sandbox/test/unit/cameraPresetInspectorService.test.ts @@ -1,17 +1,25 @@ +import { type Camera } from "core/Cameras/camera"; import { Logger } from "core/Misc/logger"; +import { createElement, type ComponentProps, type FunctionComponent } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, describe, expect, it, vi } from "vitest"; import { type InspectorV2Module } from "../../src/globalState"; -import { type CameraPresetManager } from "../../src/tools/cameraPresetManager"; +import { CameraPresetNameMaxLength, type CameraPresetManager } from "../../src/tools/cameraPresetManager"; import { TryMakeCameraPresetInspectorServiceDefinition } from "../../src/tools/cameraPresetInspectorService"; type DisposableService = { dispose(): void }; +type TextInputPropertyLineProps = ComponentProps; -function CreateInspectorModule(): InspectorV2Module { +interface ICameraPresetSectionDefinition { + content: Array<{ component: FunctionComponent<{ context: Camera }> }>; +} + +function CreateInspectorModule(textInputPropertyLine: InspectorV2Module["TextInputPropertyLine"] = () => null): InspectorV2Module { return { ShowInspector: vi.fn(), PropertiesServiceIdentity: Symbol("PropertiesService"), SelectionServiceIdentity: Symbol("SelectionService"), - TextInputPropertyLine: () => null, + TextInputPropertyLine: textInputPropertyLine, ButtonLine: () => null, } as unknown as InspectorV2Module; } @@ -40,6 +48,40 @@ describe("Sandbox camera preset Inspector compatibility", () => { }); }); + it("forwards the preset name limit to the Inspector text input", () => { + const textInputPropertyLine = vi.fn((_props: TextInputPropertyLineProps) => null); + const inspectorModule = CreateInspectorModule(textInputPropertyLine); + const definition = TryMakeCameraPresetInspectorServiceDefinition({} as CameraPresetManager, inspectorModule); + if (!definition) { + throw new Error("Expected a compatible Inspector service definition"); + } + + let cameraPresetSection: FunctionComponent<{ context: Camera }> | undefined; + const factory = definition.factory as (propertiesService: unknown, selectionService: unknown) => DisposableService; + const instance = factory( + { + addSection: vi.fn(() => ({ dispose: vi.fn() })), + addSectionContent: vi.fn((sectionDefinition: ICameraPresetSectionDefinition) => { + cameraPresetSection = sectionDefinition.content[0]?.component; + return { dispose: vi.fn() }; + }), + }, + { + selectedEntity: null, + onSelectedEntityChanged: { add: vi.fn(() => ({ remove: vi.fn() })) }, + } + ); + if (!cameraPresetSection) { + throw new Error("Expected the camera preset Inspector section to be registered"); + } + + renderToStaticMarkup(createElement(cameraPresetSection, { context: {} as Camera })); + + expect(textInputPropertyLine).toHaveBeenCalledOnce(); + expect(textInputPropertyLine.mock.calls[0][0]).toMatchObject({ label: "Name", maxLength: CameraPresetNameMaxLength }); + instance.dispose(); + }); + it.each(["ShowInspector", "PropertiesServiceIdentity", "SelectionServiceIdentity", "TextInputPropertyLine", "ButtonLine"] as const)( "does not build a contribution without %s", (property) => { diff --git a/packages/tools/sandbox/test/unit/cameraPresetManager.test.ts b/packages/tools/sandbox/test/unit/cameraPresetManager.test.ts index 80084d4cc22b..bb2081305c15 100644 --- a/packages/tools/sandbox/test/unit/cameraPresetManager.test.ts +++ b/packages/tools/sandbox/test/unit/cameraPresetManager.test.ts @@ -10,6 +10,7 @@ import { SerializationHelper } from "core/Misc/decorators.serialization"; import { Scene } from "core/scene"; import { describe, expect, it, vi } from "vitest"; import { + CameraPresetNameMaxLength, CameraPresetManager, GetUniqueCameraPresetName, ParseCameraPresetState, @@ -60,6 +61,14 @@ describe("camera preset naming", () => { it("restarts numbering when the requested suffix cannot be incremented safely", () => { expect(GetUniqueCameraPresetName(["City 99999999999999999999"], "City 99999999999999999999")).toBe("City 2"); }); + + it("keeps normalized names and duplicate suffixes within the preset name limit", () => { + const requestedName = "A".repeat(CameraPresetNameMaxLength + 10); + const normalizedName = "A".repeat(CameraPresetNameMaxLength); + + expect(GetUniqueCameraPresetName([], requestedName)).toBe(normalizedName); + expect(GetUniqueCameraPresetName([normalizedName], requestedName)).toBe(`${"A".repeat(CameraPresetNameMaxLength - 2)} 2`); + }); }); describe("camera preset storage", () => { @@ -128,6 +137,22 @@ describe("camera preset storage", () => { expect(state.activePresetId).toBe("reserved"); expect(state.presets.map((preset) => preset.name)).toEqual(["Default camera 2"]); }); + + it("normalizes overlong persisted names and keeps collision suffixes within the limit", () => { + const requestedName = "L".repeat(CameraPresetNameMaxLength + 10); + const normalizedName = "L".repeat(CameraPresetNameMaxLength); + const state = ParseCameraPresetState({ + version: 1, + activePresetId: null, + presets: [ + { id: "first", name: requestedName, cameraType: "ArcRotateCamera", cameraData: { type: "ArcRotateCamera" } }, + { id: "second", name: requestedName, cameraType: "ArcRotateCamera", cameraData: { type: "ArcRotateCamera" } }, + ], + }); + + expect(state.presets.map((preset) => preset.name)).toEqual([normalizedName, `${"L".repeat(CameraPresetNameMaxLength - 2)} 2`]); + expect(state.presets.every((preset) => preset.name.length <= CameraPresetNameMaxLength)).toBe(true); + }); }); describe("camera preset Inspector predicate", () => { @@ -178,6 +203,22 @@ describe("CameraPresetManager", () => { engine.dispose(); }); + it("normalizes overlong names before saving and restoring presets", () => { + const engine = new NullEngine(); + const scene = new Scene(engine); + const camera = new ArcRotateCamera("camera", 0, 0, 1, Vector3.Zero(), scene); + const storage = new MemoryCameraPresetStorage(); + const manager = new CameraPresetManager(storage, () => "long-name"); + const normalizedName = "S".repeat(CameraPresetNameMaxLength); + + expect(manager.saveCamera(camera, `${normalizedName} extra`)?.name).toBe(normalizedName); + expect((storage.value as ICameraPresetState).presets[0].name).toBe(normalizedName); + expect(new CameraPresetManager(storage).presets[0].name).toBe(normalizedName); + + scene.dispose(); + engine.dispose(); + }); + it("leaves the scene camera unchanged and reports once when persisted camera data cannot be applied", () => { const engine = new NullEngine(); const scene = new Scene(engine); diff --git a/packages/tools/sandbox/test/unit/footer.test.tsx b/packages/tools/sandbox/test/unit/footer.test.tsx index 7a4826cae5f2..1cc29a7c8e98 100644 --- a/packages/tools/sandbox/test/unit/footer.test.tsx +++ b/packages/tools/sandbox/test/unit/footer.test.tsx @@ -4,26 +4,30 @@ import { NullEngine } from "core/Engines/nullEngine"; import { Vector3 } from "core/Maths/math.vector"; import { Observable } from "core/Misc/observable"; import { Scene } from "core/scene"; +import { DropUpButton } from "../../src/components/dropUpButton"; import { Footer } from "../../src/components/footer"; import { type GlobalState, type SandboxSceneLoadKind } from "../../src/globalState"; interface IControlProps { children?: React.ReactNode; + className?: string; + dynamicWidth?: boolean; enabled?: boolean; label?: string; options?: string[]; + title?: string; } -function FindControl(node: React.ReactNode, label: string): React.ReactElement | undefined { +function FindElement(node: React.ReactNode, predicate: (props: IControlProps) => boolean): React.ReactElement | undefined { if (!React.isValidElement(node)) { return undefined; } - if (node.props.label === label) { + if (predicate(node.props)) { return node; } for (const child of React.Children.toArray(node.props.children)) { - const match = FindControl(child, label); + const match = FindElement(child, predicate); if (match) { return match; } @@ -32,6 +36,10 @@ function FindControl(node: React.ReactNode, label: string): React.ReactElement | undefined { + return FindElement(node, (props) => props.label === label); +} + describe("Sandbox Footer camera controls", () => { it("uses scene payload cameras, hides texture presets, and removes observers on unmount", () => { const onSceneLoaded = new Observable<{ scene: Scene; filename: string; loadKind: SandboxSceneLoadKind }>(); @@ -65,7 +73,10 @@ describe("Sandbox Footer camera controls", () => { let renderedFooter = footer.render(); expect(FindControl(renderedFooter, "Select camera")?.props).toMatchObject({ enabled: true, options: ["Embedded camera"] }); - expect(FindControl(renderedFooter, "Select camera preset")?.props.enabled).toBe(true); + expect(FindControl(renderedFooter, "Select camera preset")?.props).toMatchObject({ enabled: true, dynamicWidth: true }); + expect(FindControl(renderedFooter, "Select environment")?.props.dynamicWidth).toBeUndefined(); + expect(FindControl(renderedFooter, "Select camera")?.props.dynamicWidth).toBeUndefined(); + expect(FindControl(renderedFooter, "Select variant")?.props.dynamicWidth).toBeUndefined(); const textureScene = new Scene(engine); onSceneLoaded.notifyObservers({ scene: textureScene, filename: "texture.png", loadKind: "texture" }); @@ -84,4 +95,27 @@ describe("Sandbox Footer camera controls", () => { textureScene.dispose(); engine.dispose(); }); + + it("renders the dynamic-width selector class while preserving full option titles", () => { + const onClickInterceptorClicked = new Observable(); + const button = new DropUpButton({ + globalState: { + onClickInterceptorClicked, + onRequestClickInterceptor: new Observable(), + } as GlobalState, + enabled: true, + label: "Select camera preset", + options: ["A complete camera preset name"], + activeEntry: () => "", + onOptionPicked: vi.fn(), + dynamicWidth: true, + }); + button.state = { isOpen: true, searchText: "" }; + + const renderedButton = button.render(); + expect(FindElement(renderedButton, (props) => props.className?.includes("dropup-content") === true)?.props.className).toContain("dynamic-width"); + expect(FindElement(renderedButton, (props) => props.title === "A complete camera preset name")).toBeDefined(); + + button.componentWillUnmount(); + }); }); From 6e6d23b395c474edd92b1a9f9fd195ad3a50bb98 Mon Sep 17 00:00:00 2001 From: Vicente Cartas Espinel Date: Tue, 18 Aug 2026 15:22:27 -0700 Subject: [PATCH 10/10] Harden historical Inspector preset test --- packages/tools/sandbox/test/interaction.sandbox.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tools/sandbox/test/interaction.sandbox.test.ts b/packages/tools/sandbox/test/interaction.sandbox.test.ts index d924a65442ca..6a6901eb4052 100644 --- a/packages/tools/sandbox/test/interaction.sandbox.test.ts +++ b/packages/tools/sandbox/test/interaction.sandbox.test.ts @@ -225,7 +225,7 @@ async function closeInspector(page: Page): Promise { } async function expandInspectorNodes(page: Page): Promise { - const nodes = page.getByRole("treeitem", { name: "Nodes", exact: true }); + const nodes = page.locator('[role="treeitem"]').filter({ hasText: /^Nodes$/ }); await expect(nodes).toBeVisible(); await nodes.focus(); await nodes.press("ArrowRight");