{"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":""}