UNPKG

sorcherer

Version:

A library for attaching dynamic HTML overlays to Three.js Object3D instances with distance-based scaling, rotation, auto-centering, and DOM culling.

1 lines 18.5 kB
{"version":3,"file":"index.cjs","sources":["src/sorcherer.js"],"sourcesContent":["// src/sorcherer.js\n\n// Dynamically inject Sorcherer styles if not already present.\n(function injectMagicalStyle() {\n if (typeof document !== 'undefined' && !document.getElementById('magical-style')) {\n const style = document.createElement('style');\n style.id = 'magical-style';\n style.textContent = `\n.sorcherer-container {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n pointer-events: none;\n}\n`;\n document.head.appendChild(style);\n }\n})();\n\nimport { Vector3, Frustum, Matrix4 } from 'three';\n\nclass Sorcherer {\n // All overlay instances (stored in a Set)\n static allLoadedElements = new Set();\n // For frustum culling.\n static frustum = new Frustum();\n static matrix = new Matrix4();\n // Container element for all overlays (null in non-DOM environments).\n static container = (typeof document !== 'undefined')\n ? document.createElement('div')\n : null;\n static autoUpdateRunning = false;\n static _timeoutHandle = null;\n static _containerAttachPending = false;\n // Registry mapping Object3D names to objects.\n static objectRegistry = new Map();\n // Global dictionary mapping idm (i.e. Object3D name) to overlay instance.\n static instancesById = {};\n // Alias for instancesById.\n static get elements() { return Sorcherer.instancesById; }\n // Default scale multiplier (developers can change this via Sorcherer.defaultScaleMultiplier).\n static defaultScaleMultiplier = 1;\n static _tempWorldPos = new Vector3();\n static _tempProjectedPos = new Vector3();\n static _tempFrustumPos = new Vector3();\n\n static ensureContainerAttached() {\n if (typeof document === 'undefined' || !Sorcherer.container) return;\n Sorcherer.container.classList.add('sorcherer-container');\n\n if (Sorcherer.container.isConnected) return;\n\n if (document.body) {\n document.body.appendChild(Sorcherer.container);\n Sorcherer._containerAttachPending = false;\n return;\n }\n\n if (Sorcherer._containerAttachPending) return;\n Sorcherer._containerAttachPending = true;\n\n document.addEventListener('DOMContentLoaded', () => {\n Sorcherer._containerAttachPending = false;\n if (document.body && Sorcherer.container && !Sorcherer.container.isConnected) {\n document.body.appendChild(Sorcherer.container);\n }\n }, { once: true });\n }\n\n static _hideOverlay(instance) {\n if (instance?._parentSpan?.style) {\n instance._parentSpan.style.display = 'none';\n }\n }\n\n static _readBooleanAttribute(element, name) {\n return (element.getAttribute(name) || '').trim().toLowerCase() === 'true';\n }\n\n static _parseVector3Attribute(element, name) {\n if (!element.hasAttribute(name)) return new Vector3();\n const raw = element.getAttribute(name);\n if (!raw) return new Vector3();\n\n const parts = raw.split(',').map((value) => parseFloat(value.trim()));\n if (parts.length < 3 || parts.slice(0, 3).some((value) => Number.isNaN(value))) {\n return new Vector3();\n }\n\n return new Vector3(parts[0], parts[1], parts[2]);\n }\n\n static _parseNumberAttribute(element, name) {\n if (!element.hasAttribute(name)) return undefined;\n const raw = element.getAttribute(name);\n if (raw == null) return undefined;\n const value = parseFloat(raw);\n return Number.isFinite(value) ? value : undefined;\n }\n\n /**\n * @param {THREE.Object3D} object - The target Object3D.\n * @param {THREE.Vector3} [offset=new Vector3()] - Optional offset for the overlay.\n * @param {boolean} [simulate3D=false] - Whether to scale the overlay based on distance.\n * @param {boolean} [simulateRotation=false] - Whether to rotate the overlay with the object.\n * @param {boolean} [autoCenter=false] - Whether to auto-center the overlay relative to its computed screen position.\n * @param {number} [scaleMultiplier] - Multiplier for distance-based scaling (defaults to Sorcherer.defaultScaleMultiplier).\n */\n constructor(object, offset = new Vector3(), simulate3D = false, simulateRotation = false, autoCenter = false, scaleMultiplier) {\n this.object = object;\n this.offset = (offset && typeof offset.clone === 'function') ? offset.clone() : offset;\n this.simulate3D = simulate3D;\n this.simulateRotation = simulateRotation;\n this.autoCenter = autoCenter;\n this.scaleMultiplier = (scaleMultiplier !== undefined) ? scaleMultiplier : Sorcherer.defaultScaleMultiplier;\n this._parentSpan = this.createSpan();\n this.template = '';\n this.dynamicVars = {};\n this._dynamicVarNames = new Set();\n this._disposed = false;\n this._removedListener = null;\n\n if (typeof document !== 'undefined' && Sorcherer.container) {\n Sorcherer.ensureContainerAttached();\n Sorcherer.container.appendChild(this._parentSpan);\n Sorcherer.allLoadedElements.add(this);\n }\n\n // Auto-remove overlay when the Object3D is removed from its parent.\n if (this.object && typeof this.object.addEventListener === 'function') {\n this._removedListener = (event) => {\n if (event?.target === this.object) {\n this.dispose();\n }\n };\n this.object.addEventListener('removed', this._removedListener);\n }\n }\n\n createSpan() {\n if (typeof document === 'undefined') {\n // Minimal stub for non-DOM environments.\n return {\n style: {},\n classList: { add() {} },\n innerHTML: '',\n parentElement: null\n };\n }\n const span = document.createElement('span');\n span.classList.add('magic-MinusOne');\n span.style.position = 'absolute';\n span.style.display = 'none';\n span.style.transformOrigin = 'top left';\n return span;\n }\n\n _clearDynamicVarProperties() {\n for (const varName of this._dynamicVarNames) {\n delete this[varName];\n }\n this._dynamicVarNames.clear();\n }\n\n _defineDynamicVarAccessor(varName) {\n if (this._dynamicVarNames.has(varName)) return;\n this._dynamicVarNames.add(varName);\n Object.defineProperty(this, varName, {\n get: () => this.getDynamicVar(varName),\n set: (value) => { this.setDynamicVar(varName, value); },\n enumerable: true,\n configurable: true\n });\n }\n\n /**\n * Attaches HTML content to the overlay.\n * Dynamic variable placeholders follow the syntax:\n * $varName$ or $varName=defaultValue$\n *\n * This method parses the template, stores dynamic variables, and renders the content.\n * @param {string} innerHTML - The HTML content to display.\n */\n attach(innerHTML) {\n this.template = String(innerHTML ?? '');\n this.dynamicVars = {};\n this._clearDynamicVarProperties();\n\n const regex = /\\$([a-zA-Z0-9_]+)(?:=([^$]+))?\\$/g;\n this.template.replace(regex, (match, varName, defaultVal) => {\n if (!(varName in this.dynamicVars)) {\n this.dynamicVars[varName] = (defaultVal !== undefined) ? defaultVal : '';\n }\n this._defineDynamicVarAccessor(varName);\n return match;\n });\n\n this.renderDynamicVars();\n this._parentSpan.style.display = 'block';\n }\n\n /**\n * Renders the overlay content by replacing placeholders with current dynamic variable values.\n */\n renderDynamicVars() {\n const rendered = this.template.replace(/\\$([a-zA-Z0-9_]+)(?:=[^$]+)?\\$/g, (match, varName) => {\n return (this.dynamicVars[varName] !== undefined) ? this.dynamicVars[varName] : '';\n });\n this._parentSpan.innerHTML = rendered;\n }\n\n /**\n * Sets the value of a dynamic variable and re-renders the overlay.\n * @param {string} varName - The variable name.\n * @param {string} value - The new value.\n */\n setDynamicVar(varName, value) {\n this.dynamicVars[varName] = value;\n this.renderDynamicVars();\n }\n\n /**\n * Gets the current value of a dynamic variable.\n * @param {string} varName - The variable name.\n * @returns {string} The value.\n */\n getDynamicVar(varName) {\n return this.dynamicVars[varName];\n }\n\n /**\n * Updates the overlay's position, scaling (based on distance), and rotation.\n * @param {THREE.Camera} camera - The active camera.\n * @param {THREE.Renderer} renderer - The active renderer.\n */\n bufferInstance(camera, renderer) {\n if (!this.object || !camera || !renderer?.domElement) return;\n if (!this.object.visible) {\n Sorcherer._hideOverlay(this);\n return;\n }\n\n const domElement = renderer.domElement;\n const viewportWidth = domElement.clientWidth || domElement.width || 0;\n const viewportHeight = domElement.clientHeight || domElement.height || 0;\n if (!viewportWidth || !viewportHeight) {\n Sorcherer._hideOverlay(this);\n return;\n }\n\n const objectWorldPos = Sorcherer._tempWorldPos;\n this.object.getWorldPosition(objectWorldPos);\n if (this.offset) objectWorldPos.add(this.offset);\n\n let distance = camera.position.distanceTo(objectWorldPos);\n if (!Number.isFinite(distance) || distance <= 0) distance = 0.0001;\n\n const projectedPos = Sorcherer._tempProjectedPos;\n projectedPos.copy(objectWorldPos).project(camera);\n\n const widthHalf = viewportWidth / 2;\n const heightHalf = viewportHeight / 2;\n const x = widthHalf * (projectedPos.x + 1);\n const y = heightHalf * (1 - projectedPos.y);\n\n let transform = `translate(${x}px, ${y}px)`;\n if (this.autoCenter) {\n transform += ' translate(-50%, -50%)';\n }\n\n if (this.simulate3D) {\n const referenceDistance = 5;\n const scale = Math.max(0.1, this.scaleMultiplier * (referenceDistance / distance));\n transform += ` scale(${scale})`;\n }\n\n if (this.simulateRotation) {\n const angleDeg = (this.object.rotation?.z || 0) * (180 / Math.PI);\n transform += ` rotate(${angleDeg}deg)`;\n }\n\n this._parentSpan.style.transform = transform;\n this._parentSpan.style.zIndex = Math.round(1000 / distance).toString();\n this._parentSpan.style.display = 'block';\n }\n\n /**\n * Updates all overlays based on the active camera and renderer.\n * Performs frustum culling.\n * @param {THREE.Camera} camera - The active camera.\n * @param {THREE.Renderer} renderer - The active renderer.\n */\n static bufferAll(camera, renderer) {\n if (!camera || !renderer) return;\n Sorcherer.ensureContainerAttached();\n\n Sorcherer.matrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);\n Sorcherer.frustum.setFromProjectionMatrix(Sorcherer.matrix);\n\n for (const element of Sorcherer.allLoadedElements) {\n if (!element.object) {\n Sorcherer._hideOverlay(element);\n continue;\n }\n\n const worldPos = Sorcherer._tempFrustumPos;\n element.object.getWorldPosition(worldPos);\n if (Sorcherer.frustum.containsPoint(worldPos)) {\n element.bufferInstance(camera, renderer);\n } else {\n Sorcherer._hideOverlay(element);\n }\n }\n }\n\n /**\n * Starts the auto-update loop.\n * @param {THREE.Camera} camera - The active camera.\n * @param {THREE.Renderer} renderer - The active renderer.\n * @param {number} [interval=16] - Minimum milliseconds between updates.\n */\n static autoSetup(camera, renderer, interval = 16) {\n if (Sorcherer.autoUpdateRunning || !camera || !renderer) return;\n Sorcherer.autoUpdateRunning = true;\n\n const tickInterval = Number.isFinite(interval) ? Math.max(0, interval) : 16;\n const useRaf = (typeof requestAnimationFrame === 'function');\n\n if (useRaf) {\n let lastTime = 0;\n const loop = (time) => {\n if (!Sorcherer.autoUpdateRunning) return;\n if (!lastTime || time - lastTime >= tickInterval) {\n lastTime = time;\n Sorcherer.bufferAll(camera, renderer);\n }\n requestAnimationFrame(loop);\n };\n requestAnimationFrame(loop);\n } else {\n const loop = () => {\n if (!Sorcherer.autoUpdateRunning) return;\n Sorcherer.bufferAll(camera, renderer);\n Sorcherer._timeoutHandle = setTimeout(loop, tickInterval);\n };\n loop();\n }\n }\n\n static stopAutoSetup() {\n Sorcherer.autoUpdateRunning = false;\n if (Sorcherer._timeoutHandle) {\n clearTimeout(Sorcherer._timeoutHandle);\n Sorcherer._timeoutHandle = null;\n }\n }\n\n /**\n * Removes the overlay and cleans up references.\n */\n dispose() {\n if (this._disposed) return;\n this._disposed = true;\n\n const object = this.object;\n if (object && this._removedListener && typeof object.removeEventListener === 'function') {\n object.removeEventListener('removed', this._removedListener);\n }\n\n if (this._parentSpan.parentElement) {\n this._parentSpan.parentElement.removeChild(this._parentSpan);\n }\n\n Sorcherer.allLoadedElements.delete(this);\n if (object && object.name && Sorcherer.instancesById[object.name] === this) {\n delete Sorcherer.instancesById[object.name];\n }\n\n this._removedListener = null;\n this.object = null;\n }\n\n /**\n * Registers a Three.js Object3D using its name as the key.\n * @param {THREE.Object3D} object - The object to register.\n */\n static registerObject3D(object) {\n if (object && object.name) {\n Sorcherer.objectRegistry.set(object.name, object);\n }\n }\n\n /**\n * Convenience: register all named objects in a scene (or any Object3D subtree).\n * @param {THREE.Object3D} scene - Root to traverse.\n */\n static registerScene(scene) {\n if (!scene || typeof scene.traverse !== 'function') return;\n scene.traverse((obj) => {\n if (obj && obj.name) {\n Sorcherer.registerObject3D(obj);\n }\n });\n }\n\n /**\n * High-level convenience: register all named objects in a scene,\n * attach overlays from <realm>, and start the auto-update loop.\n *\n * This replaces manual calls to:\n * Sorcherer.registerObject3D(...)\n * Sorcherer.attachFromRealm()\n * Sorcherer.autoSetup(camera, renderer)\n *\n * @param {THREE.Scene} scene\n * @param {THREE.Camera} camera\n * @param {THREE.Renderer} renderer\n * @param {Object} [options]\n * @param {number} [options.interval=16] Min ms between updates.\n * @param {boolean} [options.autoAttach=true] Call attachFromRealm().\n * @param {boolean} [options.autoRegister=true] Call registerScene().\n */\n static bootstrap(scene, camera, renderer, options = {}) {\n const {\n interval = 16,\n autoAttach = true,\n autoRegister = true,\n } = options;\n\n if (!camera || !renderer) {\n console.warn('[Sorcherer] bootstrap() requires a camera and renderer.');\n return;\n }\n\n if (autoRegister) {\n Sorcherer.registerScene(scene);\n }\n\n if (autoAttach && typeof document !== 'undefined') {\n Sorcherer.attachFromRealm();\n }\n\n Sorcherer.autoSetup(camera, renderer, interval);\n }\n\n /**\n * Scans the DOM for custom <realm> tags. For each child element with an \"idm\" attribute,\n * this method looks up the registered Object3D and reads additional attributes:\n * - simulate3D: \"true\" enables distance-based scaling.\n * - simulateRotation: \"true\" enables rotation via CSS transform.\n * - offset: A comma-separated list (e.g., \"0,0.5,0\") defining a THREE.Vector3 offset.\n * - autoCenter: \"true\" centers the overlay relative to its computed position.\n * - scaleMultiplier: A number to multiply the computed scale factor.\n * The overlay's content may include dynamic variable placeholders.\n *\n * @param {Document|Element} [root=document] - Optional root node to search within.\n */\n static attachFromRealm(root) {\n if (typeof document === 'undefined') return;\n\n const rootNode = root || document;\n if (!rootNode || typeof rootNode.querySelectorAll !== 'function') return;\n\n const realmElements = rootNode.querySelectorAll('realm');\n if (!realmElements.length) return;\n\n realmElements.forEach((realmElement) => {\n const elements = realmElement.querySelectorAll('[idm]');\n elements.forEach((el) => {\n const idm = el.getAttribute('idm');\n if (!idm) return;\n\n const object = Sorcherer.objectRegistry.get(idm);\n if (!object) return;\n\n const simulate3D = Sorcherer._readBooleanAttribute(el, 'simulate3D');\n const simulateRotation = Sorcherer._readBooleanAttribute(el, 'simulateRotation');\n const autoCenter = Sorcherer._readBooleanAttribute(el, 'autoCenter');\n const offset = Sorcherer._parseVector3Attribute(el, 'offset');\n const scaleMultiplier = Sorcherer._parseNumberAttribute(el, 'scaleMultiplier');\n\n if (object.name && Sorcherer.instancesById[object.name]) {\n Sorcherer.instancesById[object.name].dispose();\n }\n\n const instance = new Sorcherer(object, offset, simulate3D, simulateRotation, autoCenter, scaleMultiplier);\n instance.attach(el.innerHTML);\n el.remove();\n if (object.name) {\n Sorcherer.instancesById[object.name] = instance;\n }\n });\n\n if (realmElement.children.length === 0) {\n realmElement.remove();\n }\n });\n }\n\n /**\n * Clones the current overlay instance and attaches the clone to the specified Object3D.\n * @param {THREE.Object3D} targetObject - The target Object3D to attach the clone.\n * @param {string} newName - The new name for the cloned overlay (and the target Object3D).\n * @returns {Sorcherer} The cloned overlay instance.\n */\n attachClone(targetObject, newName) {\n const clone = new Sorcherer(\n targetObject,\n (this.offset && typeof this.offset.clone === 'function') ? this.offset.clone() : this.offset,\n this.simulate3D,\n this.simulateRotation,\n this.autoCenter,\n this.scaleMultiplier\n );\n\n clone.attach(this.template);\n for (const [key, value] of Object.entries(this.dynamicVars)) {\n clone.setDynamicVar(key, value);\n }\n\n if (newName) {\n targetObject.name = newName;\n Sorcherer.instancesById[newName] = clone;\n }\n\n return clone;\n }\n}\n\nexport { Sorcherer };\n"],"names":[],"mappings":""}