import { useCallback, useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { Menu, X } from 'lucide-react';

const RELEASE = '0.17.0-rodin';
const DASHBOARD_ASPECT = 1320 / 540;
const BUTTON_ASPECT = 840 / 190;
const SPEECH_ASPECT = 900 / 540;
const VISITOR_AGENT_SCALE_AUDIT = {
  sourceRelease: '0.13.0-visit',
  targetRelease: RELEASE,
  scaleFactor: 1.35,
  previousByLayout: { desktop: 0.5, tablet: 0.44, mobile: 0.38 },
  currentByLayout: { desktop: 0.68, tablet: 0.6, mobile: 0.52 },
  previousLabelWorldWidth: { desktop: 1.56, tablet: 1.34, mobile: 1.14 },
  currentLabelWorldWidth: { desktop: 1.42, tablet: 1.26, mobile: 0.9 },
  currentLabelLocalY: { desktop: 2.86, tablet: 2.78, mobile: 2.58 },
  labelPolicy: 'Characters remain readable versus 0.13.0 but are no longer allowed to become near-camera foreground close-ups; 0.17.0-rodin keeps the service-aisle/mid-bar stop points and replaces the fixed service roles with textured Hyper3D/Rodin GLBs to reduce jagged close-ups, clipping, and prop-panel blocking.',
};

const VISUAL_POLISH_FIXES = [
  'raised effective renderer pixel ratio and softened shadow map edges',
  'added an in-scene headline in the previously empty upper-center hero area',
  'simplified Browse Agents, Enter My Booth, and elf speech textures for readable larger text',
  'moved walking visitors deeper into the service lane to reduce foreground clipping',
  'repositioned bartender/server and nearby UI to avoid tray/bar/prop intersections',
  'moved the elf server into a clearer right-side service aisle so her tray and lower body no longer read as merged with the bar table',
  'made roaming agent labels smaller and depth-tested so they do not cover the service speech bubble or characters',
  'kept the elf service role inside the right viewport margin after 2048px bounding-box review',
  'hid selected side props that made the bar read as noisy and caused apparent clipping',
];

const HIDDEN_POLISH_MODELS = new Set([
  'stall',
  'stall-bench',
  'stall-stool',
  'planks',
  'planks-half',
  'stairs-wide-wood',
  'cart',
  'rock-wide',
  'side-table-left',
  'side-table-right',
  'keg-decorated-left',
  'keg-right',
  'decorated-barrel-left',
  'barrel-stack-right',
  'stool-left-premium',
  'stool-center-premium',
  'chair-right-premium',
  'polyhaven-wine-bottles-right',
  'plate-stack-bar',
  'plate-food-left',
  'plate-food-right',
  'bottle-labeled-brown-left',
  'bottle-labeled-green-left',
  'bottle-brown-shelf',
  'bottle-green-shelf',
  'treasure-chest-left',
  'coin-stack-left',
  'crystal-blue-front-left',
  'crystal-blue-front-center',
  'crystal-purple-front-right',
  'polyhaven-vintage-tv-right',
]);

const WANTED_AGENTS = [
  { name: 'Iron Tusk', role: 'site ops', skill: 'embed runtime', bounty: '18c/h', tint: '#45e0ac', initials: 'IT' },
  { name: 'Velvet Hex', role: 'consent guide', skill: 'BYOA links', bounty: '14c/h', tint: '#b187ff', initials: 'VH' },
  { name: 'Socket Wraith', role: 'tinybox bridge', skill: 'port mapping', bounty: '16c/h', tint: '#55d8ff', initials: 'SW' },
  { name: 'Amber Ledger', role: 'contract desk', skill: 'pricing audit', bounty: '12c/h', tint: '#ffc25e', initials: 'AL' },
  { name: 'Northstar Fixer', role: 'merchant ops', skill: 'session scope', bounty: '20c/h', tint: '#9ddf72', initials: 'NF' },
  { name: 'Cinder Clerk', role: 'risk watch', skill: 'key rotation', bounty: '15c/h', tint: '#ff8c5e', initials: 'CC' },
  { name: 'Grain Runner', role: 'hosted TUI', skill: 'terminal state', bounty: '17c/h', tint: '#e9d68d', initials: 'GR' },
];

const IDLE_ANIMATIONS = [
  'Fixed service role: Hyper3D/Rodin Gen-1.5 mechanical orc bartender GLB uses packed texture material and an exported Blender root-armature idle clip behind the bar',
  'Fixed service role: Hyper3D/Rodin Gen-1.5 fantasy elf server GLB uses packed diffuse/normal textures and an exported Blender root-armature idle clip near the service lane',
  'KayKit CC0 rigged GLB actors: Knight, Mage, Barbarian, and Rogue_Hooded now enter as larger lounge visitors, order at the bar, show temporary overhead agent bubbles, then leave',
  'Kenney GLB props: lanterns, stalls, wood walls, planks, stairs, cart, banner, pillars',
  'KayKit Dungeon Remastered GLB set dressing: wall shelves, arched walls, wood floor tiles, decorated tables, stools, kegs, bottles, plates, candles, torches, treasure',
  'KayKit Adventurers GLTF mugs: full and empty cups on the bar with subtle bobbing',
  'Poly Pizza iPoly3D CC0 crystal GLBs: rotating blue/purple low-poly crystal clusters',
  'Poly Haven CC0 PBR GLTF props: brass goblets, wine bottles, and vintage television model integrated as real meshes',
  'in-scene screen/sign textures: wanted roster scroll, speech pulse, booth neon flicker',
  'camera and scene: drag-to-orbit parallax, particles, rune rotation, light flicker',
];

const MODEL_AUDIT = [
  { area: 'tavern shell', asset: 'procedural support room plus KayKit Dungeon wall/floor/shelf GLBs and Kenney wall/plank/stair GLBs', source: 'CC0 KayKit Dungeon Remastered + CC0 Kenney Fantasy Town Kit', type: 'real 3D mesh/GLB set' },
  { area: 'mechanical bartender', asset: 'hyper3d-orc-bartender-textured-root-idle.glb with complete bartender mesh, apron, metal arm, mug, packed 2048 texture, skin attributes, and idle clip', source: 'Hyper3D/Rodin Gen-1.5 webpage generation from project-owned reference, material-confirmed via SharedBrowser, finished in Blender 3.0.1', type: 'generated rigged animated textured GLB service role' },
  { area: 'female elf server', asset: 'hyper3d-elf-server-textured-root-idle.glb with complete elf server mesh, green-gold outfit, tray/bottles, packed 4096 diffuse/normal textures, skin attributes, and idle clip', source: 'Hyper3D/Rodin Gen-1.5 webpage generation from project-owned reference, material-confirmed via SharedBrowser, finished in Blender 3.0.1', type: 'generated rigged animated textured GLB service role' },
  { area: 'roaming agent staff', asset: 'KayKit Knight/Mage/Barbarian/Rogue_Hooded GLBs with Walking_A clips, bar-visit routes, and temporary overhead agent info bubbles', source: 'CC0 KayKit Adventurers Character Pack', type: 'loaded rigged animated GLB visitors' },
  { area: 'bar counter and room furniture', asset: 'KayKit Dungeon decorated long tables, tablecloth table, stools, chairs, shelves, kegs, barrels', source: 'CC0 KayKit Dungeon Remastered', type: 'loaded GLB environment models' },
  { area: 'cups and bottles', asset: 'KayKit GLTF mugs plus Poly Haven Brass Goblets/Wine Bottles PBR GLTF props', source: 'CC0 KayKit Adventurers + CC0 KayKit Dungeon Remastered + CC0 Poly Haven', type: 'loaded GLB/GLTF props with PBR textures' },
  { area: 'crystals', asset: 'five Poly Pizza/iPoly3D Crystal Pack GLBs with slow rotation/pulse', source: 'CC0 Poly Pizza Crystal Pack', type: 'loaded GLB props' },
  { area: 'Browse Agents screen', asset: 'animated CanvasTexture mounted on a modeled 3D screen, framed by KayKit wall/pillar/torch GLBs and Poly Haven Television 01 prop', source: 'repo-generated dynamic UI texture + CC0 KayKit Dungeon Remastered + CC0 Poly Haven', type: 'UI texture on modeled screen with sourced TV prop, not character sprite' },
  { area: 'Enter My Booth sign', asset: 'animated CanvasTexture mounted on bar-front mesh surface', source: 'repo-generated dynamic UI texture', type: 'UI texture on mesh, not character sprite' },
];

const INTERIOR_ASSET_AUDIT = [
  { area: 'bar', assets: ['table_long_decorated_A', 'table_long_tablecloth_decorated_A', 'keg_decorated', 'barrel_large_decorated'], source: 'KayKit Dungeon Remastered', license: 'CC0', visual: 'strong low-poly fantasy bar dressing; consistent with current rigged character style' },
  { area: 'interior', assets: ['wall_shelves', 'wall_arched', 'wall_window_open', 'floor_wood_large', 'pillar_decorated'], source: 'KayKit Dungeon Remastered', license: 'CC0', visual: 'materially better than primitive walls because shelves, masonry, wood tiles, and pillars are modeled GLBs' },
  { area: 'cups', assets: ['mug_full.gltf', 'mug_empty.gltf', 'brass_goblets_1k.gltf', 'wine_bottles_01_1k.gltf'], source: 'KayKit Adventurers Character Pack + Poly Haven', license: 'CC0', visual: 'bar top uses real cup, goblet, and bottle GLTF meshes with PBR texture detail; no cylinder placeholder' },
  { area: 'screen/TV', assets: ['wall_pillar', 'column', 'torch_lit', 'sword_shield_gold', 'Television_01_1k.gltf', 'modeled screen frame'], source: 'KayKit Dungeon Remastered + Poly Haven + repo mesh frame', license: 'CC0/repo', visual: 'animated Browse Agents screen remains dynamic UI texture, while the physical mount and adjacent TV/device props are real 3D assets' },
  { area: 'crystals', assets: ['crystal-1.glb', 'crystal-2.glb', 'crystal-3.glb', 'crystal-4.glb', 'crystal-5.glb'], source: 'Poly Pizza Crystal Pack by iPoly3D', license: 'CC0 1.0', visual: 'actual downloadable GLB crystal clusters, animated with rotation/pulse in scene' },
];

const KENNEY_MODELS = [
  { name: 'lantern', url: '/models/kenney/lantern.glb', position: [-4.7, 1.55, -2.7], rotation: [0, 0.35, 0], scale: 0.9 },
  { name: 'lantern', url: '/models/kenney/lantern.glb', position: [4.35, 1.47, -2.65], rotation: [0, -0.25, 0], scale: 0.9 },
  { name: 'stall', url: '/models/kenney/stall.glb', position: [-5.75, -1.53, -2.28], rotation: [0, 0.24, 0], scale: 0.92 },
  { name: 'stall-bench', url: '/models/kenney/stall-bench.glb', position: [4.8, -1.87, -1.18], rotation: [0, -0.35, 0], scale: 1.05 },
  { name: 'stall-stool', url: '/models/kenney/stall-stool.glb', position: [-4.2, -2.08, 1.65], rotation: [0, 0.1, 0], scale: 0.95 },
  { name: 'planks', url: '/models/kenney/planks.glb', position: [0, -2.12, 2.1], rotation: [0, 0, 0], scale: 1.15 },
  { name: 'planks-half', url: '/models/kenney/planks-half.glb', position: [3.2, -2.07, 1.95], rotation: [0, -0.15, 0], scale: 1.05 },
  { name: 'pillar-wood', url: '/models/kenney/pillar-wood.glb', position: [-6.4, -0.55, -2.8], rotation: [0, 0, 0], scale: 1.35 },
  { name: 'pillar-wood', url: '/models/kenney/pillar-wood.glb', position: [6.4, -0.55, -2.8], rotation: [0, 0, 0], scale: 1.35 },
  { name: 'wall-wood', url: '/models/kenney/wall-wood.glb', position: [-2.4, -0.85, -3.22], rotation: [0, 0, 0], scale: 1.38 },
  { name: 'wall-wood-arch', url: '/models/kenney/wall-wood-arch.glb', position: [2.7, -0.85, -3.2], rotation: [0, 0, 0], scale: 1.35 },
  { name: 'wall-wood-door', url: '/models/kenney/wall-wood-door.glb', position: [5.1, -0.82, -3.12], rotation: [0, 0, 0], scale: 1.25 },
  { name: 'stairs-wide-wood', url: '/models/kenney/stairs-wide-wood.glb', position: [5.75, -2.03, -0.2], rotation: [0, -0.55, 0], scale: 1.05 },
  { name: 'cart', url: '/models/kenney/cart.glb', position: [-5.55, -1.85, 1.05], rotation: [0, 0.68, 0], scale: 0.72 },
  { name: 'banner-red', url: '/models/kenney/banner-red.glb', position: [-4.85, 1.78, -2.95], rotation: [0, 0.05, 0], scale: 0.95 },
  { name: 'rock-wide', url: '/models/kenney/rock-wide.glb', position: [5.78, -2.12, 1.56], rotation: [0, -0.25, 0], scale: 0.72 },
];

const KAYKIT_ACTORS = [
  {
    name: 'kaykit-knight-agent',
    url: '/models/kaykit/adventurers/Knight.glb',
    group: 'knightAgent',
    animation: 'Walking_A',
    agentIndex: 0,
    route: 'frontCross',
    phase: 0.97,
    source: 'CC0 KayKit Adventurers Character Pack',
  },
  {
    name: 'kaykit-mage-agent',
    url: '/models/kaykit/adventurers/Mage.glb',
    group: 'mageAgent',
    animation: 'Walking_A',
    agentIndex: 2,
    route: 'rightLoop',
    phase: 0.33,
    source: 'CC0 KayKit Adventurers Character Pack',
  },
  {
    name: 'kaykit-barbarian-agent',
    url: '/models/kaykit/adventurers/Barbarian.glb',
    group: 'barbarianAgent',
    animation: 'Walking_A',
    agentIndex: 3,
    route: 'leftLoop',
    phase: 0.57,
    source: 'CC0 KayKit Adventurers Character Pack',
  },
  {
    name: 'kaykit-hooded-agent',
    url: '/models/kaykit/adventurers/Rogue_Hooded.glb',
    group: 'hoodedAgent',
    animation: 'Walking_A',
    agentIndex: 4,
    route: 'boothArc',
    phase: 0.77,
    source: 'CC0 KayKit Adventurers Character Pack',
  },
];

const SERVICE_CHARACTER_MODELS = [
  {
    name: 'generated-mech-orc-bartender',
    role: 'bartender',
    target: 'orc',
    url: '/models/generated/service/hyper3d-orc-bartender-textured-root-idle.glb',
    sourceUrl: 'https://hyper3d.ai/workspace/rodin/a4d6d782-d76b-49f8-b320-4e185300de6b',
    source: 'Hyper3D/Rodin Gen-1.5 webpage image-to-3D generation from ai-deck/mesh-upgrade-097/refs/mech-orc-bartender-ref-097-upload-small.jpg, material-confirmed through SharedBrowser, then Blender 3.0.1 texture preservation/smoothing/idle export',
    license: 'Generated GLB created for this project from project-owned generated reference; Hyper3D/Rodin web generation/export terms apply',
    format: 'generated textured GLB',
    rig: 'Blender root armature skin + exported orc_hyper3d_root_idle clip',
    assetMode: 'generated-glb',
    generationPipeline: 'imagegen reference -> Hyper3D/Rodin Gen-1.5 webpage geometry/material generation -> signed GLB/texture capture -> Blender 3.0.1 smooth/normalize/root-idle GLB export',
    rigState: 'root-armature skinned textured GLB, one exported idle animation clip; no sprite/image-plane fallback',
    desiredLocalHeight: 3.1,
    floorY: -1.72,
    animationPreference: ['orc_hyper3d_root_idle', 'Idle'],
    useExternalAccessories: false,
    smoothTexturedShading: true,
    visualFit: 'generated bulky fantasy/cyber mechanical orc bartender with complete apron, metal arm, shaker/mug, boots, and readable texture; replaces the previous bridge-like blocker model',
  },
  {
    name: 'generated-elf-server',
    role: 'server',
    target: 'elf',
    url: '/models/generated/service/hyper3d-elf-server-textured-root-idle.glb',
    sourceUrl: 'https://hyper3d.ai/workspace/rodin/52ce815a-c901-43f0-ae2d-b998de0ff547',
    source: 'Hyper3D/Rodin Gen-1.5 webpage image-to-3D generation from ai-deck/mesh-upgrade-097/refs/elf-tavern-server-ref-097-upload-small.jpg, material-confirmed through SharedBrowser, then Blender 3.0.1 diffuse/normal packing/smoothing/idle export',
    license: 'Generated GLB created for this project from project-owned generated reference; Hyper3D/Rodin web generation/export terms apply',
    format: 'generated textured GLB',
    rig: 'Blender root armature skin + exported elf_hyper3d_root_idle clip',
    assetMode: 'generated-glb',
    generationPipeline: 'imagegen reference -> Hyper3D/Rodin Gen-1.5 webpage geometry/material generation -> signed GLB and 4096 texture capture -> Blender 3.0.1 smooth/normalize/root-idle GLB export',
    rigState: 'root-armature skinned textured GLB, one exported idle animation clip; no sprite/image-plane fallback',
    desiredLocalHeight: 2.68,
    floorY: -1.62,
    animationPreference: ['elf_hyper3d_root_idle', 'Idle'],
    useExternalAccessories: false,
    smoothTexturedShading: true,
    visualFit: 'generated adult fantasy elf server with green-gold outfit, elf ears, tray and bottles, packed diffuse/normal texture; replaces the previous bridge-like attendant',
  },
];

const SERVICE_CHARACTER_AUDIT = SERVICE_CHARACTER_MODELS.map((item) => ({
  name: item.name,
  role: item.role,
  url: item.url,
  sourceUrl: item.sourceUrl,
  source: item.source,
  license: item.license,
  format: item.format,
  rig: item.rig,
  assetMode: item.assetMode,
  generationPipeline: item.generationPipeline,
  rigState: item.rigState,
  idleMotion: item.animationPreference.join(' -> '),
  visualFit: item.visualFit,
}));

const KAYKIT_DUNGEON = '/models/kaykit/dungeon';
const POLY_CRYSTALS = '/models/poly-pizza/crystals';
const POLY_HAVEN = '/models/polyhaven';

const PREMIUM_INTERIOR_MODELS = [
  { name: 'dungeon-wall-shelves-center', area: 'interior', url: `${KAYKIT_DUNGEON}/wall_shelves.gltf.glb`, position: [-3.65, -1.58, -3.02], rotation: [0, 0, 0], scale: 1.0, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'dungeon-wall-arched-right', area: 'interior', url: `${KAYKIT_DUNGEON}/wall_arched.gltf.glb`, position: [3.8, -1.58, -3.05], rotation: [0, 0, 0], scale: 1.05, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'dungeon-window-left', area: 'interior', url: `${KAYKIT_DUNGEON}/wall_window_open.gltf.glb`, position: [-6.15, -1.58, -2.98], rotation: [0, 0, 0], scale: 0.92, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'dungeon-wall-pillar-screen-left', area: 'screen/TV', url: `${KAYKIT_DUNGEON}/wall_pillar.gltf.glb`, position: [-2.92, -1.56, -2.82], rotation: [0, 0, 0], scale: 0.88, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'dungeon-wall-pillar-screen-right', area: 'screen/TV', url: `${KAYKIT_DUNGEON}/wall_pillar.gltf.glb`, position: [2.92, -1.56, -2.82], rotation: [0, 0, 0], scale: 0.88, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'floor-wood-left', area: 'interior', url: `${KAYKIT_DUNGEON}/floor_wood_large.gltf.glb`, position: [-3.6, -2.22, 1.12], rotation: [0, 0.02, 0], scale: 1.22, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'floor-wood-center', area: 'interior', url: `${KAYKIT_DUNGEON}/floor_wood_large_dark.gltf.glb`, position: [0, -2.215, 1.08], rotation: [0, 0, 0], scale: 1.24, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'floor-wood-right', area: 'interior', url: `${KAYKIT_DUNGEON}/floor_wood_large.gltf.glb`, position: [3.62, -2.22, 1.12], rotation: [0, -0.02, 0], scale: 1.22, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'rock-floor-corner', area: 'interior', url: `${KAYKIT_DUNGEON}/floor_tile_large_rocks.gltf.glb`, position: [5.75, -2.17, 0.2], rotation: [0, -0.18, 0], scale: 0.74, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'decorated-bar-table-main', area: 'bar', url: `${KAYKIT_DUNGEON}/table_long_decorated_A.gltf.glb`, position: [-2.4, -1.34, -0.08], rotation: [0, Math.PI / 2, 0], scale: 0.78, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'decorated-bar-table-right', area: 'bar', url: `${KAYKIT_DUNGEON}/table_long_tablecloth_decorated_A.gltf.glb`, position: [2.45, -1.34, -0.08], rotation: [0, Math.PI / 2, 0], scale: 0.78, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'side-table-left', area: 'bar', url: `${KAYKIT_DUNGEON}/table_medium_tablecloth.gltf.glb`, position: [-5.15, -2.03, 0.62], rotation: [0, 0.42, 0], scale: 0.64, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'side-table-right', area: 'bar', url: `${KAYKIT_DUNGEON}/table_small_decorated_A.gltf.glb`, position: [4.85, -2.08, 0.82], rotation: [0, -0.38, 0], scale: 0.72, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'shelf-large-left', area: 'interior', url: `${KAYKIT_DUNGEON}/shelf_large.gltf.glb`, position: [-4.55, -1.34, -2.52], rotation: [0, 0.06, 0], scale: 0.74, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'shelves-right', area: 'interior', url: `${KAYKIT_DUNGEON}/shelves.gltf.glb`, position: [4.58, -1.28, -2.46], rotation: [0, -0.1, 0], scale: 0.7, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'shelf-candles-high', area: 'interior', url: `${KAYKIT_DUNGEON}/shelf_small_candles.gltf.glb`, position: [0, 0.2, -2.72], rotation: [0, 0, 0], scale: 0.58, source: 'KayKit Dungeon Remastered CC0', light: { color: 0xffb46a, intensity: 0.55, distance: 3.2 } },
  { name: 'pillar-left-premium', area: 'interior', url: `${KAYKIT_DUNGEON}/pillar_decorated.gltf.glb`, position: [-6.42, -2.05, -1.68], rotation: [0, 0.04, 0], scale: 1.0, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'pillar-right-premium', area: 'interior', url: `${KAYKIT_DUNGEON}/pillar_decorated.gltf.glb`, position: [6.42, -2.05, -1.68], rotation: [0, -0.04, 0], scale: 1.0, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'screen-column-left', area: 'screen/TV', url: `${KAYKIT_DUNGEON}/column.gltf.glb`, position: [-3.08, -1.8, -2.4], rotation: [0, 0, 0], scale: 0.58, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'screen-column-right', area: 'screen/TV', url: `${KAYKIT_DUNGEON}/column.gltf.glb`, position: [3.08, -1.8, -2.4], rotation: [0, 0, 0], scale: 0.58, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'keg-decorated-left', area: 'bar', url: `${KAYKIT_DUNGEON}/keg_decorated.gltf.glb`, position: [-5.6, -1.94, -0.55], rotation: [0, 0.58, 0], scale: 0.62, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'keg-right', area: 'bar', url: `${KAYKIT_DUNGEON}/keg.gltf.glb`, position: [5.45, -1.94, -0.45], rotation: [0, -0.5, 0], scale: 0.58, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'decorated-barrel-left', area: 'bar', url: `${KAYKIT_DUNGEON}/barrel_large_decorated.gltf.glb`, position: [-6.1, -2.02, 0.7], rotation: [0, 0.42, 0], scale: 0.54, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'barrel-stack-right', area: 'bar', url: `${KAYKIT_DUNGEON}/barrel_small_stack.gltf.glb`, position: [5.78, -2.0, 1.02], rotation: [0, -0.4, 0], scale: 0.52, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'stool-left-premium', area: 'bar', url: `${KAYKIT_DUNGEON}/stool.gltf.glb`, position: [-3.45, -2.05, 1.34], rotation: [0, 0.1, 0], scale: 0.62, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'stool-center-premium', area: 'bar', url: `${KAYKIT_DUNGEON}/stool.gltf.glb`, position: [-0.55, -2.05, 1.35], rotation: [0, -0.06, 0], scale: 0.62, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'chair-right-premium', area: 'bar', url: `${KAYKIT_DUNGEON}/chair.gltf.glb`, position: [3.45, -2.02, 1.26], rotation: [0, -0.18, 0], scale: 0.6, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'mug-full-bartender', area: 'cups', url: `${KAYKIT_DUNGEON}/mug_full.gltf`, position: [-0.78, -0.72, 0.22], rotation: [0, 0.18, 0], scale: 0.44, source: 'KayKit Adventurers CC0', animate: 'mugBob' },
  { name: 'mug-empty-patron', area: 'cups', url: `${KAYKIT_DUNGEON}/mug_empty.gltf`, position: [0.82, -0.72, 0.2], rotation: [0, -0.2, 0], scale: 0.44, source: 'KayKit Adventurers CC0', animate: 'mugBob' },
  { name: 'polyhaven-brass-goblets-center', area: 'cups', url: `${POLY_HAVEN}/brass_goblets/brass_goblets_1k.gltf`, position: [-0.12, -0.78, -0.03], rotation: [0, 0.08, 0], scale: 1.38, source: 'Poly Haven Brass Goblets CC0 PBR GLTF', animate: 'mugBob', polish: 'pbr' },
  { name: 'polyhaven-wine-bottles-right', area: 'cups', url: `${POLY_HAVEN}/wine_bottles_01/wine_bottles_01_1k.gltf`, position: [2.95, -0.79, 0.42], rotation: [0, -0.32, 0], scale: 2.25, source: 'Poly Haven Wine Bottles 01 CC0 PBR GLTF', animate: 'mugBob', polish: 'pbr' },
  { name: 'polyhaven-wine-bottles-shelf', area: 'cups', url: `${POLY_HAVEN}/wine_bottles_01/wine_bottles_01_1k.gltf`, position: [-3.15, -0.64, -2.42], rotation: [0, 0.14, 0], scale: 1.75, source: 'Poly Haven Wine Bottles 01 CC0 PBR GLTF', polish: 'pbr' },
  { name: 'plate-stack-bar', area: 'cups', url: `${KAYKIT_DUNGEON}/plate_stack.gltf.glb`, position: [1.68, -0.8, 0.28], rotation: [0, 0, 0], scale: 0.46, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'plate-food-left', area: 'bar', url: `${KAYKIT_DUNGEON}/plate_food_A.gltf.glb`, position: [-1.78, -0.8, 0.27], rotation: [0, 0.12, 0], scale: 0.42, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'plate-food-right', area: 'bar', url: `${KAYKIT_DUNGEON}/plate_food_B.gltf.glb`, position: [2.35, -0.8, 0.24], rotation: [0, -0.08, 0], scale: 0.44, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'bottle-labeled-brown-left', area: 'cups', url: `${KAYKIT_DUNGEON}/bottle_A_labeled_brown.gltf.glb`, position: [-2.65, -0.55, -2.48], rotation: [0, 0.05, 0], scale: 0.5, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'bottle-labeled-green-left', area: 'cups', url: `${KAYKIT_DUNGEON}/bottle_A_labeled_green.gltf.glb`, position: [-2.12, -0.54, -2.48], rotation: [0, -0.1, 0], scale: 0.5, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'bottle-brown-shelf', area: 'cups', url: `${KAYKIT_DUNGEON}/bottle_B_brown.gltf.glb`, position: [2.18, -0.54, -2.48], rotation: [0, 0.11, 0], scale: 0.52, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'bottle-green-shelf', area: 'cups', url: `${KAYKIT_DUNGEON}/bottle_C_green.gltf.glb`, position: [2.7, -0.54, -2.48], rotation: [0, -0.04, 0], scale: 0.52, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'candle-lit-bar', area: 'interior', url: `${KAYKIT_DUNGEON}/candle_lit.gltf.glb`, position: [-3.25, -0.78, 0.22], rotation: [0, 0, 0], scale: 0.45, source: 'KayKit Dungeon Remastered CC0', light: { color: 0xffb15c, intensity: 0.8, distance: 2.4 }, animate: 'flame' },
  { name: 'torch-left-screen', area: 'screen/TV', url: `${KAYKIT_DUNGEON}/torch_lit.gltf.glb`, position: [-3.42, 0.65, -2.55], rotation: [0, 0.18, 0], scale: 0.52, source: 'KayKit Dungeon Remastered CC0', light: { color: 0xffa452, intensity: 0.7, distance: 3.4 }, animate: 'flame' },
  { name: 'torch-right-screen', area: 'screen/TV', url: `${KAYKIT_DUNGEON}/torch_lit.gltf.glb`, position: [3.42, 0.65, -2.55], rotation: [0, -0.18, 0], scale: 0.52, source: 'KayKit Dungeon Remastered CC0', light: { color: 0xffa452, intensity: 0.7, distance: 3.4 }, animate: 'flame' },
  { name: 'screen-gold-crest', area: 'screen/TV', url: `${KAYKIT_DUNGEON}/sword_shield_gold.gltf.glb`, position: [0, 2.02, -2.45], rotation: [0, 0, 0], scale: 0.46, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'polyhaven-vintage-tv-left', area: 'screen/TV', url: `${POLY_HAVEN}/television_01/Television_01_1k.gltf`, position: [-4.18, 0.05, -2.34], rotation: [0, 0.38, 0], scale: 1.15, source: 'Poly Haven Television 01 CC0 PBR GLTF', polish: 'pbr' },
  { name: 'polyhaven-vintage-tv-right', area: 'screen/TV', url: `${POLY_HAVEN}/television_01/Television_01_1k.gltf`, position: [4.22, 0.02, -2.3], rotation: [0, -0.42, 0], scale: 1.1, source: 'Poly Haven Television 01 CC0 PBR GLTF', polish: 'pbr' },
  { name: 'treasure-chest-left', area: 'decor', url: `${KAYKIT_DUNGEON}/chest_gold.glb`, position: [-5.35, -2.02, 1.42], rotation: [0, 0.28, 0], scale: 0.46, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'coin-stack-left', area: 'decor', url: `${KAYKIT_DUNGEON}/coin_stack_large.gltf.glb`, position: [-4.72, -1.98, 1.5], rotation: [0, 0.2, 0], scale: 0.34, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'banner-triple-red', area: 'interior', url: `${KAYKIT_DUNGEON}/banner_triple_red.gltf.glb`, position: [-5.3, 1.46, -2.58], rotation: [0, 0.04, 0], scale: 0.62, source: 'KayKit Dungeon Remastered CC0' },
  { name: 'crystal-blue-front-left', area: 'crystals', url: `${POLY_CRYSTALS}/crystal-1.glb`, position: [-6.15, -2.12, 1.28], rotation: [0, 0.12, 0], scale: 0.085, source: 'Poly Pizza iPoly3D Crystal Pack CC0', animate: 'crystalSpin', polish: 'crystal' },
  { name: 'crystal-blue-front-center', area: 'crystals', url: `${POLY_CRYSTALS}/crystal-2.glb`, position: [-5.35, -2.12, 0.62], rotation: [0, -0.08, 0], scale: 0.075, source: 'Poly Pizza iPoly3D Crystal Pack CC0', animate: 'crystalSpin', polish: 'crystal' },
  { name: 'crystal-purple-front-right', area: 'crystals', url: `${POLY_CRYSTALS}/crystal-3.glb`, position: [3.95, -2.12, 1.16], rotation: [0, 0.2, 0], scale: 0.15, source: 'Poly Pizza iPoly3D Crystal Pack CC0', animate: 'crystalSpin', polish: 'crystal' },
  { name: 'crystal-back-left', area: 'crystals', url: `${POLY_CRYSTALS}/crystal-4.glb`, position: [-5.65, -2.04, -0.18], rotation: [0, 0.32, 0], scale: 0.12, source: 'Poly Pizza iPoly3D Crystal Pack CC0', animate: 'crystalSpin', polish: 'crystal' },
  { name: 'crystal-back-right', area: 'crystals', url: `${POLY_CRYSTALS}/crystal-5.glb`, position: [5.55, -2.04, -0.2], rotation: [0, -0.26, 0], scale: 0.12, source: 'Poly Pizza iPoly3D Crystal Pack CC0', animate: 'crystalSpin', polish: 'crystal' },
];

const routes = [
  { id: 'home', label: 'Solution' },
  { id: 'agents', label: 'Staff' },
  { id: 'contracts', label: 'Pricing' },
  { id: 'signin', label: 'Sign Up' },
  { id: 'signin', label: 'Login' },
  { id: 'booth', label: 'My Booth' },
];

function ModelSceneApp() {
  const [route, setRoute] = useHashRoute();

  return (
    <main className="amerc-app">
      <h1 className="sr-only">amerc agent mercenary tavern</h1>
      <WoodTopbar route={route} setRoute={setRoute} />
      <TavernScene route={route} setRoute={setRoute} />
    </main>
  );
}

function useHashRoute() {
  const normalize = () => {
    const raw = window.location.hash.replace(/^#\/?/, '');
    return ['home', 'agents', 'booth', 'contracts', 'admin', 'signin'].includes(raw) ? raw : 'home';
  };
  const [route, setRouteState] = useState(normalize);

  useEffect(() => {
    const onHash = () => setRouteState(normalize());
    window.addEventListener('hashchange', onHash);
    window.addEventListener('popstate', onHash);
    return () => {
      window.removeEventListener('hashchange', onHash);
      window.removeEventListener('popstate', onHash);
    };
  }, []);

  const setRoute = useCallback((nextRoute) => {
    const nextHash = `/${nextRoute}`;
    if (window.location.hash !== `#${nextHash}`) {
      window.history.pushState(null, '', `${window.location.pathname}${window.location.search}#${nextHash}`);
    }
    setRouteState(nextRoute);
  }, []);

  return [route, setRoute];
}

function WoodTopbar({ route, setRoute }) {
  const [open, setOpen] = useState(false);
  const nav = (
    <>
      {routes.map((item, index) => (
        <button
          className={route === item.id ? 'active' : ''}
          key={`${item.id}-${item.label}-${index}`}
          onClick={() => {
            setRoute(item.id);
            setOpen(false);
          }}
          type="button"
        >
          {item.label}
        </button>
      ))}
      <button
        className={route === 'admin' ? 'active quartermaster-link' : 'quartermaster-link'}
        onClick={() => {
          setRoute('admin');
          setOpen(false);
        }}
        type="button"
      >
        Quartermaster
      </button>
    </>
  );

  return (
    <header className="wood-topbar">
      <button className="logo-plaque" onClick={() => setRoute('home')} type="button" aria-label="amerc home">
        <span className="logo-gem" aria-hidden="true" />
        <strong>amerc</strong>
        <small>agent mercenary</small>
        <em>v{RELEASE}</em>
      </button>
      <nav className="wood-nav" aria-label="Primary navigation">
        {nav}
      </nav>
      <button className="wood-menu" onClick={() => setOpen((value) => !value)} type="button" aria-label="Toggle menu">
        {open ? <X size={21} /> : <Menu size={21} />}
      </button>
      {open && <div className="wood-mobile">{nav}</div>}
    </header>
  );
}

function TavernScene({ route, setRoute }) {
  const mountRef = useRef(null);
  const routeRef = useRef(route);
  const setRouteRef = useRef(setRoute);
  const builtRef = useRef(null);
  const [fallback, setFallback] = useState(false);

  useEffect(() => {
    routeRef.current = route;
    if (builtRef.current) applyRouteToScene(builtRef.current, route);
  }, [route]);

  useEffect(() => {
    setRouteRef.current = setRoute;
  }, [setRoute]);

  useEffect(() => {
    const mount = mountRef.current;
    if (!mount) return undefined;

    let renderer;
    try {
      renderer = new THREE.WebGLRenderer({
        antialias: true,
        alpha: false,
        preserveDrawingBuffer: true,
        powerPreference: 'high-performance',
      });
    } catch {
      setFallback(true);
      return undefined;
    }

    setFallback(false);
    renderer.outputColorSpace = THREE.SRGBColorSpace;
    renderer.setPixelRatio(Math.min(Math.max(window.devicePixelRatio || 1, 1.65), 2.4));
    renderer.shadowMap.enabled = true;
    renderer.shadowMap.type = THREE.PCFShadowMap;
    renderer.domElement.setAttribute('data-version', RELEASE);
    renderer.domElement.style.cursor = 'grab';
    renderer.domElement.style.touchAction = 'none';
    mount.appendChild(renderer.domElement);
    const sceneBootId = `${RELEASE}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
    window.__AMERC_SCENE_BOOT_COUNT = (window.__AMERC_SCENE_BOOT_COUNT || 0) + 1;

    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x070504);
    scene.fog = new THREE.Fog(0x070504, 8, 22);

    const camera = new THREE.PerspectiveCamera(41, 16 / 9, 0.1, 60);
    const cameraTarget = new THREE.Vector3(0, 0.1, -1.1);

    const ambient = new THREE.HemisphereLight(0xd9efff, 0x2b1208, 1.55);
    const key = new THREE.DirectionalLight(0xffd39a, 2.9);
    key.position.set(-3.8, 7.5, 6);
    key.castShadow = true;
    key.shadow.mapSize.set(2048, 2048);
    key.shadow.bias = -0.00025;
    key.shadow.normalBias = 0.045;
    key.shadow.camera.near = 0.5;
    key.shadow.camera.far = 24;
    key.shadow.camera.left = -8;
    key.shadow.camera.right = 8;
    key.shadow.camera.top = 8;
    key.shadow.camera.bottom = -8;
    const cyan = new THREE.PointLight(0x27d8ff, 3.2, 10, 2);
    cyan.position.set(0, 2.4, -2.4);
    const amber = new THREE.PointLight(0xff9f38, 3.6, 9, 2);
    amber.position.set(-4.5, 1.7, -1.7);
    const violet = new THREE.PointLight(0x8d5bff, 2.4, 7, 2);
    violet.position.set(3.6, 0.8, -0.5);
    scene.add(ambient, key, cyan, amber, violet);

    const stage = new THREE.Group();
    scene.add(stage);

    const built = buildLowPolyTavern(() => routeRef.current);
    builtRef.current = built;
    applyRouteToScene(built, routeRef.current);
    stage.add(built.root);
    const gltfLoader = new GLTFLoader();
    loadKenneyModels(gltfLoader, built.kenneyModels, built.loadedGlbNames, () => {
      meshCount = countMeshes(stage);
      publishStageDebug(
        { width: renderer.domElement.clientWidth, height: renderer.domElement.clientHeight, layout, dragYaw: currentYaw, dragPitch: currentPitch, meshCount, ...dynamicMetrics() },
        tracked,
        camera,
      );
    });
    loadPremiumInteriorModels(gltfLoader, built, () => {
      meshCount = countMeshes(stage);
      publishStageDebug(
        { width: renderer.domElement.clientWidth, height: renderer.domElement.clientHeight, layout, dragYaw: currentYaw, dragPitch: currentPitch, meshCount, ...dynamicMetrics() },
        tracked,
        camera,
      );
    });
    loadServiceCharacters(gltfLoader, built, () => {
      meshCount = countMeshes(stage);
      publishStageDebug(
        { width: renderer.domElement.clientWidth, height: renderer.domElement.clientHeight, layout, dragYaw: currentYaw, dragPitch: currentPitch, meshCount, ...dynamicMetrics() },
        tracked,
        camera,
      );
    });
    loadSourcedActors(gltfLoader, built, () => {
      meshCount = countMeshes(stage);
      publishStageDebug(
        { width: renderer.domElement.clientWidth, height: renderer.domElement.clientHeight, layout, dragYaw: currentYaw, dragPitch: currentPitch, meshCount, ...dynamicMetrics() },
        tracked,
        camera,
      );
    });
    const interactives = [built.dashboard, built.boothButton, built.browseButton, built.speech, ...built.agentLabels];
    const tracked = {
      dashboard: built.dashboard,
      boothButton: built.boothButton,
      browseButton: built.browseButton,
      speech: built.speech,
      bartender: built.orc,
      elfServer: built.elf,
      knightAgent: built.actorGroups.knightAgent,
      mageAgent: built.actorGroups.mageAgent,
      barbarianAgent: built.actorGroups.barbarianAgent,
      hoodedAgent: built.actorGroups.hoodedAgent,
    };

    const raycaster = new THREE.Raycaster();
    const pointer = new THREE.Vector2();
    const drag = {
      active: false,
      startX: 0,
      startY: 0,
      moved: false,
      pointerId: null,
    };
    let hoverRoute = '';
    let frame = 0;
    let disposed = false;
    let layout = 'desktop';
    let meshCount = 0;
    let targetYaw = 0;
    let targetPitch = 0;
    let currentYaw = 0;
    let currentPitch = 0;
    let previousTime = 0;

    const dynamicMetrics = () => ({
      assetMode: 'true 3D mesh scene: fixed service roles use Hyper3D/Rodin Gen-1.5 + Blender skinned animated textured GLB characters; KayKit rigged GLB walking agents remain separate visitors; KayKit Dungeon/Adventurers interior GLB/GLTF props, Poly Haven PBR props, Poly Pizza crystal GLBs, Kenney GLB props; no sprite characters or bitmap backdrop',
      visualPolishFixes: VISUAL_POLISH_FIXES,
      hiddenPolishModels: Array.from(HIDDEN_POLISH_MODELS),
      rendererPixelRatio: renderer.getPixelRatio(),
      modelAudit: MODEL_AUDIT,
      interiorAssetAudit: INTERIOR_ASSET_AUDIT,
      serviceCharacterAudit: SERVICE_CHARACTER_AUDIT,
      glbModelSources: [...new Set([...visibleModelSpecs(KENNEY_MODELS).map((item) => item.url), ...visibleModelSpecs(PREMIUM_INTERIOR_MODELS).map((item) => item.url), ...SERVICE_CHARACTER_MODELS.map((item) => item.url), ...KAYKIT_ACTORS.map((item) => item.url)])],
      glbModelsLoaded: built.loadedGlbNames.slice(),
      glbModelCount: built.loadedGlbNames.length,
      glbModelRequestedCount: visibleModelSpecs(KENNEY_MODELS).length + visibleModelSpecs(PREMIUM_INTERIOR_MODELS).length + SERVICE_CHARACTER_MODELS.length + KAYKIT_ACTORS.length,
      premiumInteriorSources: [...new Set(visibleModelSpecs(PREMIUM_INTERIOR_MODELS).map((item) => item.source))],
      premiumInteriorAssetsRequested: visibleModelSpecs(PREMIUM_INTERIOR_MODELS).length,
      premiumInteriorLoaded: built.loadedInteriorNames.slice(),
      premiumInteriorLoadedCount: built.loadedInteriorNames.filter((name) => !name.endsWith(':failed')).length,
      premiumInteriorFailedCount: built.loadedInteriorNames.filter((name) => name.endsWith(':failed')).length,
      interiorAssetCoverage: [...new Set(visibleModelSpecs(PREMIUM_INTERIOR_MODELS).map((item) => item.area))],
      interiorAnimations: built.interiorAnimations.slice(),
      sourcedActorSources: KAYKIT_ACTORS.map((item) => ({ name: item.name, url: item.url, animation: item.animation, source: item.source })),
      sourcedActorsLoaded: built.loadedActorNames.slice(),
      sourcedActorCount: built.loadedActorNames.length,
      riggedActorCount: built.loadedActorNames.length,
      actorAnimations: built.actorAnimations.slice(),
      serviceCharacterSources: SERVICE_CHARACTER_MODELS.map((item) => ({ name: item.name, role: item.role, url: item.url, sourceUrl: item.sourceUrl, license: item.license, rig: item.rig, pipeline: item.generationPipeline })),
      generatedServiceModelPaths: SERVICE_CHARACTER_MODELS.map((item) => item.url),
      serviceGenerationPipeline: [...new Set(SERVICE_CHARACTER_MODELS.map((item) => item.generationPipeline))],
      serviceRigState: [...new Set(SERVICE_CHARACTER_MODELS.map((item) => item.rigState))],
      serviceAssetMode: built.proceduralFallbackVisible
        ? 'service-role GLB loading/fallback state: at least one procedural fallback is still visible'
        : 'generated-glb: fixed service roles are Hyper3D/Rodin + Blender generated textured skinned GLBs with exported idle clips; previous bridge-like service characters are no longer the visible primary assets',
      serviceCharactersLoaded: built.loadedServiceCharacterNames.slice(),
      serviceCharacterLoadedCount: built.loadedServiceCharacterNames.filter((name) => !name.endsWith(':failed')).length,
      serviceCharacterFailedCount: built.loadedServiceCharacterNames.filter((name) => name.endsWith(':failed')).length,
      serviceRiggedCharacterCount: built.loadedServiceCharacterNames.filter((name) => !name.endsWith(':failed')).length,
      serviceCharacterAnimations: built.serviceCharacterAnimations.slice(),
      serviceCharacterBounds: {
        bartender: summarizeWorldBounds(built.orc),
        server: summarizeWorldBounds(built.elf),
      },
      serviceRoles: {
        bartender: 'mechanical orc bartender fixed behind the bar; visible model is generated-mech-orc-bartender Hyper3D/Rodin GLB with complete textured body, metal arm, apron, mug/shaker, skin attributes, and idle clip',
        server: 'female elf server fixed in front of the bar with tray and speech bubble; visible model is generated-elf-server Hyper3D/Rodin GLB with textured body, ears, green-gold outfit, tray, bottles, skin attributes, and idle clip',
      },
      proceduralActors: built.proceduralFallbackVisible ? ['service-role procedural fallback visible because GLB failed or is still loading'] : [],
      proceduralFallbackVisible: built.proceduralFallbackVisible,
      retiredLegacyMeshes: ['old dwarf/patron mesh is not mounted in the visible scene'],
      legacyDwarfMeshVisible: built.dwarf.visible,
      bartenderVisible: built.orc.visible,
      serverVisible: built.elf.visible,
      walkingAgentCount: built.walkingAgentGroups.length,
      activeWalkingAgentCount: built.walkingAgentGroups.filter((group) => group.visible).length,
      agentRouteCount: built.agentRouteNames.length,
      agentRouteNames: built.agentRouteNames.slice(),
      agentRouteMode: 'occasional bar visit: one large 2x visitor at a time enters the side lane, approaches an off-center counter point, orders, shows a compact temporary info bubble, then leaves',
      agentLabelsVisible: built.agentLabels.filter((label) => label.visible).length,
      agentLabelCount: built.agentLabels.length,
      visitorAgentScaleAudit: VISITOR_AGENT_SCALE_AUDIT,
      visitorAgentScaleFactor: VISITOR_AGENT_SCALE_AUDIT.scaleFactor,
      visitorAgentScaleCurrentLayout: VISITOR_AGENT_SCALE_AUDIT.currentByLayout[layout],
      visitorAgentScalePreviousLayout: VISITOR_AGENT_SCALE_AUDIT.previousByLayout[layout],
      visitorAgentRuntime: summarizeVisitorAgents(built),
      visitorAgentBounds: Object.fromEntries(
        Object.entries(built.actorGroups).map(([name, group]) => [name, summarizeWorldBounds(group)]),
      ),
      agentScalePolicy: 'Task Chat #72/#73/#95/#97 scale policy: KayKit visiting agent characters remain readable versus the 0.13 visitor scale, while 0.17.0-rodin keeps the routes in the service aisle/midground and uses textured service-role GLBs to reduce clipping, jagged close-ups, and coverage of Browse Agents, Enter My Booth, the server, and the bartender.',
      routeChangeReloadsScene: false,
      sceneBootId,
      sceneBootCount: window.__AMERC_SCENE_BOOT_COUNT || 1,
      currentRoute: routeRef.current,
      spriteAssets: false,
      staticImageCharacters: false,
      bitmapBackdrops: false,
      texturePlanePolicy: 'Only dynamic UI/info surfaces use CanvasTexture planes; tavern, visible service characters, sourced walking agents, cups, bottles, bar props, crystals, room dressing, and hero composition are real 3D mesh/GLB/GLTF models.',
      idleAnimations: IDLE_ANIMATIONS,
      dynamicFrames: {
        wantedList: built.dashboard.material.map?.userData?.frame || 0,
        speech: built.speech.material.map?.userData?.frame || 0,
        boothNeon: built.boothButton.material.map?.userData?.frame || 0,
      },
      rosterScroll: round(built.dashboard.material.map?.userData?.scroll || 0),
      rosterActiveAgent: built.dashboard.material.map?.userData?.activeAgent || WANTED_AGENTS[0].name,
    });

    const setLayout = () => {
      const rect = mount.getBoundingClientRect();
      const width = Math.max(1, Math.floor(rect.width || window.innerWidth || 1));
      const height = Math.max(1, Math.floor(rect.height || window.innerHeight || 1));
      const aspect = width / height;
      const portrait = height > width * 1.05;
      layout = width < 760 && portrait ? 'mobile' : width < 1080 || aspect < 1.2 ? 'tablet' : 'desktop';

      renderer.setSize(width, height, false);
      camera.aspect = aspect;
      camera.fov = layout === 'mobile' ? 48 : layout === 'tablet' ? 43 : 40;
      if (layout === 'mobile') {
        camera.position.set(0, 3.35, 10.4);
        cameraTarget.set(0, 0.08, -1.05);
        stage.scale.setScalar(0.98);
        stage.position.set(0, 0.28, 0);
        placeMobile(built);
      } else if (layout === 'tablet') {
        camera.position.set(0, 3.25, 9.25);
        cameraTarget.set(0, 0.03, -1.1);
        stage.scale.setScalar(1);
        stage.position.set(0, -0.08, 0);
        placeTablet(built);
      } else {
        camera.position.set(0, 3.1, 8.6);
        cameraTarget.set(0, 0.05, -1.1);
        stage.scale.setScalar(1);
        stage.position.set(0, 0, 0);
        placeDesktop(built);
      }
      camera.updateProjectionMatrix();
      camera.lookAt(cameraTarget);
      meshCount = countMeshes(stage);
      publishStageDebug({ width, height, layout, dragYaw: currentYaw, dragPitch: currentPitch, meshCount, ...dynamicMetrics() }, tracked, camera);
    };

    const resizeObserver = new ResizeObserver(setLayout);
    resizeObserver.observe(mount);
    setLayout();

    const pickRoute = (event) => {
      const rect = renderer.domElement.getBoundingClientRect();
      pointer.x = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * 2 - 1;
      pointer.y = -((event.clientY - rect.top) / Math.max(rect.height, 1)) * 2 + 1;
      raycaster.setFromCamera(pointer, camera);
      const hit = raycaster.intersectObjects(interactives, false)[0];
      return hit?.object?.userData?.route || '';
    };

    const onPointerDown = (event) => {
      drag.active = true;
      drag.startX = event.clientX;
      drag.startY = event.clientY;
      drag.moved = false;
      drag.pointerId = event.pointerId;
      renderer.domElement.setPointerCapture?.(event.pointerId);
      renderer.domElement.style.cursor = 'grabbing';
    };

    const onPointerMove = (event) => {
      if (drag.active) {
        const dx = event.clientX - drag.startX;
        const dy = event.clientY - drag.startY;
        if (Math.hypot(dx, dy) > 4) drag.moved = true;
        targetYaw = clamp(dx * 0.00135, -0.22, 0.22);
        targetPitch = clamp(dy * 0.0009, -0.085, 0.085);
        return;
      }
      hoverRoute = pickRoute(event);
      renderer.domElement.style.cursor = hoverRoute ? 'pointer' : 'grab';
    };

    const onPointerUp = (event) => {
      renderer.domElement.releasePointerCapture?.(event.pointerId);
      drag.active = false;
      drag.pointerId = null;
      if (!drag.moved) {
        const nextRoute = pickRoute(event);
        if (nextRoute) setRouteRef.current(nextRoute);
      }
      hoverRoute = pickRoute(event);
      renderer.domElement.style.cursor = hoverRoute ? 'pointer' : 'grab';
    };

    const onPointerLeave = () => {
      if (!drag.active) {
        hoverRoute = '';
        renderer.domElement.style.cursor = 'grab';
      }
    };

    renderer.domElement.addEventListener('pointerdown', onPointerDown);
    renderer.domElement.addEventListener('pointermove', onPointerMove);
    renderer.domElement.addEventListener('pointerup', onPointerUp);
    renderer.domElement.addEventListener('pointercancel', onPointerUp);
    renderer.domElement.addEventListener('pointerleave', onPointerLeave);

    const startedAt = performance.now();
    const animate = () => {
      if (disposed) return;
      frame = requestAnimationFrame(animate);
      const t = (performance.now() - startedAt) / 1000;
      const delta = Math.min(0.05, Math.max(0, t - previousTime));
      previousTime = t;
      currentYaw += (targetYaw - currentYaw) * 0.12;
      currentPitch += (targetPitch - currentPitch) * 0.12;
      stage.rotation.y = currentYaw + Math.sin(t * 0.32) * 0.008;
      stage.rotation.x = currentPitch;

      built.orc.position.y = built.orc.userData.baseY + Math.sin(t * 0.9) * 0.035;
      built.orcHead.rotation.y = Math.sin(t * 0.8) * 0.12;
      built.orcArm.rotation.z = built.orcArm.userData.baseZ + Math.sin(t * 1.1) * 0.1;
      built.elf.position.y = built.elf.userData.baseY + Math.sin(t * 1.25 + 0.9) * 0.055;
      built.elf.rotation.y = built.elf.userData.baseRotY + Math.sin(t * 0.9) * 0.08;
      built.dwarf.position.y = built.dwarf.userData.baseY + Math.sin(t * 1.05 + 2.1) * 0.035;
      built.rune.rotation.z = t * 0.2;
      built.sparks.rotation.y = t * 0.035;
      built.sparks.position.y = Math.sin(t * 0.7) * 0.08;
      built.lanterns.forEach((light, index) => {
        light.intensity = light.userData.baseIntensity + Math.sin(t * 1.7 + index) * 0.35;
      });
      built.updaters.forEach((update) => update(t));
      built.serviceMixers.forEach((mixer) => mixer.update(delta));
      built.actorMixers.forEach((mixer) => mixer.update(delta));
      animateIdleRig(built, t);
      animateWalkingAgents(built, t);

      interactives.forEach((object) => {
        const targetScale = object.userData.route === hoverRoute ? 1.045 : 1;
        const base = object.userData.baseScale || object.scale;
        object.scale.lerp(new THREE.Vector3(base.x * targetScale, base.y * targetScale, base.z), 0.18);
      });

      if (Math.floor(t * 10) % 3 === 0) {
        publishStageDebug(
          { width: renderer.domElement.clientWidth, height: renderer.domElement.clientHeight, layout, dragYaw: currentYaw, dragPitch: currentPitch, meshCount, ...dynamicMetrics() },
          tracked,
          camera,
        );
      }
      renderer.render(scene, camera);
    };
    animate();

    return () => {
      disposed = true;
      resizeObserver.disconnect();
      renderer.domElement.removeEventListener('pointerdown', onPointerDown);
      renderer.domElement.removeEventListener('pointermove', onPointerMove);
      renderer.domElement.removeEventListener('pointerup', onPointerUp);
      renderer.domElement.removeEventListener('pointercancel', onPointerUp);
      renderer.domElement.removeEventListener('pointerleave', onPointerLeave);
      cancelAnimationFrame(frame);
      disposeScene(scene);
      renderer.dispose();
      if (renderer.domElement.parentNode === mount) mount.removeChild(renderer.domElement);
      builtRef.current = null;
    };
  }, []);

  return (
    <div className="tavern-stage" ref={mountRef} aria-label="Draggable low-poly Three.js AI mercenary tavern bar scene">
      {fallback && <div className="webgl-fallback" aria-hidden="true" />}
    </div>
  );
}

function resolveRouteSource(routeSource) {
  if (typeof routeSource === 'function') return routeSource();
  if (routeSource && typeof routeSource === 'object' && 'current' in routeSource) return routeSource.current;
  return routeSource || 'home';
}

function applyRouteToScene(built, route) {
  if (!built) return;
  built.currentRoute = route;
  if (built.dashboard?.material?.map?.userData) built.dashboard.material.map.userData.route = route;
  if (built.speech?.material?.map?.userData) built.speech.material.map.userData.route = route;
  if (built.speech) built.speech.userData.route = route === 'signin' ? 'booth' : 'agents';
}

function buildLowPolyTavern(routeSource) {
  const root = new THREE.Group();
  const staticSet = new THREE.Group();
  const characterSet = new THREE.Group();
  const sourcedActors = new THREE.Group();
  const uiSet = new THREE.Group();
  const kenneyModels = new THREE.Group();
  const premiumInteriorModels = new THREE.Group();
  kenneyModels.name = 'kenney-cc0-glb-model-layer';
  premiumInteriorModels.name = 'premium-interior-glb-model-layer';
  sourcedActors.name = 'kaykit-cc0-rigged-glb-actor-layer';
  root.add(staticSet, kenneyModels, premiumInteriorModels, characterSet, sourcedActors, uiSet);

  buildRoom(staticSet);
  const lanterns = buildProps(staticSet);
  const rune = makeRuneRing();
  rune.position.set(0, -1.98, 0.48);
  rune.rotation.x = -Math.PI / 2;
  root.add(rune);

  const sparks = makeSparks();
  sparks.position.set(0, 0.2, -0.8);
  root.add(sparks);

  const orc = makeOrc();
  const elf = makeElf();
  markProceduralFallback(orc);
  markProceduralFallback(elf);
  const dwarf = new THREE.Group();
  dwarf.name = 'retired-legacy-dwarf-mesh-removed';
  dwarf.visible = false;
  const agentLabels = [];
  characterSet.add(orc, elf);
  const actorGroups = {
  };
  KAYKIT_ACTORS.forEach((spec) => {
    const group = new THREE.Group();
    group.name = `kaykit-${spec.group}`;
    group.userData.actorSpec = spec;
    group.userData.routeName = spec.route;
    group.userData.routePhase = spec.phase;
    const label = makeAgentInfoLabel(WANTED_AGENTS[spec.agentIndex] || WANTED_AGENTS[0]);
    label.name = `${spec.name}-overhead-info`;
    label.position.set(0, 5.6, 0.2);
    group.userData.agent = WANTED_AGENTS[spec.agentIndex] || WANTED_AGENTS[0];
    group.userData.agentLabel = label;
    group.userData.walkingAgent = true;
    group.userData.optionalMovingActor = true;
    group.add(label);
    agentLabels.push(label);
    actorGroups[spec.group] = group;
    sourcedActors.add(group);
  });
  const routeLines = makeAgentRouteLines();
  routeLines.name = 'visible-agent-walking-routes';
  routeLines.visible = false;
  routeLines.userData.routesDefined = true;
  uiSet.add(routeLines);

  const wantedScreen = makeWantedScreenTexture(routeSource);
  const boothNeon = makeBoothNeonTexture();
  const dynamicSpeech = makeDynamicSpeechTexture(routeSource);
  const title = makeCanvasPlane(makeTitleTexture(), { renderOrder: 30, depthTest: false });
  title.visible = true;
  const screenFrame = makeScreenFrame();
  const dashboard = makeCanvasPlane(wantedScreen.texture, { renderOrder: 26, route: 'agents', depthTest: true });
  const boothButton = makeCanvasPlane(boothNeon.texture, { renderOrder: 52, route: 'booth', depthTest: false });
  const browseButton = makeCanvasPlane(makeScreenTabTexture(), { renderOrder: 32, route: 'agents', depthTest: true });
  const route = resolveRouteSource(routeSource);
  const speech = makeCanvasPlane(dynamicSpeech.texture, { renderOrder: 40, route: route === 'signin' ? 'booth' : 'agents', depthTest: true });
  uiSet.add(title, screenFrame, dashboard, boothButton, browseButton, speech);

  return {
    root,
    title,
    dashboard,
    boothButton,
    browseButton,
    speech,
    orc,
    elf,
    dwarf,
    screenFrame,
    rune,
    sparks,
    lanterns,
    kenneyModels,
    premiumInteriorModels,
    sourcedActors,
    actorGroups,
    walkingAgentGroups: Object.values(actorGroups),
    agentLabels,
    agentRouteNames: [...new Set(KAYKIT_ACTORS.map((item) => item.route))],
    routeLines,
    loadedGlbNames: [],
    loadedInteriorNames: [],
    loadedServiceCharacterNames: [],
    loadedActorNames: [],
    serviceCharacterAnimations: [],
    actorAnimations: [],
    interiorAnimations: [],
    serviceMixers: [],
    actorMixers: [],
    proceduralFallbackVisible: true,
    orcHead: orc.userData.head,
    orcArm: orc.userData.arm,
    updaters: [wantedScreen.update, boothNeon.update, dynamicSpeech.update],
  };
}

function buildRoom(group) {
  const floorMat = woodMaterial('#3a2618', '#8a4a24', '#180b06');
  const wallMat = material(0x2e2b2b);
  const upperWallMat = material(0x35261f);
  const wood = woodMaterial('#7a3a18', '#d0883c', '#2a1208');
  const darkWood = woodMaterial('#3c1a0d', '#7a3a18', '#130703');
  const stone = material(0x4b4d56);
  const gold = material(0xd7963c, 0.35, 0.1);

  const floor = box(15.5, 0.14, 8.8, floorMat);
  floor.position.set(0, -2.25, 0.85);
  floor.receiveShadow = true;
  group.add(floor);

  const foregroundFloor = box(15.8, 0.08, 3.45, woodMaterial('#4a2a17', '#b06330', '#1d0b05'));
  foregroundFloor.position.set(0, -2.18, 3.32);
  foregroundFloor.receiveShadow = true;
  group.add(foregroundFloor);

  for (let i = 0; i < 8; i += 1) {
    const seam = box(0.035, 0.012, 3.42, material(0x211008));
    seam.position.set(-6.9 + i * 1.98, -2.126, 3.32);
    group.add(seam);
  }

  const wall = box(15.8, 5.0, 0.22, wallMat);
  wall.position.set(0, 0.1, -3.65);
  wall.receiveShadow = true;
  group.add(wall);

  const upperBackdrop = box(15.6, 1.42, 0.18, upperWallMat);
  upperBackdrop.position.set(0, 1.9, -3.54);
  upperBackdrop.receiveShadow = true;
  group.add(upperBackdrop);

  const stageLintel = box(12.6, 0.24, 0.34, darkWood);
  stageLintel.position.set(0, 1.18, -3.3);
  stageLintel.castShadow = true;
  group.add(stageLintel);

  const backArch = new THREE.Group();
  for (let i = 0; i < 9; i += 1) {
    const block = box(1.2, 0.35, 0.38, stone);
    const angle = -Math.PI * 0.86 + i * (Math.PI * 0.72) / 8;
    block.position.set(Math.cos(angle) * 3.2, 1.35 + Math.sin(angle) * 2.0, -3.43);
    block.rotation.z = angle + Math.PI / 2;
    backArch.add(block);
  }
  group.add(backArch);

  for (let i = 0; i < 9; i += 1) {
    const beam = box(0.18, 5.2, 0.26, darkWood);
    beam.position.set(-7 + i * 1.75, 0.15, -3.28);
    beam.rotation.z = (i % 2 ? -1 : 1) * 0.018;
    group.add(beam);
  }

  const barTop = box(10.8, 0.34, 1.25, wood);
  barTop.position.set(0, -1.14, -0.5);
  barTop.castShadow = true;
  barTop.receiveShadow = true;
  group.add(barTop);

  const barFront = box(11.2, 1.05, 0.42, darkWood);
  barFront.position.set(0, -1.74, 0.12);
  barFront.castShadow = true;
  barFront.receiveShadow = true;
  group.add(barFront);

  for (let i = 0; i < 6; i += 1) {
    const plank = box(0.08, 0.95, 0.48, material(i % 2 ? 0x7a3b1d : 0x512511));
    plank.position.set(-5.1 + i * 2.04, -1.74, 0.36);
    group.add(plank);
  }

  for (let y = -0.8; y < 1.4; y += 1.0) {
    const shelf = box(8.2, 0.16, 0.42, wood);
    shelf.position.set(0, y, -3.0);
    shelf.castShadow = true;
    group.add(shelf);
  }

  for (let i = 0; i < 6; i += 1) {
    const stair = box(2.5, 0.18, 0.7, stone);
    stair.position.set(5.4, -2.02 + i * 0.23, -2.2 - i * 0.42);
    stair.rotation.y = -0.2;
    group.add(stair);
  }

  const sign = box(2.7, 0.55, 0.15, gold);
  sign.position.set(-4.8, 1.9, -3.18);
  sign.rotation.z = -0.05;
  group.add(sign);
}

function buildProps(group) {
  const lights = [];
  const glowMat = material(0xffc15c, 0.45, 0.05, true, 0.74);
  const metal = material(0x7c6a55, 0.4, 0.35);
  [
    [-4.6, 1.5, -2.65],
    [4.2, 1.42, -2.55],
    [0, 2.12, -2.55],
  ].forEach(([x, y, z], index) => {
    const lantern = new THREE.Group();
    const cage = cylinder(0.22, 0.22, 0.44, metal, 6);
    const glow = cylinder(0.16, 0.12, 0.34, glowMat, 6);
    cage.position.y = 0;
    glow.position.y = 0;
    lantern.add(cage, glow);
    lantern.position.set(x, y, z);
    lantern.castShadow = true;
    group.add(lantern);
    const point = new THREE.PointLight(index === 2 ? 0x2fe6ff : 0xffb451, index === 2 ? 1.9 : 2.3, 5, 2);
    point.position.set(x, y, z + 0.2);
    point.userData.baseIntensity = point.intensity;
    group.add(point);
    lights.push(point);
  });

  return lights;
}

function visibleModelSpecs(specs) {
  return specs.filter((spec) => !HIDDEN_POLISH_MODELS.has(spec.name));
}

function loadKenneyModels(loader, group, loadedNames, onChange) {
  visibleModelSpecs(KENNEY_MODELS).forEach((spec, index) => {
    loader.load(
      spec.url,
      (gltf) => {
        const model = gltf.scene;
        model.name = `kenney-${spec.name}-${index}`;
        model.position.set(...spec.position);
        model.rotation.set(...spec.rotation);
        model.scale.setScalar(spec.scale);
        model.userData.modelSource = spec.url;
        model.userData.modelKind = 'kenney-cc0-glb';
        model.traverse((object) => {
          if (object.isMesh) {
            object.castShadow = true;
            object.receiveShadow = true;
            object.userData.modelSource = spec.url;
            if (object.material) {
              object.material.flatShading = true;
              object.material.needsUpdate = true;
            }
          }
        });
        group.add(model);
        loadedNames.push(spec.name);
        onChange?.();
      },
      undefined,
      () => {
        loadedNames.push(`${spec.name}:failed`);
        onChange?.();
      },
    );
  });
}

function loadPremiumInteriorModels(loader, built, onChange) {
  visibleModelSpecs(PREMIUM_INTERIOR_MODELS).forEach((spec, index) => {
    loader.load(
      spec.url,
      (gltf) => {
        const model = gltf.scene;
        model.name = `premium-${spec.name}-${index}`;
        model.position.set(...spec.position);
        model.rotation.set(...spec.rotation);
        model.scale.setScalar(spec.scale);
        model.userData.modelSource = spec.url;
        model.userData.modelKind = 'premium-interior-3d-asset';
        model.userData.assetArea = spec.area;
        model.traverse((object) => {
          object.frustumCulled = false;
          if (object.isMesh || object.isSkinnedMesh) {
            object.castShadow = true;
            object.receiveShadow = true;
            object.userData.modelSource = spec.url;
            object.userData.assetArea = spec.area;
            polishLoadedMaterials(object, spec);
          }
        });

        if (spec.light) {
          const light = new THREE.PointLight(spec.light.color, spec.light.intensity, spec.light.distance, 2);
          light.position.set(0, 0.55, 0.08);
          light.userData.baseIntensity = spec.light.intensity;
          model.add(light);
        }

        attachInteriorIdle(model, spec, built);
        built.premiumInteriorModels.add(model);
        built.loadedGlbNames.push(spec.name);
        built.loadedInteriorNames.push(spec.name);
        onChange?.();
      },
      undefined,
      () => {
        built.loadedGlbNames.push(`${spec.name}:failed`);
        built.loadedInteriorNames.push(`${spec.name}:failed`);
        onChange?.();
      },
    );
  });
}

function markProceduralFallback(group) {
  group.userData.proceduralFallbackChildren = group.children.slice();
  group.userData.proceduralFallbackVisible = true;
}

function hideProceduralFallback(group) {
  (group.userData.proceduralFallbackChildren || []).forEach((child) => {
    if (child.parent === group) group.remove(child);
    child.visible = false;
    child.userData.hiddenByServiceGlb = true;
  });
  group.userData.proceduralFallbackVisible = false;
}

function loadServiceCharacters(loader, built, onChange) {
  SERVICE_CHARACTER_MODELS.forEach((spec) => {
    const target = built[spec.target];
    if (!target) return;
    loader.load(
      spec.url,
      (gltf) => {
        const model = gltf.scene;
        model.name = spec.name;
        model.userData.modelSource = spec.url;
        model.userData.modelKind = spec.assetMode === 'generated-glb'
          ? 'generated-triposg-blender-rigged-service-character-glb'
          : 'sourced-rigged-service-character-glb';
        model.userData.serviceRole = spec.role;
        model.userData.serviceGenerationPipeline = spec.generationPipeline;
        model.userData.serviceRigState = spec.rigState;
        prepareServiceCharacterModel(model, spec);
        normalizeServiceCharacterModel(model, spec);
        hideProceduralFallback(target);
        if (spec.useExternalAccessories === false) {
          built.serviceCharacterAnimations.push(`${spec.name}:integrated-glb-role-details`);
        } else {
          attachServiceAccessories(target, spec, built);
        }
        target.add(model);

        if (gltf.animations?.length) {
          const mixer = new THREE.AnimationMixer(model);
          const clip = chooseAnimationClip(gltf.animations, spec.animationPreference);
          const action = mixer.clipAction(clip);
          action.reset();
          action.setLoop(THREE.LoopRepeat);
          action.play();
          built.serviceMixers.push(mixer);
          built.serviceCharacterAnimations.push(`${spec.name}:${clip.name}`);
        } else {
          built.serviceCharacterAnimations.push(`${spec.name}:no-embedded-animation;container-idle`);
        }

        target.userData.loadedServiceGlb = spec.name;
        target.userData.loadedServiceRole = spec.role;
        built.proceduralFallbackVisible = SERVICE_CHARACTER_MODELS.some((item) => {
          const roleTarget = built[item.target];
          return roleTarget?.userData?.proceduralFallbackVisible !== false;
        });
        built.loadedGlbNames.push(spec.name);
        built.loadedServiceCharacterNames.push(spec.name);
        onChange?.();
      },
      undefined,
      () => {
        target.userData.serviceGlbFailed = spec.name;
        built.loadedGlbNames.push(`${spec.name}:failed`);
        built.loadedServiceCharacterNames.push(`${spec.name}:failed`);
        built.serviceCharacterAnimations.push(`${spec.name}:failed;procedural-fallback`);
        built.proceduralFallbackVisible = true;
        onChange?.();
      },
    );
  });
}

function prepareServiceCharacterModel(model, spec) {
  model.traverse((object) => {
    object.frustumCulled = false;
    object.userData.modelSource = spec.url;
    object.userData.serviceRole = spec.role;
    if (object.isMesh || object.isSkinnedMesh) {
      object.castShadow = true;
      object.receiveShadow = true;
      const materials = Array.isArray(object.material) ? object.material : [object.material].filter(Boolean);
      materials.forEach((item) => {
        if ('flatShading' in item) item.flatShading = !spec.smoothTexturedShading;
        if (item.map) item.map.colorSpace = THREE.SRGBColorSpace;
        if ('roughness' in item && item.roughness === undefined) item.roughness = 0.64;
        if ('metalness' in item && item.metalness === undefined) item.metalness = 0.05;
        item.needsUpdate = true;
      });
    }
  });
}

function normalizeServiceCharacterModel(model, spec) {
  model.position.set(0, 0, 0);
  model.rotation.set(0, spec.rotationY || 0, 0);
  model.scale.setScalar(1);
  model.updateMatrixWorld(true);
  const nativeBox = new THREE.Box3().setFromObject(model);
  const nativeSize = new THREE.Vector3();
  nativeBox.getSize(nativeSize);
  const height = Math.max(0.001, nativeSize.y);
  const scale = spec.desiredLocalHeight / height;
  model.scale.setScalar(scale);
  model.updateMatrixWorld(true);
  const scaledBox = new THREE.Box3().setFromObject(model);
  const centerX = (scaledBox.min.x + scaledBox.max.x) / 2;
  const centerZ = (scaledBox.min.z + scaledBox.max.z) / 2;
  model.position.x -= centerX;
  model.position.z -= centerZ;
  model.position.y += spec.floorY - scaledBox.min.y;
  model.userData.normalizedHeight = round(spec.desiredLocalHeight);
  model.userData.normalizedFloorY = round(spec.floorY);
}

function chooseAnimationClip(clips, preference = []) {
  for (const name of preference) {
    const exact = THREE.AnimationClip.findByName(clips, name);
    if (exact) return exact;
  }
  return clips.find((clip) => /(^|[|_])idle/i.test(clip.name)) || clips[0];
}

function attachServiceAccessories(target, spec, built) {
  const gear = spec.role === 'bartender'
    ? makeBartenderServiceAccessories()
    : makeElfServerServiceAccessories();
  gear.name = `${spec.name}-3d-service-accessories`;
  gear.userData.serviceAccessory = true;
  target.add(gear);
  built.serviceCharacterAnimations.push(`${spec.name}:accessory-idle`);
  built.updaters.push((time) => {
    if (spec.role === 'bartender') {
      const shake = Math.sin(time * 2.2) * 0.055;
      gear.userData.shaker.position.y = gear.userData.shaker.userData.baseY + shake;
      gear.userData.shaker.rotation.z = gear.userData.shaker.userData.baseZ + Math.sin(time * 2.0) * 0.16;
      gear.userData.mug.position.y = gear.userData.mug.userData.baseY + Math.sin(time * 1.45 + 0.6) * 0.035;
      const pulse = 1 + Math.sin(time * 3.1) * 0.08;
      gear.userData.core.scale.setScalar(pulse);
      return;
    }
    const lift = Math.sin(time * 1.6 + 0.7) * 0.035;
    gear.userData.tray.position.y = gear.userData.tray.userData.baseY + lift;
    gear.userData.tray.rotation.z = Math.sin(time * 1.2) * 0.025;
    [gear.userData.bottle, gear.userData.bottleNeck, gear.userData.cupA, gear.userData.cupB].forEach((item) => {
      item.position.y = item.userData.baseY + lift;
    });
    gear.userData.hipCrystal.rotation.y = time * 1.5;
  });
}

function makeBartenderServiceAccessories() {
  const group = new THREE.Group();
  const metal = material(0x8f846f, 0.34, 0.42);
  const dark = material(0x17191d, 0.72, 0.1);
  const amber = material(0xffb45b, 0.28, 0.08, true, 0.86);
  const cyan = material(0x1fc9ff, 0.3, 0.1, true, 0.88);

  const apron = box(0.9, 0.9, 0.06, dark);
  apron.position.set(0, -0.2, 0.57);
  const badge = cylinder(0.11, 0.11, 0.035, cyan, 10);
  badge.rotation.x = Math.PI / 2;
  badge.position.set(0, 0.08, 0.62);
  const mug = cylinder(0.18, 0.21, 0.42, metal, 10);
  mug.position.set(-0.85, -0.08, 0.52);
  mug.rotation.z = -0.18;
  mug.userData.baseY = mug.position.y;
  const shaker = cylinder(0.13, 0.18, 0.52, metal, 10);
  shaker.position.set(0.82, 0.02, 0.52);
  shaker.rotation.z = 0.36;
  shaker.userData.baseY = shaker.position.y;
  shaker.userData.baseZ = shaker.rotation.z;
  const core = cylinder(0.18, 0.18, 0.08, cyan, 12);
  core.rotation.x = Math.PI / 2;
  core.position.set(0.42, 0.42, -0.52);
  const coil = cylinder(0.26, 0.26, 0.06, amber, 12);
  coil.rotation.x = Math.PI / 2;
  coil.position.set(0.42, 0.42, -0.62);
  const brow = box(0.58, 0.08, 0.05, metal);
  brow.position.set(0, 0.84, 0.66);
  const eyeL = box(0.1, 0.065, 0.05, cyan);
  eyeL.position.set(-0.16, 0.76, 0.7);
  const eyeR = eyeL.clone();
  eyeR.position.x = 0.16;
  const tuskL = cone(0.055, 0.28, material(0xf0dfbd), 6);
  tuskL.position.set(-0.19, 0.58, 0.7);
  tuskL.rotation.x = Math.PI / 2;
  tuskL.rotation.z = 0.12;
  const tuskR = tuskL.clone();
  tuskR.position.x = 0.19;
  tuskR.rotation.z = -0.12;
  group.add(apron, badge, mug, shaker, core, coil, brow, eyeL, eyeR, tuskL, tuskR);
  group.userData = { mug, shaker, core };
  markShadows(group);
  return group;
}

function makeElfServerServiceAccessories() {
  const group = new THREE.Group();
  const skin = material(0xe6c2a8);
  const trayMat = material(0x2d2439, 0.38, 0.28);
  const glass = material(0xa9f4ff, 0.18, 0.04, true, 0.54);
  const bottleMat = material(0x1fb58a, 0.34, 0.08, true, 0.78);
  const cyan = material(0x7ff6ff, 0.26, 0.05, true, 0.82);

  const earL = cone(0.07, 0.38, skin, 5);
  earL.position.set(-0.34, 0.96, 0.02);
  earL.rotation.z = Math.PI / 2;
  const earR = earL.clone();
  earR.position.x = 0.34;
  earR.rotation.z = -Math.PI / 2;
  const tray = cylinder(0.34, 0.34, 0.045, trayMat, 16);
  tray.position.set(-0.66, -0.12, 0.44);
  tray.rotation.x = Math.PI / 2;
  tray.userData.baseY = tray.position.y;
  const bottle = cylinder(0.075, 0.095, 0.48, bottleMat, 8);
  bottle.position.set(-0.74, -0.07, 0.48);
  bottle.userData.baseY = bottle.position.y;
  const bottleNeck = cylinder(0.034, 0.04, 0.28, glass, 8);
  bottleNeck.position.set(-0.74, 0.3, 0.48);
  bottleNeck.userData.baseY = bottleNeck.position.y;
  const cupA = cylinder(0.075, 0.06, 0.22, glass, 8);
  cupA.position.set(-0.48, -0.05, 0.49);
  cupA.userData.baseY = cupA.position.y;
  const cupB = cupA.clone();
  cupB.position.set(-0.92, -0.05, 0.46);
  cupB.userData.baseY = cupB.position.y;
  const hipCrystal = new THREE.Mesh(new THREE.OctahedronGeometry(0.1, 0), cyan);
  hipCrystal.position.set(0.28, -0.64, 0.24);
  group.add(earL, earR, tray, bottle, bottleNeck, cupA, cupB, hipCrystal);
  group.userData = { tray, bottle, bottleNeck, cupA, cupB, hipCrystal };
  markShadows(group);
  return group;
}

function polishLoadedMaterials(object, spec) {
  const materials = Array.isArray(object.material) ? object.material : [object.material].filter(Boolean);
  materials.forEach((item) => {
    item.flatShading = spec.polish === 'pbr' ? false : true;
    if (spec.polish === 'pbr') {
      if ('envMapIntensity' in item) item.envMapIntensity = 0.7;
      if ('roughness' in item && item.roughness === undefined) item.roughness = 0.35;
      if (spec.name.includes('brass')) {
        if ('color' in item) item.color = new THREE.Color(0xffc66a);
        if ('metalness' in item) item.metalness = Math.min(item.metalness ?? 0.45, 0.48);
        if ('roughness' in item) item.roughness = Math.max(item.roughness ?? 0.28, 0.26);
      }
    }
    if (spec.polish === 'crystal') {
      item.transparent = true;
      item.opacity = Math.max(item.opacity ?? 1, 0.82);
      item.roughness = 0.22;
      item.metalness = 0.05;
      if ('emissive' in item) item.emissive = new THREE.Color(0x1fd7ff);
      if ('emissiveIntensity' in item) item.emissiveIntensity = 0.28;
    }
    item.needsUpdate = true;
  });
}

function attachInteriorIdle(model, spec, built) {
  if (!spec.animate) return;
  const phase = PREMIUM_INTERIOR_MODELS.findIndex((item) => item.name === spec.name) * 0.43;
  const baseY = spec.position[1];
  const baseScale = spec.scale;
  if (spec.animate === 'crystalSpin') {
    built.interiorAnimations.push(`${spec.name}:crystalSpin`);
    built.updaters.push((time) => {
      model.rotation.y = spec.rotation[1] + Math.sin(time * 0.38 + phase) * 0.14 + time * 0.08;
      const pulse = baseScale * (1 + Math.sin(time * 1.45 + phase) * 0.035);
      model.scale.setScalar(pulse);
    });
    return;
  }
  if (spec.animate === 'mugBob') {
    built.interiorAnimations.push(`${spec.name}:mugBob`);
    built.updaters.push((time) => {
      model.position.y = baseY + Math.sin(time * 1.6 + phase) * 0.018;
      model.rotation.z = Math.sin(time * 1.2 + phase) * 0.025;
    });
    return;
  }
  if (spec.animate === 'flame') {
    built.interiorAnimations.push(`${spec.name}:lightFlicker`);
    built.updaters.push((time) => {
      model.children.forEach((child) => {
        if (child.isPointLight) {
          child.intensity = child.userData.baseIntensity + Math.sin(time * 4.6 + phase) * 0.16;
        }
      });
    });
  }
}

function loadSourcedActors(loader, built, onChange) {
  KAYKIT_ACTORS.forEach((spec) => {
    const target = built.actorGroups[spec.group];
    if (!target) return;
    loader.load(
      spec.url,
      (gltf) => {
        const model = gltf.scene;
        model.name = spec.name;
        model.userData.modelSource = spec.url;
        model.userData.modelKind = 'kaykit-cc0-rigged-animated-glb';
        model.userData.animationName = spec.animation;
        model.traverse((object) => {
          object.frustumCulled = false;
          if (object.isMesh || object.isSkinnedMesh) {
            object.castShadow = true;
            object.receiveShadow = true;
            object.userData.modelSource = spec.url;
            if (object.material) {
              const materials = Array.isArray(object.material) ? object.material : [object.material];
              materials.forEach((item) => {
                item.flatShading = true;
                item.needsUpdate = true;
              });
            }
          }
        });

        target.add(model);
        const bounds = new THREE.Box3().setFromObject(model);
        const label = target.userData.agentLabel;
        if (label && !bounds.isEmpty()) {
          const labelY = Math.max(4.6, Math.min(7.2, bounds.max.y + 0.72));
          label.userData.autoModelLabelY = labelY;
          if (!label.userData.baseLocalY) {
            label.position.y = labelY;
            label.userData.baseLocalY = labelY;
          }
        }

        if (gltf.animations?.length) {
          const mixer = new THREE.AnimationMixer(model);
          const clip = THREE.AnimationClip.findByName(gltf.animations, spec.animation)
            || THREE.AnimationClip.findByName(gltf.animations, 'Idle')
            || gltf.animations[0];
          mixer.clipAction(clip).play();
          built.actorMixers.push(mixer);
          built.actorAnimations.push(`${spec.name}:${clip.name}`);
        } else {
          built.actorAnimations.push(`${spec.name}:no-animation`);
        }

        built.loadedGlbNames.push(spec.name);
        built.loadedActorNames.push(spec.name);
        onChange?.();
      },
      undefined,
      () => {
        built.loadedGlbNames.push(`${spec.name}:failed`);
        built.loadedActorNames.push(`${spec.name}:failed`);
        onChange?.();
      },
    );
  });
}

function makeAgentLabelPlane(agent) {
  const texture = makeTexture(640, 230, (ctx, width, height) => {
    ctx.clearRect(0, 0, width, height);
    ctx.save();
    ctx.shadowColor = agent.tint;
    ctx.shadowBlur = 22;
    ctx.fillStyle = 'rgba(3, 15, 20, .88)';
    ctx.strokeStyle = agent.tint;
    ctx.lineWidth = 7;
    drawRound(ctx, 18, 22, width - 36, height - 48, 24);
    ctx.fill();
    ctx.stroke();
    ctx.restore();

    drawAgentBadge(ctx, agent, 96, 106, 44, 0, true);
    ctx.textAlign = 'left';
    ctx.textBaseline = 'middle';
    ctx.shadowColor = agent.tint;
    ctx.shadowBlur = 12;
    ctx.fillStyle = '#f4feff';
    ctx.font = '900 42px Georgia, Times New Roman, serif';
    fitText(ctx, agent.name, 160, 74, 402, 42, 'left');
    ctx.shadowBlur = 0;
    ctx.fillStyle = '#9ef0ff';
    ctx.font = '800 23px Inter, Arial, sans-serif';
    fitText(ctx, agent.role, 162, 116, 350, 23, 'left');
    ctx.fillStyle = '#ffc25e';
    ctx.font = '900 25px Inter, Arial, sans-serif';
    fitText(ctx, agent.skill, 162, 151, 350, 25, 'left');
    ctx.fillStyle = agent.tint;
    ctx.font = '900 24px Inter, Arial, sans-serif';
    fitText(ctx, agent.bounty, 482, 151, 92, 24, 'right');
  });
  return makeCanvasPlane(texture, { renderOrder: 58, depthTest: false });
}

function makeAgentRouteLines() {
  const group = new THREE.Group();
  const routes = makeRouteSets('desktop');
  Object.entries(routes).forEach(([name, points], index) => {
    const routePoints = points.map(([x, y, z]) => new THREE.Vector3(x, y + 0.035, z));
    const geometry = new THREE.BufferGeometry().setFromPoints(routePoints);
    const line = new THREE.Line(
      geometry,
      new THREE.LineBasicMaterial({
        color: index % 2 ? 0x35f0b5 : 0x35d8ff,
        transparent: true,
        opacity: 0.18,
        depthTest: false,
      }),
    );
    line.name = `agent-route-${name}`;
    line.renderOrder = 12;
    group.add(line);

    routePoints.slice(0, -1).forEach((point, markerIndex) => {
      const marker = new THREE.Mesh(
        new THREE.OctahedronGeometry(0.055, 0),
        new THREE.MeshBasicMaterial({
          color: index % 2 ? 0x35f0b5 : 0x35d8ff,
          transparent: true,
          opacity: markerIndex === 2 ? 0.36 : 0.18,
          depthTest: false,
        }),
      );
      marker.name = `agent-route-${name}-marker-${markerIndex}`;
      marker.position.copy(point);
      marker.renderOrder = 13;
      group.add(marker);
    });
  });
  return group;
}

function makeRouteSets(layout) {
  const y = layout === 'mobile' ? -2.2 : layout === 'tablet' ? -2.02 : -1.98;
  const zEntry = layout === 'mobile' ? 0.08 : layout === 'tablet' ? -0.48 : -0.82;
  const zMid = layout === 'mobile' ? -0.02 : layout === 'tablet' ? -0.62 : -1.0;
  const zBar = layout === 'mobile' ? -0.12 : layout === 'tablet' ? -0.76 : -1.14;
  const xExit = layout === 'mobile' ? 1.82 : layout === 'tablet' ? 3.55 : 4.85;
  const xBar = layout === 'mobile' ? 1.02 : layout === 'tablet' ? 2.48 : 3.25;
  const leftOrderX = layout === 'mobile' ? -1.32 : layout === 'tablet' ? -2.7 : -3.55;
  const rightOrderX = layout === 'mobile' ? 1.4 : layout === 'tablet' ? 2.78 : 3.55;
  const boothOrderX = layout === 'mobile' ? 1.66 : rightOrderX * 0.92;
  const boothOrderZ = layout === 'mobile' ? zBar + 0.02 : zBar + 0.08;
  return {
    frontCross: [
      [-xExit, y, zEntry],
      [-xBar * 1.08, y + 0.03, zMid],
      [leftOrderX, y + 0.04, zBar],
      [leftOrderX, y + 0.04, zBar],
      [-xBar * 1.12, y + 0.03, zMid],
      [-xExit, y, zEntry],
    ],
    rightLoop: [
      [xExit, y, zEntry],
      [xBar * 1.26, y + 0.03, zMid],
      [rightOrderX, y + 0.04, zBar + 0.06],
      [rightOrderX, y + 0.04, zBar + 0.06],
      [xBar * 1.28, y + 0.03, zMid],
      [xExit, y, zEntry],
    ],
    leftLoop: [
      [-xExit * 0.92, y, zEntry - 0.08],
      [-xBar * 1.48, y + 0.03, zMid + 0.02],
      [leftOrderX * 1.08, y + 0.04, zBar + 0.08],
      [leftOrderX * 1.08, y + 0.04, zBar + 0.08],
      [-xBar * 1.48, y + 0.03, zMid + 0.02],
      [-xExit * 0.92, y, zEntry - 0.08],
    ],
    boothArc: [
      [xExit * 0.88, y, zEntry + 0.02],
      [xBar * 1.02, y + 0.03, zMid + 0.12],
      [boothOrderX, y + 0.04, boothOrderZ],
      [boothOrderX, y + 0.04, boothOrderZ],
      [xBar * 1.02, y + 0.03, zMid + 0.12],
      [xExit * 0.88, y, zEntry + 0.02],
    ],
  };
}

function animateWalkingAgents(built, time) {
  built.walkingAgentGroups.forEach((group, index) => {
    const points = group.userData.routePoints;
    const spec = group.userData.actorSpec || KAYKIT_ACTORS[index];
    if (!points?.length || !spec) return;
    const slotDuration = group.userData.slotDuration || 13.8;
    const cycle = slotDuration * built.walkingAgentGroups.length;
    const localTime = positiveModulo(time - index * slotDuration + (spec.phase || 0) * 0.8, cycle);
    const approachDuration = 3.2;
    const orderDuration = 6.2;
    const leaveDuration = 3.2;
    const activeDuration = approachDuration + orderDuration + leaveDuration;
    const label = group.userData.agentLabel;
    const active = localTime <= activeDuration;
    group.visible = active;
    if (!active) {
      if (label) label.visible = false;
      return;
    }

    let a;
    let b;
    let segmentProgress;
    let ordering = false;
    if (localTime < approachDuration) {
      const scaled = (localTime / approachDuration) * 2;
      const pointIndex = Math.min(1, Math.floor(scaled));
      segmentProgress = scaled - pointIndex;
      a = points[pointIndex];
      b = points[pointIndex + 1];
    } else if (localTime < approachDuration + orderDuration) {
      ordering = true;
      const orderProgress = (localTime - approachDuration) / orderDuration;
      a = points[2];
      b = points[3];
      segmentProgress = 0;
      group.rotation.y = Math.PI + Math.sin(time * 0.8 + index) * 0.08;
      const orderBob = Math.sin(orderProgress * Math.PI);
      group.position.set(a[0], a[1] + 0.02 + orderBob * 0.035, a[2]);
    } else {
      const leaveTime = localTime - approachDuration - orderDuration;
      const scaled = (leaveTime / leaveDuration) * 2;
      const pointIndex = Math.min(1, Math.floor(scaled));
      segmentProgress = scaled - pointIndex;
      a = points[3 + pointIndex];
      b = points[4 + pointIndex];
    }

    if (!ordering) {
      const eased = segmentProgress * segmentProgress * (3 - 2 * segmentProgress);
      const x = THREE.MathUtils.lerp(a[0], b[0], eased);
      const y = THREE.MathUtils.lerp(a[1], b[1], eased);
      const z = THREE.MathUtils.lerp(a[2], b[2], eased);
      group.position.set(x, y + Math.sin(time * 2.2 + index) * 0.012, z);
      const dx = b[0] - a[0];
      const dz = b[2] - a[2];
      group.rotation.y = Math.atan2(dx, dz);
    }

    if (label) {
      const bubbleWindow = ordering && localTime > approachDuration + 0.6 && localTime < approachDuration + orderDuration - 0.45;
      label.visible = bubbleWindow;
      label.rotation.y = -group.rotation.y;
      label.position.y = (label.userData.baseLocalY || 5.2) + Math.sin(time * 2.1 + index) * 0.08;
      label.position.x = (label.userData.offsetX || 0) + Math.sin(time * 0.9 + index) * 0.04;
      label.position.z = 0.1;
      if (label.material) {
        label.material.opacity = bubbleWindow ? 0.82 + Math.sin(time * 5.4 + index) * 0.12 : 0;
        label.material.needsUpdate = true;
      }
    }
  });
}

function makeOrc() {
  const group = new THREE.Group();
  const skin = material(0x63a33a);
  const darkSkin = material(0x3d7428);
  const leather = material(0x4c2b1a);
  const metal = material(0x8e7f68, 0.35, 0.45);
  const blue = material(0x1fc9ff, 0.3, 0.1, true, 0.85);

  const hips = box(0.95, 0.42, 0.46, leather);
  hips.position.y = -0.52;
  const torso = cylinder(0.55, 0.72, 1.45, leather, 7);
  torso.position.y = 0.3;
  const chestPlate = box(1.1, 0.72, 0.14, metal);
  chestPlate.position.set(0, 0.47, 0.42);
  const apron = box(0.88, 0.92, 0.075, material(0x15181c, 0.82, 0.04));
  apron.position.set(0, 0.02, 0.5);
  const apronBadge = cylinder(0.1, 0.1, 0.025, blue, 8);
  apronBadge.position.set(0, 0.28, 0.55);
  apronBadge.rotation.x = Math.PI / 2;
  const towel = box(0.18, 0.62, 0.055, material(0xd8c8a2, 0.72, 0.02));
  towel.position.set(-0.46, -0.34, 0.56);
  const neck = cylinder(0.22, 0.26, 0.22, darkSkin, 6);
  neck.position.y = 1.12;
  const head = new THREE.Mesh(new THREE.DodecahedronGeometry(0.48, 0), skin);
  head.position.y = 1.55;
  head.scale.set(1.05, 0.9, 0.92);
  const brow = box(0.82, 0.14, 0.14, darkSkin);
  brow.position.set(0, 1.65, 0.38);
  const eyeL = box(0.1, 0.07, 0.045, blue);
  eyeL.position.set(-0.18, 1.59, 0.53);
  const eyeR = eyeL.clone();
  eyeR.position.x = 0.18;
  const tuskL = cone(0.055, 0.34, material(0xead9ba), 5);
  tuskL.position.set(-0.22, 1.31, 0.5);
  tuskL.rotation.x = Math.PI / 2;
  tuskL.rotation.z = 0.18;
  const tuskR = tuskL.clone();
  tuskR.position.x = 0.22;
  tuskR.rotation.z = -0.18;
  const earL = cone(0.12, 0.44, darkSkin, 5);
  earL.position.set(-0.55, 1.57, 0.0);
  earL.rotation.z = Math.PI / 2;
  const earR = earL.clone();
  earR.position.x = 0.55;
  earR.rotation.z = -Math.PI / 2;

  const armL = makeLimb(0.28, 1.25, skin, 7);
  armL.position.set(-0.72, 0.42, 0.1);
  armL.rotation.z = -0.42;
  armL.userData.baseZ = armL.rotation.z;
  const armR = makeLimb(0.28, 1.22, skin, 7);
  armR.position.set(0.75, 0.36, 0.15);
  armR.rotation.z = 0.52;
  armR.userData.baseZ = armR.rotation.z;
  const shoulderL = cylinder(0.32, 0.36, 0.24, metal, 7);
  shoulderL.position.set(-0.7, 0.96, 0.12);
  shoulderL.rotation.z = Math.PI / 2;
  const shoulderR = shoulderL.clone();
  shoulderR.position.x = 0.72;
  const mug = cylinder(0.2, 0.22, 0.42, metal, 8);
  mug.position.set(-1.05, 0.92, 0.35);
  mug.rotation.z = -0.15;
  mug.userData.baseY = mug.position.y;
  mug.userData.baseZ = mug.rotation.z;
  const shaker = cylinder(0.14, 0.18, 0.5, metal, 8);
  shaker.position.set(1.0, 0.78, 0.38);
  shaker.rotation.z = 0.28;
  shaker.userData.baseZ = shaker.rotation.z;

  const legL = makeLimb(0.25, 1.08, darkSkin, 6);
  legL.position.set(-0.28, -1.15, 0.05);
  const legR = legL.clone();
  legR.position.x = 0.28;
  const bootL = box(0.42, 0.22, 0.62, leather);
  bootL.position.set(-0.32, -1.78, 0.18);
  const bootR = bootL.clone();
  bootR.position.x = 0.32;
  const pack = box(0.58, 0.82, 0.28, metal);
  pack.position.set(0.58, 0.25, -0.42);
  const coil = cylinder(0.24, 0.24, 0.08, blue, 8);
  coil.position.set(0.6, 0.25, -0.61);
  coil.rotation.x = Math.PI / 2;

  group.add(hips, torso, chestPlate, apron, apronBadge, towel, neck, head, brow, eyeL, eyeR, tuskL, tuskR, earL, earR, armL, armR, shoulderL, shoulderR, mug, shaker, legL, legR, bootL, bootR, pack, coil);
  group.scale.setScalar(0.95);
  group.userData.head = head;
  group.userData.arm = armR;
  group.userData.armL = armL;
  group.userData.armR = armR;
  group.userData.torso = torso;
  group.userData.chestPlate = chestPlate;
  group.userData.apronBadge = apronBadge;
  group.userData.towel = towel;
  group.userData.mug = mug;
  group.userData.shaker = shaker;
  group.userData.coil = coil;
  group.userData.eyeL = eyeL;
  group.userData.eyeR = eyeR;
  markShadows(group);
  return group;
}

function makeElf() {
  const group = new THREE.Group();
  group.name = 'velvet-elf-server-service-role';
  const skin = material(0xe6bca2);
  const dress = material(0x5a2fb3);
  const corset = material(0x27152f);
  const dark = material(0x1d1730);
  const gold = material(0xd8a948, 0.44, 0.16);
  const glass = material(0xa9f4ff, 0.18, 0.04, true, 0.54);
  const hair = material(0xf0e6f7);
  const trayMat = material(0x2d2439, 0.38, 0.28);

  const hips = cylinder(0.38, 0.52, 0.38, dress, 6);
  hips.position.y = -0.58;
  const skirt = cone(0.58, 0.84, dress, 7);
  skirt.position.y = -0.76;
  const torso = cylinder(0.31, 0.42, 0.92, corset, 7);
  torso.position.y = 0.06;
  torso.scale.x = 0.86;
  const shoulderWrap = box(0.78, 0.16, 0.28, gold);
  shoulderWrap.position.set(0, 0.54, 0.04);
  const neck = cylinder(0.12, 0.14, 0.18, skin, 6);
  neck.position.y = 0.68;
  const head = new THREE.Mesh(new THREE.IcosahedronGeometry(0.25, 0), skin);
  head.position.y = 0.94;
  const hairCap = cone(0.31, 0.5, hair, 6);
  hairCap.position.set(-0.02, 1.1, -0.02);
  hairCap.rotation.z = 0.1;
  const hairTail = cone(0.18, 0.74, hair, 6);
  hairTail.position.set(0.18, 0.58, -0.18);
  hairTail.rotation.z = -0.18;
  const earL = cone(0.06, 0.34, skin, 5);
  earL.position.set(-0.3, 0.96, 0);
  earL.rotation.z = Math.PI / 2;
  const earR = earL.clone();
  earR.position.x = 0.28;
  earR.rotation.z = -Math.PI / 2;
  const eyeL = box(0.055, 0.035, 0.032, material(0x3ee8ff, 0.22, 0.05, true, 0.88));
  eyeL.position.set(-0.09, 0.96, 0.22);
  const eyeR = eyeL.clone();
  eyeR.position.x = 0.09;

  const armL = makeLimb(0.075, 0.74, skin, 5);
  armL.position.set(-0.42, 0.16, 0.08);
  armL.rotation.z = -0.98;
  armL.rotation.x = 0.22;
  armL.userData.baseZ = armL.rotation.z;
  const armR = armL.clone();
  armR.position.x = 0.42;
  armR.rotation.z = 0.82;
  armR.rotation.x = -0.18;
  armR.userData.baseZ = armR.rotation.z;
  const legL = makeLimb(0.075, 0.72, dark, 5);
  legL.position.set(-0.18, -1.22, 0.02);
  legL.rotation.z = 0.12;
  const legR = legL.clone();
  legR.position.x = 0.18;
  legR.rotation.z = -0.12;
  const shoeL = box(0.22, 0.12, 0.36, dark);
  shoeL.position.set(-0.18, -1.64, 0.1);
  const shoeR = shoeL.clone();
  shoeR.position.x = 0.18;

  const tray = cylinder(0.34, 0.34, 0.045, trayMat, 16);
  tray.position.set(-0.76, 0.42, 0.32);
  tray.rotation.x = Math.PI / 2;
  tray.userData.baseY = tray.position.y;
  const bottle = cylinder(0.075, 0.095, 0.48, material(0x1fb58a, 0.34, 0.08, true, 0.78), 8);
  bottle.position.set(-0.82, 0.47, 0.36);
  bottle.userData.baseY = bottle.position.y;
  const bottleNeck = cylinder(0.034, 0.04, 0.28, material(0xcff8dc, 0.25, 0.02, true, 0.72), 8);
  bottleNeck.position.set(-0.82, 0.84, 0.36);
  bottleNeck.userData.baseY = bottleNeck.position.y;
  const cupA = cylinder(0.075, 0.06, 0.22, glass, 8);
  cupA.position.set(-0.58, 0.49, 0.38);
  cupA.userData.baseY = cupA.position.y;
  const cupB = cupA.clone();
  cupB.position.set(-1.02, 0.49, 0.34);
  cupB.userData.baseY = cupB.position.y;
  const hipGem = new THREE.Mesh(new THREE.OctahedronGeometry(0.1, 0), material(0x7ff6ff, 0.26, 0.05, true, 0.82));
  hipGem.position.set(0.34, -0.36, 0.22);

  group.add(hips, skirt, torso, shoulderWrap, neck, head, hairCap, hairTail, earL, earR, eyeL, eyeR, armL, armR, legL, legR, shoeL, shoeR, tray, bottle, bottleNeck, cupA, cupB, hipGem);
  group.userData.baseRotY = -0.34;
  group.userData.head = head;
  group.userData.hair = hairCap;
  group.userData.hairTail = hairTail;
  group.userData.armL = armL;
  group.userData.armR = armR;
  group.userData.tray = tray;
  group.userData.bottle = bottle;
  group.userData.bottleNeck = bottleNeck;
  group.userData.cupA = cupA;
  group.userData.cupB = cupB;
  group.userData.hipGem = hipGem;
  group.userData.eyeL = eyeL;
  group.userData.eyeR = eyeR;
  markShadows(group);
  return group;
}

function makeDwarf() {
  const group = new THREE.Group();
  const skin = material(0xc17c4d);
  const beard = material(0xe06e25);
  const armor = material(0x6a5d50, 0.4, 0.35);
  const leather = material(0x4a2414);
  const body = cylinder(0.42, 0.48, 0.82, armor, 7);
  body.position.y = -0.08;
  const head = new THREE.Mesh(new THREE.DodecahedronGeometry(0.28, 0), skin);
  head.position.y = 0.52;
  const beardMesh = cone(0.28, 0.48, beard, 6);
  beardMesh.position.set(0, 0.26, 0.22);
  beardMesh.rotation.x = Math.PI;
  const helm = cylinder(0.26, 0.31, 0.22, armor, 7);
  helm.position.y = 0.76;
  const armL = makeLimb(0.13, 0.62, skin, 6);
  armL.position.set(-0.42, 0.02, 0.04);
  armL.rotation.z = -0.58;
  const armR = armL.clone();
  armR.position.x = 0.42;
  armR.rotation.z = 0.65;
  const hammerHandle = cylinder(0.035, 0.035, 0.85, leather, 5);
  hammerHandle.position.set(0.62, 0.25, 0.12);
  hammerHandle.rotation.z = -0.65;
  const hammerHead = box(0.42, 0.2, 0.22, armor);
  hammerHead.position.set(0.84, 0.55, 0.13);
  group.add(body, head, beardMesh, helm, armL, armR, hammerHandle, hammerHead);
  group.userData.head = head;
  group.userData.armR = armR;
  group.userData.hammerHandle = hammerHandle;
  group.userData.hammerHead = hammerHead;
  group.userData.beard = beardMesh;
  markShadows(group);
  return group;
}

function animateIdleRig(built, time) {
  const orc = built.orc.userData;
  const elf = built.elf.userData;
  const dwarf = built.dwarf.userData;

  if (orc.torso) {
    orc.torso.scale.y = 1 + Math.sin(time * 1.35) * 0.018;
    orc.chestPlate.position.y = 0.47 + Math.sin(time * 1.35) * 0.012;
  }
  if (orc.armL && orc.armR) {
    orc.armL.rotation.z = -0.42 + Math.sin(time * 1.45) * 0.13;
    orc.armL.rotation.x = Math.sin(time * 1.1) * 0.08;
    orc.armR.rotation.z = 0.52 + Math.sin(time * 2.0) * 0.18;
    orc.armR.rotation.x = Math.sin(time * 1.75 + 1.2) * 0.1;
  }
  if (orc.mug) {
    orc.mug.position.y = 0.92 + Math.sin(time * 2.0 + 0.3) * 0.08;
    orc.mug.rotation.x = Math.sin(time * 1.9) * 0.12;
  }
  if (orc.coil) {
    const coilPulse = 1 + Math.sin(time * 3.1) * 0.08;
    orc.coil.scale.set(coilPulse, coilPulse, coilPulse);
  }
  if (orc.eyeL && orc.eyeR) {
    const blink = Math.sin(time * 4.2) > 0.92 ? 0.35 : 1;
    orc.eyeL.scale.y = blink;
    orc.eyeR.scale.y = blink;
  }

  if (elf.head) {
    elf.head.rotation.z = Math.sin(time * 1.15) * 0.1;
    elf.hair.rotation.z = 0.06 + Math.sin(time * 1.35 + 1) * 0.08;
    elf.hairTail.rotation.z = -0.18 + Math.sin(time * 1.55 + 0.4) * 0.08;
  }
  if (elf.armL && elf.armR) {
    elf.armL.rotation.z = -0.98 + Math.sin(time * 1.6) * 0.08;
    elf.armR.rotation.z = 0.82 + Math.sin(time * 1.9 + 1.4) * 0.14;
  }
  if (elf.tray) {
    const trayLift = Math.sin(time * 1.75 + 0.6) * 0.045;
    elf.tray.position.y = elf.tray.userData.baseY + trayLift;
    elf.tray.rotation.z = Math.sin(time * 1.45) * 0.035;
    [elf.bottle, elf.bottleNeck, elf.cupA, elf.cupB].forEach((item) => {
      if (item) item.position.y = item.userData.baseY + trayLift;
    });
  }
  if (elf.hipGem) {
    elf.hipGem.rotation.y = time * 1.8;
    elf.hipGem.position.y = -0.36 + Math.sin(time * 2.2) * 0.035;
  }
  if (elf.eyeL && elf.eyeR) {
    const blink = Math.sin(time * 3.7 + 0.5) > 0.94 ? 0.38 : 1;
    elf.eyeL.scale.y = blink;
    elf.eyeR.scale.y = blink;
  }

  if (dwarf.head) {
    dwarf.head.rotation.x = Math.sin(time * 1.7) * 0.06;
    dwarf.beard.rotation.z = Math.sin(time * 1.9) * 0.045;
  }
  if (dwarf.armR && dwarf.hammerHandle && dwarf.hammerHead) {
    const tap = Math.max(0, Math.sin(time * 2.4));
    dwarf.armR.rotation.z = 0.65 + tap * 0.18;
    dwarf.hammerHandle.rotation.z = -0.65 + tap * 0.2;
    dwarf.hammerHead.position.y = 0.55 - tap * 0.05;
  }
}

function placeDesktop(built) {
  placePlane(built.title, 3.65, 0.3, 0.0, 2.12, -1.02, 0);
  placeScreen(built, 4.75, 1.2, -0.15, 1.35, -2.1, 0.01);
  placePlane(built.browseButton, 1.42, 0.22, -1.78, 0.62, -2.0, 0.01);
  placePlane(built.boothButton, 2.72, 0.36, 0.0, -1.42, 0.36, 0);
  placePlane(built.speech, 1.68, 0.8, 4.22, 0.76, 0.28, -0.08);
  placeGroup(built.orc, -0.86, -0.7, -0.66, 0.78, 0.04);
  placeGroup(built.elf, 4.36, -0.86, 1.08, 0.58, -0.52);
  built.dwarf.visible = false;
  placeWalkingAgents(built, 'desktop');
}

function placeTablet(built) {
  placePlane(built.title, 3.08, 0.28, 0.0, 1.98, -0.98, 0);
  placeScreen(built, 4.1, 1.1, -0.04, 1.27, -2.08, 0);
  placePlane(built.browseButton, 1.2, 0.2, -1.34, 0.54, -2.0, 0);
  placePlane(built.boothButton, 2.44, 0.34, 0.06, -1.42, 0.46, 0);
  placePlane(built.speech, 1.28, 0.68, 2.38, -0.22, 0.72, -0.08);
  placeGroup(built.orc, -0.54, -0.8, -0.58, 0.72, 0.04);
  placeGroup(built.elf, 2.24, -1.0, 0.92, 0.52, -0.44);
  built.dwarf.visible = false;
  placeWalkingAgents(built, 'tablet');
}

function placeMobile(built) {
  placePlane(built.title, 2.34, 0.24, 0, 1.84, -0.9, 0);
  placeScreen(built, 3.36, 1.02, 0, 1.13, -2.02, 0);
  placePlane(built.browseButton, 1.12, 0.18, -0.98, 0.43, -1.94, 0);
  placePlane(built.boothButton, 1.94, 0.31, -0.1, -1.2, 0.84, 0);
  placePlane(built.speech, 1.0, 0.54, 1.04, -0.54, 0.82, -0.03);
  placeGroup(built.orc, -0.34, -0.95, -0.28, 0.58, 0.03);
  placeGroup(built.elf, 1.04, -1.1, 0.9, 0.42, -0.38);
  built.dwarf.visible = false;
  placeWalkingAgents(built, 'mobile');
}

function placeWalkingAgents(built, layout) {
  const routes = makeRouteSets(layout);
  const scale = VISITOR_AGENT_SCALE_AUDIT.currentByLayout[layout] || VISITOR_AGENT_SCALE_AUDIT.currentByLayout.desktop;
  const previousScale = VISITOR_AGENT_SCALE_AUDIT.previousByLayout[layout] || VISITOR_AGENT_SCALE_AUDIT.previousByLayout.desktop;
  const labelWorldWidth =
    VISITOR_AGENT_SCALE_AUDIT.currentLabelWorldWidth[layout] || VISITOR_AGENT_SCALE_AUDIT.currentLabelWorldWidth.desktop;
  const labelLocalY =
    VISITOR_AGENT_SCALE_AUDIT.currentLabelLocalY[layout] || VISITOR_AGENT_SCALE_AUDIT.currentLabelLocalY.desktop;
  KAYKIT_ACTORS.forEach((spec) => {
    const group = built.actorGroups[spec.group];
    if (!group) return;
    group.visible = true;
    group.scale.setScalar(scale);
    group.userData.baseScale = scale;
    group.userData.previousScale = previousScale;
    group.userData.scaleFactor = round(scale / previousScale);
    group.userData.scaleAudit = VISITOR_AGENT_SCALE_AUDIT;
    group.userData.routePoints = routes[spec.route] || routes.frontCross;
    group.userData.routePhase = spec.phase;
    group.userData.actorSpec = spec;
    group.userData.slotDuration = layout === 'mobile' ? 14.8 : layout === 'tablet' ? 14.4 : 14.0;
    const label = group.userData.agentLabel;
    if (label) {
      label.scale.set(labelWorldWidth / scale, (labelWorldWidth * 0.32) / scale, 1);
      label.userData.baseScale = label.scale.clone();
      label.userData.worldWidth = labelWorldWidth;
      label.userData.scaleFactor = round(scale / previousScale);
      label.userData.baseLocalY = labelLocalY;
      const routeSide = spec.route === 'frontCross' || spec.route === 'leftLoop' ? -1 : 1;
      const offsetMagnitude = layout === 'mobile' ? 0.56 : layout === 'tablet' ? 0.32 : 0.25;
      const bubbleDirection = routeSide;
      label.userData.offsetX = (bubbleDirection * offsetMagnitude) / scale;
      label.visible = false;
      if (label.material) label.material.opacity = 0;
    }
  });
}

function placeScreen(built, width, height, x, y, z, rotationY = 0) {
  placePlane(built.dashboard, width, height, x, y, z, rotationY);
  placeFrame(built.screenFrame, width, height, x, y, z + 0.02, rotationY);
}

function placeGroup(group, x, y, z, scale, rotationY) {
  group.position.set(x, y, z);
  group.scale.setScalar(scale);
  group.rotation.y = rotationY;
  group.userData.baseY = y;
  group.userData.baseRotY = rotationY;
  group.userData.baseScale = scale;
}

function placePlane(mesh, width, height, x, y, z, rotationY = 0) {
  mesh.scale.set(width, height, 1);
  mesh.position.set(x, y, z);
  mesh.rotation.set(0, rotationY, 0);
  mesh.userData.baseScale = mesh.scale.clone();
}

function placeFrame(group, width, height, x, y, z, rotationY = 0) {
  group.scale.set(width, height, 1);
  group.position.set(x, y, z);
  group.rotation.set(0, rotationY, 0);
  group.userData.baseScale = group.scale.clone();
}

function makeAgentInfoLabel(agent) {
  const plane = makeCanvasPlane(makeAgentInfoLabelTexture(agent), { renderOrder: 34, route: 'agents', depthTest: true });
  plane.userData.agentInfoLabel = true;
  plane.userData.agentName = agent.name;
  plane.userData.agentRole = agent.role;
  plane.userData.uiSurface = true;
  plane.visible = false;
  if (plane.material) plane.material.opacity = 0;
  plane.scale.set(2.22, 0.72, 1);
  return plane;
}

function makeAgentInfoLabelTexture(agent) {
  return makeTexture(640, 210, (ctx, width, height) => {
    ctx.clearRect(0, 0, width, height);
    ctx.save();
    ctx.shadowColor = agent.tint;
    ctx.shadowBlur = 24;
    drawHoloBox(ctx, 14, 16, width - 28, height - 42, 22, 'rgba(3, 14, 22, .88)', agent.tint);
    ctx.restore();

    ctx.textAlign = 'left';
    ctx.textBaseline = 'middle';
    ctx.shadowColor = agent.tint;
    ctx.shadowBlur = 10;
    ctx.fillStyle = '#f5feff';
    ctx.font = '900 40px Georgia, Times New Roman, serif';
    fitText(ctx, agent.name, 118, 62, width - 150, 40, 'left');
    ctx.shadowBlur = 0;
    ctx.fillStyle = '#bff4ff';
    ctx.font = '800 24px Inter, Arial, sans-serif';
    fitText(ctx, agent.role, 118, 103, width - 160, 24, 'left');
    ctx.fillStyle = agent.tint;
    fitText(ctx, agent.skill, 118, 138, width - 160, 22, 'left');
    ctx.fillStyle = '#ffc76b';
    ctx.font = '900 24px Inter, Arial, sans-serif';
    fitText(ctx, agent.bounty, width - 112, 147, 92, 24, 'right');

    ctx.save();
    ctx.translate(64, 96);
    ctx.beginPath();
    for (let i = 0; i < 8; i += 1) {
      const angle = -Math.PI / 2 + (i * Math.PI * 2) / 8;
      const x = Math.cos(angle) * 39;
      const y = Math.sin(angle) * 39;
      if (i === 0) ctx.moveTo(x, y);
      else ctx.lineTo(x, y);
    }
    ctx.closePath();
    ctx.fillStyle = 'rgba(4, 20, 28, .92)';
    ctx.strokeStyle = agent.tint;
    ctx.lineWidth = 6;
    ctx.fill();
    ctx.stroke();
    ctx.fillStyle = agent.tint;
    ctx.font = '900 26px Inter, Arial, sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText(agent.initials, 0, 5);
    ctx.restore();
  });
}

function makeCanvasPlane(texture, { renderOrder = 1, route = '', depthTest = false } = {}) {
  const mesh = new THREE.Mesh(
    new THREE.PlaneGeometry(1, 1),
    new THREE.MeshBasicMaterial({
      map: texture,
      transparent: true,
      depthTest,
      depthWrite: false,
      side: THREE.DoubleSide,
    }),
  );
  mesh.renderOrder = renderOrder;
  mesh.userData.route = route;
  mesh.userData.uiSurface = true;
  mesh.userData.staticBitmapSprite = false;
  return mesh;
}

function makeTitleTexture() {
  return makeTexture(1500, 210, (ctx, width, height) => {
    ctx.clearRect(0, 0, width, height);
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.shadowColor = 'rgba(0, 0, 0, .85)';
    ctx.shadowBlur = 12;
    ctx.font = '900 82px Georgia, Times New Roman, serif';
    ctx.fillStyle = '#fff2d4';
    fitText(ctx, 'AI MERCENARY TAVERN', width / 2, 74, width - 120, 82);
    ctx.shadowBlur = 8;
    ctx.font = '800 34px Georgia, Times New Roman, serif';
    ctx.fillStyle = '#e9c78c';
    fitText(ctx, 'browse agents or enter your booth', width / 2, 143, width - 180, 34);
  });
}

function makeDashboardTexture(route) {
  const panel = panelData(route);
  return makeTexture(1320, 540, (ctx, width, height) => {
    ctx.clearRect(0, 0, width, height);
    drawHoloBox(ctx, 18, 18, width - 36, height - 36, 28, 'rgba(3, 18, 34, .88)', panel.tint);
    ctx.fillStyle = 'rgba(64, 211, 255, .08)';
    for (let y = 68; y < height - 58; y += 42) ctx.fillRect(64, y, width - 128, 2);
    for (let x = 82; x < width - 80; x += 68) ctx.fillRect(x, 70, 2, height - 136);

    ctx.textAlign = 'left';
    ctx.textBaseline = 'middle';
    ctx.shadowColor = panel.tint;
    ctx.shadowBlur = 14;
    ctx.fillStyle = '#54e6ff';
    ctx.font = '900 70px Georgia, Times New Roman, serif';
    fitText(ctx, panel.title, 84, 94, 760, 70, 'left');
    ctx.shadowBlur = 8;
    ctx.fillStyle = '#e6fbff';
    ctx.font = '800 30px Inter, Arial, sans-serif';
    fitText(ctx, panel.subtitle, 88, 152, 840, 30, 'left');

    panel.rows.forEach((row, index) => {
      const y = 218 + index * 58;
      ctx.fillStyle = index % 2 ? 'rgba(64, 211, 255, .14)' : 'rgba(64, 211, 255, .08)';
      drawRound(ctx, 84, y - 26, 740, 42, 10);
      ctx.fill();
      ctx.fillStyle = '#dffaff';
      ctx.font = '800 28px Inter, Arial, sans-serif';
      fitText(ctx, row, 110, y - 4, 685, 28, 'left');
    });

    ctx.strokeStyle = '#32d5ff';
    ctx.lineWidth = 8;
    ctx.beginPath();
    ctx.moveTo(902, 352);
    ctx.lineTo(955, 288);
    ctx.lineTo(1005, 315);
    ctx.lineTo(1068, 222);
    ctx.lineTo(1118, 255);
    ctx.lineTo(1225, 132);
    ctx.stroke();
    ctx.fillStyle = '#40f0a7';
    ctx.font = '900 32px Inter, Arial, sans-serif';
    panel.stats.forEach((stat, index) => fitText(ctx, stat, 918, 228 + index * 62, 290, 32, 'left'));
    ctx.fillStyle = '#f4b650';
    for (let i = 0; i < 24; i += 1) {
      ctx.beginPath();
      ctx.arc(638 + Math.cos(i * 1.5) * (75 + (i % 3) * 20), 346 + Math.sin(i * 1.1) * 58, 14, 0, Math.PI * 2);
      ctx.fill();
    }
  });
}

function makeButtonTexture(text, tint) {
  return makeTexture(840, 190, (ctx, width, height) => {
    drawHoloBox(ctx, 16, 18, width - 32, height - 36, 24, 'rgba(3, 18, 34, .9)', tint);
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.shadowColor = tint;
    ctx.shadowBlur = 18;
    ctx.font = '900 54px Georgia, Times New Roman, serif';
    ctx.fillStyle = '#dbfbff';
    fitText(ctx, text, width / 2, height / 2 + 2, width - 130, 54);
  });
}

function makeSpeechTexture(route) {
  const panel = panelData(route);
  return makeTexture(900, 540, (ctx, width, height) => {
    drawHoloBox(ctx, 16, 18, width - 32, height - 36, 28, 'rgba(7, 13, 31, .9)', '#7d5cff');
    ctx.textAlign = 'left';
    ctx.textBaseline = 'middle';
    ctx.shadowColor = '#9d73ff';
    ctx.shadowBlur = 14;
    ctx.font = '900 36px Georgia, Times New Roman, serif';
    ctx.fillStyle = '#c9b8ff';
    fitText(ctx, 'Velvet Hex serves', 64, 70, width - 120, 36, 'left');
    ctx.font = '900 34px Inter, Arial, sans-serif';
    ctx.fillStyle = '#56e5ff';
    panel.actions.forEach((action, index) => {
      const y = 146 + index * 68;
      ctx.strokeStyle = 'rgba(81, 225, 255, .75)';
      ctx.lineWidth = 4;
      ctx.beginPath();
      ctx.arc(70, y, 12, 0, Math.PI * 2);
      ctx.stroke();
      fitText(ctx, action, 102, y + 1, width - 150, 34, 'left');
    });
  });
}

function makeAnimatedTexture(width, height, draw) {
  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext('2d');
  const texture = new THREE.CanvasTexture(canvas);
  prepareTexture(texture);
  texture.userData = { canvas, ctx, dynamic: true, frame: -1, scroll: 0, activeAgent: WANTED_AGENTS[0].name };

  const update = (time = 0) => {
    const frame = Math.floor(time * 18);
    if (frame === texture.userData.frame) return;
    texture.userData.frame = frame;
    const state = draw(ctx, width, height, time) || {};
    Object.assign(texture.userData, state);
    texture.needsUpdate = true;
  };

  update(0);
  return { texture, update };
}

function makeScreenFrame() {
  const group = new THREE.Group();
  const frame = material(0x14212b, 0.44, 0.35);
  const brass = material(0xa56d2f, 0.5, 0.22);
  const glow = new THREE.MeshBasicMaterial({
    color: 0x42dcff,
    transparent: true,
    opacity: 0.62,
    depthWrite: false,
  });

  const top = box(1.07, 0.06, 0.16, frame);
  top.position.y = 0.53;
  const bottom = top.clone();
  bottom.position.y = -0.53;
  const left = box(0.035, 1.12, 0.14, frame);
  left.position.x = -0.535;
  const right = left.clone();
  right.position.x = 0.535;
  const glowStrip = box(0.96, 0.014, 0.03, glow);
  glowStrip.position.set(0, 0.465, 0.08);
  group.add(top, bottom, left, right, glowStrip);

  [
    [-0.49, 0.47],
    [0.49, 0.47],
    [-0.49, -0.47],
    [0.49, -0.47],
  ].forEach(([x, y]) => {
    const bolt = cylinder(0.022, 0.022, 0.045, brass, 6);
    bolt.position.set(x, y, 0.1);
    bolt.rotation.x = Math.PI / 2;
    group.add(bolt);
  });

  group.renderOrder = 24;
  return group;
}

function makeScreenTabTexture() {
  return makeTexture(720, 120, (ctx, width, height) => {
    ctx.clearRect(0, 0, width, height);
    drawHoloBox(ctx, 10, 12, width - 20, height - 24, 18, 'rgba(3, 16, 25, .78)', '#49e2ff');
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.shadowColor = '#49e2ff';
    ctx.shadowBlur = 16;
    ctx.font = '900 42px Georgia, Times New Roman, serif';
    ctx.fillStyle = '#e9fdff';
    fitText(ctx, 'BROWSE AGENTS', width / 2, 48, width - 96, 42);
    ctx.font = '800 18px Inter, Arial, sans-serif';
    ctx.fillStyle = '#ffc46c';
    fitText(ctx, 'LIVE WANTED ROSTER', width / 2, 83, width - 120, 18);
  });
}

function makeWantedScreenTexture(routeSource) {
  return makeAnimatedTexture(1320, 540, (ctx, width, height, time) => {
    const panel = panelData(resolveRouteSource(routeSource));
    const activeAgent = WANTED_AGENTS[Math.floor(time * 0.48) % WANTED_AGENTS.length];
    ctx.clearRect(0, 0, width, height);

    drawHoloBox(ctx, 20, 18, width - 40, height - 36, 26, 'rgba(1, 12, 18, .94)', '#42dcff');
    const scan = Math.floor((time * 26) % 44);
    ctx.fillStyle = 'rgba(68, 224, 255, .075)';
    for (let y = 84 + scan; y < height - 44; y += 44) ctx.fillRect(54, y, width - 108, 2);

    ctx.textAlign = 'left';
    ctx.textBaseline = 'middle';
    ctx.shadowColor = '#42dcff';
    ctx.shadowBlur = 18;
    ctx.fillStyle = '#dcfbff';
    ctx.font = '900 72px Georgia, Times New Roman, serif';
    fitText(ctx, 'BROWSE AGENTS', 74, 78, 670, 72, 'left');
    ctx.shadowBlur = 7;
    ctx.fillStyle = '#ffc46c';
    ctx.font = '900 31px Inter, Arial, sans-serif';
    fitText(ctx, 'scoped mercenaries for hire', 80, 136, 640, 31, 'left');

    for (let i = 0; i < 3; i += 1) {
      const agentIndex = positiveModulo(i + Math.floor(time * 0.42), WANTED_AGENTS.length);
      const agent = WANTED_AGENTS[agentIndex];
      const y = 174 + i * 104;
      drawWantedRow(ctx, agent, 72, y, 790, 78, time, agent.name === activeAgent.name);
    }

    drawRound(ctx, 906, 118, 330, 350, 24);
    ctx.fillStyle = 'rgba(3, 23, 27, .72)';
    ctx.fill();
    ctx.strokeStyle = 'rgba(255, 198, 104, .75)';
    ctx.lineWidth = 6;
    ctx.stroke();
    drawAgentBadge(ctx, activeAgent, 1072, 206, 92, time, true);
    ctx.shadowColor = activeAgent.tint;
    ctx.shadowBlur = 14;
    ctx.fillStyle = '#fff4d9';
    ctx.font = '900 42px Georgia, Times New Roman, serif';
    fitText(ctx, activeAgent.name, 950, 320, 245, 42, 'left');
    ctx.shadowBlur = 0;
    ctx.fillStyle = '#9ef0ff';
    ctx.font = '800 26px Inter, Arial, sans-serif';
    fitText(ctx, activeAgent.role, 950, 366, 250, 26, 'left');
    fitText(ctx, activeAgent.skill, 950, 402, 250, 26, 'left');
    ctx.fillStyle = '#ffc25e';
    ctx.font = '900 30px Inter, Arial, sans-serif';
    fitText(ctx, `Bounty ${activeAgent.bounty}`, 950, 444, 250, 30, 'left');

    ctx.fillStyle = '#c8f7ff';
    ctx.font = '800 23px Inter, Arial, sans-serif';
    fitText(ctx, panel.subtitle, 788, 76, 430, 23, 'left');

    return { scroll: 0, activeAgent: activeAgent.name };
  });
}

function makeBoothNeonTexture() {
  return makeAnimatedTexture(1100, 220, (ctx, width, height, time) => {
    const flicker = 0.78 + Math.sin(time * 11.5) * 0.06 + Math.sin(time * 23.7) * 0.035;
    ctx.clearRect(0, 0, width, height);
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';

    drawHoloBox(ctx, 34, 30, width - 68, height - 58, 24, 'rgba(5, 16, 21, .88)', '#22dfff');

    ctx.shadowColor = '#22dfff';
    ctx.shadowBlur = 24 * flicker;
    ctx.lineWidth = 8;
    ctx.strokeStyle = `rgba(30, 229, 255, ${0.82 * flicker})`;
    ctx.font = '900 74px Georgia, Times New Roman, serif';
    ctx.strokeText('ENTER MY BOOTH', width / 2, 110, width - 150);
    ctx.shadowBlur = 12 * flicker;
    ctx.fillStyle = `rgba(238, 254, 255, ${0.96 * flicker})`;
    ctx.fillText('ENTER MY BOOTH', width / 2, 110, width - 150);

    ctx.shadowBlur = 0;
    ctx.fillStyle = `rgba(255, 196, 92, ${0.64 * flicker})`;
    ctx.fillRect(210, 158, 680, 5);

    return { flicker };
  });
}

function makeDynamicSpeechTexture(routeSource) {
  return makeAnimatedTexture(900, 540, (ctx, width, height, time) => {
    const panel = panelData(resolveRouteSource(routeSource));
    const lines = panel.actions.slice(0, 3);
    const active = Math.floor(time / 1.8) % lines.length;
    const pulse = 0.78 + Math.sin(time * 3.4) * 0.12;
    ctx.clearRect(0, 0, width, height);

    ctx.save();
    ctx.shadowColor = '#b08cff';
    ctx.shadowBlur = 20 * pulse;
    ctx.fillStyle = 'rgba(8, 14, 31, .88)';
    ctx.strokeStyle = `rgba(171, 139, 255, ${0.68 + pulse * 0.2})`;
    ctx.lineWidth = 7;
    drawRound(ctx, 18, 18, width - 56, height - 68, 30);
    ctx.fill();
    ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(width - 122, height - 50);
    ctx.lineTo(width - 44, height - 16);
    ctx.lineTo(width - 88, height - 86);
    ctx.closePath();
    ctx.fill();
    ctx.stroke();
    ctx.restore();

    ctx.textAlign = 'left';
    ctx.textBaseline = 'middle';
    ctx.shadowColor = '#9d73ff';
    ctx.shadowBlur = 12;
    ctx.fillStyle = '#dfd4ff';
    ctx.font = '900 52px Georgia, Times New Roman, serif';
    fitText(ctx, 'Velvet Hex', 64, 88, width - 140, 52, 'left');
    ctx.shadowBlur = 0;
    ctx.fillStyle = '#bff5ff';
    ctx.font = '800 30px Inter, Arial, sans-serif';
    fitText(ctx, 'choose a service', 66, 142, width - 150, 30, 'left');
    ctx.shadowBlur = 8;
    ctx.font = '900 44px Inter, Arial, sans-serif';
    lines.forEach((line, index) => {
      const y = 224 + index * 76;
      const selected = index === active;
      ctx.globalAlpha = selected ? 1 : 0.45;
      ctx.strokeStyle = selected ? '#5ee8ff' : 'rgba(94, 232, 255, .54)';
      ctx.lineWidth = selected ? 5 : 3;
      ctx.beginPath();
      ctx.arc(76, y, selected ? 18 + Math.sin(time * 5) * 2 : 12, 0, Math.PI * 2);
      ctx.stroke();
      ctx.fillStyle = selected ? '#64edff' : '#c8f3ff';
      fitText(ctx, line, 120, y + 1, width - 190, selected ? 44 : 36, 'left');
    });
    ctx.globalAlpha = 1;

    return { activeSpeech: lines[active] };
  });
}

function drawWantedRow(ctx, agent, x, y, width, height, time, active) {
  ctx.save();
  ctx.globalAlpha = active ? 1 : 0.82;
  ctx.fillStyle = active ? 'rgba(255, 198, 92, .2)' : 'rgba(50, 218, 255, .12)';
  drawRound(ctx, x, y, width, height, 18);
  ctx.fill();
  ctx.strokeStyle = active ? agent.tint : 'rgba(83, 224, 255, .45)';
  ctx.lineWidth = active ? 5 : 3;
  ctx.stroke();
  drawAgentBadge(ctx, agent, x + 58, y + height / 2, 32, time, active);
  ctx.shadowColor = agent.tint;
  ctx.shadowBlur = active ? 14 : 8;
  ctx.fillStyle = '#f5feff';
  ctx.font = '900 30px Georgia, Times New Roman, serif';
  fitText(ctx, agent.name, x + 112, y + 28, 256, 30, 'left');
  ctx.shadowBlur = 0;
  ctx.fillStyle = '#b9edf5';
  ctx.font = '800 19px Inter, Arial, sans-serif';
  fitText(ctx, agent.role, x + 112, y + 58, 230, 19, 'left');
  ctx.fillStyle = '#ffc46c';
  ctx.font = '900 25px Inter, Arial, sans-serif';
  fitText(ctx, agent.bounty, x + width - 118, y + 42, 94, 25, 'right');
  ctx.strokeStyle = agent.tint;
  ctx.lineWidth = 6;
  ctx.beginPath();
  ctx.moveTo(x + 430, y + 58);
  ctx.lineTo(x + 430 + 160 + Math.sin(time * 1.3 + x) * 28, y + 58);
  ctx.stroke();
  ctx.fillStyle = '#dffcff';
  ctx.font = '800 20px Inter, Arial, sans-serif';
  fitText(ctx, agent.skill, x + 438, y + 31, 208, 20, 'left');
  ctx.restore();
}

function drawAgentBadge(ctx, agent, x, y, radius, time, active) {
  const sides = 8;
  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(time * (active ? 0.24 : 0.08));
  ctx.beginPath();
  for (let i = 0; i < sides; i += 1) {
    const angle = -Math.PI / 2 + (i * Math.PI * 2) / sides;
    const px = Math.cos(angle) * radius;
    const py = Math.sin(angle) * radius;
    if (i === 0) ctx.moveTo(px, py);
    else ctx.lineTo(px, py);
  }
  ctx.closePath();
  ctx.fillStyle = 'rgba(4, 20, 28, .92)';
  ctx.strokeStyle = agent.tint;
  ctx.lineWidth = active ? 7 : 4;
  ctx.shadowColor = agent.tint;
  ctx.shadowBlur = active ? 18 : 10;
  ctx.fill();
  ctx.stroke();
  ctx.rotate(-time * (active ? 0.24 : 0.08));
  ctx.shadowBlur = 0;
  ctx.fillStyle = agent.tint;
  ctx.font = `900 ${Math.max(20, radius * 0.52)}px Inter, Arial, sans-serif`;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText(agent.initials, 0, 2);
  ctx.restore();
}

function positiveModulo(value, length) {
  return ((value % length) + length) % length;
}

function panelData(route) {
  const panels = {
    home: {
      title: 'Browse Agents',
      subtitle: 'Click the holo-board to inspect the tavern roster.',
      rows: ['Vertical site embeds a command booth', 'Customer backend mints a scoped session', 'Agent acts through supplied skills in tinybox'],
      stats: ['12,458 sessions', '7,326 deliveries', '+23.7% usage'],
      actions: ['Inspect Roster', 'Publish Agent', 'Remote Use', 'Port Mapping', 'Run Directly'],
      tint: '#22c9ff',
    },
    agents: {
      title: 'Mercenary Staff',
      subtitle: 'Specialists with scoped tools, runtime limits, and visible feedback.',
      rows: ['Iron Tusk / site ops / 18 coin per hour', 'Velvet Hex / user consent / 14 coin per hour', 'Socket Wraith / tinybox bridge / 16 coin per hour'],
      stats: ['6 active ranks', 'SLA visible', 'skills scoped'],
      actions: ['Filter by Skill', 'Inspect Runtime', 'Hire Specialist', 'Watch Feedback', 'Swap Agent'],
      tint: '#22c9ff',
    },
    booth: {
      title: 'My Booth',
      subtitle: 'Mount, message, inspect, and carry personal agents into websites.',
      rows: ['Iron Tusk / running / cart.update / 00:13', 'Socket Wraith / waiting / claim.lookup / 02:41', 'Nightforge / paused / terminal.chat / 18:09'],
      stats: ['3 mounted', '4 callbacks', 'healthy'],
      actions: ['Mount Agent', 'Open TUI', 'Send Message', 'Review Logs', 'Detach Booth'],
      tint: '#36f0b0',
    },
    contracts: {
      title: 'Contract Pricing',
      subtitle: 'Three paths: embedded agent, bring-your-own agent, or hosted agent.',
      rows: ['Site hires agent / API key to session', 'User brings agent / connect amerc', 'Hosted mercenary / TUI operations'],
      stats: ['session', 'skills', 'runtime'],
      actions: ['Create API Key', 'Bind Skills', 'Set Budget', 'Publish Terms', 'Start Session'],
      tint: '#f2b85f',
    },
    admin: {
      title: 'Quartermaster',
      subtitle: 'Tenant, key, session, skill, and risk controls for operators.',
      rows: ['northstar-retail / embedded / green', 'claims-lab / byoa / amber', 'sandbox / hosted / red'],
      stats: ['tenants', 'keys', 'risk queue'],
      actions: ['Audit Tenants', 'Rotate Keys', 'Trace Sessions', 'Block Skill', 'Review Risk'],
      tint: '#f2b85f',
    },
    signin: {
      title: 'Open Booth',
      subtitle: 'Sign in and login preview for the mercenary control surface.',
      rows: ['Guild handle / operator@vertical-site.ai', 'Access sigil / ************', `Release / ${RELEASE}`],
      stats: ['preview', 'amerc.ai', 'frontend'],
      actions: ['Choose Account', 'Link Agent', 'Grant Scope', 'Enter Booth', 'Return to Site'],
      tint: '#9d73ff',
    },
  };
  return panels[route] || panels.home;
}

function makeRuneRing() {
  const group = new THREE.Group();
  const mat = new THREE.MeshBasicMaterial({
    color: 0x28e6ff,
    transparent: true,
    opacity: 0.1,
    depthTest: false,
    depthWrite: false,
  });
  for (let i = 0; i < 3; i += 1) {
    const ring = new THREE.Mesh(new THREE.TorusGeometry(0.72 + i * 0.18, 0.008, 5, 24), mat);
    ring.renderOrder = 14;
    group.add(ring);
  }
  return group;
}

function makeSparks() {
  const positions = new Float32Array(180 * 3);
  for (let i = 0; i < 180; i += 1) {
    positions[i * 3] = (Math.random() - 0.5) * 12;
    positions[i * 3 + 1] = (Math.random() - 0.36) * 5.2;
    positions[i * 3 + 2] = -2.6 + Math.random() * 3.6;
  }
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  const materialSpark = new THREE.PointsMaterial({
    color: 0xffbc57,
    size: 0.035,
    transparent: true,
    opacity: 0.75,
    depthWrite: false,
  });
  return new THREE.Points(geometry, materialSpark);
}

function makeLimb(radius, height, mat, segments = 6) {
  const mesh = cylinder(radius, radius * 0.92, height, mat, segments);
  mesh.castShadow = true;
  return mesh;
}

function box(width, height, depth, mat) {
  const mesh = new THREE.Mesh(new THREE.BoxGeometry(width, height, depth, 1, 1, 1), mat);
  mesh.castShadow = true;
  mesh.receiveShadow = true;
  return mesh;
}

function cylinder(radiusTop, radiusBottom, height, mat, segments = 6) {
  const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radiusTop, radiusBottom, height, segments, 1, false), mat);
  mesh.castShadow = true;
  mesh.receiveShadow = true;
  return mesh;
}

function cone(radius, height, mat, segments = 5) {
  const mesh = new THREE.Mesh(new THREE.ConeGeometry(radius, height, segments, 1), mat);
  mesh.castShadow = true;
  mesh.receiveShadow = true;
  return mesh;
}

function material(color, roughness = 0.78, metalness = 0.02, transparent = false, opacity = 1) {
  return new THREE.MeshStandardMaterial({
    color,
    roughness,
    metalness,
    flatShading: true,
    transparent,
    opacity,
  });
}

function woodMaterial(base, grain, dark) {
  const texture = makeTexture(512, 512, (ctx, width, height) => {
    ctx.fillStyle = base;
    ctx.fillRect(0, 0, width, height);
    for (let y = 0; y < height; y += 18) {
      ctx.fillStyle = y % 36 === 0 ? 'rgba(255, 226, 150, .08)' : 'rgba(0, 0, 0, .14)';
      ctx.fillRect(0, y, width, 4);
    }
    for (let i = 0; i < 54; i += 1) {
      const y = (i * 29) % height;
      ctx.strokeStyle = i % 3 ? grain : dark;
      ctx.globalAlpha = i % 3 ? 0.24 : 0.16;
      ctx.lineWidth = 2 + (i % 4);
      ctx.beginPath();
      for (let x = -20; x < width + 20; x += 28) {
        const wave = Math.sin((x + i * 37) * 0.03) * (5 + (i % 5));
        if (x === -20) ctx.moveTo(x, y + wave);
        else ctx.lineTo(x, y + wave);
      }
      ctx.stroke();
    }
    ctx.globalAlpha = 1;
    for (let i = 0; i < 8; i += 1) {
      const x = 42 + ((i * 149) % 430);
      const y = 46 + ((i * 97) % 420);
      const r = 16 + (i % 3) * 6;
      const gradient = ctx.createRadialGradient(x, y, 4, x, y, r);
      gradient.addColorStop(0, 'rgba(20, 6, 2, .45)');
      gradient.addColorStop(0.45, 'rgba(120, 55, 22, .2)');
      gradient.addColorStop(1, 'rgba(255, 210, 120, 0)');
      ctx.fillStyle = gradient;
      ctx.beginPath();
      ctx.ellipse(x, y, r * 1.4, r * 0.72, (i % 4) * 0.55, 0, Math.PI * 2);
      ctx.fill();
    }
  });
  texture.wrapS = THREE.RepeatWrapping;
  texture.wrapT = THREE.RepeatWrapping;
  texture.repeat.set(2.4, 1.2);
  return new THREE.MeshStandardMaterial({
    map: texture,
    roughness: 0.72,
    metalness: 0.01,
    flatShading: true,
  });
}

function markShadows(group) {
  group.traverse((object) => {
    if (object.isMesh) {
      object.castShadow = true;
      object.receiveShadow = true;
    }
  });
}

function countMeshes(object) {
  let count = 0;
  object.traverse((child) => {
    if (child.isMesh || child.isPoints) count += 1;
  });
  return count;
}

function publishStageDebug(metrics, objects, camera) {
  if (typeof window === 'undefined') return;
  const placements = Object.fromEntries(
    Object.entries(objects).map(([name, object]) => {
      const bounds = projectedBounds(object, camera);
      const visible =
        object.userData.optionalMovingActor ||
        (bounds.left >= -1.08 &&
          bounds.right <= 1.08 &&
          bounds.bottom >= -1.08 &&
          bounds.top <= 1.08);
      return [name, { bounds, visible }];
    }),
  );
  window.__AMERC_STAGE = {
    version: RELEASE,
    release: RELEASE,
    lowPoly: true,
    draggable: true,
    true3DModels: true,
    ...metrics,
    placements,
    allVisible: Object.values(placements).every((item) => item.visible),
  };
}

function projectedBounds(object, camera) {
  if (!object.visible) return { left: 0, right: 0, top: 0, bottom: 0 };
  object.updateWorldMatrix(true, true);
  const box3 = new THREE.Box3().setFromObject(object);
  if (box3.isEmpty()) return { left: 0, right: 0, top: 0, bottom: 0 };
  const points = [
    new THREE.Vector3(box3.min.x, box3.min.y, box3.min.z),
    new THREE.Vector3(box3.min.x, box3.min.y, box3.max.z),
    new THREE.Vector3(box3.min.x, box3.max.y, box3.min.z),
    new THREE.Vector3(box3.min.x, box3.max.y, box3.max.z),
    new THREE.Vector3(box3.max.x, box3.min.y, box3.min.z),
    new THREE.Vector3(box3.max.x, box3.min.y, box3.max.z),
    new THREE.Vector3(box3.max.x, box3.max.y, box3.min.z),
    new THREE.Vector3(box3.max.x, box3.max.y, box3.max.z),
  ].map((point) => point.project(camera));
  return {
    left: round(Math.min(...points.map((point) => point.x))),
    right: round(Math.max(...points.map((point) => point.x))),
    top: round(Math.max(...points.map((point) => point.y))),
    bottom: round(Math.min(...points.map((point) => point.y))),
  };
}

function summarizeWorldBounds(object) {
  if (!object?.visible) return null;
  object.updateWorldMatrix(true, true);
  const box3 = new THREE.Box3().setFromObject(object);
  if (box3.isEmpty()) return null;
  const size = new THREE.Vector3();
  const center = new THREE.Vector3();
  box3.getSize(size);
  box3.getCenter(center);
  return {
    min: box3.min.toArray().map(round),
    max: box3.max.toArray().map(round),
    size: size.toArray().map(round),
    center: center.toArray().map(round),
  };
}

function summarizeVisitorAgents(built) {
  return Object.entries(built.actorGroups).map(([name, group]) => {
    const label = group.userData.agentLabel;
    return {
      name,
      visible: group.visible,
      route: group.userData.routeName,
      currentScale: round(group.scale.x),
      previousScale: round(group.userData.previousScale || 0),
      scaleFactor: round(group.userData.scaleFactor || 0),
      labelVisible: Boolean(label?.visible),
      labelWorldWidth: round(label?.userData?.worldWidth || 0),
      labelBaseLocalY: round(label?.userData?.baseLocalY || 0),
      labelOffsetX: round(label?.userData?.offsetX || 0),
      bounds: summarizeWorldBounds(group),
    };
  });
}

function round(value) {
  return Math.round(value * 1000) / 1000;
}

function makeTexture(width, height, draw) {
  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext('2d');
  draw(ctx, width, height);
  const texture = new THREE.CanvasTexture(canvas);
  prepareTexture(texture);
  texture.needsUpdate = true;
  return texture;
}

function prepareTexture(texture) {
  texture.colorSpace = THREE.SRGBColorSpace;
  texture.wrapS = THREE.ClampToEdgeWrapping;
  texture.wrapT = THREE.ClampToEdgeWrapping;
  texture.minFilter = THREE.LinearFilter;
  texture.magFilter = THREE.LinearFilter;
  texture.generateMipmaps = false;
  texture.anisotropy = 1;
}

function drawHoloBox(ctx, x, y, width, height, radius, fill, stroke) {
  ctx.save();
  ctx.shadowColor = stroke;
  ctx.shadowBlur = 18;
  ctx.fillStyle = fill;
  ctx.strokeStyle = stroke;
  ctx.lineWidth = 8;
  drawRound(ctx, x, y, width, height, radius);
  ctx.fill();
  ctx.stroke();
  ctx.restore();
}

function drawRound(ctx, x, y, width, height, radius) {
  const r = Math.min(radius, width / 2, height / 2);
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.arcTo(x + width, y, x + width, y + height, r);
  ctx.arcTo(x + width, y + height, x, y + height, r);
  ctx.arcTo(x, y + height, x, y, r);
  ctx.arcTo(x, y, x + width, y, r);
  ctx.closePath();
}

function fitText(ctx, text, x, y, maxWidth, startSize, align = 'center') {
  const previous = ctx.textAlign;
  const parts = ctx.font.split(' ');
  const family = parts.slice(2).join(' ') || 'Inter, Arial, sans-serif';
  const weight = parts[0] || '800';
  let size = startSize;
  while (size > 16) {
    ctx.font = `${weight} ${size}px ${family}`;
    if (ctx.measureText(text).width <= maxWidth) break;
    size -= 2;
  }
  ctx.textAlign = align;
  ctx.fillText(text, x, y, maxWidth);
  ctx.textAlign = previous;
}

function disposeScene(scene) {
  scene.traverse((object) => {
    if (object.geometry) object.geometry.dispose();
    if (!object.material) return;
    const materials = Array.isArray(object.material) ? object.material : [object.material];
    materials.forEach((item) => {
      if (item.map) item.map.dispose();
      item.dispose();
    });
  });
}

function clamp(value, min, max) {
  return Math.min(Math.max(value, min), max);
}

export default ModelSceneApp;
