From c32baab123bc676e801c313bae5bbf5b7c51fd26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl?= Date: Thu, 25 Aug 2022 17:10:13 +0200 Subject: [PATCH 1/8] chore(core): add .gitignore file --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76add87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file From c78bfbcb97dd101cd236ac934c8e84ca3cf7bdd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl?= Date: Thu, 25 Aug 2022 17:13:25 +0200 Subject: [PATCH 2/8] feat(controls): add custom controller constructor --- src/three-render-objects.js | 519 ++++++++++++++++++++++-------------- 1 file changed, 317 insertions(+), 202 deletions(-) diff --git a/src/three-render-objects.js b/src/three-render-objects.js index 72c0e5a..3f1f5b1 100644 --- a/src/three-render-objects.js +++ b/src/three-render-objects.js @@ -18,51 +18,61 @@ import { MOUSE, Quaternion, Spherical, - Clock -} from 'three'; + Clock, +} from "three"; const three = window.THREE ? window.THREE // Prefer consumption from global THREE, if exists : { - WebGLRenderer, - Scene, - PerspectiveCamera, - Raycaster, - TextureLoader, - Vector2, - Vector3, - Box3, - Color, - Mesh, - SphereGeometry, - MeshBasicMaterial, - BackSide, - - EventDispatcher, - MOUSE, - Quaternion, - Spherical, - Clock -}; - -import { TrackballControls as ThreeTrackballControls } from 'three/examples/jsm/controls/TrackballControls.js'; -import { OrbitControls as ThreeOrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; -import { FlyControls as ThreeFlyControls } from 'three/examples/jsm/controls/FlyControls.js'; - -import { EffectComposer as ThreeEffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'; -import { RenderPass as ThreeRenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'; - -import { parseToRgb, opacify } from 'polished'; -import TWEEN from '@tweenjs/tween.js'; - -import accessorFn from 'accessor-fn'; -import Kapsule from 'kapsule'; + WebGLRenderer, + Scene, + PerspectiveCamera, + Raycaster, + TextureLoader, + Vector2, + Vector3, + Box3, + Color, + Mesh, + SphereGeometry, + MeshBasicMaterial, + BackSide, + + EventDispatcher, + MOUSE, + Quaternion, + Spherical, + Clock, + }; + +import { TrackballControls as ThreeTrackballControls } from "three/examples/jsm/controls/TrackballControls.js"; +import { OrbitControls as ThreeOrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { FlyControls as ThreeFlyControls } from "three/examples/jsm/controls/FlyControls.js"; + +import { EffectComposer as ThreeEffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js"; +import { RenderPass as ThreeRenderPass } from "three/examples/jsm/postprocessing/RenderPass.js"; + +import { parseToRgb, opacify } from "polished"; +import TWEEN from "@tweenjs/tween.js"; + +import accessorFn from "accessor-fn"; +import Kapsule from "kapsule"; export default Kapsule({ props: { - width: { default: window.innerWidth, onChange(width, state, prevWidth) { isNaN(width) && (state.width = prevWidth) } }, - height: { default: window.innerHeight, onChange(height, state, prevHeight) { isNaN(height) && (state.height = prevHeight) } }, - backgroundColor: { default: '#000011' }, + width: { + default: window.innerWidth, + onChange(width, state, prevWidth) { + isNaN(width) && (state.width = prevWidth); + }, + }, + height: { + default: window.innerHeight, + onChange(height, state, prevHeight) { + isNaN(height) && (state.height = prevHeight); + }, + }, + backgroundColor: { default: "#000011" }, backgroundImageUrl: {}, onBackgroundImageLoaded: {}, showNavInfo: { default: true }, @@ -73,9 +83,9 @@ export default Kapsule({ onChange(_, state) { // Reset hover state state.hoverObj = null; - if (state.toolTipElem) state.toolTipElem.innerHTML = ''; + if (state.toolTipElem) state.toolTipElem.innerHTML = ""; }, - triggerUpdate: false + triggerUpdate: false, }, lineHoverPrecision: { default: 1, triggerUpdate: false }, hoverOrderComparator: { default: () => -1, triggerUpdate: false }, // keep existing order by default @@ -85,11 +95,11 @@ export default Kapsule({ clickAfterDrag: { default: false, triggerUpdate: false }, onHover: { default: () => {}, triggerUpdate: false }, onClick: { default: () => {}, triggerUpdate: false }, - onRightClick: { triggerUpdate: false } + onRightClick: { triggerUpdate: false }, }, methods: { - tick: function(state) { + tick: function (state) { if (state.initialised) { state.controls.update && state.controls.update(state.clock.getDelta()); // timedelta is required for fly controls @@ -97,14 +107,19 @@ export default Kapsule({ ? state.postProcessingComposer.render() // if using postprocessing, switch the output to it : state.renderer.render(state.scene, state.camera); - state.extraRenderers.forEach(r => r.render(state.scene, state.camera)); + state.extraRenderers.forEach((r) => + r.render(state.scene, state.camera) + ); if (state.enablePointerInteraction) { // Update tooltip and trigger onHover events let topObject = null; if (state.hoverDuringDrag || !state.isPointerDragging) { - const intersects = this.intersectingObjects(state.pointerPos.x, state.pointerPos.y) - .filter(d => state.hoverFilter(d.object)) + const intersects = this.intersectingObjects( + state.pointerPos.x, + state.pointerPos.y + ) + .filter((d) => state.hoverFilter(d.object)) .sort((a, b) => state.hoverOrderComparator(a.object, b.object)); const topIntersect = intersects.length ? intersects[0] : null; @@ -115,7 +130,9 @@ export default Kapsule({ if (topObject !== state.hoverObj) { state.onHover(topObject, state.hoverObj); - state.toolTipElem.innerHTML = topObject ? accessorFn(state.tooltipContent)(topObject) || '' : ''; + state.toolTipElem.innerHTML = topObject + ? accessorFn(state.tooltipContent)(topObject) || "" + : ""; state.hoverObj = topObject; } } @@ -125,19 +142,20 @@ export default Kapsule({ return this; }, - getPointerPos: function(state) { + getPointerPos: function (state) { const { x, y } = state.pointerPos; return { x, y }; }, - cameraPosition: function(state, position, lookAt, transitionDuration) { + cameraPosition: function (state, position, lookAt, transitionDuration) { const camera = state.camera; // Setter if (position && state.initialised) { const finalPos = position; - const finalLookAt = lookAt || {x: 0, y: 0, z: 0}; + const finalLookAt = lookAt || { x: 0, y: 0, z: 0 }; - if (!transitionDuration) { // no animation + if (!transitionDuration) { + // no animation setCameraPos(finalPos); setLookAt(finalLookAt); } else { @@ -177,21 +195,31 @@ export default Kapsule({ const lookAtVect = new three.Vector3(lookAt.x, lookAt.y, lookAt.z); if (state.controls.target) { state.controls.target = lookAtVect; - } else { // Fly controls doesn't have target attribute + } else { + // Fly controls doesn't have target attribute camera.lookAt(lookAtVect); // note: lookAt may be overridden by other controls in some cases } } function getLookAt() { return Object.assign( - (new three.Vector3(0, 0, -1000)) + new three.Vector3(0, 0, -1000) .applyQuaternion(camera.quaternion) .add(camera.position) ); } }, - zoomToFit: function (state, transitionDuration = 0, padding = 10, ...bboxArgs) { - return this.fitToBbox(this.getBbox(...bboxArgs), transitionDuration, padding); + zoomToFit: function ( + state, + transitionDuration = 0, + padding = 10, + ...bboxArgs + ) { + return this.fitToBbox( + this.getBbox(...bboxArgs), + transitionDuration, + padding + ); }, fitToBbox: function (state, bbox, transitionDuration = 0, padding = 10) { // based on https://discourse.threejs.org/t/camera-zoom-to-fit-object/936/24 @@ -199,18 +227,23 @@ export default Kapsule({ if (bbox) { const center = new three.Vector3(0, 0, 0); // reset camera aim to center - const maxBoxSide = Math.max(...Object.entries(bbox) - .map(([coordType, coords]) => Math.max(...coords.map(c => Math.abs(center[coordType] - c)))) - ) * 2; + const maxBoxSide = + Math.max( + ...Object.entries(bbox).map(([coordType, coords]) => + Math.max(...coords.map((c) => Math.abs(center[coordType] - c))) + ) + ) * 2; // find distance that fits whole bbox within padded fov - const paddedFov = (1 - (padding * 2 / state.height)) * camera.fov; - const fitHeightDistance = maxBoxSide / Math.atan(paddedFov * Math.PI / 180); + const paddedFov = (1 - (padding * 2) / state.height) * camera.fov; + const fitHeightDistance = + maxBoxSide / Math.atan((paddedFov * Math.PI) / 180); const fitWidthDistance = fitHeightDistance / camera.aspect; const distance = Math.max(fitHeightDistance, fitWidthDistance); if (distance > 0) { - const newCameraPosition = center.clone() + const newCameraPosition = center + .clone() .sub(camera.position) .normalize() .multiplyScalar(-distance); @@ -222,27 +255,33 @@ export default Kapsule({ return this; }, getBbox: function (state, objFilter = () => true) { - const box = new three.Box3(new three.Vector3(0, 0, 0), new three.Vector3(0, 0, 0)); + const box = new three.Box3( + new three.Vector3(0, 0, 0), + new three.Vector3(0, 0, 0) + ); const objs = state.objects.filter(objFilter); if (!objs.length) return null; - objs.forEach(obj => box.expandByObject(obj)); + objs.forEach((obj) => box.expandByObject(obj)); // extract global x,y,z min/max - return Object.assign(...['x', 'y', 'z'].map(c => ({ - [c]: [box.min[c], box.max[c]] - }))); + return Object.assign( + ...["x", "y", "z"].map((c) => ({ + [c]: [box.min[c], box.max[c]], + })) + ); }, - getScreenCoords: function(state, x, y, z) { + getScreenCoords: function (state, x, y, z) { const vec = new three.Vector3(x, y, z); vec.project(this.camera()); // project to the camera plane - return { // align relative pos to canvas dimensions - x: (vec.x + 1) * state.width / 2, - y: -(vec.y - 1) * state.height / 2, + return { + // align relative pos to canvas dimensions + x: ((vec.x + 1) * state.width) / 2, + y: (-(vec.y - 1) * state.height) / 2, }; }, - getSceneCoords: function(state, screenX, screenY, distance = 0) { + getSceneCoords: function (state, screenX, screenY, distance = 0) { const relCoords = new three.Vector2( (screenX / state.width) * 2 - 1, -(screenY / state.height) * 2 + 1 @@ -252,7 +291,7 @@ export default Kapsule({ raycaster.setFromCamera(relCoords, state.camera); return Object.assign({}, raycaster.ray.at(distance, new three.Vector3())); }, - intersectingObjects: function(state, x, y) { + intersectingObjects: function (state, x, y) { const relCoords = new three.Vector2( (x / state.width) * 2 - 1, -(y / state.height) * 2 + 1 @@ -263,170 +302,226 @@ export default Kapsule({ raycaster.setFromCamera(relCoords, state.camera); return raycaster.intersectObjects(state.objects, true); }, - renderer: state => state.renderer, - scene: state => state.scene, - camera: state => state.camera, - postProcessingComposer: state => state.postProcessingComposer, - controls: state => state.controls, - tbControls: state => state.controls // to be deprecated + renderer: (state) => state.renderer, + scene: (state) => state.scene, + camera: (state) => state.camera, + postProcessingComposer: (state) => state.postProcessingComposer, + controls: (state) => state.controls, + tbControls: (state) => state.controls, // to be deprecated }, stateInit: () => ({ scene: new three.Scene(), camera: new three.PerspectiveCamera(), - clock: new three.Clock() + clock: new three.Clock(), }), - init(domNode, state, { - controlType = 'trackball', - rendererConfig = {}, - extraRenderers = [], - waitForLoadComplete = true - } = {}) { + init( + domNode, + state, + { + controlType = "trackball", + rendererConfig = {}, + extraRenderers = [], + waitForLoadComplete = true, + } = {} + ) { // Wipe DOM - domNode.innerHTML = ''; + domNode.innerHTML = ""; // Add relative container - domNode.appendChild(state.container = document.createElement('div')); - state.container.className = 'scene-container'; - state.container.style.position = 'relative'; + domNode.appendChild((state.container = document.createElement("div"))); + state.container.className = "scene-container"; + state.container.style.position = "relative"; + + // Get controller type + const controlTypeIsString = typeof controlType === typeof "string"; + const controlTypeIsFunction = typeof controlType === typeof (() => {}); // Add nav info section - state.container.appendChild(state.navInfo = document.createElement('div')); - state.navInfo.className = 'scene-nav-info'; - state.navInfo.textContent = { - orbit: 'Left-click: rotate, Mouse-wheel/middle-click: zoom, Right-click: pan', - trackball: 'Left-click: rotate, Mouse-wheel/middle-click: zoom, Right-click: pan', - fly: 'WASD: move, R|F: up | down, Q|E: roll, up|down: pitch, left|right: yaw' - }[controlType] || ''; - state.navInfo.style.display = state.showNavInfo ? null : 'none'; + state.container.appendChild( + (state.navInfo = document.createElement("div")) + ); + state.navInfo.className = "scene-nav-info"; + + if (controlTypeIsString) { + state.navInfo.textContent = + { + orbit: + "Left-click: rotate, Mouse-wheel/middle-click: zoom, Right-click: pan", + trackball: + "Left-click: rotate, Mouse-wheel/middle-click: zoom, Right-click: pan", + fly: "WASD: move, R|F: up | down, Q|E: roll, up|down: pitch, left|right: yaw", + }[controlType] || ""; + } + state.navInfo.style.display = state.showNavInfo ? null : "none"; // Setup tooltip - state.toolTipElem = document.createElement('div'); - state.toolTipElem.classList.add('scene-tooltip'); + state.toolTipElem = document.createElement("div"); + state.toolTipElem.classList.add("scene-tooltip"); state.container.appendChild(state.toolTipElem); // Capture pointer coords on move or touchstart state.pointerPos = new three.Vector2(); state.pointerPos.x = -2; // Initialize off canvas state.pointerPos.y = -2; - ['pointermove', 'pointerdown'].forEach(evType => - state.container.addEventListener(evType, ev => { - // track click state - evType === 'pointerdown' && (state.isPointerPressed = true); - - // detect point drag - !state.isPointerDragging && ev.type === 'pointermove' - && (ev.pressure > 0 || state.isPointerPressed) // ev.pressure always 0 on Safari, so we used the isPointerPressed tracker - && (ev.pointerType !== 'touch' || ev.movementX === undefined || [ev.movementX, ev.movementY].some(m => Math.abs(m) > 1)) // relax drag trigger sensitivity on touch events - && (state.isPointerDragging = true); - - if (state.enablePointerInteraction) { - // update the pointer pos - const offset = getOffset(state.container); - state.pointerPos.x = ev.pageX - offset.left; - state.pointerPos.y = ev.pageY - offset.top; - - // Move tooltip - state.toolTipElem.style.top = `${state.pointerPos.y}px`; - state.toolTipElem.style.left = `${state.pointerPos.x}px`; - // adjust horizontal position to not exceed canvas boundaries - state.toolTipElem.style.transform = `translate(-${state.pointerPos.x / state.width * 100}%, ${ - // flip to above if near bottom - state.height - state.pointerPos.y < 100 ? 'calc(-100% - 8px)' : '21px' - })`; - } + ["pointermove", "pointerdown"].forEach((evType) => + state.container.addEventListener( + evType, + (ev) => { + // track click state + evType === "pointerdown" && (state.isPointerPressed = true); + + // detect point drag + !state.isPointerDragging && + ev.type === "pointermove" && + (ev.pressure > 0 || state.isPointerPressed) && // ev.pressure always 0 on Safari, so we used the isPointerPressed tracker + (ev.pointerType !== "touch" || + ev.movementX === undefined || + [ev.movementX, ev.movementY].some((m) => Math.abs(m) > 1)) && // relax drag trigger sensitivity on touch events + (state.isPointerDragging = true); + + if (state.enablePointerInteraction) { + // update the pointer pos + const offset = getOffset(state.container); + state.pointerPos.x = ev.pageX - offset.left; + state.pointerPos.y = ev.pageY - offset.top; + + // Move tooltip + state.toolTipElem.style.top = `${state.pointerPos.y}px`; + state.toolTipElem.style.left = `${state.pointerPos.x}px`; + // adjust horizontal position to not exceed canvas boundaries + state.toolTipElem.style.transform = `translate(-${ + (state.pointerPos.x / state.width) * 100 + }%, ${ + // flip to above if near bottom + state.height - state.pointerPos.y < 100 + ? "calc(-100% - 8px)" + : "21px" + })`; + } - function getOffset(el) { - const rect = el.getBoundingClientRect(), - scrollLeft = window.pageXOffset || document.documentElement.scrollLeft, - scrollTop = window.pageYOffset || document.documentElement.scrollTop; - return { top: rect.top + scrollTop, left: rect.left + scrollLeft }; - } - }, { passive: true }) + function getOffset(el) { + const rect = el.getBoundingClientRect(), + scrollLeft = + window.pageXOffset || document.documentElement.scrollLeft, + scrollTop = + window.pageYOffset || document.documentElement.scrollTop; + return { top: rect.top + scrollTop, left: rect.left + scrollLeft }; + } + }, + { passive: true } + ) ); // Handle click events on objs - state.container.addEventListener('pointerup', ev => { - state.isPointerPressed = false; - if (state.isPointerDragging) { - state.isPointerDragging = false; - if (!state.clickAfterDrag) return; // don't trigger onClick after pointer drag (camera motion via controls) - } - - requestAnimationFrame(() => { // trigger click events asynchronously, to allow hoverObj to be set (on frame) - if (ev.button === 0) { // left-click - state.onClick(state.hoverObj || null, ev, state.intersectionPoint) // trigger background clicks with null + state.container.addEventListener( + "pointerup", + (ev) => { + state.isPointerPressed = false; + if (state.isPointerDragging) { + state.isPointerDragging = false; + if (!state.clickAfterDrag) return; // don't trigger onClick after pointer drag (camera motion via controls) } - if (ev.button === 2 && state.onRightClick) { // right-click - state.onRightClick(state.hoverObj || null, ev, state.intersectionPoint) - } - }); - }, { passive: true, capture: true }); // use capture phase to prevent propagation blocking from controls (specifically for fly) + requestAnimationFrame(() => { + // trigger click events asynchronously, to allow hoverObj to be set (on frame) + if (ev.button === 0) { + // left-click + state.onClick(state.hoverObj || null, ev, state.intersectionPoint); // trigger background clicks with null + } + + if (ev.button === 2 && state.onRightClick) { + // right-click + state.onRightClick( + state.hoverObj || null, + ev, + state.intersectionPoint + ); + } + }); + }, + { passive: true, capture: true } + ); // use capture phase to prevent propagation blocking from controls (specifically for fly) - state.container.addEventListener('contextmenu', ev => { + state.container.addEventListener("contextmenu", (ev) => { if (state.onRightClick) ev.preventDefault(); // prevent default contextmenu behavior and allow pointerup to fire instead }); // Setup renderer, camera and controls - state.renderer = new three.WebGLRenderer(Object.assign({ antialias: true, alpha: true }, rendererConfig)); + state.renderer = new three.WebGLRenderer( + Object.assign({ antialias: true, alpha: true }, rendererConfig) + ); state.renderer.setPixelRatio(Math.min(2, window.devicePixelRatio)); // clamp device pixel ratio state.container.appendChild(state.renderer.domElement); // Setup extra renderers state.extraRenderers = extraRenderers; - state.extraRenderers.forEach(r => { + state.extraRenderers.forEach((r) => { // overlay them on top of main renderer - r.domElement.style.position = 'absolute'; - r.domElement.style.top = '0px'; - r.domElement.style.pointerEvents = 'none'; + r.domElement.style.position = "absolute"; + r.domElement.style.top = "0px"; + r.domElement.style.pointerEvents = "none"; state.container.appendChild(r.domElement); }); // configure post-processing composer state.postProcessingComposer = new ThreeEffectComposer(state.renderer); - state.postProcessingComposer.addPass(new ThreeRenderPass(state.scene, state.camera)); // render scene as first pass + state.postProcessingComposer.addPass( + new ThreeRenderPass(state.scene, state.camera) + ); // render scene as first pass + + // create controls + if (controlTypeIsString) { + state.controls = new { + trackball: ThreeTrackballControls, + orbit: ThreeOrbitControls, + fly: ThreeFlyControls, + }[controlType](state.camera, state.renderer.domElement); + } else if (controlTypeIsFunction) { + state.controls = controlType(state.camera, state.renderer.domElement); + } else { + throw "Invalid controlType argument"; + } // configure controls - state.controls = new ({ - trackball: ThreeTrackballControls, - orbit: ThreeOrbitControls, - fly: ThreeFlyControls - }[controlType])(state.camera, state.renderer.domElement); - - if (controlType === 'fly') { - state.controls.movementSpeed = 300; - state.controls.rollSpeed = Math.PI / 6; - state.controls.dragToLook = true; - } + if (controlTypeIsString) { + if (controlType === "fly") { + state.controls.movementSpeed = 300; + state.controls.rollSpeed = Math.PI / 6; + state.controls.dragToLook = true; + } - if (controlType === 'trackball' || controlType === 'orbit') { - state.controls.minDistance = 0.1; - state.controls.maxDistance = state.skyRadius; - state.controls.addEventListener('start', () => { - state.controlsEngaged = true; - }); - state.controls.addEventListener('change', () => { - if (state.controlsEngaged) { - state.controlsDragging = true; - } - }); - state.controls.addEventListener('end', () => { - state.controlsEngaged = false; - state.controlsDragging = false; - }); + if (controlType === "trackball" || controlType === "orbit") { + state.controls.minDistance = 0.1; + state.controls.maxDistance = state.skyRadius; + state.controls.addEventListener("start", () => { + state.controlsEngaged = true; + }); + state.controls.addEventListener("change", () => { + if (state.controlsEngaged) { + state.controlsDragging = true; + } + }); + state.controls.addEventListener("end", () => { + state.controlsEngaged = false; + state.controlsDragging = false; + }); + } } - [state.renderer, state.postProcessingComposer, ...state.extraRenderers] - .forEach(r => r.setSize(state.width, state.height)); - state.camera.aspect = state.width/state.height; + [ + state.renderer, + state.postProcessingComposer, + ...state.extraRenderers, + ].forEach((r) => r.setSize(state.width, state.height)); + state.camera.aspect = state.width / state.height; state.camera.updateProjectionMatrix(); state.camera.position.z = 1000; // add sky - state.scene.add(state.skysphere = new three.Mesh()); + state.scene.add((state.skysphere = new three.Mesh())); state.skysphere.visible = false; state.loadComplete = state.scene.visible = !waitForLoadComplete; @@ -435,53 +530,73 @@ export default Kapsule({ update(state, changedProps) { // resize canvas - if (state.width && state.height && (changedProps.hasOwnProperty('width') || changedProps.hasOwnProperty('height'))) { + if ( + state.width && + state.height && + (changedProps.hasOwnProperty("width") || + changedProps.hasOwnProperty("height")) + ) { state.container.style.width = `${state.width}px`; state.container.style.height = `${state.height}px`; - [state.renderer, state.postProcessingComposer, ...state.extraRenderers] - .forEach(r => r.setSize(state.width, state.height)); - state.camera.aspect = state.width/state.height; + [ + state.renderer, + state.postProcessingComposer, + ...state.extraRenderers, + ].forEach((r) => r.setSize(state.width, state.height)); + state.camera.aspect = state.width / state.height; state.camera.updateProjectionMatrix(); } - if (changedProps.hasOwnProperty('skyRadius') && state.skyRadius) { - state.controls.hasOwnProperty('maxDistance') && changedProps.skyRadius - && (state.controls.maxDistance = Math.min(state.controls.maxDistance, state.skyRadius)); + if (changedProps.hasOwnProperty("skyRadius") && state.skyRadius) { + state.controls.hasOwnProperty("maxDistance") && + changedProps.skyRadius && + (state.controls.maxDistance = Math.min( + state.controls.maxDistance, + state.skyRadius + )); state.camera.far = state.skyRadius * 2.5; state.camera.updateProjectionMatrix(); state.skysphere.geometry = new three.SphereGeometry(state.skyRadius); } - if (changedProps.hasOwnProperty('backgroundColor')) { + if (changedProps.hasOwnProperty("backgroundColor")) { let alpha = parseToRgb(state.backgroundColor).alpha; if (alpha === undefined) alpha = 1; - state.renderer.setClearColor(new three.Color(opacify(1, state.backgroundColor)), alpha); + state.renderer.setClearColor( + new three.Color(opacify(1, state.backgroundColor)), + alpha + ); } - if (changedProps.hasOwnProperty('backgroundImageUrl')) { + if (changedProps.hasOwnProperty("backgroundImageUrl")) { if (!state.backgroundImageUrl) { state.skysphere.visible = false; state.skysphere.material.map = null; !state.loadComplete && finishLoad(); } else { - new three.TextureLoader().load(state.backgroundImageUrl, texture => { - state.skysphere.material = new three.MeshBasicMaterial({ map: texture, side: three.BackSide }); + new three.TextureLoader().load(state.backgroundImageUrl, (texture) => { + state.skysphere.material = new three.MeshBasicMaterial({ + map: texture, + side: three.BackSide, + }); state.skysphere.visible = true; // triggered when background image finishes loading (asynchronously to allow 1 frame to load texture) - state.onBackgroundImageLoaded && setTimeout(state.onBackgroundImageLoaded); + state.onBackgroundImageLoaded && + setTimeout(state.onBackgroundImageLoaded); !state.loadComplete && finishLoad(); }); } } - changedProps.hasOwnProperty('showNavInfo') && (state.navInfo.style.display = state.showNavInfo ? null : 'none'); + changedProps.hasOwnProperty("showNavInfo") && + (state.navInfo.style.display = state.showNavInfo ? null : "none"); - if (changedProps.hasOwnProperty('objects')) { - (changedProps.objects || []).forEach(obj => state.scene.remove(obj)); // Clear the place - state.objects.forEach(obj => state.scene.add(obj)); // Add to scene + if (changedProps.hasOwnProperty("objects")) { + (changedProps.objects || []).forEach((obj) => state.scene.remove(obj)); // Clear the place + state.objects.forEach((obj) => state.scene.add(obj)); // Add to scene } // @@ -489,5 +604,5 @@ export default Kapsule({ function finishLoad() { state.loadComplete = state.scene.visible = true; } - } -}); \ No newline at end of file + }, +}); From 20c5baa2c29ec55dd09bd784c8f7cfab557c87e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl?= Date: Thu, 25 Aug 2022 17:14:01 +0200 Subject: [PATCH 3/8] doc(controls): add documentation about controller constructor --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b353bec..928d919 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ ThreeRenderObjects({ configOptions })() | Config options | Description | Default | | --- | --- | :--: | -| controlType: str | Which type of control to use to control the camera. Choice between [trackball](https://threejs.org/examples/misc_controls_trackball.html), [orbit](https://threejs.org/examples/#misc_controls_orbit) or [fly](https://threejs.org/examples/misc_controls_fly.html). | `trackball` | +| controlType: str or (camera, domElement) => Controller | Which type of control to use to control the camera. Choice between [trackball](https://threejs.org/examples/misc_controls_trackball.html), [orbit](https://threejs.org/examples/#misc_controls_orbit) or [fly](https://threejs.org/examples/misc_controls_fly.html) or pass your own controller as a function. | `trackball` | | rendererConfig: object | Configuration parameters to pass to the [ThreeJS WebGLRenderer](https://threejs.org/docs/#api/en/renderers/WebGLRenderer) constructor. | `{ antialias: true, alpha: true }` | | extraRenderers: array | If you wish to include objects that require a dedicated renderer besides `WebGL`, such as [CSS3DRenderer](https://threejs.org/docs/#examples/en/renderers/CSS3DRenderer), include in this array those extra renderer instances. | `[]` | | waitForLoadComplete: boolean | Whether to wait until all the asynchronous loading operations are finished (such as the background image) before rendering the objects in the scene for the first time. | `true` | From 2e021c7f61a009a450c4b516ad80296a8c471776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl?= Date: Thu, 25 Aug 2022 17:15:55 +0200 Subject: [PATCH 4/8] example(controls): add custom controller example --- example/custom-controller/controller.js | 306 ++++++++++++++++++++++++ example/custom-controller/index.html | 65 +++++ 2 files changed, 371 insertions(+) create mode 100644 example/custom-controller/controller.js create mode 100644 example/custom-controller/index.html diff --git a/example/custom-controller/controller.js b/example/custom-controller/controller.js new file mode 100644 index 0000000..13c739d --- /dev/null +++ b/example/custom-controller/controller.js @@ -0,0 +1,306 @@ +const _lookDirection = new THREE.Vector3(); +const _spherical = new THREE.Spherical(); +const _target = new THREE.Vector3(); + +class FirstPersonControls { + constructor(object, domElement) { + if (domElement === undefined) { + console.warn( + 'THREE.FirstPersonControls: The second parameter "domElement" is now mandatory.' + ); + domElement = document; + } + + this.object = object; + this.domElement = domElement; + + // API + + this.enabled = true; + + this.movementSpeed = 1.0; + this.lookSpeed = 0.005; + + this.lookVertical = true; + this.autoForward = false; + + this.activeLook = true; + + this.heightSpeed = false; + this.heightCoef = 1.0; + this.heightMin = 0.0; + this.heightMax = 1.0; + + this.constrainVertical = false; + this.verticalMin = 0; + this.verticalMax = Math.PI; + + this.mouseDragOn = false; + + // internals + + this.autoSpeedFactor = 0.0; + + this.mouseX = 0; + this.mouseY = 0; + + this.moveForward = false; + this.moveBackward = false; + this.moveLeft = false; + this.moveRight = false; + + this.viewHalfX = 0; + this.viewHalfY = 0; + + // private variables + + let lat = 0; + let lon = 0; + + // + + this.handleResize = function () { + if (this.domElement === document) { + this.viewHalfX = window.innerWidth / 2; + this.viewHalfY = window.innerHeight / 2; + } else { + this.viewHalfX = this.domElement.offsetWidth / 2; + this.viewHalfY = this.domElement.offsetHeight / 2; + } + }; + + this.onMouseDown = function (event) { + if (this.domElement !== document) { + this.domElement.focus(); + } + + if (this.activeLook) { + switch (event.button) { + case 0: + this.moveForward = true; + break; + case 2: + this.moveBackward = true; + break; + } + } + + this.mouseDragOn = true; + }; + + this.onMouseUp = function (event) { + if (this.activeLook) { + switch (event.button) { + case 0: + this.moveForward = false; + break; + case 2: + this.moveBackward = false; + break; + } + } + + this.mouseDragOn = false; + }; + + this.onMouseMove = function (event) { + if (this.domElement === document) { + this.mouseX = event.pageX - this.viewHalfX; + this.mouseY = event.pageY - this.viewHalfY; + } else { + this.mouseX = event.pageX - this.domElement.offsetLeft - this.viewHalfX; + this.mouseY = event.pageY - this.domElement.offsetTop - this.viewHalfY; + } + }; + + this.onKeyDown = function (event) { + console.log("DEBUG keydown", event) + + switch (event.code) { + case "ArrowUp": + case "KeyW": + this.moveForward = true; + break; + + case "ArrowLeft": + case "KeyA": + this.moveLeft = true; + break; + + case "ArrowDown": + case "KeyS": + this.moveBackward = true; + break; + + case "ArrowRight": + case "KeyD": + this.moveRight = true; + break; + + case "KeyR": + this.moveUp = true; + break; + case "KeyF": + this.moveDown = true; + break; + } + }; + + this.onKeyUp = function (event) { + switch (event.code) { + case "ArrowUp": + case "KeyW": + this.moveForward = false; + break; + + case "ArrowLeft": + case "KeyA": + this.moveLeft = false; + break; + + case "ArrowDown": + case "KeyS": + this.moveBackward = false; + break; + + case "ArrowRight": + case "KeyD": + this.moveRight = false; + break; + + case "KeyR": + this.moveUp = false; + break; + case "KeyF": + this.moveDown = false; + break; + } + }; + + this.lookAt = function (x, y, z) { + if (x.isVector3) { + _target.copy(x); + } else { + _target.set(x, y, z); + } + + this.object.lookAt(_target); + + setOrientation(this); + + return this; + }; + + this.update = (function () { + const targetPosition = new THREE.Vector3(); + + return function update(delta) { + if (this.enabled === false) return; + + if (this.heightSpeed) { + const y = THREE.MathUtils.clamp( + this.object.position.y, + this.heightMin, + this.heightMax + ); + const heightDelta = y - this.heightMin; + + this.autoSpeedFactor = delta * (heightDelta * this.heightCoef); + } else { + this.autoSpeedFactor = 0.0; + } + + const actualMoveSpeed = delta * this.movementSpeed; + + if (this.moveForward || (this.autoForward && !this.moveBackward)) + this.object.translateZ(-(actualMoveSpeed + this.autoSpeedFactor)); + if (this.moveBackward) this.object.translateZ(actualMoveSpeed); + + if (this.moveLeft) this.object.translateX(-actualMoveSpeed); + if (this.moveRight) this.object.translateX(actualMoveSpeed); + + if (this.moveUp) this.object.translateY(actualMoveSpeed); + if (this.moveDown) this.object.translateY(-actualMoveSpeed); + + let actualLookSpeed = delta * this.lookSpeed; + + if (!this.activeLook) { + actualLookSpeed = 0; + } + + let verticalLookRatio = 1; + + if (this.constrainVertical) { + verticalLookRatio = Math.PI / (this.verticalMax - this.verticalMin); + } + + lon -= this.mouseX * actualLookSpeed; + if (this.lookVertical) + lat -= this.mouseY * actualLookSpeed * verticalLookRatio; + + lat = Math.max(-85, Math.min(85, lat)); + + let phi = THREE.MathUtils.degToRad(90 - lat); + const theta = THREE.MathUtils.degToRad(lon); + + if (this.constrainVertical) { + phi = THREE.MathUtils.mapLinear( + phi, + 0, + Math.PI, + this.verticalMin, + this.verticalMax + ); + } + + const position = this.object.position; + + targetPosition.setFromSphericalCoords(1, phi, theta).add(position); + + this.object.lookAt(targetPosition); + }; + })(); + + this.dispose = function () { + this.domElement.removeEventListener("contextmenu", contextmenu); + this.domElement.removeEventListener("mousedown", _onMouseDown); + this.domElement.removeEventListener("mousemove", _onMouseMove); + this.domElement.removeEventListener("mouseup", _onMouseUp); + + window.removeEventListener("keydown", _onKeyDown); + window.removeEventListener("keyup", _onKeyUp); + }; + + const _onMouseMove = this.onMouseMove.bind(this); + const _onMouseDown = this.onMouseDown.bind(this); + const _onMouseUp = this.onMouseUp.bind(this); + const _onKeyDown = this.onKeyDown.bind(this); + const _onKeyUp = this.onKeyUp.bind(this); + + this.domElement.addEventListener("contextmenu", contextmenu); + this.domElement.addEventListener("mousemove", _onMouseMove); + this.domElement.addEventListener("mousedown", _onMouseDown); + this.domElement.addEventListener("mouseup", _onMouseUp); + + window.addEventListener("keydown", _onKeyDown); + window.addEventListener("keyup", _onKeyUp); + + function setOrientation(controls) { + const quaternion = controls.object.quaternion; + + _lookDirection.set(0, 0, -1).applyQuaternion(quaternion); + _spherical.setFromVector3(_lookDirection); + + lat = 90 - THREE.MathUtils.radToDeg(_spherical.phi); + lon = THREE.MathUtils.radToDeg(_spherical.theta); + } + + this.handleResize(); + + setOrientation(this); + } +} + +function contextmenu(event) { + event.preventDefault(); +} diff --git a/example/custom-controller/index.html b/example/custom-controller/index.html new file mode 100644 index 0000000..a906f64 --- /dev/null +++ b/example/custom-controller/index.html @@ -0,0 +1,65 @@ + + + + + + + + + + + + +
+ + + From 1450662fbc3366b68ae4c7dd5b102af9933409bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl?= Date: Thu, 25 Aug 2022 17:18:26 +0200 Subject: [PATCH 5/8] fix(examples): fix custom controller example --- example/custom-controller/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/example/custom-controller/index.html b/example/custom-controller/index.html index a906f64..8f3e7a0 100644 --- a/example/custom-controller/index.html +++ b/example/custom-controller/index.html @@ -5,10 +5,10 @@ } - + - - + + From 2ec7fec3a127ffe85129023e313664327cdcc603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl?= Date: Thu, 25 Aug 2022 19:31:56 +0200 Subject: [PATCH 6/8] perf(example): improve custom-controller example --- example/custom-controller/controller.js | 14 +++++++++++--- example/custom-controller/index.html | 5 ----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/example/custom-controller/controller.js b/example/custom-controller/controller.js index 13c739d..970d66b 100644 --- a/example/custom-controller/controller.js +++ b/example/custom-controller/controller.js @@ -52,13 +52,17 @@ class FirstPersonControls { this.viewHalfX = 0; this.viewHalfY = 0; + // resizing + + const resizeObserver = new ResizeObserver(() => { + this.handleResize(); + }); + // private variables let lat = 0; let lon = 0; - // - this.handleResize = function () { if (this.domElement === document) { this.viewHalfX = window.innerWidth / 2; @@ -114,7 +118,7 @@ class FirstPersonControls { }; this.onKeyDown = function (event) { - console.log("DEBUG keydown", event) + console.log("DEBUG keydown", event); switch (event.code) { case "ArrowUp": @@ -269,6 +273,8 @@ class FirstPersonControls { window.removeEventListener("keydown", _onKeyDown); window.removeEventListener("keyup", _onKeyUp); + + resizeObserver.disconnect(); }; const _onMouseMove = this.onMouseMove.bind(this); @@ -297,6 +303,8 @@ class FirstPersonControls { this.handleResize(); + resizeObserver.observe(domElement); + setOrientation(this); } } diff --git a/example/custom-controller/index.html b/example/custom-controller/index.html index 8f3e7a0..2f18204 100644 --- a/example/custom-controller/index.html +++ b/example/custom-controller/index.html @@ -56,10 +56,5 @@ ObjRender.tick(); // render it requestAnimationFrame(animate); })(); // IIFE - - const resizeObserver = new ResizeObserver((entries) => { - ObjRender.controls().handleResize(); - }); - resizeObserver.observe(document.querySelector("#myscene")); From b2d21099620bce69a8539f1b401bc862badc573b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl?= Date: Fri, 26 Aug 2022 08:37:30 +0200 Subject: [PATCH 7/8] Revert "feat(controls): add custom controller constructor" This reverts commit c78bfbcb97dd101cd236ac934c8e84ca3cf7bdd5. --- src/three-render-objects.js | 519 ++++++++++++++---------------------- 1 file changed, 202 insertions(+), 317 deletions(-) diff --git a/src/three-render-objects.js b/src/three-render-objects.js index 3f1f5b1..72c0e5a 100644 --- a/src/three-render-objects.js +++ b/src/three-render-objects.js @@ -18,61 +18,51 @@ import { MOUSE, Quaternion, Spherical, - Clock, -} from "three"; + Clock +} from 'three'; const three = window.THREE ? window.THREE // Prefer consumption from global THREE, if exists : { - WebGLRenderer, - Scene, - PerspectiveCamera, - Raycaster, - TextureLoader, - Vector2, - Vector3, - Box3, - Color, - Mesh, - SphereGeometry, - MeshBasicMaterial, - BackSide, - - EventDispatcher, - MOUSE, - Quaternion, - Spherical, - Clock, - }; - -import { TrackballControls as ThreeTrackballControls } from "three/examples/jsm/controls/TrackballControls.js"; -import { OrbitControls as ThreeOrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; -import { FlyControls as ThreeFlyControls } from "three/examples/jsm/controls/FlyControls.js"; - -import { EffectComposer as ThreeEffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js"; -import { RenderPass as ThreeRenderPass } from "three/examples/jsm/postprocessing/RenderPass.js"; - -import { parseToRgb, opacify } from "polished"; -import TWEEN from "@tweenjs/tween.js"; - -import accessorFn from "accessor-fn"; -import Kapsule from "kapsule"; + WebGLRenderer, + Scene, + PerspectiveCamera, + Raycaster, + TextureLoader, + Vector2, + Vector3, + Box3, + Color, + Mesh, + SphereGeometry, + MeshBasicMaterial, + BackSide, + + EventDispatcher, + MOUSE, + Quaternion, + Spherical, + Clock +}; + +import { TrackballControls as ThreeTrackballControls } from 'three/examples/jsm/controls/TrackballControls.js'; +import { OrbitControls as ThreeOrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { FlyControls as ThreeFlyControls } from 'three/examples/jsm/controls/FlyControls.js'; + +import { EffectComposer as ThreeEffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'; +import { RenderPass as ThreeRenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'; + +import { parseToRgb, opacify } from 'polished'; +import TWEEN from '@tweenjs/tween.js'; + +import accessorFn from 'accessor-fn'; +import Kapsule from 'kapsule'; export default Kapsule({ props: { - width: { - default: window.innerWidth, - onChange(width, state, prevWidth) { - isNaN(width) && (state.width = prevWidth); - }, - }, - height: { - default: window.innerHeight, - onChange(height, state, prevHeight) { - isNaN(height) && (state.height = prevHeight); - }, - }, - backgroundColor: { default: "#000011" }, + width: { default: window.innerWidth, onChange(width, state, prevWidth) { isNaN(width) && (state.width = prevWidth) } }, + height: { default: window.innerHeight, onChange(height, state, prevHeight) { isNaN(height) && (state.height = prevHeight) } }, + backgroundColor: { default: '#000011' }, backgroundImageUrl: {}, onBackgroundImageLoaded: {}, showNavInfo: { default: true }, @@ -83,9 +73,9 @@ export default Kapsule({ onChange(_, state) { // Reset hover state state.hoverObj = null; - if (state.toolTipElem) state.toolTipElem.innerHTML = ""; + if (state.toolTipElem) state.toolTipElem.innerHTML = ''; }, - triggerUpdate: false, + triggerUpdate: false }, lineHoverPrecision: { default: 1, triggerUpdate: false }, hoverOrderComparator: { default: () => -1, triggerUpdate: false }, // keep existing order by default @@ -95,11 +85,11 @@ export default Kapsule({ clickAfterDrag: { default: false, triggerUpdate: false }, onHover: { default: () => {}, triggerUpdate: false }, onClick: { default: () => {}, triggerUpdate: false }, - onRightClick: { triggerUpdate: false }, + onRightClick: { triggerUpdate: false } }, methods: { - tick: function (state) { + tick: function(state) { if (state.initialised) { state.controls.update && state.controls.update(state.clock.getDelta()); // timedelta is required for fly controls @@ -107,19 +97,14 @@ export default Kapsule({ ? state.postProcessingComposer.render() // if using postprocessing, switch the output to it : state.renderer.render(state.scene, state.camera); - state.extraRenderers.forEach((r) => - r.render(state.scene, state.camera) - ); + state.extraRenderers.forEach(r => r.render(state.scene, state.camera)); if (state.enablePointerInteraction) { // Update tooltip and trigger onHover events let topObject = null; if (state.hoverDuringDrag || !state.isPointerDragging) { - const intersects = this.intersectingObjects( - state.pointerPos.x, - state.pointerPos.y - ) - .filter((d) => state.hoverFilter(d.object)) + const intersects = this.intersectingObjects(state.pointerPos.x, state.pointerPos.y) + .filter(d => state.hoverFilter(d.object)) .sort((a, b) => state.hoverOrderComparator(a.object, b.object)); const topIntersect = intersects.length ? intersects[0] : null; @@ -130,9 +115,7 @@ export default Kapsule({ if (topObject !== state.hoverObj) { state.onHover(topObject, state.hoverObj); - state.toolTipElem.innerHTML = topObject - ? accessorFn(state.tooltipContent)(topObject) || "" - : ""; + state.toolTipElem.innerHTML = topObject ? accessorFn(state.tooltipContent)(topObject) || '' : ''; state.hoverObj = topObject; } } @@ -142,20 +125,19 @@ export default Kapsule({ return this; }, - getPointerPos: function (state) { + getPointerPos: function(state) { const { x, y } = state.pointerPos; return { x, y }; }, - cameraPosition: function (state, position, lookAt, transitionDuration) { + cameraPosition: function(state, position, lookAt, transitionDuration) { const camera = state.camera; // Setter if (position && state.initialised) { const finalPos = position; - const finalLookAt = lookAt || { x: 0, y: 0, z: 0 }; + const finalLookAt = lookAt || {x: 0, y: 0, z: 0}; - if (!transitionDuration) { - // no animation + if (!transitionDuration) { // no animation setCameraPos(finalPos); setLookAt(finalLookAt); } else { @@ -195,31 +177,21 @@ export default Kapsule({ const lookAtVect = new three.Vector3(lookAt.x, lookAt.y, lookAt.z); if (state.controls.target) { state.controls.target = lookAtVect; - } else { - // Fly controls doesn't have target attribute + } else { // Fly controls doesn't have target attribute camera.lookAt(lookAtVect); // note: lookAt may be overridden by other controls in some cases } } function getLookAt() { return Object.assign( - new three.Vector3(0, 0, -1000) + (new three.Vector3(0, 0, -1000)) .applyQuaternion(camera.quaternion) .add(camera.position) ); } }, - zoomToFit: function ( - state, - transitionDuration = 0, - padding = 10, - ...bboxArgs - ) { - return this.fitToBbox( - this.getBbox(...bboxArgs), - transitionDuration, - padding - ); + zoomToFit: function (state, transitionDuration = 0, padding = 10, ...bboxArgs) { + return this.fitToBbox(this.getBbox(...bboxArgs), transitionDuration, padding); }, fitToBbox: function (state, bbox, transitionDuration = 0, padding = 10) { // based on https://discourse.threejs.org/t/camera-zoom-to-fit-object/936/24 @@ -227,23 +199,18 @@ export default Kapsule({ if (bbox) { const center = new three.Vector3(0, 0, 0); // reset camera aim to center - const maxBoxSide = - Math.max( - ...Object.entries(bbox).map(([coordType, coords]) => - Math.max(...coords.map((c) => Math.abs(center[coordType] - c))) - ) - ) * 2; + const maxBoxSide = Math.max(...Object.entries(bbox) + .map(([coordType, coords]) => Math.max(...coords.map(c => Math.abs(center[coordType] - c)))) + ) * 2; // find distance that fits whole bbox within padded fov - const paddedFov = (1 - (padding * 2) / state.height) * camera.fov; - const fitHeightDistance = - maxBoxSide / Math.atan((paddedFov * Math.PI) / 180); + const paddedFov = (1 - (padding * 2 / state.height)) * camera.fov; + const fitHeightDistance = maxBoxSide / Math.atan(paddedFov * Math.PI / 180); const fitWidthDistance = fitHeightDistance / camera.aspect; const distance = Math.max(fitHeightDistance, fitWidthDistance); if (distance > 0) { - const newCameraPosition = center - .clone() + const newCameraPosition = center.clone() .sub(camera.position) .normalize() .multiplyScalar(-distance); @@ -255,33 +222,27 @@ export default Kapsule({ return this; }, getBbox: function (state, objFilter = () => true) { - const box = new three.Box3( - new three.Vector3(0, 0, 0), - new three.Vector3(0, 0, 0) - ); + const box = new three.Box3(new three.Vector3(0, 0, 0), new three.Vector3(0, 0, 0)); const objs = state.objects.filter(objFilter); if (!objs.length) return null; - objs.forEach((obj) => box.expandByObject(obj)); + objs.forEach(obj => box.expandByObject(obj)); // extract global x,y,z min/max - return Object.assign( - ...["x", "y", "z"].map((c) => ({ - [c]: [box.min[c], box.max[c]], - })) - ); + return Object.assign(...['x', 'y', 'z'].map(c => ({ + [c]: [box.min[c], box.max[c]] + }))); }, - getScreenCoords: function (state, x, y, z) { + getScreenCoords: function(state, x, y, z) { const vec = new three.Vector3(x, y, z); vec.project(this.camera()); // project to the camera plane - return { - // align relative pos to canvas dimensions - x: ((vec.x + 1) * state.width) / 2, - y: (-(vec.y - 1) * state.height) / 2, + return { // align relative pos to canvas dimensions + x: (vec.x + 1) * state.width / 2, + y: -(vec.y - 1) * state.height / 2, }; }, - getSceneCoords: function (state, screenX, screenY, distance = 0) { + getSceneCoords: function(state, screenX, screenY, distance = 0) { const relCoords = new three.Vector2( (screenX / state.width) * 2 - 1, -(screenY / state.height) * 2 + 1 @@ -291,7 +252,7 @@ export default Kapsule({ raycaster.setFromCamera(relCoords, state.camera); return Object.assign({}, raycaster.ray.at(distance, new three.Vector3())); }, - intersectingObjects: function (state, x, y) { + intersectingObjects: function(state, x, y) { const relCoords = new three.Vector2( (x / state.width) * 2 - 1, -(y / state.height) * 2 + 1 @@ -302,226 +263,170 @@ export default Kapsule({ raycaster.setFromCamera(relCoords, state.camera); return raycaster.intersectObjects(state.objects, true); }, - renderer: (state) => state.renderer, - scene: (state) => state.scene, - camera: (state) => state.camera, - postProcessingComposer: (state) => state.postProcessingComposer, - controls: (state) => state.controls, - tbControls: (state) => state.controls, // to be deprecated + renderer: state => state.renderer, + scene: state => state.scene, + camera: state => state.camera, + postProcessingComposer: state => state.postProcessingComposer, + controls: state => state.controls, + tbControls: state => state.controls // to be deprecated }, stateInit: () => ({ scene: new three.Scene(), camera: new three.PerspectiveCamera(), - clock: new three.Clock(), + clock: new three.Clock() }), - init( - domNode, - state, - { - controlType = "trackball", - rendererConfig = {}, - extraRenderers = [], - waitForLoadComplete = true, - } = {} - ) { + init(domNode, state, { + controlType = 'trackball', + rendererConfig = {}, + extraRenderers = [], + waitForLoadComplete = true + } = {}) { // Wipe DOM - domNode.innerHTML = ""; + domNode.innerHTML = ''; // Add relative container - domNode.appendChild((state.container = document.createElement("div"))); - state.container.className = "scene-container"; - state.container.style.position = "relative"; - - // Get controller type - const controlTypeIsString = typeof controlType === typeof "string"; - const controlTypeIsFunction = typeof controlType === typeof (() => {}); + domNode.appendChild(state.container = document.createElement('div')); + state.container.className = 'scene-container'; + state.container.style.position = 'relative'; // Add nav info section - state.container.appendChild( - (state.navInfo = document.createElement("div")) - ); - state.navInfo.className = "scene-nav-info"; - - if (controlTypeIsString) { - state.navInfo.textContent = - { - orbit: - "Left-click: rotate, Mouse-wheel/middle-click: zoom, Right-click: pan", - trackball: - "Left-click: rotate, Mouse-wheel/middle-click: zoom, Right-click: pan", - fly: "WASD: move, R|F: up | down, Q|E: roll, up|down: pitch, left|right: yaw", - }[controlType] || ""; - } - state.navInfo.style.display = state.showNavInfo ? null : "none"; + state.container.appendChild(state.navInfo = document.createElement('div')); + state.navInfo.className = 'scene-nav-info'; + state.navInfo.textContent = { + orbit: 'Left-click: rotate, Mouse-wheel/middle-click: zoom, Right-click: pan', + trackball: 'Left-click: rotate, Mouse-wheel/middle-click: zoom, Right-click: pan', + fly: 'WASD: move, R|F: up | down, Q|E: roll, up|down: pitch, left|right: yaw' + }[controlType] || ''; + state.navInfo.style.display = state.showNavInfo ? null : 'none'; // Setup tooltip - state.toolTipElem = document.createElement("div"); - state.toolTipElem.classList.add("scene-tooltip"); + state.toolTipElem = document.createElement('div'); + state.toolTipElem.classList.add('scene-tooltip'); state.container.appendChild(state.toolTipElem); // Capture pointer coords on move or touchstart state.pointerPos = new three.Vector2(); state.pointerPos.x = -2; // Initialize off canvas state.pointerPos.y = -2; - ["pointermove", "pointerdown"].forEach((evType) => - state.container.addEventListener( - evType, - (ev) => { - // track click state - evType === "pointerdown" && (state.isPointerPressed = true); - - // detect point drag - !state.isPointerDragging && - ev.type === "pointermove" && - (ev.pressure > 0 || state.isPointerPressed) && // ev.pressure always 0 on Safari, so we used the isPointerPressed tracker - (ev.pointerType !== "touch" || - ev.movementX === undefined || - [ev.movementX, ev.movementY].some((m) => Math.abs(m) > 1)) && // relax drag trigger sensitivity on touch events - (state.isPointerDragging = true); - - if (state.enablePointerInteraction) { - // update the pointer pos - const offset = getOffset(state.container); - state.pointerPos.x = ev.pageX - offset.left; - state.pointerPos.y = ev.pageY - offset.top; - - // Move tooltip - state.toolTipElem.style.top = `${state.pointerPos.y}px`; - state.toolTipElem.style.left = `${state.pointerPos.x}px`; - // adjust horizontal position to not exceed canvas boundaries - state.toolTipElem.style.transform = `translate(-${ - (state.pointerPos.x / state.width) * 100 - }%, ${ - // flip to above if near bottom - state.height - state.pointerPos.y < 100 - ? "calc(-100% - 8px)" - : "21px" - })`; - } + ['pointermove', 'pointerdown'].forEach(evType => + state.container.addEventListener(evType, ev => { + // track click state + evType === 'pointerdown' && (state.isPointerPressed = true); - function getOffset(el) { - const rect = el.getBoundingClientRect(), - scrollLeft = - window.pageXOffset || document.documentElement.scrollLeft, - scrollTop = - window.pageYOffset || document.documentElement.scrollTop; - return { top: rect.top + scrollTop, left: rect.left + scrollLeft }; - } - }, - { passive: true } - ) + // detect point drag + !state.isPointerDragging && ev.type === 'pointermove' + && (ev.pressure > 0 || state.isPointerPressed) // ev.pressure always 0 on Safari, so we used the isPointerPressed tracker + && (ev.pointerType !== 'touch' || ev.movementX === undefined || [ev.movementX, ev.movementY].some(m => Math.abs(m) > 1)) // relax drag trigger sensitivity on touch events + && (state.isPointerDragging = true); + + if (state.enablePointerInteraction) { + // update the pointer pos + const offset = getOffset(state.container); + state.pointerPos.x = ev.pageX - offset.left; + state.pointerPos.y = ev.pageY - offset.top; + + // Move tooltip + state.toolTipElem.style.top = `${state.pointerPos.y}px`; + state.toolTipElem.style.left = `${state.pointerPos.x}px`; + // adjust horizontal position to not exceed canvas boundaries + state.toolTipElem.style.transform = `translate(-${state.pointerPos.x / state.width * 100}%, ${ + // flip to above if near bottom + state.height - state.pointerPos.y < 100 ? 'calc(-100% - 8px)' : '21px' + })`; + } + + function getOffset(el) { + const rect = el.getBoundingClientRect(), + scrollLeft = window.pageXOffset || document.documentElement.scrollLeft, + scrollTop = window.pageYOffset || document.documentElement.scrollTop; + return { top: rect.top + scrollTop, left: rect.left + scrollLeft }; + } + }, { passive: true }) ); // Handle click events on objs - state.container.addEventListener( - "pointerup", - (ev) => { - state.isPointerPressed = false; - if (state.isPointerDragging) { - state.isPointerDragging = false; - if (!state.clickAfterDrag) return; // don't trigger onClick after pointer drag (camera motion via controls) - } + state.container.addEventListener('pointerup', ev => { + state.isPointerPressed = false; + if (state.isPointerDragging) { + state.isPointerDragging = false; + if (!state.clickAfterDrag) return; // don't trigger onClick after pointer drag (camera motion via controls) + } - requestAnimationFrame(() => { - // trigger click events asynchronously, to allow hoverObj to be set (on frame) - if (ev.button === 0) { - // left-click - state.onClick(state.hoverObj || null, ev, state.intersectionPoint); // trigger background clicks with null - } + requestAnimationFrame(() => { // trigger click events asynchronously, to allow hoverObj to be set (on frame) + if (ev.button === 0) { // left-click + state.onClick(state.hoverObj || null, ev, state.intersectionPoint) // trigger background clicks with null + } - if (ev.button === 2 && state.onRightClick) { - // right-click - state.onRightClick( - state.hoverObj || null, - ev, - state.intersectionPoint - ); - } - }); - }, - { passive: true, capture: true } - ); // use capture phase to prevent propagation blocking from controls (specifically for fly) + if (ev.button === 2 && state.onRightClick) { // right-click + state.onRightClick(state.hoverObj || null, ev, state.intersectionPoint) + } + }); + }, { passive: true, capture: true }); // use capture phase to prevent propagation blocking from controls (specifically for fly) - state.container.addEventListener("contextmenu", (ev) => { + state.container.addEventListener('contextmenu', ev => { if (state.onRightClick) ev.preventDefault(); // prevent default contextmenu behavior and allow pointerup to fire instead }); // Setup renderer, camera and controls - state.renderer = new three.WebGLRenderer( - Object.assign({ antialias: true, alpha: true }, rendererConfig) - ); + state.renderer = new three.WebGLRenderer(Object.assign({ antialias: true, alpha: true }, rendererConfig)); state.renderer.setPixelRatio(Math.min(2, window.devicePixelRatio)); // clamp device pixel ratio state.container.appendChild(state.renderer.domElement); // Setup extra renderers state.extraRenderers = extraRenderers; - state.extraRenderers.forEach((r) => { + state.extraRenderers.forEach(r => { // overlay them on top of main renderer - r.domElement.style.position = "absolute"; - r.domElement.style.top = "0px"; - r.domElement.style.pointerEvents = "none"; + r.domElement.style.position = 'absolute'; + r.domElement.style.top = '0px'; + r.domElement.style.pointerEvents = 'none'; state.container.appendChild(r.domElement); }); // configure post-processing composer state.postProcessingComposer = new ThreeEffectComposer(state.renderer); - state.postProcessingComposer.addPass( - new ThreeRenderPass(state.scene, state.camera) - ); // render scene as first pass - - // create controls - if (controlTypeIsString) { - state.controls = new { - trackball: ThreeTrackballControls, - orbit: ThreeOrbitControls, - fly: ThreeFlyControls, - }[controlType](state.camera, state.renderer.domElement); - } else if (controlTypeIsFunction) { - state.controls = controlType(state.camera, state.renderer.domElement); - } else { - throw "Invalid controlType argument"; - } + state.postProcessingComposer.addPass(new ThreeRenderPass(state.scene, state.camera)); // render scene as first pass // configure controls - if (controlTypeIsString) { - if (controlType === "fly") { - state.controls.movementSpeed = 300; - state.controls.rollSpeed = Math.PI / 6; - state.controls.dragToLook = true; - } + state.controls = new ({ + trackball: ThreeTrackballControls, + orbit: ThreeOrbitControls, + fly: ThreeFlyControls + }[controlType])(state.camera, state.renderer.domElement); + + if (controlType === 'fly') { + state.controls.movementSpeed = 300; + state.controls.rollSpeed = Math.PI / 6; + state.controls.dragToLook = true; + } - if (controlType === "trackball" || controlType === "orbit") { - state.controls.minDistance = 0.1; - state.controls.maxDistance = state.skyRadius; - state.controls.addEventListener("start", () => { - state.controlsEngaged = true; - }); - state.controls.addEventListener("change", () => { - if (state.controlsEngaged) { - state.controlsDragging = true; - } - }); - state.controls.addEventListener("end", () => { - state.controlsEngaged = false; - state.controlsDragging = false; - }); - } + if (controlType === 'trackball' || controlType === 'orbit') { + state.controls.minDistance = 0.1; + state.controls.maxDistance = state.skyRadius; + state.controls.addEventListener('start', () => { + state.controlsEngaged = true; + }); + state.controls.addEventListener('change', () => { + if (state.controlsEngaged) { + state.controlsDragging = true; + } + }); + state.controls.addEventListener('end', () => { + state.controlsEngaged = false; + state.controlsDragging = false; + }); } - [ - state.renderer, - state.postProcessingComposer, - ...state.extraRenderers, - ].forEach((r) => r.setSize(state.width, state.height)); - state.camera.aspect = state.width / state.height; + [state.renderer, state.postProcessingComposer, ...state.extraRenderers] + .forEach(r => r.setSize(state.width, state.height)); + state.camera.aspect = state.width/state.height; state.camera.updateProjectionMatrix(); state.camera.position.z = 1000; // add sky - state.scene.add((state.skysphere = new three.Mesh())); + state.scene.add(state.skysphere = new three.Mesh()); state.skysphere.visible = false; state.loadComplete = state.scene.visible = !waitForLoadComplete; @@ -530,73 +435,53 @@ export default Kapsule({ update(state, changedProps) { // resize canvas - if ( - state.width && - state.height && - (changedProps.hasOwnProperty("width") || - changedProps.hasOwnProperty("height")) - ) { + if (state.width && state.height && (changedProps.hasOwnProperty('width') || changedProps.hasOwnProperty('height'))) { state.container.style.width = `${state.width}px`; state.container.style.height = `${state.height}px`; - [ - state.renderer, - state.postProcessingComposer, - ...state.extraRenderers, - ].forEach((r) => r.setSize(state.width, state.height)); - state.camera.aspect = state.width / state.height; + [state.renderer, state.postProcessingComposer, ...state.extraRenderers] + .forEach(r => r.setSize(state.width, state.height)); + state.camera.aspect = state.width/state.height; state.camera.updateProjectionMatrix(); } - if (changedProps.hasOwnProperty("skyRadius") && state.skyRadius) { - state.controls.hasOwnProperty("maxDistance") && - changedProps.skyRadius && - (state.controls.maxDistance = Math.min( - state.controls.maxDistance, - state.skyRadius - )); + if (changedProps.hasOwnProperty('skyRadius') && state.skyRadius) { + state.controls.hasOwnProperty('maxDistance') && changedProps.skyRadius + && (state.controls.maxDistance = Math.min(state.controls.maxDistance, state.skyRadius)); state.camera.far = state.skyRadius * 2.5; state.camera.updateProjectionMatrix(); state.skysphere.geometry = new three.SphereGeometry(state.skyRadius); } - if (changedProps.hasOwnProperty("backgroundColor")) { + if (changedProps.hasOwnProperty('backgroundColor')) { let alpha = parseToRgb(state.backgroundColor).alpha; if (alpha === undefined) alpha = 1; - state.renderer.setClearColor( - new three.Color(opacify(1, state.backgroundColor)), - alpha - ); + state.renderer.setClearColor(new three.Color(opacify(1, state.backgroundColor)), alpha); } - if (changedProps.hasOwnProperty("backgroundImageUrl")) { + if (changedProps.hasOwnProperty('backgroundImageUrl')) { if (!state.backgroundImageUrl) { state.skysphere.visible = false; state.skysphere.material.map = null; !state.loadComplete && finishLoad(); } else { - new three.TextureLoader().load(state.backgroundImageUrl, (texture) => { - state.skysphere.material = new three.MeshBasicMaterial({ - map: texture, - side: three.BackSide, - }); + new three.TextureLoader().load(state.backgroundImageUrl, texture => { + state.skysphere.material = new three.MeshBasicMaterial({ map: texture, side: three.BackSide }); state.skysphere.visible = true; // triggered when background image finishes loading (asynchronously to allow 1 frame to load texture) - state.onBackgroundImageLoaded && - setTimeout(state.onBackgroundImageLoaded); + state.onBackgroundImageLoaded && setTimeout(state.onBackgroundImageLoaded); !state.loadComplete && finishLoad(); }); } } - changedProps.hasOwnProperty("showNavInfo") && - (state.navInfo.style.display = state.showNavInfo ? null : "none"); + changedProps.hasOwnProperty('showNavInfo') && (state.navInfo.style.display = state.showNavInfo ? null : 'none'); - if (changedProps.hasOwnProperty("objects")) { - (changedProps.objects || []).forEach((obj) => state.scene.remove(obj)); // Clear the place - state.objects.forEach((obj) => state.scene.add(obj)); // Add to scene + if (changedProps.hasOwnProperty('objects')) { + (changedProps.objects || []).forEach(obj => state.scene.remove(obj)); // Clear the place + state.objects.forEach(obj => state.scene.add(obj)); // Add to scene } // @@ -604,5 +489,5 @@ export default Kapsule({ function finishLoad() { state.loadComplete = state.scene.visible = true; } - }, -}); + } +}); \ No newline at end of file From 996cc7e49a79269fc48673bc106aed242c83376d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl?= Date: Fri, 26 Aug 2022 08:41:29 +0200 Subject: [PATCH 8/8] feat(controls): add custom controller constructor --- src/three-render-objects.js | 77 ++++++++++++++++++++++--------------- 1 file changed, 46 insertions(+), 31 deletions(-) diff --git a/src/three-render-objects.js b/src/three-render-objects.js index 72c0e5a..0211476 100644 --- a/src/three-render-objects.js +++ b/src/three-render-objects.js @@ -291,14 +291,20 @@ export default Kapsule({ state.container.className = 'scene-container'; state.container.style.position = 'relative'; + // Get controller type + const controlTypeIsString = typeof controlType === typeof 'string'; + const controlTypeIsFunction = typeof controlType === typeof (() => {}); + // Add nav info section state.container.appendChild(state.navInfo = document.createElement('div')); state.navInfo.className = 'scene-nav-info'; - state.navInfo.textContent = { - orbit: 'Left-click: rotate, Mouse-wheel/middle-click: zoom, Right-click: pan', - trackball: 'Left-click: rotate, Mouse-wheel/middle-click: zoom, Right-click: pan', - fly: 'WASD: move, R|F: up | down, Q|E: roll, up|down: pitch, left|right: yaw' - }[controlType] || ''; + if (controlTypeIsString) { + state.navInfo.textContent = { + orbit: 'Left-click: rotate, Mouse-wheel/middle-click: zoom, Right-click: pan', + trackball: 'Left-click: rotate, Mouse-wheel/middle-click: zoom, Right-click: pan', + fly: 'WASD: move, R|F: up | down, Q|E: roll, up|down: pitch, left|right: yaw' + }[controlType] || ''; + } state.navInfo.style.display = state.showNavInfo ? null : 'none'; // Setup tooltip @@ -388,34 +394,43 @@ export default Kapsule({ state.postProcessingComposer = new ThreeEffectComposer(state.renderer); state.postProcessingComposer.addPass(new ThreeRenderPass(state.scene, state.camera)); // render scene as first pass - // configure controls - state.controls = new ({ - trackball: ThreeTrackballControls, - orbit: ThreeOrbitControls, - fly: ThreeFlyControls - }[controlType])(state.camera, state.renderer.domElement); - - if (controlType === 'fly') { - state.controls.movementSpeed = 300; - state.controls.rollSpeed = Math.PI / 6; - state.controls.dragToLook = true; + // create controls + if (controlTypeIsString) { + state.controls = new ({ + trackball: ThreeTrackballControls, + orbit: ThreeOrbitControls, + fly: ThreeFlyControls + }[controlType])(state.camera, state.renderer.domElement); + } else if (controlTypeIsFunction) { + state.controls = controlType(state.camera, state.renderer.domElement); + } else { + throw "Invalid controlType argument"; } - if (controlType === 'trackball' || controlType === 'orbit') { - state.controls.minDistance = 0.1; - state.controls.maxDistance = state.skyRadius; - state.controls.addEventListener('start', () => { - state.controlsEngaged = true; - }); - state.controls.addEventListener('change', () => { - if (state.controlsEngaged) { - state.controlsDragging = true; - } - }); - state.controls.addEventListener('end', () => { - state.controlsEngaged = false; - state.controlsDragging = false; - }); + // configure controls + if (controlTypeIsString) { + if (controlType === 'fly') { + state.controls.movementSpeed = 300; + state.controls.rollSpeed = Math.PI / 6; + state.controls.dragToLook = true; + } + + if (controlType === 'trackball' || controlType === 'orbit') { + state.controls.minDistance = 0.1; + state.controls.maxDistance = state.skyRadius; + state.controls.addEventListener('start', () => { + state.controlsEngaged = true; + }); + state.controls.addEventListener('change', () => { + if (state.controlsEngaged) { + state.controlsDragging = true; + } + }); + state.controls.addEventListener('end', () => { + state.controlsEngaged = false; + state.controlsDragging = false; + }); + } } [state.renderer, state.postProcessingComposer, ...state.extraRenderers]