diff --git a/rollup.config.mjs b/rollup.config.mjs index 2b4eceaf..908af753 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -44,7 +44,7 @@ const pipeline = input => { sourcemap: true }, plugins: [ - input === 'src/main.ts' && + input === 'src/bootstrap.ts' && copyAndWatch({ targets: [ { @@ -90,6 +90,6 @@ export default [ }) ] }, - pipeline('src/main.ts'), - pipeline('src/app.ts') + pipeline('src/bootstrap.ts'), + pipeline('src/main.ts') ]; diff --git a/src/app.ts b/src/app.ts deleted file mode 100644 index f8e096b0..00000000 --- a/src/app.ts +++ /dev/null @@ -1,86 +0,0 @@ -import {dracoInitialize} from 'playcanvas'; -import {Scene} from './scene'; -import {initMaterials} from './material'; -import {GlobalState} from './types'; -import {getSceneConfig} from './scene-config'; -import {Debug} from './debug'; - -declare global { - interface Window { - snap: any; - viewerBootstrap: Promise; - scene: Scene; - } -} - -const getURLArgs = () => { - // extract settings from command line in non-prod builds only - const config = {}; - - const apply = (key: string, value: string) => { - let obj: any = config; - key.split('.').forEach((k, i, a) => { - if (i === a.length - 1) { - obj[k] = value; - } else { - if (!obj.hasOwnProperty(k)) { - obj[k] = {}; - } - obj = obj[k]; - } - }); - }; - - const params = new URLSearchParams(window.location.search.slice(1)); - params.forEach((value: string, key: string) => { - if (key.startsWith('sc_pc_')) { - apply(key.slice(6), value); - } - }); - - return config; -}; - -window.viewerBootstrap.then(async (globalState: GlobalState) => { - // monkey-patch materials for premul alpha rendering - initMaterials(); - - // wait for async loads to complete - const [dracoJsURL, dracoWasmURL, envImageURL] = await Promise.all([ - globalState.dracoJs, - globalState.dracoWasm, - globalState.envImage - ]); - - // initialize draco - dracoInitialize({ - jsUrl: dracoJsURL, - wasmUrl: dracoWasmURL, - numWorkers: 2 - }); - - const overrides = [getURLArgs(), globalState.aresSdkConfig]; - - // resolve scene config - const sceneConfig = getSceneConfig( - globalState.aresSdkConfig.productVisualizationUrl, - globalState.gltfContents, - envImageURL, - overrides - ); - - // construct the manager - const scene = new Scene( - sceneConfig, - globalState.inputEventHandlers, - globalState.canvas, - globalState.gl, - globalState.modelLoadComplete, - ); - - // load async models - await scene.load(); - - window.scene = scene; - globalState.loadComplete(); -}); diff --git a/src/asset-loader.ts b/src/asset-loader.ts index f5c22f15..bd4d76e5 100644 --- a/src/asset-loader.ts +++ b/src/asset-loader.ts @@ -35,6 +35,7 @@ class AssetLoader { 'container', { url: loadRequest.url, + filename: loadRequest.filename, contents: loadRequest.contents }, null, diff --git a/src/bootstrap.ts b/src/bootstrap.ts new file mode 100644 index 00000000..ab3d57aa --- /dev/null +++ b/src/bootstrap.ts @@ -0,0 +1,202 @@ +import {GlobalState} from './types'; +import {Debug} from './debug'; +import {startSpinner, stopSpinner} from './spinner'; +import {CreateDropHandler} from './drop-handler'; + +// BOOTSTRAP + +declare global { + interface Window { + snap: any; + viewerBootstrap: Promise; + ready: Promise; + } + + interface Navigator { + xr: any; + } +} + +// create the target canvas and gl context +const createCanvas = () => { + const canvas: HTMLCanvasElement = document.createElement('canvas'); + canvas.setAttribute('id', 'canvas'); + document.getElementById('app').appendChild(canvas); + + // fit the window with it + const ratio = window.devicePixelRatio; + const width = document.body.clientWidth; + const height = document.body.clientHeight; + const w = Math.ceil(width * ratio); + const h = Math.ceil(height * ratio); + + canvas.width = w; + canvas.height = h; + canvas.style.width = '100%'; + canvas.style.height = '100%'; + + // create gl context + const deviceOptions = { + alpha: true, + antialias: false, + depth: false, + preserveDrawingBuffer: true, + powerPreference: 'high-performance', + // create xrCompatible context if xr is available since we're not + // sure whether xr mode will be requested or not at this point. + xrCompatible: !!navigator.xr + }; + + const names = ['webgl2', 'webgl', 'experimental-webgl']; + let gl: RenderingContext; + for (let i = 0; i < names.length; ++i) { + gl = canvas.getContext(names[i], deviceOptions); + if (gl) { + break; + } + } + + return { + canvas: canvas, + gl: gl + }; +}; + +// takes a relative URL and returns the absolute version +const url = (() => { + const scripts = Array.from(document.getElementsByTagName('script')).filter(s => s.src); + const urlBase = scripts[scripts.length - 1].src.split('/').slice(0, -1).join('/'); + + return (url: string) => { + return `${urlBase}/${url}`; + }; +})(); + +Debug.time('load'); + +startSpinner(); + +// prefetch static assets +const envImage = fetch(url('static/env/VertebraeHDRI_v1_512.png')) + .then(r => r.arrayBuffer()) + .then(arrayBuffer => URL.createObjectURL(new File([arrayBuffer], 'env.png', {type: 'image/png'}))); + +const dracoJs = fetch(url('static/lib/draco_decoder.js')) + .then(r => r.text()) + .then(text => URL.createObjectURL(new File([text], 'draco.js', {type: 'application/javascript'}))); + +const dracoWasm = fetch(url('static/lib/draco_decoder.wasm')) + .then(r => r.arrayBuffer()) + .then(arrayBuffer => URL.createObjectURL(new File([arrayBuffer], 'draco.wasm', {type: 'application/wasm'}))); + +// construct the canvas and gl context +const {canvas, gl} = createCanvas(); + +// application listens for this promise to resolve +let resolver: (globalState: GlobalState) => void; +window.viewerBootstrap = new Promise(resolve => { + resolver = resolve; +}); + +window.snap = { + ar: { + experience: { + onLoad: (modelUrl: string, modelFilename: string, config: any) => { + resolver({ + canvas: canvas, + gl: gl, + config: config, + modelUrl: modelUrl, + modelFilename: modelFilename, + envImage: envImage, + dracoJs: dracoJs, + dracoWasm: dracoWasm, + loadComplete: () => { + stopSpinner(); + Debug.timeEnd('load'); + }, + modelLoadComplete: (timing: number) => { + + }, + }); + }, + getEngagementContext: () => 'ARES_CONTEXT_VISUALIZATION' + } + } +}; + +const startBootstrap = () => { + // this block is removed from prod builds + let readyResolver: (loadTime: number) => void; + window.ready = new Promise(resolve => { + readyResolver = resolve; + }); + + // manually invoke the object viewer onLoad function using a mockup'd + // aresSdkConfig object + const start = (url: string, filename: string) => { + const config = { + // scene config overrides + camera: { + pixelScale: 1, + toneMapping: 'aces2' + }, + controls: { + autoRotate: true + }, + shadow: { + fade: true + }, + + onViewReady: () => { + readyResolver(0); + } + }; + + window.snap.ar.experience.onLoad(url, filename, config); + }; + + // handle load param and ready promise for visual testing harness + const url = new URL(window.location.href); + + // handle load model (used in visual testing and debugging) + const loadUrl = url.searchParams.get('load'); + if (loadUrl) { + const decodedUrl = decodeURIComponent(loadUrl); + start(decodedUrl, decodedUrl); + } else { + let loaded = false; + const load = (url: string, filename: string) => { + if (!loaded) { + loaded = true; + start(url, filename); + } + }; + + // add a 'choose file' button + const selector = document.createElement('input'); + selector.setAttribute('id', 'file-selector'); + selector.setAttribute('type', 'file'); + selector.setAttribute('accept', '.gltf,.glb,.ply'); + selector.onchange = () => { + const files = selector.files; + if (files.length > 0) { + document.body.removeChild(selector); + load(URL.createObjectURL(selector.files[0]), selector.files[0].name); + } + }; + document.body.appendChild(selector); + + // also support user dragging and dropping a local glb file onto the canvas + CreateDropHandler(canvas, urls => { + const modelExtensions = ['.glb', '.gltf', '.ply'] + const model = urls.find(url => modelExtensions.some(extension => url.filename.endsWith(extension))); + if (model) { + document.body.removeChild(selector); + load(model.url, model.filename); + } + }); + } +} + +startBootstrap(); diff --git a/src/camera.ts b/src/camera.ts index 3ccb425d..12b6d813 100644 --- a/src/camera.ts +++ b/src/camera.ts @@ -278,9 +278,6 @@ class Camera extends Element { this.autoRotateDelayValue = Math.max(0, this.autoRotateDelayValue - deltaTime); this.autoRotateTimer = 0; } else { - if (this.autoRotateTimer === 0 && deltaTime > 0) { - this.scene.inputEventHandlers.onCameraAutoRotate(); - } this.autoRotateTimer += deltaTime; const rotateSpeed = Math.min(1, Math.pow(this.autoRotateTimer * 0.5 - 1, 5) + 1); // soften the initial rotation speedup this.setAzimElev( diff --git a/src/controllers.ts b/src/controllers.ts index 154efd28..45f88499 100644 --- a/src/controllers.ts +++ b/src/controllers.ts @@ -134,17 +134,6 @@ class MouseController { case MOUSEBUTTON_LEFT: this.leftButton = false; this.camera.notify('mouseEnd'); - const eventTarget = event.event.target as Node, - snapLogo = document.querySelector('.snap-logo'), - privacyPolicyTooltip = document.querySelector('#privacy-text'); - if ( - !this.hasDragged(event) && - !snapLogo?.contains(eventTarget) && - !privacyPolicyTooltip?.contains(eventTarget) - ) { - this.camera.scene.inputEventHandlers.onModelClicked(); - } - break; case MOUSEBUTTON_MIDDLE: this.middleButton = false; @@ -157,7 +146,6 @@ class MouseController { } if (this.hasDragged(event)) { - this.camera.scene.inputEventHandlers.onModelDragged(); this.xMouse = event.x; this.yMouse = event.y; } @@ -186,7 +174,6 @@ class MouseController { this.camera.notify('mouseZoom'); event.event.preventDefault(); if (event.wheelDelta !== this.zoomedIn) { - this.camera.scene.inputEventHandlers.onModelZoomed(); this.zoomedIn = event.wheelDelta; } } @@ -256,10 +243,6 @@ class TouchController { this.camera.notify('touchStart'); this.xTouch = touches[0].x; this.yTouch = touches[0].y; - // check if user just ended a zoom manoeuvre - if (event.event.type === 'touchend' && this.enableZoom) { - this.camera.scene.inputEventHandlers.onModelZoomed(); - } } else if (touches.length === 2) { // If there are 2 touches on the screen, then set the pinch distance this.lastPinchDistance = this.getPinchDistance(touches[0], touches[1]); @@ -267,12 +250,6 @@ class TouchController { this.camera.notify('touchStart'); } else { this.camera.notify('touchEnd'); - if ( - (this.enableOrbit && Math.abs(this.lastTouchPoint.x - this.xTouch) >= 10) || - Math.abs(this.lastTouchPoint.y - this.yTouch) >= 10 - ) { - this.camera.scene.inputEventHandlers.onModelDragged(); - } } } diff --git a/src/index.html b/src/index.html index 9d61fa31..4af30f78 100644 --- a/src/index.html +++ b/src/index.html @@ -41,6 +41,7 @@
- + + diff --git a/src/main.ts b/src/main.ts index d70a8167..379fee8c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,223 +1,89 @@ +import {dracoInitialize} from 'playcanvas'; +import {Scene} from './scene'; +import {initMaterials} from './material'; import {GlobalState} from './types'; -import {Debug} from './debug'; -import {startSpinner, stopSpinner} from './spinner'; -import {CreateDropHandler} from './drop-handler'; - -// BOOTSTRAP +import {getSceneConfig} from './scene-config'; declare global { interface Window { snap: any; viewerBootstrap: Promise; - ready: Promise; - } - - interface Navigator { - xr: any; + scene: Scene; } } -// create the target canvas and gl context -const createCanvas = () => { - const canvas: HTMLCanvasElement = document.createElement('canvas'); - canvas.setAttribute('id', 'canvas'); - document.getElementById('app').appendChild(canvas); - - // fit the window with it - const ratio = window.devicePixelRatio; - const width = document.body.clientWidth; - const height = document.body.clientHeight; - const w = Math.ceil(width * ratio); - const h = Math.ceil(height * ratio); - - canvas.width = w; - canvas.height = h; - canvas.style.width = '100%'; - canvas.style.height = '100%'; - - // create gl context - const deviceOptions = { - alpha: true, - antialias: false, - depth: false, - preserveDrawingBuffer: true, - powerPreference: 'high-performance', - // create xrCompatible context if xr is available since we're not - // sure whether xr mode will be requested or not at this point. - xrCompatible: !!navigator.xr - }; - - const names = ['webgl2', 'webgl', 'experimental-webgl']; - let gl: RenderingContext; - for (let i = 0; i < names.length; ++i) { - gl = canvas.getContext(names[i], deviceOptions); - if (gl) { - break; - } - } - - return { - canvas: canvas, - gl: gl - }; -}; - -// takes a relative URL and returns the absolute version -const url = (() => { - const scripts = Array.from(document.getElementsByTagName('script')).filter(s => s.src); - const urlBase = scripts[scripts.length - 1].src.split('/').slice(0, -1).join('/'); - - return (url: string) => { - return `${urlBase}/${url}`; +const getURLArgs = () => { + // extract settings from command line in non-prod builds only + const config = {}; + + const apply = (key: string, value: string) => { + let obj: any = config; + key.split('.').forEach((k, i, a) => { + if (i === a.length - 1) { + obj[k] = value; + } else { + if (!obj.hasOwnProperty(k)) { + obj[k] = {}; + } + obj = obj[k]; + } + }); }; -})(); - -Debug.time('load'); - -startSpinner(); - -// load the main application -const app = document.createElement('script'); -app.src = url('app.js'); -app.type = 'module'; -document.body.appendChild(app); - -// prefetch static assets -const envImage = fetch(url('static/env/VertebraeHDRI_v1_512.png')) - .then(r => r.arrayBuffer()) - .then(arrayBuffer => URL.createObjectURL(new File([arrayBuffer], 'env.png', {type: 'image/png'}))); - -const dracoJs = fetch(url('static/lib/draco_decoder.js')) - .then(r => r.text()) - .then(text => URL.createObjectURL(new File([text], 'draco.js', {type: 'application/javascript'}))); - -const dracoWasm = fetch(url('static/lib/draco_decoder.wasm')) - .then(r => r.arrayBuffer()) - .then(arrayBuffer => URL.createObjectURL(new File([arrayBuffer], 'draco.wasm', {type: 'application/wasm'}))); - -// construct the canvas and gl context -const {canvas, gl} = createCanvas(); -// application listens for this promise to resolve -let resolver: (globalState: GlobalState) => void; -window.viewerBootstrap = new Promise(resolve => { - resolver = resolve; -}); + const params = new URLSearchParams(window.location.search.slice(1)); + params.forEach((value: string, key: string) => { + apply(key, value); + }); -window.snap = { - ar: { - experience: { - onLoad: (config: any) => { - resolver({ - aresSdkConfig: config, - canvas: canvas, - gl: gl, - gltfContents: fetch(config.productVisualizationUrl).then(r => r.arrayBuffer()), - envImage: envImage, - dracoJs: dracoJs, - dracoWasm: dracoWasm, - loadComplete: () => { - stopSpinner(); - Debug.timeEnd('load'); - }, - modelLoadComplete: (timing: number) => { - - }, - inputEventHandlers: { - onCameraAutoRotate: () => { - }, - onModelClicked: () => { - }, - onModelDragged: () => { - }, - onModelZoomed: () => { - } - } - }); - }, - getEngagementContext: () => 'ARES_CONTEXT_VISUALIZATION' - } - } + return config; }; -const main = () => { - // this block is removed from prod builds - let readyResolver: (loadTime: number) => void; - window.ready = new Promise(resolve => { - readyResolver = resolve; +window.viewerBootstrap.then(async (globalState: GlobalState) => { + // monkey-patch materials for premul alpha rendering + initMaterials(); + + // wait for async loads to complete + const [dracoJsURL, dracoWasmURL, envImageURL] = await Promise.all([ + globalState.dracoJs, + globalState.dracoWasm, + globalState.envImage + ]); + + // initialize draco + dracoInitialize({ + jsUrl: dracoJsURL, + wasmUrl: dracoWasmURL, + numWorkers: 2 }); - // manually invoke the object viewer onLoad function using a mockup'd - // aresSdkConfig object - const start = (url: string) => { - window.snap.ar.experience.onLoad({ - // scene config overrides - camera: { - pixelScale: 1, - toneMapping: 'aces2' - }, - controls: { - autoRotate: true - }, - shadow: { - fade: true + const overrides = [ + { + model: { + url: globalState.modelUrl, + filename: globalState.modelFilename }, - - productVisualizationUrl: url, - - onViewReady: () => { - readyResolver(0); + env: { + url: envImageURL } - }); - }; - - // handle load param and ready promise for visual testing harness - const url = new URL(window.location.href); - - // handle load model (used in visual testing and debugging) - const loadUrl = url.searchParams.get('load'); - if (loadUrl) { - const getModelUrl = (key: string) => { - const isHash = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/; - return isHash.test(key) - ? `https://api.vertebrae-axis.com/assets/models/${key}/objectViewerHash/model.gltf` - : key; - }; - - start(getModelUrl(decodeURIComponent(loadUrl))); - } else { - let loaded = false; - const load = (url: string) => { - if (!loaded) { - loaded = true; - start(url); - } - }; - - // add a 'choose file' button - const selector = document.createElement('input'); - selector.setAttribute('id', 'file-selector'); - selector.setAttribute('type', 'file'); - selector.setAttribute('accept', '.gltf,.glb'); - selector.onchange = () => { - const files = selector.files; - if (files.length > 0) { - document.body.removeChild(selector); - load(URL.createObjectURL(selector.files[0])); - } - }; - document.body.appendChild(selector); - - // also support user dragging and dropping a local glb file onto the canvas - CreateDropHandler(canvas, urls => { - const modelExtensions = ['.glb', '.gltf', '.ply'] - const model = urls.find(url => modelExtensions.some(extension => url.filename.endsWith(extension))); - if (model) { - document.body.removeChild(selector); - load(model.url); - } - }); - } -} - -main(); + }, + getURLArgs(), + globalState.config + ]; + + // resolve scene config + const sceneConfig = getSceneConfig(overrides); + + // construct the manager + const scene = new Scene( + sceneConfig, + globalState.canvas, + globalState.gl, + globalState.modelLoadComplete, + ); + + // load async models + await scene.load(); + + window.scene = scene; + globalState.loadComplete(); +}); diff --git a/src/scene-config.ts b/src/scene-config.ts index e2d58870..53958123 100644 --- a/src/scene-config.ts +++ b/src/scene-config.ts @@ -15,6 +15,7 @@ const hotSpots: HotSpot[] = []; const sceneConfig = { model: { url: '', + filename: '', contents: contentsPromise, position: {x: 0, y: 0, z: 0}, rotation: {x: 0, y: 0, z: 0}, @@ -145,12 +146,7 @@ class Params { } } -const getSceneConfig = ( - modelUrl: string, - gltfContents: Promise, - envImageUrl: string, - overrides: any[] -) => { +const getSceneConfig = (overrides: any[]) => { const params = new Params(overrides); // recurse the object and replace concrete leaf values with overrides @@ -175,13 +171,9 @@ const getSceneConfig = ( } }; - const result = sceneConfig; - sceneConfig.model.url = modelUrl; - sceneConfig.model.contents = gltfContents; - sceneConfig.env.url = envImageUrl; rec(sceneConfig, ''); - return result; + return sceneConfig; }; export {SceneConfig, getSceneConfig, XRModeConfig}; diff --git a/src/scene.ts b/src/scene.ts index 3f279a09..1b9b5fde 100644 --- a/src/scene.ts +++ b/src/scene.ts @@ -13,7 +13,6 @@ import {MiniStats} from 'playcanvas-extras'; import {PCApp} from './pc-app'; import {Element, ElementType, ElementTypeList} from './element'; import {SceneState} from './scene-state'; -import {Debug} from './debug'; import {SceneConfig, XRModeConfig} from './scene-config'; import {AssetLoader} from './asset-loader'; import {Model} from './model'; @@ -24,7 +23,6 @@ import {CustomShadow as Shadow} from './custom-shadow'; // import {VsmShadow as Shadow} from './vsm-shadow'; // import {BakedShadow as Shadow} from './baked-shadow'; import {HotSpots} from './hotspots'; -import {InputEventHandlers} from './types'; import {XRMode} from './xr-mode'; import { registerPlyParser } from '../submodules/model-viewer/src/splat/ply-parser'; @@ -33,7 +31,6 @@ const bound = new BoundingBox(); class Scene extends EventHandler { config: SceneConfig; - inputEventHandlers: InputEventHandlers; canvas: HTMLCanvasElement; modelLoadCallback: (timer: number) => void; app: PCApp; @@ -62,7 +59,6 @@ class Scene extends EventHandler { constructor( config: SceneConfig, - inputEventHandlers: InputEventHandlers, canvas: HTMLCanvasElement, gl: any, modelLoadCallback: (timer: number) => void @@ -70,7 +66,6 @@ class Scene extends EventHandler { super(); this.config = config; - this.inputEventHandlers = inputEventHandlers; this.canvas = canvas; this.modelLoadCallback = modelLoadCallback; @@ -197,19 +192,16 @@ class Scene extends EventHandler { async load() { const config = this.config; + const modelStartTime = Date.now(); + // load scene assets const promises: Promise[] = [ - config.model.contents.then((contents: ArrayBuffer) => { - const modelStartTime = Date.now(); - return this.assetLoader - .loadModel({ - url: config.model.url, - contents: contents - }) - .then((model: Model) => { - this.modelLoadCallback(Date.now() - modelStartTime); - return model; - }); + this.assetLoader.loadModel({ + url: config.model.url, + filename: config.model.filename + }).then((model: Model) => { + this.modelLoadCallback(Date.now() - modelStartTime); + return model; }) ]; diff --git a/src/types.ts b/src/types.ts index 0029196f..639e1051 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,21 +1,15 @@ -interface InputEventHandlers { - onCameraAutoRotate: () => void; - onModelClicked: () => void; - onModelDragged: () => void; - onModelZoomed: () => void; -} interface GlobalState { - aresSdkConfig: any; canvas: HTMLCanvasElement; gl: RenderingContext; - gltfContents: Promise; + config: any; + modelFilename: string; + modelUrl: string; envImage: Promise; dracoJs: Promise; dracoWasm: Promise; loadComplete: () => void; modelLoadComplete: (timing: number) => void; - inputEventHandlers: InputEventHandlers; } -export {InputEventHandlers, GlobalState}; +export {GlobalState}; diff --git a/tsconfig.json b/tsconfig.json index e755697e..a91e19ba 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,6 @@ "allowSyntheticDefaultImports": true, "sourceMap": true, "inlineSources": true, - "noEmit": true, "baseUrl": ".", "paths": { "pcui": ["node_modules/@playcanvas/pcui"],