diff --git a/packages/tools/viewer-configurator/src/App.tsx b/packages/tools/viewer-configurator/src/App.tsx index 46c0cf343c1..37b49e90af1 100644 --- a/packages/tools/viewer-configurator/src/App.tsx +++ b/packages/tools/viewer-configurator/src/App.tsx @@ -1,4 +1,5 @@ import type { FunctionComponent } from "react"; +import type { ViewerOptions } from "viewer/viewer"; import type { ViewerElement } from "viewer/viewerElement"; import "./App.scss"; @@ -11,6 +12,7 @@ import { Configurator } from "./components/configurator/configurator"; import { useEventfulState } from "./hooks/observableHooks"; export const App: FunctionComponent = () => { + const [viewerOptions, setViewerOptions] = useState(); const [viewerElement, setViewerElement] = useState(); const viewerDetails = useEventfulState(() => viewerElement?.viewerDetails, viewerElement, "viewerready"); const viewer = useMemo(() => viewerDetails?.viewer, [viewerDetails]); @@ -18,9 +20,13 @@ export const App: FunctionComponent = () => { return ( <> - + -
{viewerElement && viewerDetails && viewer && }
+
+ {viewerOptions && viewerElement && viewerDetails && viewer && ( + + )} +
Viewer Configurator needs a horizontal resolution of at least 900px
diff --git a/packages/tools/viewer-configurator/src/components/babylonViewer/viewer.tsx b/packages/tools/viewer-configurator/src/components/babylonViewer/viewer.tsx index aa6cbcff6df..b5cde192783 100644 --- a/packages/tools/viewer-configurator/src/components/babylonViewer/viewer.tsx +++ b/packages/tools/viewer-configurator/src/components/babylonViewer/viewer.tsx @@ -1,6 +1,9 @@ -import "./viewer.scss"; -import type { FunctionComponent } from "react"; +import type { ViewerOptions } from "viewer/viewer"; import type { ViewerElement } from "viewer/viewerElement"; +import "./viewer.scss"; +import { useEffect, type FunctionComponent } from "react"; +import { Logger } from "core/Misc/logger"; +import { ConfigureCustomViewerElement } from "viewer/viewerElement"; import "viewer"; interface HTML3DElementAttributes extends React.DetailedHTMLProps, HTMLElement> { @@ -16,11 +19,30 @@ declare global { // eslint-disable-next-line @typescript-eslint/naming-convention interface IntrinsicElements { // eslint-disable-next-line @typescript-eslint/naming-convention - "babylon-viewer": HTML3DElementAttributes; + "configured-babylon-viewer": HTML3DElementAttributes; } } } -export const Viewer: FunctionComponent<{ onViewerCreated: (viewerElement: ViewerElement) => void }> = (props) => { - return ; +export const Viewer: FunctionComponent<{ onViewerCreated: (element: ViewerElement) => void; onOptionsLoaded: (options: ViewerOptions) => void }> = (props) => { + useEffect(() => { + (async () => { + let options: ViewerOptions = {}; + if (window.location.hash) { + try { + const id = window.location.hash.substring(1).replace("#", "/"); + const response = await fetch(`https://snippet.babylonjs.com/${id}`); + options = JSON.parse((await response.json()).jsonPayload); + } catch (error: unknown) { + Logger.Error(`Failed to load snippet from URL: ${error}`); + } + } + + ConfigureCustomViewerElement("configured-babylon-viewer", options); + + props.onOptionsLoaded(options); + })(); + }, []); + + return ; }; diff --git a/packages/tools/viewer-configurator/src/components/configurator/configurator.scss b/packages/tools/viewer-configurator/src/components/configurator/configurator.scss index 09c708dca37..6a1c75ce3ce 100644 --- a/packages/tools/viewer-configurator/src/components/configurator/configurator.scss +++ b/packages/tools/viewer-configurator/src/components/configurator/configurator.scss @@ -194,6 +194,13 @@ } } + .messageLine { + text-align: center; + font-size: 12px; + font-style: italic; + opacity: 0.6; + } + .textInputLine { height: 100%; diff --git a/packages/tools/viewer-configurator/src/components/configurator/configurator.tsx b/packages/tools/viewer-configurator/src/components/configurator/configurator.tsx index 2aa1657542c..42c0161490b 100644 --- a/packages/tools/viewer-configurator/src/components/configurator/configurator.tsx +++ b/packages/tools/viewer-configurator/src/components/configurator/configurator.tsx @@ -2,14 +2,14 @@ import "./configurator.scss"; // eslint-disable-next-line import/no-internal-modules import type { IDisposable, IInspectableOptions, Nullable, Observable } from "core/index"; // eslint-disable-next-line import/no-internal-modules -import type { HotSpot, PostProcessing, ToneMapping, Viewer, ViewerDetails, ViewerElement } from "viewer/index"; +import type { HotSpot, PostProcessing, ToneMapping, Viewer, ViewerDetails, ViewerElement, ViewerOptions } from "viewer/index"; import type { DragEndEvent } from "@dnd-kit/core"; import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons"; -import { faBullseye, faCamera, faCheck, faCopy, faGripVertical, faRotateLeft, faSquarePlus, faTrashCan, faUpload } from "@fortawesome/free-solid-svg-icons"; +import { faBullseye, faCamera, faCheck, faCopy, faGripVertical, faRotateLeft, faSave, faSquarePlus, faTrashCan, faUpload } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useCallback, useEffect, useMemo, useRef, useState, type FunctionComponent } from "react"; @@ -18,15 +18,19 @@ import { ButtonLineComponent } from "shared-ui-components/lines/buttonLineCompon import { CheckBoxLineComponent } from "shared-ui-components/lines/checkBoxLineComponent"; import { Color4LineComponent } from "shared-ui-components/lines/color4LineComponent"; import { LineContainerComponent } from "shared-ui-components/lines/lineContainerComponent"; +import { MessageLineComponent } from "shared-ui-components/lines/messageLineComponent"; import { OptionsLine } from "shared-ui-components/lines/optionsLineComponent"; import { SliderLineComponent } from "shared-ui-components/lines/sliderLineComponent"; import { TextInputLineComponent } from "shared-ui-components/lines/textInputLineComponent"; import { LockObject } from "shared-ui-components/tabs/propertyGrids/lockObject"; +import { DefaultViewerOptions } from "viewer/viewer"; import { HTML3DAnnotationElement } from "viewer/viewerAnnotationElement"; import { PointerEventTypes } from "core/Events/pointerEvents"; +import { Color4 } from "core/Maths/math.color"; import { Epsilon } from "core/Maths/math.constants"; +import { Vector3 } from "core/Maths/math.vector"; import { WithinEpsilon } from "core/Maths/math.scalar.functions"; import { CreateHotSpotQueryForPickingInfo } from "core/Meshes/abstractMesh.hotSpot"; @@ -40,6 +44,18 @@ const defaultModelUrl = "https://assets.babylonjs.com/meshes/Demos/optimized/acr type HotSpotInfo = { name: string; id: string; data: HotSpot }; +let currentHotSpotId = 0; +function createHotSpotId() { + return (currentHotSpotId++).toString(); +} + +type OutputFormat = "html" | "json"; + +const outputOptions = [ + { label: "HTML", value: "html" }, + { label: "JSON", value: "json" }, +] as const satisfies IInspectableOptions[] & { label: string; value: OutputFormat }[]; + const toneMappingOptions = [ { label: "Standard", value: "standard" }, { label: "None", value: "none" }, @@ -53,19 +69,21 @@ const hotSpotsDndModifers = [restrictToVerticalAxis, restrictToParentElement]; function useConfiguration( defaultState: T, + initialConfiguredState: T, get: () => T, set: ((data: T) => void) | undefined, - equals: (left: T, right: T) => boolean = (left, right) => left === right, + equals: (baseState: T, configuredState: T) => boolean = (baseState, configuredState) => baseState === configuredState, observables: Observable[] = [], dependencies?: unknown[] ) { const memoDefaultState = useMemo(() => defaultState, dependencies ?? []); + const memoInitialConfiguredState = useMemo(() => initialConfiguredState, dependencies ?? []); const memoSet = useCallback(set ?? (() => {}), dependencies ?? []); const memoGet = useCallback(get, dependencies ?? []); const memoEquals = useCallback(equals, []); - const [configuredState, setConfiguredState] = useState(memoDefaultState); const liveState = useObservableState(memoGet, ...observables); - const [isConfigured, setIsConfigured] = useState(false); + const [configuredState, setConfiguredState] = useState(initialConfiguredState); + const [isConfigured, setIsConfigured] = useState(!memoEquals(memoDefaultState, memoInitialConfiguredState)); useEffect(() => { memoSet?.(configuredState); @@ -283,8 +301,8 @@ const HotSpotEntry: FunctionComponent<{ ); }; -export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; viewerDetails: ViewerDetails; viewer: Viewer }> = (props) => { - const { viewerElement, viewerDetails, viewer } = props; +export const Configurator: FunctionComponent<{ viewerOptions: ViewerOptions; viewerElement: ViewerElement; viewerDetails: ViewerDetails; viewer: Viewer }> = (props) => { + const { viewerOptions, viewerElement, viewerDetails, viewer } = props; const model = useObservableState(() => viewerDetails.model, viewer.onModelChanged, viewer.onModelError); const lockObject = useMemo(() => new LockObject(), []); @@ -308,7 +326,8 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie }; }, [viewerElement]); - const [modelUrl, setModelUrl] = useState(defaultModelUrl); + const initialModelUrl = useMemo(() => viewerOptions.source ?? defaultModelUrl, [viewerOptions.source]); + const [modelUrl, setModelUrl] = useState(initialModelUrl); // Whenever the model changes, update the model URL. useEffect(() => { @@ -340,10 +359,35 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie }; }, [viewerElement]); - const lightingUrlConfig = useConfiguration("", () => viewerElement.environment.lighting ?? "", undefined, undefined, [viewer.onEnvironmentChanged], [viewerElement]); - const skyboxUrlConfig = useConfiguration("", () => viewerElement.environment.skybox ?? "", undefined, undefined, [viewer.onEnvironmentChanged], [viewerElement]); + const [outputFormat, setOutputFormat] = useState("html"); + const onOutputFormatChange = useCallback((value: string | number) => { + setOutputFormat(value as OutputFormat); + }, []); + // This is only needed because the select expects to "bind" to an object and a property. + const outputFormatWrapper = useMemo(() => { + return { outputFormat }; + }, [outputFormat]); + + const lightingUrlConfig = useConfiguration( + "", + viewerOptions.environmentLighting ?? "", + () => viewerElement.environment.lighting ?? "", + undefined, + undefined, + [viewer.onEnvironmentChanged], + [viewerElement] + ); + const skyboxUrlConfig = useConfiguration( + "", + viewerOptions.environmentSkybox === viewerOptions.environmentLighting ? "" : (viewerOptions.environmentSkybox ?? ""), + () => viewerElement.environment.skybox ?? "", + undefined, + undefined, + [viewer.onEnvironmentChanged], + [viewerElement] + ); - const [syncEnvironment, setSyncEnvironment] = useState(false); + const [syncEnvironment, setSyncEnvironment] = useState(!!viewerOptions.environmentLighting && viewerOptions.environmentLighting === viewerOptions.environmentSkybox); const [needsEnvironmentUpdate, setNeedsEnvironmentUpdate] = useState(false); const onEnvironmentUrlSubmit = useCallback(() => { @@ -358,7 +402,8 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie }, [syncEnvironment, lightingUrlConfig.configuredState, skyboxUrlConfig.configuredState]); const skyboxBlurConfig = useConfiguration( - viewer.environmentConfig.blur, + DefaultViewerOptions.environmentConfig.blur, + viewerOptions.environmentConfig?.blur ?? DefaultViewerOptions.environmentConfig.blur, () => viewer.environmentConfig.blur, (blur) => (viewer.environmentConfig = { blur }), undefined, @@ -367,7 +412,8 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie ); const environmentIntensityConfig = useConfiguration( - viewer.environmentConfig.intensity, + DefaultViewerOptions.environmentConfig.intensity, + viewerOptions.environmentConfig?.intensity ?? DefaultViewerOptions.environmentConfig.intensity, () => viewer.environmentConfig.intensity, (intensity) => (viewer.environmentConfig = { intensity }), undefined, @@ -376,7 +422,8 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie ); const environmentRotationConfig = useConfiguration( - viewer.environmentConfig.rotation, + DefaultViewerOptions.environmentConfig.rotation, + viewerOptions.environmentConfig?.rotation ?? DefaultViewerOptions.environmentConfig.rotation, () => viewer.environmentConfig.rotation, (rotation) => (viewer.environmentConfig = { rotation }), undefined, @@ -386,9 +433,10 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie const clearColorConfig = useConfiguration( viewerDetails.scene.clearColor, + new Color4(...(viewerOptions.clearColor ? viewerOptions.clearColor : DefaultViewerOptions.clearColor)), () => viewerDetails.scene.clearColor, (color) => (viewerDetails.scene.clearColor = color), - (left, right) => left.equals(right), + (baseState, configuredState) => baseState.equals(configuredState), [viewerDetails.scene.onClearColorChangedObservable], [viewerDetails.scene] ); @@ -398,7 +446,18 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie }, [clearColorConfig.configuredState]); const cameraConfig = useConfiguration( - undefined, + { + alpha: NaN, + beta: NaN, + radius: NaN, + target: new Vector3(NaN, NaN, NaN), + }, + { + alpha: viewerOptions.cameraOrbit?.[0] ?? NaN, + beta: viewerOptions.cameraOrbit?.[1] ?? NaN, + radius: viewerOptions.cameraOrbit?.[2] ?? NaN, + target: new Vector3(viewerOptions.cameraTarget?.[0] ?? NaN, viewerOptions.cameraTarget?.[1] ?? NaN, viewerOptions.cameraTarget?.[2] ?? NaN), + }, () => { return { alpha: viewerDetails.camera.alpha, @@ -408,22 +467,35 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie }; }, (cameraState) => { - if (cameraState) { - viewerDetails.camera.interpolateTo(cameraState.alpha, cameraState.beta, cameraState.radius, cameraState.target); + if (!cameraState || (isNaN(cameraState.alpha) && isNaN(cameraState.beta) && isNaN(cameraState.radius))) { + viewerElement.removeAttribute("camera-orbit"); + delete viewerOptions.cameraOrbit; } else { - viewer.resetCamera(); + viewerElement.setAttribute("camera-orbit", `${cameraState.alpha} ${cameraState.beta} ${cameraState.radius}`); + } + + if (!cameraState || (isNaN(cameraState.target.x) && isNaN(cameraState.target.y) && isNaN(cameraState.target.z))) { + viewerElement.removeAttribute("camera-target"); + delete viewerOptions.cameraTarget; + } else { + viewerElement.setAttribute("camera-target", `${cameraState.target.x} ${cameraState.target.y} ${cameraState.target.z}`); } }, - (left, right) => { + (baseState, configuredState) => { + const valueEquals = (baseState: number, configuredState: number) => { + return isNaN(configuredState) || baseState === configuredState || WithinEpsilon(baseState, configuredState, Epsilon); + }; + return ( - left == right || - (!!left && - !!right && - // TODO: Figure out why the final alpha/beta are as far from the goal value as they are. - WithinEpsilon(left.alpha, right.alpha, Epsilon * 10) && - WithinEpsilon(left.beta, right.beta, Epsilon * 10) && - WithinEpsilon(left.radius, right.radius, Epsilon) && - left.target.equalsWithEpsilon(right.target, Epsilon)) + baseState == configuredState || + (!!baseState && + !!configuredState && + valueEquals(baseState.alpha, configuredState.alpha) && + valueEquals(baseState.beta, configuredState.beta) && + valueEquals(baseState.radius, configuredState.radius) && + valueEquals(baseState.target.x, configuredState.target.x) && + valueEquals(baseState.target.y, configuredState.target.y) && + valueEquals(baseState.target.z, configuredState.target.z)) ); }, [viewerDetails.camera.onViewMatrixChangedObservable], @@ -431,7 +503,8 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie ); const toneMappingConfig = useConfiguration( - viewer.postProcessing.toneMapping, + DefaultViewerOptions.postProcessing.toneMapping, + viewerOptions.postProcessing?.toneMapping ?? DefaultViewerOptions.postProcessing.toneMapping, () => viewer.postProcessing.toneMapping, (toneMapping) => (viewer.postProcessing = { toneMapping }), undefined, @@ -444,7 +517,8 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie }, [toneMappingConfig.configuredState]); const contrastConfig = useConfiguration( - viewer.postProcessing.contrast, + DefaultViewerOptions.postProcessing.contrast, + viewerOptions.postProcessing?.contrast ?? DefaultViewerOptions.postProcessing.contrast, () => viewer.postProcessing.contrast, (contrast) => (viewer.postProcessing = { contrast }), undefined, @@ -453,7 +527,8 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie ); const exposureConfig = useConfiguration( - viewer.postProcessing.exposure, + DefaultViewerOptions.postProcessing.exposure, + viewerOptions.postProcessing?.exposure ?? DefaultViewerOptions.postProcessing.exposure, () => viewer.postProcessing.exposure, (exposure) => (viewer.postProcessing = { exposure }), undefined, @@ -462,8 +537,8 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie ); const autoOrbitConfig = useConfiguration( - // TODO: Viewer should have autoOrbit false by default at the Viewer layer. - false, + DefaultViewerOptions.cameraAutoOrbit.enabled, + viewerOptions.cameraAutoOrbit?.enabled ?? DefaultViewerOptions.cameraAutoOrbit.enabled, () => viewer.cameraAutoOrbit.enabled, (enabled) => (viewer.cameraAutoOrbit = { enabled }), undefined, @@ -472,7 +547,8 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie ); const autoOrbitSpeedConfig = useConfiguration( - viewer.cameraAutoOrbit.speed, + DefaultViewerOptions.cameraAutoOrbit.speed, + viewerOptions.cameraAutoOrbit?.speed ?? DefaultViewerOptions.cameraAutoOrbit.speed, () => viewer.cameraAutoOrbit.speed, (speed) => (viewer.cameraAutoOrbit = { speed }), undefined, @@ -481,7 +557,8 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie ); const autoOrbitDelayConfig = useConfiguration( - viewer.cameraAutoOrbit.delay, + DefaultViewerOptions.cameraAutoOrbit.delay, + viewerOptions.cameraAutoOrbit?.delay ?? DefaultViewerOptions.cameraAutoOrbit.delay, () => viewer.cameraAutoOrbit.delay, (delay) => (viewer.cameraAutoOrbit = { delay }), undefined, @@ -490,7 +567,14 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie ); const animationStateConfig = useConfiguration( - undefined, + { + animationSpeed: DefaultViewerOptions.animationSpeed, + selectedAnimation: 0, + }, + { + animationSpeed: viewerOptions.animationSpeed ?? DefaultViewerOptions.animationSpeed, + selectedAnimation: viewerOptions.selectedAnimation ?? 0, + }, () => { return { animationSpeed: viewer.animationSpeed, @@ -506,18 +590,29 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie viewer.selectedAnimation = 0; } }, - (left, right) => { - return left == right || (!!left && !!right && WithinEpsilon(left.animationSpeed, right.animationSpeed, Epsilon) && left.selectedAnimation === right.selectedAnimation); + (baseState, configuredState) => { + return ( + baseState == configuredState || + (!!baseState && + !!configuredState && + WithinEpsilon(baseState.animationSpeed, configuredState.animationSpeed, Epsilon) && + baseState.selectedAnimation === configuredState.selectedAnimation) + ); }, [viewer.onAnimationSpeedChanged, viewer.onSelectedAnimationChanged], [viewer] ); const animationAutoPlayConfig = useConfiguration( - false, + DefaultViewerOptions.animationAutoPlay, + viewerOptions.animationAutoPlay ?? DefaultViewerOptions.animationAutoPlay, () => viewerElement.animationAutoPlay, (autoPlay) => { - viewerElement.animationAutoPlay = autoPlay; + if (autoPlay) { + viewerElement.setAttribute("animation-auto-play", ""); + } else { + viewerElement.removeAttribute("animation-auto-play"); + } autoPlay ? viewer.playAnimation() : viewer.pauseAnimation(); }, undefined, @@ -527,6 +622,7 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie const selectedMaterialVariantConfig = useConfiguration( "", + viewerOptions.selectedMaterialVariant ?? "", () => viewer.selectedMaterialVariant, (materialVariant) => { if (materialVariant) { @@ -540,11 +636,13 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie [viewer] ); - const [hotspots, setHotspots] = useState([]); + const [hotspots, setHotspots] = useState(Object.entries(viewerOptions.hotSpots ?? {}).map(([name, data]) => ({ name, id: createHotSpotId(), data }))); useEffect(() => { - setHotspots([]); - }, [model]); + if (modelUrl !== initialModelUrl) { + setHotspots([]); + } + }, [modelUrl, initialModelUrl]); useEffect(() => { viewerElement.hotSpots = hotspots.reduce>((hotspots, { name, data }) => { @@ -564,6 +662,44 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie const hasAnimations = useMemo(() => viewer && viewer.animations.length > 0, [viewer.animations]); const hasMaterialVariants = useMemo(() => viewer && viewer.materialVariants.length > 0, [viewer.materialVariants]); + const hotSpotsSnippet = useMemo(() => { + if (hotspots.length > 0) { + let hotSpotsJSON = `{\n`; + hotSpotsJSON += hotspots + .map((hotspot) => { + let hotspotJson = ` "${hotspot.name}": {\n`; + const hotspotProperties: string[] = []; + if (hotspot.data.type === "surface") { + hotspotProperties.push( + ` "type": "surface"`, + ` "meshIndex": ${hotspot.data.meshIndex}`, + ` "pointIndex": [${hotspot.data.pointIndex.join(", ")}]`, + ` "barycentric": [${hotspot.data.barycentric.map((value) => value.toFixed(3)).join(", ")}]` + ); + } else { + hotspotProperties.push( + ` "type": "world"`, + ` "position": [${hotspot.data.position.map((value) => value.toFixed(3)).join(", ")}]`, + ` "normal": [${hotspot.data.normal.map((value) => value.toFixed(3)).join(", ")}]` + ); + } + if (hotspot.data.cameraOrbit) { + const [alpha, beta, radius] = hotspot.data.cameraOrbit; + hotspotProperties.push(` "cameraOrbit": [${alpha.toFixed(3)}, ${beta.toFixed(3)}, ${radius.toFixed(3)}]`); + } + hotspotJson += hotspotProperties.join(",\n"); + hotspotJson += `\n }`; + return hotspotJson; + }) + .join(",\n"); + hotSpotsJSON += `\n }`; + + return hotSpotsJSON; + } else { + return null; + } + }, [hotspots]); + // This is all the configured attributes, as an array of strings. const attributes = useMemo(() => { const attributes: string[] = [`source="${modelUrl || "[model url]"}"`]; @@ -586,17 +722,17 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie if (skyboxBlurConfig.canReset) { attributes.push(`skybox-blur="${skyboxBlurConfig.configuredState}"`); } - if (environmentIntensityConfig.canReset) { - attributes.push(`skybox-intensity="${environmentIntensityConfig.configuredState}"`); - } - if (environmentRotationConfig.canReset) { - attributes.push(`skybox-rotation="${environmentRotationConfig.configuredState}"`); - } } else { if (clearColorConfig.canReset) { attributes.push(`clear-color="${clearColorConfig.configuredState.toHexString()}"`); } } + if (environmentIntensityConfig.canReset) { + attributes.push(`skybox-intensity="${environmentIntensityConfig.configuredState}"`); + } + if (environmentRotationConfig.canReset) { + attributes.push(`skybox-rotation="${environmentRotationConfig.configuredState}"`); + } if (toneMappingConfig.canReset) { attributes.push(`tone-mapping="${toneMappingConfig.configuredState}"`); @@ -610,7 +746,7 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie attributes.push(`exposure="${exposureConfig.configuredState.toFixed(1)}"`); } - if (cameraConfig.configuredState) { + if (cameraConfig.canReset) { const { alpha, beta, radius, target } = cameraConfig.configuredState; attributes.push(`camera-orbit="${alpha.toFixed(3)} ${beta.toFixed(3)} ${radius.toFixed(3)}"`); attributes.push(`camera-target="${target.x.toFixed(3)} ${target.y.toFixed(3)} ${target.z.toFixed(3)}"`); @@ -643,41 +779,8 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie attributes.push(`material-variant="${selectedMaterialVariantConfig.configuredState}"`); } - if (hotspots.length > 0) { - let hotspotsAttribute = `hotspots='{\n`; - hotspotsAttribute += hotspots - .map((hotspot) => { - let hotspotJson = ` "${hotspot.name}": {\n`; - const hotspotAttributes: string[] = []; - if (hotspot.data.type === "surface") { - hotspotAttributes.push( - ...[ - ` "type": "surface"`, - ` "meshIndex": ${hotspot.data.meshIndex}`, - ` "pointIndex": [${hotspot.data.pointIndex.join(", ")}]`, - ` "barycentric": [${hotspot.data.barycentric.map((value) => value.toFixed(3)).join(", ")}]`, - ] - ); - } else { - hotspotAttributes.push( - ...[ - ` "type": "world"`, - ` "position": [${hotspot.data.position.map((value) => value.toFixed(3)).join(", ")}]`, - ` "normal": [${hotspot.data.normal.map((value) => value.toFixed(3)).join(", ")}]`, - ] - ); - } - if (hotspot.data.cameraOrbit) { - const [alpha, beta, radius] = hotspot.data.cameraOrbit; - hotspotAttributes.push(` "cameraOrbit": [${alpha.toFixed(3)}, ${beta.toFixed(3)}, ${radius.toFixed(3)}]`); - } - hotspotJson += hotspotAttributes.join(",\n"); - hotspotJson += `\n }`; - return hotspotJson; - }) - .join(",\n"); - hotspotsAttribute += `\n }'`; - attributes.push(hotspotsAttribute); + if (hotSpotsSnippet) { + attributes.push(`hotspots='${hotSpotsSnippet}'`); } return attributes; @@ -703,7 +806,7 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie animationAutoPlayConfig.configuredState, hasMaterialVariants, selectedMaterialVariantConfig.configuredState, - hotspots, + hotSpotsSnippet, ]); // This is all the child annotation elements, as a single string. @@ -718,10 +821,104 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie // This is the full html snippet (attributes and child elements). const htmlSnippet = useMemo(() => { const formattedAttributes = attributes.map((attribute) => `\n ${attribute}`).join(""); - const snippet = `${children}\n`; - return snippet; + return `${children}\n`; }, [attributes, children]); + // This is the full json snippet + const jsonSnippet = useMemo(() => { + const properties: string[] = [`"source": "${modelUrl || "[model url]"}"`]; + + if (lightingUrlConfig.canReset) { + properties.push(`"environmentLighting": "${lightingUrlConfig.configuredState}"`); + } + + if (syncEnvironment && lightingUrlConfig.canReset) { + properties.push(`"environmentSkybox": "${lightingUrlConfig.configuredState}"`); + } else if (skyboxUrlConfig.canReset) { + properties.push(`"environmentSkybox": "${skyboxUrlConfig.configuredState}"`); + } + + const environmentConfigProperties: string[] = []; + if (hasSkybox) { + if (skyboxBlurConfig.canReset) { + environmentConfigProperties.push(`"blur": ${skyboxBlurConfig.configuredState}`); + } + } else if (clearColorConfig.canReset) { + properties.push(`"clearColor": "${clearColorConfig.configuredState.toHexString()}"`); + } + if (environmentIntensityConfig.canReset) { + environmentConfigProperties.push(`"intensity": ${environmentIntensityConfig.configuredState}`); + } + if (environmentRotationConfig.canReset) { + environmentConfigProperties.push(`"rotation": ${environmentRotationConfig.configuredState}`); + } + if (environmentConfigProperties.length > 0) { + properties.push(`"environmentConfig": {${environmentConfigProperties.map((property) => `\n ${property}`).join(",")}\n }`); + } + + if (cameraConfig.canReset) { + const { + alpha, + beta, + radius, + target: { x: targetX, y: targetY, z: targetZ }, + } = cameraConfig.configuredState; + properties.push(`"cameraOrbit": [${alpha.toFixed(3)}, ${beta.toFixed(3)}, ${radius.toFixed(3)}]`); + properties.push(`"cameraTarget": [${targetX.toFixed(3)}, ${targetY.toFixed(3)}, ${targetZ.toFixed(3)}]`); + } + + const autoOrbitProperties: string[] = []; + if (autoOrbitConfig.canReset) { + autoOrbitProperties.push(`"enabled": ${autoOrbitConfig.configuredState}`); + } + if (autoOrbitSpeedConfig.canReset) { + autoOrbitProperties.push(`"speed": ${autoOrbitSpeedConfig.configuredState}`); + } + if (autoOrbitDelayConfig.canReset) { + autoOrbitProperties.push(`"delay": ${autoOrbitDelayConfig.configuredState}`); + } + if (autoOrbitProperties.length > 0) { + properties.push(`"cameraAutoOrbit": {${autoOrbitProperties.map((property) => `\n ${property}`).join(",")}\n }`); + } + + if (animationStateConfig.canReset) { + properties.push(`"animationSpeed": ${animationStateConfig.configuredState.animationSpeed}`); + properties.push(`"selectedAnimation": ${animationStateConfig.configuredState.selectedAnimation}`); + } + + if (animationAutoPlayConfig.canReset) { + properties.push(`"animationAutoPlay": ${animationAutoPlayConfig.configuredState}`); + } + + if (selectedMaterialVariantConfig.canReset) { + properties.push(`"selectedMaterialVariant": "${selectedMaterialVariantConfig.configuredState}"`); + } + + if (hotSpotsSnippet) { + properties.push(`"hotSpots": ${hotSpotsSnippet}`); + } + + return `{${properties.map((property) => `\n ${property}`).join(",")}\n}`; + }, [ + modelUrl, + syncEnvironment, + lightingUrlConfig.configuredState, + skyboxUrlConfig.configuredState, + hasSkybox, + environmentIntensityConfig.configuredState, + environmentRotationConfig.configuredState, + skyboxBlurConfig.configuredState, + clearColorConfig.configuredState, + cameraConfig.configuredState, + autoOrbitConfig.configuredState, + autoOrbitSpeedConfig.configuredState, + autoOrbitDelayConfig.configuredState, + animationStateConfig.configuredState, + animationAutoPlayConfig.configuredState, + selectedMaterialVariantConfig.configuredState, + hotSpotsSnippet, + ]); + const isModelUrlValid = useMemo(() => { return URL.canParse(modelUrl); }, [modelUrl]); @@ -864,7 +1061,7 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie ...hotspots, { name: `HotSpot ${hotspots.length + 1}`, - id: performance.now().toString(), + id: createHotSpotId(), data: { type: "surface", meshIndex: 0, pointIndex: [0, 0, 0], barycentric: [0, 0, 0] }, }, ]; @@ -901,6 +1098,42 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie navigator.clipboard.writeText(htmlSnippet); }, [htmlSnippet]); + const [canSaveSnippet, setCanSaveSnippet] = useState(true); + + const saveSnippet = useCallback(async () => { + if (canSaveSnippet) { + setCanSaveSnippet(false); + try { + let url = "https://snippet.babylonjs.com"; + if (window.location.hash) { + url = `${url}/${window.location.hash.substring(1)}`; + } + + const response = await fetch(url, { + method: "POST", + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + "Content-Type": "application/json", + }, + body: JSON.stringify({ + payload: jsonSnippet, + }), + }); + + const result = await response.json(); + let id = result.id; + if (result.version) { + id = `${id}#${result.version}`; + } + window.location.hash = id; + } catch (error: unknown) { + alert(`Error saving snippet: ${error}`); + } finally { + setCanSaveSnippet(true); + } + } + }, [canSaveSnippet, jsonSnippet]); + const canRevertAll = useMemo( () => cameraConfig.canRevert || animationStateConfig.canRevert || selectedMaterialVariantConfig.canRevert, [cameraConfig.canRevert, animationStateConfig.canRevert, selectedMaterialVariantConfig.canRevert] @@ -963,15 +1196,30 @@ export const Configurator: FunctionComponent<{ viewerElement: ViewerElement; vie
VIEWER CONFIGURATOR
- +
- +
+ +
+ +
+
diff --git a/packages/tools/viewer/src/viewer.ts b/packages/tools/viewer/src/viewer.ts index 60e495aeaf1..e61877cf923 100644 --- a/packages/tools/viewer/src/viewer.ts +++ b/packages/tools/viewer/src/viewer.ts @@ -422,7 +422,7 @@ export type ViewerOptions = Partial<{ * The default options for the Viewer. */ export const DefaultViewerOptions = { - clearColor: [0, 0, 0, 0] as const, + clearColor: [0, 0, 0, 0], autoSuspendRendering: true, environmentConfig: { intensity: 1,