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.

362 lines (327 loc) 13.4 kB
// src/sorcherer.js // Dynamically load magicalStyle.css if not already loaded. (function loadMagicalStyle() { if (typeof document !== "undefined" && !document.getElementById('magical-style')) { const link = document.createElement('link'); link.id = 'magical-style'; link.rel = 'stylesheet'; // Adjust the path if your structure changes. link.href = '/magicalStyle.css'; document.head.appendChild(link); } })(); import { Vector3, Frustum, Matrix4 } from 'three'; /** * Sorcherer - A library for attaching HTML overlays to Three.js Object3D instances. * * Overlays can be defined via a custom `<realm>` tag and support: * - Distance-based scaling (simulate3D) * - Rotation via CSS's `rotate` property (simulateRotation) * - Auto-centering * - Custom offsets * - DOM culling based on camera frustum * - Dynamic variables using placeholders like $varName$ or $varName=defaultValue$ */ export class Sorcherer { // All overlay instances (stored in a Set) static allLoadedElements = new Set(); // For frustum culling. static frustum = new Frustum(); static matrix = new Matrix4(); // Container element for all overlays. static container = document.createElement('div'); static autoUpdateRunning = false; // Registry mapping Object3D names to objects. static objectRegistry = new Map(); // Global dictionary mapping idm (i.e. Object3D name) to overlay instance. static instancesById = {}; // Alias for instancesById. static get elements() { return Sorcherer.instancesById; } // Default scale multiplier (developers can change this via Sorcherer.defaultScaleMultiplier). static defaultScaleMultiplier = 1; // Static initializer: attach the overlay container. static { if (typeof document !== "undefined") { Sorcherer.container.classList.add('sorcherer-container'); document.body.appendChild(Sorcherer.container); } } /** * @param {THREE.Object3D} object - The target Object3D. * @param {THREE.Vector3} [offset=new Vector3()] - Optional offset for the overlay. * @param {boolean} [simulate3D=false] - Whether to scale the overlay based on distance. * @param {boolean} [simulateRotation=false] - Whether to rotate the overlay with the object. * @param {boolean} [autoCenter=false] - Whether to auto-center the overlay relative to its computed screen position. * @param {number} [scaleMultiplier] - Multiplier for distance-based scaling (defaults to Sorcherer.defaultScaleMultiplier). */ constructor(object, offset = new Vector3(), simulate3D = false, simulateRotation = false, autoCenter = false, scaleMultiplier) { this.object = object; this.offset = offset; this.simulate3D = simulate3D; this.simulateRotation = simulateRotation; this.autoCenter = autoCenter; this.scaleMultiplier = (scaleMultiplier !== undefined) ? scaleMultiplier : Sorcherer.defaultScaleMultiplier; this._parentSpan = this.createSpan(); this.innerHTML = ""; // For dynamic variables: store the original template and dynamicVars. this.template = ""; this.dynamicVars = {}; if (typeof document !== "undefined") { Sorcherer.container.appendChild(this._parentSpan); Sorcherer.allLoadedElements.add(this); } // Auto-remove overlay when the Object3D is disposed. if (object && typeof object.dispose === 'function') { const originalDispose = object.dispose.bind(object); object.dispose = () => { this.dispose(); originalDispose(); }; } } /** * Creates the overlay element. * @returns {HTMLSpanElement} */ createSpan() { const span = document.createElement('span'); span.classList.add('magic-MinusOne'); span.style.position = 'absolute'; span.style.display = 'none'; span.style.transformOrigin = 'top left'; return span; } /** * Attaches HTML content to the overlay. * Dynamic variable placeholders follow the syntax: * $varName$ or $varName=defaultValue$ * * This method parses the template, stores dynamic variables, and renders the content. * @param {string} innerHTML - The HTML content to display. */ attach(innerHTML) { // Save the template. this.template = innerHTML; this.dynamicVars = {}; // Regex to match placeholders: $varName$ or $varName=defaultValue$ this.template.replace(/\$([a-zA-Z0-9_]+)(?:=([^$]+))?\$/g, (match, varName, defaultVal) => { this.dynamicVars[varName] = (defaultVal !== undefined) ? defaultVal : ''; // Define getter/setter for direct property access. Object.defineProperty(this, varName, { get: () => this.getDynamicVar(varName), set: (value) => { this.setDynamicVar(varName, value); }, enumerable: true, configurable: true }); return match; }); this.renderDynamicVars(); this._parentSpan.style.display = 'block'; } /** * Renders the overlay content by replacing placeholders with current dynamic variable values. */ renderDynamicVars() { const rendered = this.template.replace(/\$([a-zA-Z0-9_]+)(?:=[^$]+)?\$/g, (match, varName) => { return (this.dynamicVars[varName] !== undefined) ? this.dynamicVars[varName] : ''; }); this._parentSpan.innerHTML = rendered; } /** * Sets the value of a dynamic variable and re-renders the overlay. * @param {string} varName - The variable name. * @param {string} value - The new value. */ setDynamicVar(varName, value) { this.dynamicVars[varName] = value; this.renderDynamicVars(); } /** * Gets the current value of a dynamic variable. * @param {string} varName - The variable name. * @returns {string} The value. */ getDynamicVar(varName) { return this.dynamicVars[varName]; } /** * Updates the overlay’s position, scaling (based on distance), and rotation. * Also re-renders dynamic variables. * @param {THREE.Camera} camera - The active camera. * @param {THREE.Renderer} renderer - The active renderer. */ async bufferInstance(camera, renderer) { if (!this.object) return; if (!this.object.visible) { this._parentSpan.style.display = 'none'; return; } // Get the object's world position and add the offset. const objectWorldPos = new Vector3(); this.object.getWorldPosition(objectWorldPos); objectWorldPos.add(this.offset); // Compute the Euclidean distance from the camera. const distance = camera.position.distanceTo(objectWorldPos); // Project the position to screen space. const projectedPos = objectWorldPos.clone(); projectedPos.project(camera); const widthHalf = renderer.domElement.clientWidth / 2; const heightHalf = renderer.domElement.clientHeight / 2; const x = widthHalf * (projectedPos.x + 1); const y = heightHalf * (1 - projectedPos.y); // Build the transform string for translation. let transform = `translate(${x}px, ${y}px)`; if (this.autoCenter) { transform += ' translate(-50%, -50%)'; } // Apply distance-based scaling if enabled. if (this.simulate3D) { const referenceDistance = 5; const scale = Math.max(0.1, this.scaleMultiplier * (referenceDistance / distance)); transform += ` scale(${scale})`; } this._parentSpan.style.transform = transform; // Apply rotation via the CSS "rotate" property if enabled. if (this.simulateRotation) { const angleDeg = this.object.rotation.z * (180 / Math.PI); this._parentSpan.style.rotate = `${angleDeg}deg`; } else { this._parentSpan.style.rotate = ''; } // Adjust z-index based on distance. this._parentSpan.style.zIndex = Math.round(1000 / distance).toString(); this._parentSpan.style.display = 'block'; // Re-render dynamic variables. this.renderDynamicVars(); } /** * Updates all overlays based on the active camera and renderer. * Performs frustum culling. * @param {THREE.Camera} camera - The active camera. * @param {THREE.Renderer} renderer - The active renderer. */ static async bufferAll(camera, renderer) { Sorcherer.matrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); Sorcherer.frustum.setFromProjectionMatrix(Sorcherer.matrix); for (let element of Sorcherer.allLoadedElements) { const worldPos = new Vector3(); element.object.getWorldPosition(worldPos); if (Sorcherer.frustum.containsPoint(worldPos)) { await element.bufferInstance(camera, renderer); } else { element._parentSpan.style.display = 'none'; } } } /** * Starts the auto-update loop. * @param {THREE.Camera} camera - The active camera. * @param {THREE.Renderer} renderer - The active renderer. * @param {number} [interval=16] - Update interval in milliseconds. */ static autoSetup(camera, renderer, interval = 16) { if (Sorcherer.autoUpdateRunning) return; Sorcherer.autoUpdateRunning = true; const loop = async () => { if (!Sorcherer.autoUpdateRunning) return; await Sorcherer.bufferAll(camera, renderer); setTimeout(loop, interval); }; loop(); } /** * Removes the overlay and cleans up references. */ dispose() { if (this._parentSpan.parentElement) { this._parentSpan.parentElement.removeChild(this._parentSpan); } Sorcherer.allLoadedElements.delete(this); if (this.object && this.object.name && Sorcherer.instancesById[this.object.name]) { delete Sorcherer.instancesById[this.object.name]; } } /** * Registers a Three.js Object3D using its name as the key. * @param {THREE.Object3D} object - The object to register. */ static registerObject3D(object) { if (object && object.name) { Sorcherer.objectRegistry.set(object.name, object); } } /** * Scans the DOM for a custom <realm> tag. For each child element with an "idm" attribute, * this method looks up the registered Object3D and reads additional attributes: * - simulate3D: "true" enables distance-based scaling. * - simulateRotation: "true" enables rotation via CSS "rotate". * - offset: A comma-separated list (e.g., "0,0.5,0") defining a THREE.Vector3 offset. * - autoCenter: "true" centers the overlay relative to its computed position. * - scaleMultiplier: A number to multiply the computed scale factor. * The overlay's content may include dynamic variable placeholders. */ static attachFromRealm() { const realmElement = document.querySelector('realm'); if (!realmElement) return; const elements = realmElement.querySelectorAll('[idm]'); elements.forEach(el => { const idm = el.getAttribute('idm'); if (!idm) return; const object = Sorcherer.objectRegistry.get(idm); if (object) { const simulate3D = (el.getAttribute('simulate3D') || '').toLowerCase() === 'true'; const simulateRotation = (el.getAttribute('simulateRotation') || '').toLowerCase() === 'true'; const autoCenter = (el.getAttribute('autoCenter') || '').toLowerCase() === 'true'; let offset = new Vector3(); if (el.hasAttribute('offset')) { const offsetStr = el.getAttribute('offset'); const parts = offsetStr.split(',').map(s => parseFloat(s.trim())); if (parts.length >= 3 && parts.every(n => !isNaN(n))) { offset = new Vector3(parts[0], parts[1], parts[2]); } } let scaleMultiplier; if (el.hasAttribute('scaleMultiplier')) { const parsed = parseFloat(el.getAttribute('scaleMultiplier')); if (!isNaN(parsed)) { scaleMultiplier = parsed; } } const instance = new Sorcherer(object, offset, simulate3D, simulateRotation, autoCenter, scaleMultiplier); instance.attach(el.innerHTML); el.remove(); if (object.name) { Sorcherer.instancesById[object.name] = instance; } } }); if (realmElement.children.length === 0) { realmElement.remove(); } } /** * Clones the current overlay instance and attaches the clone to the specified Object3D. * @param {THREE.Object3D} targetObject - The target Object3D to attach the clone. * @param {string} newName - The new name for the cloned overlay (and the target Object3D). * @returns {Sorcherer} The cloned overlay instance. */ attachClone(targetObject, newName) { const clone = new Sorcherer( targetObject, this.offset, this.simulate3D, this.simulateRotation, this.autoCenter, this.scaleMultiplier ); clone.template = this.template; // Shallow-clone dynamicVars. clone.dynamicVars = { ...this.dynamicVars }; clone.renderDynamicVars(); if (newName) { targetObject.name = newName; Sorcherer.instancesById[newName] = clone; } return clone; } }