commit b2b555ef161d5aa2f683ad50e66ee1af0f4d789f Author: jessikitty Date: Tue Jul 14 11:38:17 2026 +1000 update Tue 07/14/2026 11:38:16.92 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ffe333a --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# copy to .env and edit +EXHIBIT_MODE=dev +# required when EXHIBIT_MODE=final: +ADMIN_PASSWORD=change-me diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9fc24ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +data/live/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..96d8ec4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev +COPY server ./server +COPY data ./data +COPY public ./public +ENV PORT=33044 +ENV LIVE_DIR=/app/data/live +EXPOSE 33044 +CMD ["node", "server/server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d439660 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# Newbury Exhibit v2 + +Fan-made shared multi-viewer AR experience for a physical LEGO® Hidden Side™ town +display. Multiple phones point at printed ArUco markers ("Newbury Crests") and see +the same server-driven ghosts anchored to real-world positions, each from their own +viewpoint. Tribute project — not affiliated with or endorsed by the LEGO Group. + +## Stack +Node 20 (Express + ws) in Docker · Three.js r160 (CDN import map) · js-aruco2 +(ARUCO_4X4_1000, vendored) · vanilla ES-module client. + +## Baked-in AR fixes (hard-won, do not "simplify" these) +- **POS-IT pose**: rotation used **as-is**; translation **Y and Z negated** (`-t[1]`, `-t[2]`). +- **Planar-ambiguity flips**: per-marker temporal consistency — pick the POS-IT solution + closest to the previous frame (`public/js/ar/pose.js`). +- **Phantom marker IDs**: `maxHammingDistance: 0` (`public/js/ar/detect.js`). +- **Multi-marker flicker**: confidence-weighted quaternion slerp fusion, hemisphere-aligned, + weighted by marker screen area (`public/js/ar/fuse.js`). Place anchors densely so ≥2 + crests are visible from any viewing angle. +- **Vendor load order matters**: `cv → svd → posit1 → aruco → dictionary`. + +## Features +- **Playlist engine** (server-driven, all viewers in sync): N concurrent ghosts, + per-ghost dwell time then rotate through the whole roster, shuffle/roster order, + color/rarity filters, optional time-of-day windows, crossfade in/out. +- **Behaviors**: static float (bob + idle sway) or wander (deterministic orbit-drift — + a pure function of server spawn record + wall clock, so every phone computes the + same motion with zero position streaming). +- **Anchors ("Newbury Crests")**: flat (horizontal) / wall (vertical) / custom mounts, + fully editable — position, yaw/pitch/roll trim, printed size, enable toggle. +- **Layout editor** (`/admin/layout.html`): to-scale top-down canvas; drag anchors, + occlusion buildings, and spawn points; property panel; 5 mm snap; save/reset-to-seed. +- **Playlist manager** (`/admin/playlist.html`), **crest print sheet** (`/admin/print.html`, + true-to-size), **calibration** (`/admin/calibrate.html`: live distance/angle/jitter + + fused pose), **client error log** (`/admin/errors.html`). +- **Occlusion**: buildings render as invisible depth-only volumes so ghosts pass behind them. +- **Multi-part OBJ ghosts**: legs/wisp + torso + head + headpiece assembled and tinted with + the recovered Hidden Side gradient shader (Red/Angry, Yellow/Crazy, Blue/Sad). Procedural + wisp fallback until models are dropped into `public/models/` and listed in the model + manifest (`GET/PUT /api/models`). + +## Modes +- `EXHIBIT_MODE=dev` — admin pages open, on-screen client error overlay, errors reported + to `/api/client-errors`. +- `EXHIBIT_MODE=final` — admin + setup pages require `ADMIN_PASSWORD` (12 h tokens). + +## Run +```bash +cp .env.example .env # set mode + password +docker compose up -d --build +# http://host:33044 → viewer +# http://host:33044/admin/layout.html → admin +``` +Live edits (layout, playlist, models) persist in the `exhibit-live` volume and survive +rebuilds; `POST /api/scene/reset` restores the committed seed. + +## Deploy on blackpearl +```bash +git clone https://gitea.hideawaygaming.com.au/jessikitty/newbury-exhibit-v2.git +cd newbury-exhibit-v2 && cp .env.example .env && bash deploy.sh +``` +Then nginx-proxy 33044 with HTTPS (camera + sensors require a secure context). + +## Adding your OBJ ghost models +1. Drop parts into `public/models//` (e.g. `legs.obj`, `torso.obj`, `head.obj`, `headpiece.obj`). +2. Register via the models API (or edit `data/models.json` seed): +```json +{ "models": [ { "id": "minifig", "scale": 1.0, "parts": { + "legs": "/models/minifig/legs.obj", "torso": "/models/minifig/torso.obj", + "head": "/models/minifig/head.obj", "headpiece": "/models/minifig/headpiece.obj" } } ] } +``` +The gradient shader recolors every part by the ghost's color type automatically. diff --git a/data/ghosts.json b/data/ghosts.json new file mode 100644 index 0000000..14a3964 --- /dev/null +++ b/data/ghosts.json @@ -0,0 +1,2666 @@ +[ + { + "id": "air-marshall-brooks", + "name": "Air Marshall Brooks", + "color": "Red", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 4, + "rangePips": 1, + "chargePips": 3, + "healthMax": 263, + "damageMax": 161, + "abilityId": "sentry", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SentryIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "barney-mcphly", + "name": "Barney McPhly", + "color": "Red", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 4, + "rangePips": 1, + "chargePips": 3, + "healthMax": 263, + "damageMax": 161, + "abilityId": "sentry", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SentryIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "hilary-delivery", + "name": "Hilary Delivery", + "color": "Red", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 4, + "rangePips": 1, + "chargePips": 2, + "healthMax": 300, + "damageMax": 192, + "abilityId": "speed-boost", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "lumberjack-leroy", + "name": "Lumberjack Leroy", + "color": "Red", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 4, + "rangePips": 1, + "chargePips": 3, + "healthMax": 263, + "damageMax": 161, + "abilityId": "jump", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "JumpIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "nagging-nathan", + "name": "Nagging Nathan", + "color": "Red", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 300, + "damageMax": 192, + "abilityId": "speed-boost", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "poor-thaddeus", + "name": "Poor Thaddeus", + "color": "Red", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 2, + "rangePips": 1, + "chargePips": 3, + "healthMax": 338, + "damageMax": 212, + "abilityId": "flight", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "FlightIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "vito-andale", + "name": "Vito Andale", + "color": "Red", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 4, + "rangePips": 1, + "chargePips": 3, + "healthMax": 263, + "damageMax": 161, + "abilityId": "ghost-gang", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SmokeDecoyIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "ben-batter-jackson", + "name": "Ben \"Batter\" Jackson", + "color": "Red", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 5, + "chargePips": 1, + "healthMax": 263, + "damageMax": 212, + "abilityId": "tracking-gloom", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HomingShotIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "doctor-julius", + "name": "Doctor Julius", + "color": "Red", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 4, + "chargePips": 2, + "healthMax": null, + "damageMax": null, + "abilityId": "healing-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealPadIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "janick", + "name": "Janick", + "color": "Red", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": null, + "damageMax": null, + "abilityId": "gloombrella", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomCeilingIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "jenny", + "name": "Jenny", + "color": "Red", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 4, + "chargePips": 2, + "healthMax": 338, + "damageMax": 192, + "abilityId": "healing-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealPadIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "jeremy-jones", + "name": "Jeremy Jones", + "color": "Red", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 300, + "damageMax": 176, + "abilityId": "gloombrella", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomCeilingIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "jimothy", + "name": "Jimothy", + "color": "Red", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 4, + "chargePips": 2, + "healthMax": null, + "damageMax": null, + "abilityId": "ghost-gang", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SmokeDecoyIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "mad-fenton", + "name": "Mad Fenton", + "color": "Red", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 2, + "chargePips": 1, + "healthMax": 300, + "damageMax": 176, + "abilityId": "jump-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkForwardIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "sir-kane-the-insane", + "name": "Sir Kane The Insane", + "color": "Red", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 2, + "rangePips": 1, + "chargePips": 2, + "healthMax": 413, + "damageMax": 225, + "abilityId": "random-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkRandomIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "bobby-big-cheese-marchese", + "name": "Bobby \"Big Cheese\" Marchese", + "color": "Red", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 263, + "damageMax": 225, + "abilityId": "invisibility", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "InvisibilityIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "chef-brennan", + "name": "Chef Brennan", + "color": "Red", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 3, + "rangePips": 3, + "chargePips": 1, + "healthMax": 300, + "damageMax": 212, + "abilityId": "heal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "don-hernando", + "name": "Don Hernando", + "color": "Red", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "shifting-bait", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "MatchingDecoyIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "joe-rotten", + "name": "Joe Rotten", + "color": "Red", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 300, + "damageMax": 192, + "abilityId": "jump-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkForwardIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "lil-bo-pumpkins", + "name": "Lil' Bo Pumpkins", + "color": "Red", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "shifting-bait", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "MatchingDecoyIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "murray-andreeves", + "name": "Murray Andreeves", + "color": "Red", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "shifting-bait", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "MatchingDecoyIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "old-leon", + "name": "Old Leon", + "color": "Red", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": null, + "damageMax": null, + "abilityId": "gloom-wall", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomWallIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "ugha", + "name": "Ugha", + "color": "Red", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "heal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "blaze-m-barr", + "name": "Blaze M. Barr", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "firestarter", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "FireIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "butch-lindenburger", + "name": "Butch Lindenburger", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "healing-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealPadIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "captain-archibald", + "name": "Captain Archibald", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "kraken-call", + "faceSprite": "ArchibaldIcon.png", + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": true, + "locations": [], + "abilityIcon": "ArchibaldIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "cassius-blackeye-sockem", + "name": "Cassius 'Blackeye' Sockem", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 1, + "chargePips": 1, + "healthMax": 338, + "damageMax": 192, + "abilityId": "jump-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkForwardIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "crooked-sven-robin", + "name": "Crooked Sven Robin", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 2, + "chargePips": 2, + "healthMax": 313, + "damageMax": 212, + "abilityId": "speed-boost-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostPadIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "dr-drewell", + "name": "Dr. Drewell", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "powerful-potions", + "faceSprite": "DrewellIcon.png", + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": true, + "locations": [], + "abilityIcon": "DrewellIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "drew-jockstrap-jones", + "name": "Drew \"Jockstrap\" Jones", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 1, + "chargePips": 2, + "healthMax": 375, + "damageMax": 225, + "abilityId": "gloom-wall", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomWallIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "graak", + "name": "Graak", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 2, + "chargePips": 1, + "healthMax": null, + "damageMax": null, + "abilityId": "jump-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "JumpPadIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "lady-e", + "name": "Lady E", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 178, + "abilityId": "hidden-portal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HiddenPortalIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "lumber-zack", + "name": "Lumber Zack", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": null, + "damageMax": null, + "abilityId": "invisibility", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "InvisibilityIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "madame-digamberetto", + "name": "Madame Digamberetto", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 4, + "chargePips": 2, + "healthMax": 338, + "damageMax": 192, + "abilityId": "flight", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "FlightIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "maxine-turbo", + "name": "Maxine Turbo", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "drag-race", + "faceSprite": "HotrodIcon.png", + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": true, + "locations": [], + "abilityIcon": "HotrodIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "monsieur-forte", + "name": "Monsieur Forte", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 2, + "chargePips": 3, + "healthMax": 313, + "damageMax": 192, + "abilityId": "gloombrella", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomCeilingIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "samuel-mason", + "name": "Samuel Mason", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 4, + "chargePips": 1, + "healthMax": 338, + "damageMax": 225, + "abilityId": "soul-shot", + "faceSprite": "SamuelMasonIcon.png", + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": true, + "locations": [], + "abilityIcon": "SamuelMasonIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "tragico", + "name": "Tragico", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "clown-car", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "ClownCar.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "trucker-dale", + "name": "Trucker Dale", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 4, + "chargePips": 1, + "healthMax": 338, + "damageMax": 225, + "abilityId": "gloom-engine", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomEngine.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "alice", + "name": "Alice", + "color": "Yellow", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 5, + "rangePips": 1, + "chargePips": 3, + "healthMax": 263, + "damageMax": 161, + "abilityId": "speed-boost-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostPadIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "diana", + "name": "Diana", + "color": "Yellow", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 4, + "rangePips": 4, + "chargePips": 3, + "healthMax": 263, + "damageMax": 161, + "abilityId": "jump-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "JumpPadIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "james", + "name": "James", + "color": "Yellow", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 4, + "rangePips": 3, + "chargePips": 2, + "healthMax": 375, + "damageMax": 161, + "abilityId": "flight", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "FlightIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "luis-piedrecita", + "name": "Luis Piedrecita", + "color": "Yellow", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 3, + "rangePips": 5, + "chargePips": 3, + "healthMax": 300, + "damageMax": 135, + "abilityId": "ghost-gang", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SmokeDecoyIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "mr-mueller", + "name": "Mr. Mueller", + "color": "Yellow", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 4, + "rangePips": 4, + "chargePips": 3, + "healthMax": 300, + "damageMax": 176, + "abilityId": "random-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkRandomIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "ms-sandri", + "name": "Ms. Sandri", + "color": "Yellow", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 375, + "damageMax": 161, + "abilityId": "jump", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "JumpIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "rufus", + "name": "Rufus", + "color": "Yellow", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "flight", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "FlightIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "wild-thomas", + "name": "Wild Thomas", + "color": "Yellow", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 4, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 161, + "abilityId": "jump", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "JumpIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "zeb-jackson", + "name": "Zeb Jackson", + "color": "Yellow", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 375, + "damageMax": 161, + "abilityId": "shifting-bait", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "MatchingDecoyIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "archibald-the-1st", + "name": "Archibald The 1st", + "color": "Yellow", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 2, + "chargePips": 2, + "healthMax": 375, + "damageMax": 176, + "abilityId": "heal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "don-janko", + "name": "Don Janko", + "color": "Yellow", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 4, + "rangePips": 2, + "chargePips": 2, + "healthMax": 338, + "damageMax": 161, + "abilityId": "jump", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "JumpIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "doug", + "name": "Doug", + "color": "Yellow", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 4, + "rangePips": 2, + "chargePips": 1, + "healthMax": 375, + "damageMax": 161, + "abilityId": "healing-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealPadIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "everett", + "name": "Everett", + "color": "Yellow", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 2, + "chargePips": 2, + "healthMax": 375, + "damageMax": 176, + "abilityId": "tracking-gloom", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HomingShotIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "garfield-gonzalez", + "name": "Garfield Gonzalez", + "color": "Yellow", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 4, + "rangePips": 5, + "chargePips": 1, + "healthMax": 338, + "damageMax": 161, + "abilityId": "jump-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkForwardIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "nanna-borom", + "name": "Nanna Borom", + "color": "Yellow", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 3, + "chargePips": 3, + "healthMax": 338, + "damageMax": 161, + "abilityId": "teleportal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "TeleportToPortalIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "phil", + "name": "Phil", + "color": "Yellow", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 2, + "chargePips": 2, + "healthMax": 375, + "damageMax": 176, + "abilityId": "tracking-gloom", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HomingShotIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "summer", + "name": "Summer", + "color": "Yellow", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 2, + "chargePips": 3, + "healthMax": 300, + "damageMax": 150, + "abilityId": "random-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkRandomIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "timothy", + "name": "Timothy", + "color": "Yellow", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 2, + "chargePips": 2, + "healthMax": 413, + "damageMax": 176, + "abilityId": "speed-boost", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "dave", + "name": "Dave", + "color": "Yellow", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 413, + "damageMax": 150, + "abilityId": "invisibility", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "InvisibilityIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "ivan-monroe", + "name": "Ivan Monroe", + "color": "Yellow", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 3, + "rangePips": 5, + "chargePips": 3, + "healthMax": 300, + "damageMax": 135, + "abilityId": "speed-boost", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "jake-rosebud", + "name": "Jake Rosebud", + "color": "Yellow", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 4, + "rangePips": 4, + "chargePips": 2, + "healthMax": 263, + "damageMax": 176, + "abilityId": "invisibility", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "InvisibilityIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "matt-with-the-propeller-hat", + "name": "Matt with the propeller hat", + "color": "Yellow", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 4, + "rangePips": 4, + "chargePips": 2, + "healthMax": 263, + "damageMax": 176, + "abilityId": "invisibility", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "InvisibilityIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "ollie", + "name": "Ollie", + "color": "Yellow", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 5, + "rangePips": 1, + "chargePips": 2, + "healthMax": 338, + "damageMax": 150, + "abilityId": "speed-boost", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "percy", + "name": "Percy", + "color": "Yellow", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 4, + "rangePips": 4, + "chargePips": 2, + "healthMax": null, + "damageMax": null, + "abilityId": "invisibility", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "InvisibilityIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "pickles", + "name": "Pickles", + "color": "Yellow", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 300, + "damageMax": 150, + "abilityId": "flight", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "FlightIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "professor-iken", + "name": "Professor Iken", + "color": "Yellow", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 3, + "rangePips": 5, + "chargePips": 3, + "healthMax": 300, + "damageMax": 135, + "abilityId": "jump-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "JumpPadIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "anomolo", + "name": "Anomolo", + "color": "Yellow", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 4, + "rangePips": 3, + "chargePips": 2, + "healthMax": 375, + "damageMax": 176, + "abilityId": "spew", + "faceSprite": "AnomoloIcon.png", + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": true, + "locations": [], + "abilityIcon": "AnomoloIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "chester-the-jester", + "name": "Chester The Jester", + "color": "Yellow", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "heal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "daisy", + "name": "Daisy", + "color": "Yellow", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 4, + "rangePips": 3, + "chargePips": 2, + "healthMax": 375, + "damageMax": 176, + "abilityId": "speed-boost-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostPadIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "harry-cane", + "name": "Harry Cane", + "color": "Yellow", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 5, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 176, + "abilityId": "sonic-boom", + "faceSprite": "AirShowIcon.png", + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": true, + "locations": [], + "abilityIcon": "AirShowIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "kreatoru", + "name": "Kreatoru", + "color": "Yellow", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 4, + "rangePips": 3, + "chargePips": 2, + "healthMax": 375, + "damageMax": 176, + "abilityId": "bubble-curtain", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BubbleShieldIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "old-niek", + "name": "Old Niek", + "color": "Yellow", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 3, + "chargePips": 3, + "healthMax": 338, + "damageMax": 161, + "abilityId": "sentry", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SentryIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "rat-shaun", + "name": "Rat Shaun", + "color": "Yellow", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 4, + "rangePips": 3, + "chargePips": 2, + "healthMax": 375, + "damageMax": 176, + "abilityId": "rat-king", + "faceSprite": "RatkingIcon.png", + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": true, + "locations": [], + "abilityIcon": "RatkingIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "the-bawa", + "name": "The Bawa", + "color": "Yellow", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 4, + "rangePips": 4, + "chargePips": 1, + "healthMax": 375, + "damageMax": 176, + "abilityId": "shadowflight", + "faceSprite": "BawaIcon.png", + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": true, + "locations": [], + "abilityIcon": "BawaIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "the-duke", + "name": "The Duke", + "color": "Yellow", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 4, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 176, + "abilityId": "random-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkRandomIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "andy-lasky", + "name": "Andy Lasky", + "color": "Blue", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 1, + "rangePips": 5, + "chargePips": 2, + "healthMax": 375, + "damageMax": 176, + "abilityId": "tracking-gloom", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HomingShotIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "annie-bonnie", + "name": "Annie Bonnie", + "color": "Blue", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 2, + "rangePips": 4, + "chargePips": 2, + "healthMax": null, + "damageMax": null, + "abilityId": "tracking-gloom", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HomingShotIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "gary-attric", + "name": "Gary Attric", + "color": "Blue", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 2, + "rangePips": 4, + "chargePips": 2, + "healthMax": 375, + "damageMax": 176, + "abilityId": "tracking-gloom", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HomingShotIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "maiken-hill", + "name": "Maiken Hill", + "color": "Blue", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 1, + "rangePips": 2, + "chargePips": 3, + "healthMax": 413, + "damageMax": 192, + "abilityId": "shifting-bait", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "MatchingDecoyIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "molly-jones", + "name": "Molly Jones", + "color": "Blue", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 413, + "damageMax": 176, + "abilityId": "healing-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealPadIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "ol-elinor", + "name": "Ol' Elinor", + "color": "Blue", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 413, + "damageMax": 176, + "abilityId": "teleportal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "TeleportToPortalIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "ted-big-bun-bixby", + "name": "Ted \"Big Bun\" Bixby", + "color": "Blue", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 413, + "damageMax": 176, + "abilityId": "heal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "wendy-batt", + "name": "Wendy Batt", + "color": "Blue", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 2, + "rangePips": 4, + "chargePips": 1, + "healthMax": 375, + "damageMax": 176, + "abilityId": "gloom-wall", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomWallIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "xiao-wen", + "name": "Xiao Wen", + "color": "Blue", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 1, + "rangePips": 3, + "chargePips": 2, + "healthMax": null, + "damageMax": null, + "abilityId": "gloombrella", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomCeilingIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "barry-the-bomber", + "name": "Barry The Bomber", + "color": "Blue", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 2, + "rangePips": 5, + "chargePips": 2, + "healthMax": 313, + "damageMax": 225, + "abilityId": "ghost-gang", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SmokeDecoyIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "grandma-winnie", + "name": "Grandma Winnie", + "color": "Blue", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 1, + "rangePips": 4, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "jump-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkForwardIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "grandpa-sam", + "name": "Grandpa Sam", + "color": "Blue", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 1, + "rangePips": 5, + "chargePips": 3, + "healthMax": 338, + "damageMax": 192, + "abilityId": "random-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkRandomIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "mr-tucker", + "name": "Mr. Tucker", + "color": "Blue", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 2, + "rangePips": 2, + "chargePips": 1, + "healthMax": 313, + "damageMax": 161, + "abilityId": "speed-boost-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostPadIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "mr-white", + "name": "Mr. White", + "color": "Blue", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 2, + "rangePips": 3, + "chargePips": 1, + "healthMax": 450, + "damageMax": 176, + "abilityId": "jump", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "JumpIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "nurse-nellie", + "name": "Nurse Nellie", + "color": "Blue", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 1, + "rangePips": 4, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "healing-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealPadIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "old-fuller", + "name": "Old Fuller", + "color": "Blue", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 1, + "rangePips": 3, + "chargePips": 2, + "healthMax": 313, + "damageMax": 176, + "abilityId": "sentry", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SentryIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "ron-duke", + "name": "Ron Duke", + "color": "Blue", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 2, + "rangePips": 4, + "chargePips": 3, + "healthMax": 375, + "damageMax": 161, + "abilityId": "shifting-bait", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "MatchingDecoyIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "t-e-anderson", + "name": "T.E. Anderson", + "color": "Blue", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 2, + "rangePips": 4, + "chargePips": 3, + "healthMax": 375, + "damageMax": 161, + "abilityId": "gloom-wall", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomWallIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "alex-boudoun", + "name": "Alex Boudoun", + "color": "Blue", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 375, + "damageMax": 192, + "abilityId": "jump-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "JumpPadIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "belulah-demonay", + "name": "Belulah Demonay", + "color": "Blue", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 3, + "rangePips": 3, + "chargePips": 1, + "healthMax": 450, + "damageMax": 192, + "abilityId": "random-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkRandomIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "joe-snow", + "name": "Joe Snow", + "color": "Blue", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 3, + "rangePips": 3, + "chargePips": 3, + "healthMax": 338, + "damageMax": 161, + "abilityId": "sentry", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SentryIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "judge-higgins", + "name": "Judge Higgins", + "color": "Blue", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 3, + "rangePips": 3, + "chargePips": 3, + "healthMax": null, + "damageMax": null, + "abilityId": "gloom-wall", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomWallIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "luigi-gelato", + "name": "Luigi Gelato", + "color": "Blue", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 3, + "rangePips": 3, + "chargePips": 3, + "healthMax": 338, + "damageMax": 161, + "abilityId": "speed-boost-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostPadIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "morgan-walken", + "name": "Morgan Walken", + "color": "Blue", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 1, + "rangePips": 4, + "chargePips": 2, + "healthMax": 413, + "damageMax": 176, + "abilityId": "teleportal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "TeleportToPortalIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "roy", + "name": "Roy", + "color": "Blue", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 1, + "rangePips": 3, + "chargePips": 2, + "healthMax": 313, + "damageMax": 176, + "abilityId": "ghost-gang", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SmokeDecoyIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "trudy-joy", + "name": "Trudy Joy", + "color": "Blue", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 3, + "rangePips": 2, + "chargePips": 2, + "healthMax": 450, + "damageMax": 150, + "abilityId": "speed-boost-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostPadIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "bart-chaney", + "name": "Bart Chaney", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 4, + "chargePips": 2, + "healthMax": 413, + "damageMax": 192, + "abilityId": "prison-break", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "PrisonbreakIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "beau", + "name": "Beau", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 1, + "rangePips": 1, + "chargePips": 2, + "healthMax": 413, + "damageMax": 176, + "abilityId": "shifting-bait", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "MatchingDecoyIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "flavio", + "name": "Flavio", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 1, + "rangePips": 3, + "chargePips": 2, + "healthMax": 375, + "damageMax": 176, + "abilityId": "teleportal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "TeleportToPortalIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "gary-linemann", + "name": "Gary Linemann", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 1, + "rangePips": 2, + "chargePips": 2, + "healthMax": 450, + "damageMax": 161, + "abilityId": "gloombrella", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomCeilingIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "giacomo", + "name": "Giacomo", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 1, + "rangePips": 3, + "chargePips": 2, + "healthMax": null, + "damageMax": null, + "abilityId": "teleportal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "TeleportToPortalIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "joe-ishmael", + "name": "Joe Ishmael", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 1, + "rangePips": 4, + "chargePips": 2, + "healthMax": 413, + "damageMax": 176, + "abilityId": "gloom-seeker", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomSeeker.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "magnus-gornason", + "name": "Magnus Gornason", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 1, + "rangePips": 2, + "chargePips": 3, + "healthMax": 375, + "damageMax": 161, + "abilityId": "ghost-gang", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SmokeDecoyIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "mamali", + "name": "Mamali", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 5, + "chargePips": 1, + "healthMax": 413, + "damageMax": 192, + "abilityId": "root-roof", + "faceSprite": "MamaliIcon.png", + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": true, + "locations": [], + "abilityIcon": "MamaliIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "mr-nibs", + "name": "Mr. Nibs", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 4, + "chargePips": 2, + "healthMax": 413, + "damageMax": 192, + "abilityId": "shadow-bears", + "faceSprite": "MrNibsIcon.png", + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": true, + "locations": [], + "abilityIcon": "MrNibsIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "spewer", + "name": "Spewer", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 5, + "chargePips": 1, + "healthMax": 413, + "damageMax": 192, + "abilityId": "recall", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "TempAbilityIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "the-maw", + "name": "The Maw", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 5, + "chargePips": 1, + "healthMax": 413, + "damageMax": 192, + "abilityId": "gloom-snack", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomSnackIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + } +] diff --git a/data/gradients.json b/data/gradients.json new file mode 100644 index 0000000..718ca41 --- /dev/null +++ b/data/gradients.json @@ -0,0 +1,26 @@ +{ + "_note": "Ghost tint gradients recovered from GhostType_* MonoBehaviours (AssetRipper export). The original renders ghosts from shared base visuals recoloured by these gradients. GhostType maps 1:1 to GhostColor lure.", + "gradients": { + "Red": { + "ghostType": "Angry", + "top": "#F65151", + "bottom": "#FF2678", + "topRGBA": [0.9647059, 0.31764707, 0.31764707, 1], + "bottomRGBA": [1, 0.14901961, 0.47058824, 1] + }, + "Yellow": { + "ghostType": "Crazy", + "top": "#B57F0B", + "bottom": "#FFF35D", + "topRGBA": [0.70980394, 0.49803922, 0.043137256, 1], + "bottomRGBA": [1, 0.9529412, 0.3647059, 1] + }, + "Blue": { + "ghostType": "Sad", + "top": "#529EFF", + "bottom": "#51EAF1", + "topRGBA": [0.32156864, 0.61960787, 1, 1], + "bottomRGBA": [0.31764707, 0.91764706, 0.94509804, 1] + } + } +} diff --git a/data/models.json b/data/models.json new file mode 100644 index 0000000..3f59877 --- /dev/null +++ b/data/models.json @@ -0,0 +1,4 @@ +{ + "_note": "Multi-part OBJ ghost models. Drop OBJs into public/models// and list parts here (legs|wisp, torso, head, headpiece). While 'models' is empty the exhibit uses the procedural wisp fallback.", + "models": [] +} diff --git a/data/playlist.json b/data/playlist.json new file mode 100644 index 0000000..f99b188 --- /dev/null +++ b/data/playlist.json @@ -0,0 +1,9 @@ +{ + "slots": 5, + "dwellSeconds": 120, + "crossfadeSeconds": 3, + "order": "shuffle", + "include": { "colors": [], "rarities": [], "ids": [] }, + "behaviors": { "staticChance": 0.5, "wanderRadius": 0.6, "wanderSpeed": 0.15 }, + "timeWindows": [] +} diff --git a/data/scene.json b/data/scene.json new file mode 100644 index 0000000..4887541 --- /dev/null +++ b/data/scene.json @@ -0,0 +1,20 @@ +{ + "world": { "units": "m", "note": "origin = front-left corner of the table; +X right, +Z toward the back, +Y up" }, + "anchors": [ + { "markerId": 0, "position": [0.20, 0.00, 0.20], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true }, + { "markerId": 1, "position": [0.90, 0.00, 0.20], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true }, + { "markerId": 2, "position": [0.20, 0.00, 0.90], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true }, + { "markerId": 3, "position": [0.90, 0.00, 0.90], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true }, + { "markerId": 4, "position": [0.55, 0.12, 0.00], "mount": "wall", "yawDeg": 180, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true } + ], + "buildings": [ + { "name": "Newbury High", "position": [0.55, 0.00, 0.55], "size": [0.35, 0.30, 0.25], "yawDeg": 0 } + ], + "spawns": [ + { "id": "spawn-1", "position": [0.35, 0.30, 0.40], "enabled": true }, + { "id": "spawn-2", "position": [0.75, 0.35, 0.40], "enabled": true }, + { "id": "spawn-3", "position": [0.55, 0.45, 0.70], "enabled": true }, + { "id": "spawn-4", "position": [0.25, 0.25, 0.75], "enabled": true }, + { "id": "spawn-5", "position": [0.85, 0.28, 0.75], "enabled": true } + ] +} diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..c035118 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# deploy.sh — run on hal-blackpearl. Pulls latest and (re)builds the Docker container. +set -e +cd "$(dirname "$0")" +git fetch origin +git reset --hard origin/main +docker compose up -d --build +docker image prune -f >/dev/null +echo "Deployed. Logs: docker logs -f newbury-exhibit" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0ea1179 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + exhibit: + build: . + container_name: newbury-exhibit + restart: unless-stopped + ports: + - "33044:33044" + environment: + # dev = admin open + on-screen error overlay; final = password-protected admin + EXHIBIT_MODE: ${EXHIBIT_MODE:-dev} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} + volumes: + # live scene/playlist edits survive rebuilds and redeploys + - exhibit-live:/app/data/live + # drop OBJ ghost models here without rebuilding the image + - ./public/models:/app/public/models +volumes: + exhibit-live: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c601e22 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,850 @@ +{ + "name": "newbury-exhibit-v2", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "newbury-exhibit-v2", + "version": "2.0.0", + "dependencies": { + "express": "^4.19.2", + "ws": "^8.17.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6ec2460 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "newbury-exhibit-v2", + "version": "2.0.0", + "private": true, + "description": "Fan-made shared multi-viewer AR exhibit for a LEGO Hidden Side town display (tribute project)", + "main": "server/server.js", + "scripts": { "start": "node server/server.js" }, + "dependencies": { + "express": "^4.19.2", + "ws": "^8.17.0" + } +} diff --git a/public/admin/admin.js b/public/admin/admin.js new file mode 100644 index 0000000..69f55ed --- /dev/null +++ b/public/admin/admin.js @@ -0,0 +1,46 @@ +/* admin.js — shared auth + API helpers for admin pages. */ +export async function api(path, method = 'GET', body) { + const res = await fetch(path, { + method, + headers: { + 'Content-Type': 'application/json', + 'x-exhibit-token': localStorage.getItem('exhibitToken') || '', + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (res.status === 401) { location.href = '/admin/login.html?next=' + encodeURIComponent(location.pathname); throw new Error('unauthorized'); } + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.statusText); + return res.json(); +} + +export async function requireAuth() { + const info = await (await fetch('/api/info')).json(); + if (!info.authRequired) return info; + // probe a guarded endpoint + const res = await fetch('/api/client-errors', { headers: { 'x-exhibit-token': localStorage.getItem('exhibitToken') || '' } }); + if (res.status === 401) location.href = '/admin/login.html?next=' + encodeURIComponent(location.pathname); + return info; +} + +export const styles = ` + :root { color-scheme: dark; } + body { margin:0; background:#0a0d18; color:#e8ecff; font-family:system-ui,sans-serif; } + header { display:flex; gap:14px; align-items:center; padding:10px 16px; background:#111730; } + header a { color:#8fb8ff; text-decoration:none; font-size:.9rem; } + header a.active { color:#51eaf1; } + h1 { font-size:1.05rem; margin:0 auto 0 0; } + button { background:#2a3a6e; color:#fff; border:0; border-radius:6px; padding:6px 14px; cursor:pointer; } + button.primary { background:linear-gradient(135deg,#51eaf1,#529eff); color:#04121a; font-weight:600; } + button.danger { background:#7a2437; } + input, select { background:#141a33; color:#fff; border:1px solid #2a3a6e; border-radius:5px; padding:4px 8px; } + label { font-size:.8rem; opacity:.85; } +`; + +export function nav(active) { + return `
+

Newbury Exhibit — Admin

+ ${['layout', 'playlist', 'print', 'calibrate', 'errors'].map(p => + `${p}`).join('')} + viewer ↗ +
`; +} diff --git a/public/admin/calibrate.html b/public/admin/calibrate.html new file mode 100644 index 0000000..71beda1 --- /dev/null +++ b/public/admin/calibrate.html @@ -0,0 +1,92 @@ + + + +Calibrate — Newbury Exhibit + + + + +
Starting camera…
+ + + + + + + + + diff --git a/public/admin/errors.html b/public/admin/errors.html new file mode 100644 index 0000000..064765b --- /dev/null +++ b/public/admin/errors.html @@ -0,0 +1,31 @@ + + +Client Errors — Newbury Exhibit + +
+ + diff --git a/public/admin/layout.html b/public/admin/layout.html new file mode 100644 index 0000000..e37f984 --- /dev/null +++ b/public/admin/layout.html @@ -0,0 +1,237 @@ + + +Layout Editor — Newbury Exhibit + +
+ + diff --git a/public/admin/login.html b/public/admin/login.html new file mode 100644 index 0000000..1b88b5e --- /dev/null +++ b/public/admin/login.html @@ -0,0 +1,24 @@ + + +Admin Login — Newbury Exhibit + +
+

Exhibit Admin

+ + +
+
+ + diff --git a/public/admin/playlist.html b/public/admin/playlist.html new file mode 100644 index 0000000..dcabeb9 --- /dev/null +++ b/public/admin/playlist.html @@ -0,0 +1,99 @@ + + +Playlist — Newbury Exhibit + +
+ + diff --git a/public/admin/print.html b/public/admin/print.html new file mode 100644 index 0000000..b395b53 --- /dev/null +++ b/public/admin/print.html @@ -0,0 +1,56 @@ + + +Print Crests — Newbury Exhibit + + +
+
+ + + + + + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..af7815f --- /dev/null +++ b/public/index.html @@ -0,0 +1,65 @@ + + + + + +Newbury Exhibit + + + + + + +
+

NEWBURY EXHIBIT

+
Point your phone at the Newbury Crests to reveal the hidden side of the town.
+ +
Fan-made tribute experience. Not affiliated with, sponsored, or endorsed by the LEGO Group. + LEGO® and Hidden Side™ are trademarks of the LEGO Group.
+
+ + +
+ + + + + + + + + + + + diff --git a/public/js/ar/detect.js b/public/js/ar/detect.js new file mode 100644 index 0000000..27998d6 --- /dev/null +++ b/public/js/ar/detect.js @@ -0,0 +1,22 @@ +/* detect.js — tuned ArUco 4x4 detection. + * Phantom-ID fix: reject any marker decoded with hamming distance > 0 (maxHamming: 0). + */ +export function createDetector() { + return new AR.Detector({ dictionaryName: 'ARUCO_4X4_1000', maxHammingDistance: 0 }); +} + +export function detectMarkers(detector, imageData, knownIds) { + const markers = detector.detect(imageData); + // keep only markers that exist in the scene, with sane geometry + return markers.filter(m => knownIds.has(m.id) && quadArea(m.corners) > 100); +} + +export function quadArea(c) { + // shoelace + let a = 0; + for (let i = 0; i < 4; i++) { + const p = c[i], q = c[(i + 1) % 4]; + a += p.x * q.y - q.x * p.y; + } + return Math.abs(a) / 2; +} diff --git a/public/js/ar/fuse.js b/public/js/ar/fuse.js new file mode 100644 index 0000000..d9920df --- /dev/null +++ b/public/js/ar/fuse.js @@ -0,0 +1,88 @@ +/* fuse.js — fuseWorld: combine per-marker camera pose estimates into one world pose. + * + * Each detected marker yields a camera-in-world estimate: + * cameraWorld = anchorWorld * inverse(markerPoseInCamera) + * Estimates are fused by confidence-weighted quaternion slerp (incremental + * weighted average) and weighted position mean. Confidence = marker screen area + * (bigger/closer markers dominate). A light temporal smooth removes residual jitter. + * + * Requiring >= 2 visible markers is handled upstream by anchor placement density; + * fuseWorld itself works with 1..N. + */ +import * as THREE from 'three'; +import { anchorWorldMatrix } from './pose.js'; + +const _inv = new THREE.Matrix4(); +const _m = new THREE.Matrix4(); +const _p = new THREE.Vector3(); +const _q = new THREE.Quaternion(); +const _s = new THREE.Vector3(); + +export class WorldFuser { + constructor() { + this.anchorMats = new Map(); // markerId -> Matrix4 + this.smoothPos = null; + this.smoothQuat = null; + this.posAlpha = 0.35; // smoothing factors (higher = snappier) + this.quatAlpha = 0.35; + this.lastFuseT = 0; + } + + setScene(scene) { + this.anchorMats.clear(); + for (const a of scene.anchors || []) { + if (a.enabled === false) continue; + this.anchorMats.set(a.markerId, { mat: anchorWorldMatrix(a), sizeMM: a.sizeMM || 60 }); + } + } + + sizeFor(markerId) { return this.anchorMats.get(markerId)?.sizeMM || 60; } + knownIds() { return new Set(this.anchorMats.keys()); } + + /** estimates: [{ markerId, position(mm), quaternion, area }] */ + fuse(estimates) { + const MM = 0.001; + const cams = []; + for (const e of estimates) { + const entry = this.anchorMats.get(e.markerId); + if (!entry) continue; + // marker pose in camera space -> matrix (translate mm->m) + _m.compose(_p.copy(e.position).multiplyScalar(MM), e.quaternion, _s.set(1, 1, 1)); + _inv.copy(_m).invert(); // camera in marker space + const camWorld = new THREE.Matrix4().multiplyMatrices(entry.mat, _inv); + const pos = new THREE.Vector3(); + const quat = new THREE.Quaternion(); + camWorld.decompose(pos, quat, _s); + cams.push({ pos, quat, w: Math.max(1, e.area) }); + } + if (!cams.length) return null; + + // weighted position mean + incremental weighted slerp for orientation + let wSum = cams[0].w; + const pos = cams[0].pos.clone().multiplyScalar(cams[0].w); + const quat = cams[0].quat.clone(); + for (let i = 1; i < cams.length; i++) { + const c = cams[i]; + // hemisphere alignment before slerp (quaternion double-cover) + if (quat.dot(c.quat) < 0) c.quat.set(-c.quat.x, -c.quat.y, -c.quat.z, -c.quat.w); + const t = c.w / (wSum + c.w); + quat.slerp(c.quat, t); + pos.add(c.pos.clone().multiplyScalar(c.w)); + wSum += c.w; + } + pos.multiplyScalar(1 / wSum); + + // temporal smoothing + const now = performance.now(); + if (this.smoothPos && now - this.lastFuseT < 500) { + this.smoothPos.lerp(pos, this.posAlpha); + if (this.smoothQuat.dot(quat) < 0) quat.set(-quat.x, -quat.y, -quat.z, -quat.w); + this.smoothQuat.slerp(quat, this.quatAlpha); + } else { + this.smoothPos = pos.clone(); + this.smoothQuat = quat.clone(); + } + this.lastFuseT = now; + return { position: this.smoothPos.clone(), quaternion: this.smoothQuat.clone(), markerCount: cams.length }; + } +} diff --git a/public/js/ar/pose.js b/public/js/ar/pose.js new file mode 100644 index 0000000..1b6a388 --- /dev/null +++ b/public/js/ar/pose.js @@ -0,0 +1,93 @@ +/* pose.js — marker pose estimation with the confirmed fixes: + * 1. POS-IT rotation used AS-IS; translation Y and Z negated (-t[1], -t[2]). + * 2. POS-IT planar ambiguity resolved by temporal consistency: + * pick the solution (bestError vs alternativeError) closest to the previous frame. + * + * Requires vendor chain loaded in order: cv -> svd -> posit1 -> aruco -> dictionary. + */ +import * as THREE from 'three'; + +const _m = new THREE.Matrix4(); +const _q = new THREE.Quaternion(); + +export class PoseEstimator { + constructor(focalLength) { + this.focal = focalLength; + this.posits = new Map(); // sizeMM -> POS.Posit + this.prev = new Map(); // markerId -> { quat, pos, t } + this.prevTTL = 1500; // ms before history is considered stale + } + + positFor(sizeMM) { + if (!this.posits.has(sizeMM)) this.posits.set(sizeMM, new POS.Posit(sizeMM, this.focal)); + return this.posits.get(sizeMM); + } + + /** corners: aruco marker corners, image-space; cx/cy: image center. + * Returns { position: THREE.Vector3 (mm, marker->camera), quaternion, error } */ + estimate(markerId, corners, cx, cy, sizeMM) { + const centered = corners.map(c => ({ x: c.x - cx, y: (cy - c.y) })); + const pose = this.positFor(sizeMM).pose(centered); + if (!pose) return null; + + const cand = [ + this.candidate(pose.bestRotation, pose.bestTranslation, pose.bestError), + this.candidate(pose.alternativeRotation, pose.alternativeTranslation, pose.alternativeError), + ]; + + // Temporal consistency: prefer the solution nearest the previous frame's quat. + const prev = this.prev.get(markerId); + let pick; + if (prev && (performance.now() - prev.t) < this.prevTTL) { + const d0 = Math.abs(cand[0].quaternion.dot(prev.quat)); + const d1 = Math.abs(cand[1].quaternion.dot(prev.quat)); + // only override error-order if the alternative is clearly more consistent + pick = (d1 > d0 + 0.05) ? cand[1] : (d0 > d1 + 0.05 ? cand[0] : (cand[0].error <= cand[1].error ? cand[0] : cand[1])); + } else { + pick = cand[0].error <= cand[1].error ? cand[0] : cand[1]; + } + + this.prev.set(markerId, { quat: pick.quaternion.clone(), pos: pick.position.clone(), t: performance.now() }); + return pick; + } + + candidate(rot, t, error) { + // Rotation as-is (row-major 3x3 -> Matrix4) + _m.set( + rot[0][0], rot[0][1], rot[0][2], 0, + rot[1][0], rot[1][1], rot[1][2], 0, + rot[2][0], rot[2][1], rot[2][2], 0, + 0, 0, 0, 1 + ); + const quaternion = new THREE.Quaternion().setFromRotationMatrix(_m); + // Translation: negate Y and Z only (confirmed fix) + const position = new THREE.Vector3(t[0], -t[1], -t[2]); + return { position, quaternion, error }; + } +} + +/** Build the marker->world transform for an anchor. + * mount 'flat': marker printed face-up on a horizontal surface. + * mount 'wall': marker on a vertical surface; yawDeg = facing direction. + * mount 'custom': explicit yaw/pitch/roll (deg) applied in YXZ order. + * All mounts additionally honour yaw/pitch/roll offsets for fine trim. + */ +export function anchorWorldMatrix(anchor) { + const pos = new THREE.Vector3(...anchor.position); + const yaw = THREE.MathUtils.degToRad(anchor.yawDeg || 0); + const pitch = THREE.MathUtils.degToRad(anchor.pitchDeg || 0); + const roll = THREE.MathUtils.degToRad(anchor.rollDeg || 0); + + // Base orientation by mount: + // flat: marker face-up — marker +Z (out of print face) -> world +Y + // wall: marker vertical — marker +Z faces world +Z when yawDeg = 0 + const base = new THREE.Quaternion(); + if (anchor.mount !== 'wall' && anchor.mount !== 'custom') { + base.setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2); + } + + // Trim (fully editable): yaw about world Y, then pitch/roll fine adjustment + const trim = new THREE.Quaternion().setFromEuler(new THREE.Euler(pitch, yaw, roll, 'YXZ')); + const q = trim.multiply(base); + return new THREE.Matrix4().compose(pos, q, new THREE.Vector3(1, 1, 1)); +} diff --git a/public/js/exhibit.js b/public/js/exhibit.js new file mode 100644 index 0000000..851e18e --- /dev/null +++ b/public/js/exhibit.js @@ -0,0 +1,193 @@ +/* exhibit.js — main viewer. + * Pipeline: camera video -> ArUco detect (maxHamming 0) -> per-marker POS-IT pose + * (rotation as-is, -t[1] -t[2]) -> fuseWorld (confidence-weighted slerp) -> Three.js + * camera placed in world -> server-driven ghosts + building occlusion meshes. + */ +import * as THREE from 'three'; +import { createDetector, detectMarkers, quadArea } from './ar/detect.js'; +import { PoseEstimator } from './ar/pose.js'; +import { WorldFuser } from './ar/fuse.js'; +import { buildGhost } from './ghosts/loader.js'; +import { ghostTransform } from './ghosts/behavior.js'; +import { ExhibitNet, installErrorReporter } from './net.js'; + +const $ = (s) => document.querySelector(s); +let info = { mode: 'dev' }; +let scene3, camera3, renderer, video, canvas2d, ctx2d; +let detector, poseEst, fuser; +let gradients = {}, modelManifest = null; +const activeGhosts = new Map(); // uid -> { rec, group } +let tracking = { markers: 0, lastSeen: 0 }; +const clockOffsetSamples = []; +let clockOffset = 0; // serverNow - clientNow + +async function boot() { + info = await (await fetch('/api/info')).json(); + installErrorReporter(info.mode === 'dev'); + + const g = await (await fetch('/api/ghosts')).json(); + gradients = g.gradients.gradients || g.gradients; + modelManifest = await (await fetch('/api/models')).json(); + + $('#start').addEventListener('click', start, { once: true }); +} + +async function start() { + $('#startScreen').classList.add('hidden'); + $('#hud').classList.remove('hidden'); + + // camera + video = $('#cam'); + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }, + audio: false, + }); + video.srcObject = stream; + await video.play(); + + // detection canvas + canvas2d = document.createElement('canvas'); + ctx2d = canvas2d.getContext('2d', { willReadFrequently: true }); + + // three.js + renderer = new THREE.WebGLRenderer({ canvas: $('#gl'), alpha: true, antialias: true }); + renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); + scene3 = new THREE.Scene(); + camera3 = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.02, 50); + scene3.add(new THREE.AmbientLight(0xffffff, 1.2)); + onResize(); addEventListener('resize', onResize); + + detector = createDetector(); + fuser = new WorldFuser(); + + // network + const net = new ExhibitNet(); + net.on('scene', (m) => { fuser.setScene(m.scene); buildOcclusion(m.scene); }) + .on('active', async (m) => { + for (const uid of [...activeGhosts.keys()]) removeGhost(uid); + for (const rec of m.ghosts) await addGhost(rec); + }) + .on('spawn', (m) => addGhost(m.ghost)) + .on('despawn', (m) => scheduleRemove(m.uid)) + .on('pong', (m) => { + const rtt = performance.now() - m.t; + clockOffsetSamples.push(m.server + rtt / 2 - Date.now()); + if (clockOffsetSamples.length > 5) clockOffsetSamples.shift(); + clockOffset = clockOffsetSamples.reduce((a, b) => a + b, 0) / clockOffsetSamples.length; + }); + net.connect(); + setInterval(() => { try { net.ws.send(JSON.stringify({ type: 'ping', t: performance.now() })); } catch {} }, 5000); + + requestAnimationFrame(loop); +} + +function onResize() { + renderer.setSize(innerWidth, innerHeight); + camera3.aspect = innerWidth / innerHeight; + camera3.updateProjectionMatrix(); +} + +// ---------- occlusion: invisible depth-only building volumes ---------- +const occlusionGroup = new THREE.Group(); +function buildOcclusion(scene) { + occlusionGroup.clear(); + const mat = new THREE.MeshBasicMaterial({ colorWrite: false }); // depth-only + for (const b of scene.buildings || []) { + const geo = new THREE.BoxGeometry(b.size[0], b.size[1], b.size[2]); + const mesh = new THREE.Mesh(geo, mat); + mesh.position.set(b.position[0], b.position[1] + b.size[1] / 2, b.position[2]); + mesh.rotation.y = THREE.MathUtils.degToRad(b.yawDeg || 0); + mesh.renderOrder = -1; + occlusionGroup.add(mesh); + } + if (!occlusionGroup.parent) scene3.add(occlusionGroup); +} + +// ---------- ghosts ---------- +async function addGhost(rec) { + if (activeGhosts.has(rec.uid)) return; + const group = await buildGhost(rec, gradients, modelManifest); + scene3.add(group); + activeGhosts.set(rec.uid, { rec, group }); + toast(`${rec.name} appeared`); +} +function removeGhost(uid) { + const e = activeGhosts.get(uid); + if (!e) return; + scene3.remove(e.group); + activeGhosts.delete(uid); +} +function scheduleRemove(uid) { + const e = activeGhosts.get(uid); + if (!e) return removeGhost(uid); + // let the client-side crossfade (driven by rec.until) finish, then remove + const wait = Math.max(0, e.rec.until - (Date.now() + clockOffset)) + (e.rec.crossfade || 3) * 1000; + setTimeout(() => removeGhost(uid), Math.min(wait, 8000)); +} + +// ---------- main loop ---------- +const _tmp = { position: new THREE.Vector3(), rotationY: 0, opacity: 1 }; +let frameCount = 0; + +function loop(t) { + requestAnimationFrame(loop); + if (video && video.readyState >= 2) { + // downscale for detection speed + const W = 640; + const H = Math.round(W * video.videoHeight / video.videoWidth); + if (canvas2d.width !== W) { canvas2d.width = W; canvas2d.height = H; } + ctx2d.drawImage(video, 0, 0, W, H); + const img = ctx2d.getImageData(0, 0, W, H); + + if (!poseEst) { + // focal length in detection-canvas pixels from camera FOV assumption (~60° h-fov) + poseEst = new PoseEstimator(W / (2 * Math.tan(THREE.MathUtils.degToRad(60) / 2))); + } + + const markers = detectMarkers(detector, img, fuser.knownIds()); + tracking.markers = markers.length; + if (markers.length) { + tracking.lastSeen = performance.now(); + const estimates = markers.map(m => { + const e = poseEst.estimate(m.id, m.corners, W / 2, H / 2, fuser.sizeFor(m.id)); + return e && { markerId: m.id, position: e.position, quaternion: e.quaternion, area: quadArea(m.corners) }; + }).filter(Boolean); + const fused = fuser.fuse(estimates); + if (fused) { + camera3.position.copy(fused.position); + camera3.quaternion.copy(fused.quaternion); + } + } + } + + // ghosts: deterministic motion on synced clock + const now = Date.now() + clockOffset; + for (const { rec, group } of activeGhosts.values()) { + ghostTransform(rec, now, _tmp); + group.position.copy(_tmp.position); + group.rotation.y = _tmp.rotationY; + group.userData.setOpacity(_tmp.opacity * 0.92); + group.userData.tick(t / 1000); + } + + // HUD + if ((frameCount++ & 15) === 0) { + const stale = performance.now() - tracking.lastSeen > 1500; + $('#trk').textContent = stale ? 'Point at a Newbury Crest' : `Tracking ${tracking.markers} crest${tracking.markers === 1 ? '' : 's'}`; + $('#trk').classList.toggle('warn', stale || tracking.markers < 2); + $('#cnt').textContent = `${activeGhosts.size} ghost${activeGhosts.size === 1 ? '' : 's'} nearby`; + } + + renderer.render(scene3, camera3); +} + +let toastTimer = null; +function toast(msg) { + const el = $('#toast'); + el.textContent = msg; + el.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => el.classList.remove('show'), 2500); +} + +boot().catch(e => { console.error(e); alert('Failed to start: ' + e.message); }); diff --git a/public/js/ghosts/behavior.js b/public/js/ghosts/behavior.js new file mode 100644 index 0000000..4c5cb2b --- /dev/null +++ b/public/js/ghosts/behavior.js @@ -0,0 +1,52 @@ +/* behavior.js — deterministic ghost motion so all viewers see the same movement. + * Motion is a pure function of (server spawn record, wall-clock time): no per-client + * randomness, so phones stay in sync without streaming positions. + */ +import * as THREE from 'three'; + +function hashNoise(seed, k) { + // cheap deterministic pseudo-noise in [-1, 1] + const x = Math.sin(seed * 127.1 + k * 311.7) * 43758.5453; + return (x - Math.floor(x)) * 2 - 1; +} + +export function ghostTransform(rec, nowMs, out) { + const t = (nowMs - rec.spawnedAt) / 1000; + const base = new THREE.Vector3(...rec.pos); + const b = rec.behavior || { type: 'static' }; + + if (b.type === 'wander') { + const s = b.seed || 1; + const R = b.radius ?? 0.6; + const v = b.speed ?? 0.15; + // smooth pseudo-random orbit-drift: two incommensurate sines per axis + const ph = t * v; + out.position.set( + base.x + R * 0.9 * Math.sin(ph * 1.0 + hashNoise(s, 1) * 6.28) * 0.7 + + R * 0.4 * Math.sin(ph * 2.3 + hashNoise(s, 2) * 6.28) * 0.3, + base.y + 0.06 * Math.sin(t * 1.7 + hashNoise(s, 3) * 6.28), + base.z + R * 0.9 * Math.cos(ph * 0.8 + hashNoise(s, 4) * 6.28) * 0.7 + + R * 0.4 * Math.cos(ph * 1.9 + hashNoise(s, 5) * 6.28) * 0.3 + ); + // face travel direction (finite difference) + const eps = 0.05; + const ahead = (t2) => new THREE.Vector3( + base.x + R * 0.9 * Math.sin(t2 * v + hashNoise(s, 1) * 6.28) * 0.7, + 0, + base.z + R * 0.9 * Math.cos(t2 * v * 0.8 + hashNoise(s, 4) * 6.28) * 0.7); + const dir = ahead(t + eps).sub(ahead(t)); + out.rotationY = Math.atan2(dir.x, dir.z); + } else { + const amp = b.bobAmp ?? 0.06; + const hz = b.bobHz ?? 0.4; + out.position.set(base.x, base.y + amp * Math.sin(t * hz * Math.PI * 2), base.z); + out.rotationY = 0.25 * Math.sin(t * 0.3); // slow idle sway + } + + // crossfade opacity: fade in on spawn, fade out approaching `until` + const fade = (rec.crossfade || 3) * 1000; + const inA = Math.min(1, (nowMs - rec.spawnedAt) / fade); + const outA = Math.min(1, Math.max(0, (rec.until - nowMs) / fade)); + out.opacity = Math.min(inA, outA); + return out; +} diff --git a/public/js/ghosts/loader.js b/public/js/ghosts/loader.js new file mode 100644 index 0000000..e8f5411 --- /dev/null +++ b/public/js/ghosts/loader.js @@ -0,0 +1,110 @@ +/* loader.js — ghost visuals. + * Multi-part OBJ ghosts: { legs|wisp, torso, head, headpiece } assembled into one Group, + * every part rendered with the recovered Hidden Side gradient shader (top->bottom tint + * by GhostColor: Red / Yellow / Blue). Until models exist, a procedural wisp fallback + * is used so the exhibit always has something to show. + * + * Model manifest (data/models.json, editable via /api/models): + * { "models": [ { "id": "classic", "scale": 1.0, + * "parts": { "legs": "/models/classic/legs.obj", "torso": "...", "head": "...", "headpiece": "..." } } ] } + */ +import * as THREE from 'three'; +import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'; + +const objLoader = new OBJLoader(); +const objCache = new Map(); + +function gradientMaterial(top, bottom, opacity = 0.92) { + return new THREE.ShaderMaterial({ + transparent: true, + depthWrite: false, + uniforms: { + topColor: { value: new THREE.Color(top) }, + bottomColor: { value: new THREE.Color(bottom) }, + opacity: { value: opacity }, + uMinY: { value: 0 }, + uMaxY: { value: 1 }, + uTime: { value: 0 }, + }, + vertexShader: ` + varying float vY; + varying vec3 vNormal; + uniform float uMinY, uMaxY; + void main() { + vY = clamp((position.y - uMinY) / max(uMaxY - uMinY, 0.001), 0.0, 1.0); + vNormal = normalize(normalMatrix * normal); + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + }`, + fragmentShader: ` + varying float vY; + varying vec3 vNormal; + uniform vec3 topColor, bottomColor; + uniform float opacity, uTime; + void main() { + vec3 c = mix(bottomColor, topColor, vY); + float rim = pow(1.0 - abs(dot(vNormal, vec3(0.0, 0.0, 1.0))), 2.0); + c += rim * 0.35; + float pulse = 0.92 + 0.08 * sin(uTime * 2.2); + gl_FragColor = vec4(c, opacity * pulse); + }`, + }); +} + +async function loadOBJ(url) { + if (objCache.has(url)) return objCache.get(url).clone(); + const obj = await objLoader.loadAsync(url); + objCache.set(url, obj); + return obj.clone(); +} + +function proceduralWisp() { + const g = new THREE.Group(); + const body = new THREE.Mesh(new THREE.SphereGeometry(0.09, 20, 16)); + body.scale.set(1, 1.35, 1); + body.position.y = 0.14; + const tail = new THREE.Mesh(new THREE.ConeGeometry(0.07, 0.16, 16)); + tail.rotation.x = Math.PI; + tail.position.y = 0.0; + g.add(body, tail); + return g; +} + +export async function buildGhost(ghost, gradients, manifest) { + const grad = gradients[ghost.color] || gradients.Blue; + const mat = gradientMaterial(grad.top, grad.bottom); + + let group; + const model = (manifest?.models || [])[0]; // default model; per-ghost mapping can extend later + if (model && model.parts) { + group = new THREE.Group(); + try { + for (const key of ['legs', 'wisp', 'torso', 'head', 'headpiece']) { + const url = model.parts[key]; + if (!url) continue; + group.add(await loadOBJ(url)); + } + if (model.scale) group.scale.setScalar(model.scale); + if (!group.children.length) group = proceduralWisp(); + } catch (e) { + console.warn('ghost model load failed, using wisp fallback', e); + group = proceduralWisp(); + } + } else { + group = proceduralWisp(); + } + + // apply gradient material to every mesh; compute Y-range for gradient mapping + const box = new THREE.Box3().setFromObject(group); + group.traverse(o => { + if (o.isMesh) { + o.material = mat; + o.material.uniforms.uMinY.value = box.min.y; + o.material.uniforms.uMaxY.value = box.max.y; + } + }); + + group.userData.material = mat; + group.userData.setOpacity = (v) => { mat.uniforms.opacity.value = v; }; + group.userData.tick = (t) => { mat.uniforms.uTime.value = t; }; + return group; +} diff --git a/public/js/net.js b/public/js/net.js new file mode 100644 index 0000000..a14dece --- /dev/null +++ b/public/js/net.js @@ -0,0 +1,51 @@ +/* net.js — WebSocket sync client + dev-mode error pipeline. */ +export class ExhibitNet { + constructor() { + this.handlers = new Map(); + this.ws = null; + this.retry = 1000; + } + on(type, fn) { this.handlers.set(type, fn); return this; } + connect() { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + this.ws = new WebSocket(`${proto}://${location.host}/ws`); + this.ws.onopen = () => { this.retry = 1000; }; + this.ws.onmessage = (ev) => { + let m; try { m = JSON.parse(ev.data); } catch { return; } + const h = this.handlers.get(m.type); + if (h) h(m); + }; + this.ws.onclose = () => { + setTimeout(() => this.connect(), this.retry); + this.retry = Math.min(this.retry * 2, 15000); + }; + } +} + +/* Dev-mode error checking: capture JS errors + unhandled rejections, show an + * on-screen overlay, and report to the server for the admin error log. */ +export function installErrorReporter(devMode) { + const report = (payload) => { + fetch('/api/client-error', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ua: navigator.userAgent, page: location.pathname, ...payload }), + }).catch(() => {}); + if (devMode) showOverlay(payload); + }; + window.addEventListener('error', (e) => + report({ kind: 'error', msg: String(e.message), src: e.filename, line: e.lineno })); + window.addEventListener('unhandledrejection', (e) => + report({ kind: 'rejection', msg: String(e.reason && e.reason.message || e.reason) })); + return report; +} + +let overlayEl = null; +function showOverlay(p) { + if (!overlayEl) { + overlayEl = document.createElement('div'); + overlayEl.style.cssText = 'position:fixed;bottom:0;left:0;right:0;max-height:35vh;overflow:auto;' + + 'background:rgba(120,0,0,.88);color:#fff;font:12px monospace;padding:8px;z-index:99999;white-space:pre-wrap'; + document.body.appendChild(overlayEl); + } + overlayEl.textContent += `[${p.kind}] ${p.msg}${p.src ? ` (${p.src}:${p.line})` : ''}\n`; +} diff --git a/public/vendor/aruco.js b/public/vendor/aruco.js new file mode 100644 index 0000000..c0b3546 --- /dev/null +++ b/public/vendor/aruco.js @@ -0,0 +1,468 @@ +/* +Copyright (c) 2020 Damiano Falcioni +Copyright (c) 2011 Juan Mellado + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +/* +References: +- "ArUco: a minimal library for Augmented Reality applications based on OpenCv" + http://www.uco.es/investiga/grupos/ava/node/26 +- "js-aruco: a port to JavaScript of the ArUco library" + https://github.com/jcmellado/js-aruco +*/ + +var AR = {}; +var CV = this.CV || require('./cv').CV; +this.AR = AR; + +AR.DICTIONARIES = { + ARUCO: { + nBits: 25, + tau: 3, + codeList: [0x1084210,0x1084217,0x1084209,0x108420e,0x10842f0,0x10842f7,0x10842e9,0x10842ee,0x1084130,0x1084137,0x1084129,0x108412e,0x10841d0,0x10841d7,0x10841c9,0x10841ce,0x1085e10,0x1085e17,0x1085e09,0x1085e0e,0x1085ef0,0x1085ef7,0x1085ee9,0x1085eee,0x1085d30,0x1085d37,0x1085d29,0x1085d2e,0x1085dd0,0x1085dd7,0x1085dc9,0x1085dce,0x1082610,0x1082617,0x1082609,0x108260e,0x10826f0,0x10826f7,0x10826e9,0x10826ee,0x1082530,0x1082537,0x1082529,0x108252e,0x10825d0,0x10825d7,0x10825c9,0x10825ce,0x1083a10,0x1083a17,0x1083a09,0x1083a0e,0x1083af0,0x1083af7,0x1083ae9,0x1083aee,0x1083930,0x1083937,0x1083929,0x108392e,0x10839d0,0x10839d7,0x10839c9,0x10839ce,0x10bc210,0x10bc217,0x10bc209,0x10bc20e,0x10bc2f0,0x10bc2f7,0x10bc2e9,0x10bc2ee,0x10bc130,0x10bc137,0x10bc129,0x10bc12e,0x10bc1d0,0x10bc1d7,0x10bc1c9,0x10bc1ce,0x10bde10,0x10bde17,0x10bde09,0x10bde0e,0x10bdef0,0x10bdef7,0x10bdee9,0x10bdeee,0x10bdd30,0x10bdd37,0x10bdd29,0x10bdd2e,0x10bddd0,0x10bddd7,0x10bddc9,0x10bddce,0x10ba610,0x10ba617,0x10ba609,0x10ba60e,0x10ba6f0,0x10ba6f7,0x10ba6e9,0x10ba6ee,0x10ba530,0x10ba537,0x10ba529,0x10ba52e,0x10ba5d0,0x10ba5d7,0x10ba5c9,0x10ba5ce,0x10bba10,0x10bba17,0x10bba09,0x10bba0e,0x10bbaf0,0x10bbaf7,0x10bbae9,0x10bbaee,0x10bb930,0x10bb937,0x10bb929,0x10bb92e,0x10bb9d0,0x10bb9d7,0x10bb9c9,0x10bb9ce,0x104c210,0x104c217,0x104c209,0x104c20e,0x104c2f0,0x104c2f7,0x104c2e9,0x104c2ee,0x104c130,0x104c137,0x104c129,0x104c12e,0x104c1d0,0x104c1d7,0x104c1c9,0x104c1ce,0x104de10,0x104de17,0x104de09,0x104de0e,0x104def0,0x104def7,0x104dee9,0x104deee,0x104dd30,0x104dd37,0x104dd29,0x104dd2e,0x104ddd0,0x104ddd7,0x104ddc9,0x104ddce,0x104a610,0x104a617,0x104a609,0x104a60e,0x104a6f0,0x104a6f7,0x104a6e9,0x104a6ee,0x104a530,0x104a537,0x104a529,0x104a52e,0x104a5d0,0x104a5d7,0x104a5c9,0x104a5ce,0x104ba10,0x104ba17,0x104ba09,0x104ba0e,0x104baf0,0x104baf7,0x104bae9,0x104baee,0x104b930,0x104b937,0x104b929,0x104b92e,0x104b9d0,0x104b9d7,0x104b9c9,0x104b9ce,0x1074210,0x1074217,0x1074209,0x107420e,0x10742f0,0x10742f7,0x10742e9,0x10742ee,0x1074130,0x1074137,0x1074129,0x107412e,0x10741d0,0x10741d7,0x10741c9,0x10741ce,0x1075e10,0x1075e17,0x1075e09,0x1075e0e,0x1075ef0,0x1075ef7,0x1075ee9,0x1075eee,0x1075d30,0x1075d37,0x1075d29,0x1075d2e,0x1075dd0,0x1075dd7,0x1075dc9,0x1075dce,0x1072610,0x1072617,0x1072609,0x107260e,0x10726f0,0x10726f7,0x10726e9,0x10726ee,0x1072530,0x1072537,0x1072529,0x107252e,0x10725d0,0x10725d7,0x10725c9,0x10725ce,0x1073a10,0x1073a17,0x1073a09,0x1073a0e,0x1073af0,0x1073af7,0x1073ae9,0x1073aee,0x1073930,0x1073937,0x1073929,0x107392e,0x10739d0,0x10739d7,0x10739c9,0x10739ce,0x1784210,0x1784217,0x1784209,0x178420e,0x17842f0,0x17842f7,0x17842e9,0x17842ee,0x1784130,0x1784137,0x1784129,0x178412e,0x17841d0,0x17841d7,0x17841c9,0x17841ce,0x1785e10,0x1785e17,0x1785e09,0x1785e0e,0x1785ef0,0x1785ef7,0x1785ee9,0x1785eee,0x1785d30,0x1785d37,0x1785d29,0x1785d2e,0x1785dd0,0x1785dd7,0x1785dc9,0x1785dce,0x1782610,0x1782617,0x1782609,0x178260e,0x17826f0,0x17826f7,0x17826e9,0x17826ee,0x1782530,0x1782537,0x1782529,0x178252e,0x17825d0,0x17825d7,0x17825c9,0x17825ce,0x1783a10,0x1783a17,0x1783a09,0x1783a0e,0x1783af0,0x1783af7,0x1783ae9,0x1783aee,0x1783930,0x1783937,0x1783929,0x178392e,0x17839d0,0x17839d7,0x17839c9,0x17839ce,0x17bc210,0x17bc217,0x17bc209,0x17bc20e,0x17bc2f0,0x17bc2f7,0x17bc2e9,0x17bc2ee,0x17bc130,0x17bc137,0x17bc129,0x17bc12e,0x17bc1d0,0x17bc1d7,0x17bc1c9,0x17bc1ce,0x17bde10,0x17bde17,0x17bde09,0x17bde0e,0x17bdef0,0x17bdef7,0x17bdee9,0x17bdeee,0x17bdd30,0x17bdd37,0x17bdd29,0x17bdd2e,0x17bddd0,0x17bddd7,0x17bddc9,0x17bddce,0x17ba610,0x17ba617,0x17ba609,0x17ba60e,0x17ba6f0,0x17ba6f7,0x17ba6e9,0x17ba6ee,0x17ba530,0x17ba537,0x17ba529,0x17ba52e,0x17ba5d0,0x17ba5d7,0x17ba5c9,0x17ba5ce,0x17bba10,0x17bba17,0x17bba09,0x17bba0e,0x17bbaf0,0x17bbaf7,0x17bbae9,0x17bbaee,0x17bb930,0x17bb937,0x17bb929,0x17bb92e,0x17bb9d0,0x17bb9d7,0x17bb9c9,0x17bb9ce,0x174c210,0x174c217,0x174c209,0x174c20e,0x174c2f0,0x174c2f7,0x174c2e9,0x174c2ee,0x174c130,0x174c137,0x174c129,0x174c12e,0x174c1d0,0x174c1d7,0x174c1c9,0x174c1ce,0x174de10,0x174de17,0x174de09,0x174de0e,0x174def0,0x174def7,0x174dee9,0x174deee,0x174dd30,0x174dd37,0x174dd29,0x174dd2e,0x174ddd0,0x174ddd7,0x174ddc9,0x174ddce,0x174a610,0x174a617,0x174a609,0x174a60e,0x174a6f0,0x174a6f7,0x174a6e9,0x174a6ee,0x174a530,0x174a537,0x174a529,0x174a52e,0x174a5d0,0x174a5d7,0x174a5c9,0x174a5ce,0x174ba10,0x174ba17,0x174ba09,0x174ba0e,0x174baf0,0x174baf7,0x174bae9,0x174baee,0x174b930,0x174b937,0x174b929,0x174b92e,0x174b9d0,0x174b9d7,0x174b9c9,0x174b9ce,0x1774210,0x1774217,0x1774209,0x177420e,0x17742f0,0x17742f7,0x17742e9,0x17742ee,0x1774130,0x1774137,0x1774129,0x177412e,0x17741d0,0x17741d7,0x17741c9,0x17741ce,0x1775e10,0x1775e17,0x1775e09,0x1775e0e,0x1775ef0,0x1775ef7,0x1775ee9,0x1775eee,0x1775d30,0x1775d37,0x1775d29,0x1775d2e,0x1775dd0,0x1775dd7,0x1775dc9,0x1775dce,0x1772610,0x1772617,0x1772609,0x177260e,0x17726f0,0x17726f7,0x17726e9,0x17726ee,0x1772530,0x1772537,0x1772529,0x177252e,0x17725d0,0x17725d7,0x17725c9,0x17725ce,0x1773a10,0x1773a17,0x1773a09,0x1773a0e,0x1773af0,0x1773af7,0x1773ae9,0x1773aee,0x1773930,0x1773937,0x1773929,0x177392e,0x17739d0,0x17739d7,0x17739c9,0x17739ce,0x984210,0x984217,0x984209,0x98420e,0x9842f0,0x9842f7,0x9842e9,0x9842ee,0x984130,0x984137,0x984129,0x98412e,0x9841d0,0x9841d7,0x9841c9,0x9841ce,0x985e10,0x985e17,0x985e09,0x985e0e,0x985ef0,0x985ef7,0x985ee9,0x985eee,0x985d30,0x985d37,0x985d29,0x985d2e,0x985dd0,0x985dd7,0x985dc9,0x985dce,0x982610,0x982617,0x982609,0x98260e,0x9826f0,0x9826f7,0x9826e9,0x9826ee,0x982530,0x982537,0x982529,0x98252e,0x9825d0,0x9825d7,0x9825c9,0x9825ce,0x983a10,0x983a17,0x983a09,0x983a0e,0x983af0,0x983af7,0x983ae9,0x983aee,0x983930,0x983937,0x983929,0x98392e,0x9839d0,0x9839d7,0x9839c9,0x9839ce,0x9bc210,0x9bc217,0x9bc209,0x9bc20e,0x9bc2f0,0x9bc2f7,0x9bc2e9,0x9bc2ee,0x9bc130,0x9bc137,0x9bc129,0x9bc12e,0x9bc1d0,0x9bc1d7,0x9bc1c9,0x9bc1ce,0x9bde10,0x9bde17,0x9bde09,0x9bde0e,0x9bdef0,0x9bdef7,0x9bdee9,0x9bdeee,0x9bdd30,0x9bdd37,0x9bdd29,0x9bdd2e,0x9bddd0,0x9bddd7,0x9bddc9,0x9bddce,0x9ba610,0x9ba617,0x9ba609,0x9ba60e,0x9ba6f0,0x9ba6f7,0x9ba6e9,0x9ba6ee,0x9ba530,0x9ba537,0x9ba529,0x9ba52e,0x9ba5d0,0x9ba5d7,0x9ba5c9,0x9ba5ce,0x9bba10,0x9bba17,0x9bba09,0x9bba0e,0x9bbaf0,0x9bbaf7,0x9bbae9,0x9bbaee,0x9bb930,0x9bb937,0x9bb929,0x9bb92e,0x9bb9d0,0x9bb9d7,0x9bb9c9,0x9bb9ce,0x94c210,0x94c217,0x94c209,0x94c20e,0x94c2f0,0x94c2f7,0x94c2e9,0x94c2ee,0x94c130,0x94c137,0x94c129,0x94c12e,0x94c1d0,0x94c1d7,0x94c1c9,0x94c1ce,0x94de10,0x94de17,0x94de09,0x94de0e,0x94def0,0x94def7,0x94dee9,0x94deee,0x94dd30,0x94dd37,0x94dd29,0x94dd2e,0x94ddd0,0x94ddd7,0x94ddc9,0x94ddce,0x94a610,0x94a617,0x94a609,0x94a60e,0x94a6f0,0x94a6f7,0x94a6e9,0x94a6ee,0x94a530,0x94a537,0x94a529,0x94a52e,0x94a5d0,0x94a5d7,0x94a5c9,0x94a5ce,0x94ba10,0x94ba17,0x94ba09,0x94ba0e,0x94baf0,0x94baf7,0x94bae9,0x94baee,0x94b930,0x94b937,0x94b929,0x94b92e,0x94b9d0,0x94b9d7,0x94b9c9,0x94b9ce,0x974210,0x974217,0x974209,0x97420e,0x9742f0,0x9742f7,0x9742e9,0x9742ee,0x974130,0x974137,0x974129,0x97412e,0x9741d0,0x9741d7,0x9741c9,0x9741ce,0x975e10,0x975e17,0x975e09,0x975e0e,0x975ef0,0x975ef7,0x975ee9,0x975eee,0x975d30,0x975d37,0x975d29,0x975d2e,0x975dd0,0x975dd7,0x975dc9,0x975dce,0x972610,0x972617,0x972609,0x97260e,0x9726f0,0x9726f7,0x9726e9,0x9726ee,0x972530,0x972537,0x972529,0x97252e,0x9725d0,0x9725d7,0x9725c9,0x9725ce,0x973a10,0x973a17,0x973a09,0x973a0e,0x973af0,0x973af7,0x973ae9,0x973aee,0x973930,0x973937,0x973929,0x97392e,0x9739d0,0x9739d7,0x9739c9,0x9739ce,0xe84210,0xe84217,0xe84209,0xe8420e,0xe842f0,0xe842f7,0xe842e9,0xe842ee,0xe84130,0xe84137,0xe84129,0xe8412e,0xe841d0,0xe841d7,0xe841c9,0xe841ce,0xe85e10,0xe85e17,0xe85e09,0xe85e0e,0xe85ef0,0xe85ef7,0xe85ee9,0xe85eee,0xe85d30,0xe85d37,0xe85d29,0xe85d2e,0xe85dd0,0xe85dd7,0xe85dc9,0xe85dce,0xe82610,0xe82617,0xe82609,0xe8260e,0xe826f0,0xe826f7,0xe826e9,0xe826ee,0xe82530,0xe82537,0xe82529,0xe8252e,0xe825d0,0xe825d7,0xe825c9,0xe825ce,0xe83a10,0xe83a17,0xe83a09,0xe83a0e,0xe83af0,0xe83af7,0xe83ae9,0xe83aee,0xe83930,0xe83937,0xe83929,0xe8392e,0xe839d0,0xe839d7,0xe839c9,0xe839ce,0xebc210,0xebc217,0xebc209,0xebc20e,0xebc2f0,0xebc2f7,0xebc2e9,0xebc2ee,0xebc130,0xebc137,0xebc129,0xebc12e,0xebc1d0,0xebc1d7,0xebc1c9,0xebc1ce,0xebde10,0xebde17,0xebde09,0xebde0e,0xebdef0,0xebdef7,0xebdee9,0xebdeee,0xebdd30,0xebdd37,0xebdd29,0xebdd2e,0xebddd0,0xebddd7,0xebddc9,0xebddce,0xeba610,0xeba617,0xeba609,0xeba60e,0xeba6f0,0xeba6f7,0xeba6e9,0xeba6ee,0xeba530,0xeba537,0xeba529,0xeba52e,0xeba5d0,0xeba5d7,0xeba5c9,0xeba5ce,0xebba10,0xebba17,0xebba09,0xebba0e,0xebbaf0,0xebbaf7,0xebbae9,0xebbaee,0xebb930,0xebb937,0xebb929,0xebb92e,0xebb9d0,0xebb9d7,0xebb9c9,0xebb9ce,0xe4c210,0xe4c217,0xe4c209,0xe4c20e,0xe4c2f0,0xe4c2f7,0xe4c2e9,0xe4c2ee,0xe4c130,0xe4c137,0xe4c129,0xe4c12e,0xe4c1d0,0xe4c1d7,0xe4c1c9,0xe4c1ce,0xe4de10,0xe4de17,0xe4de09,0xe4de0e,0xe4def0,0xe4def7,0xe4dee9,0xe4deee,0xe4dd30,0xe4dd37,0xe4dd29,0xe4dd2e,0xe4ddd0,0xe4ddd7,0xe4ddc9,0xe4ddce,0xe4a610,0xe4a617,0xe4a609,0xe4a60e,0xe4a6f0,0xe4a6f7,0xe4a6e9,0xe4a6ee,0xe4a530,0xe4a537,0xe4a529,0xe4a52e,0xe4a5d0,0xe4a5d7,0xe4a5c9,0xe4a5ce,0xe4ba10,0xe4ba17,0xe4ba09,0xe4ba0e,0xe4baf0,0xe4baf7,0xe4bae9,0xe4baee,0xe4b930,0xe4b937,0xe4b929,0xe4b92e,0xe4b9d0,0xe4b9d7,0xe4b9c9,0xe4b9ce,0xe74210,0xe74217,0xe74209,0xe7420e,0xe742f0,0xe742f7,0xe742e9,0xe742ee,0xe74130,0xe74137,0xe74129,0xe7412e,0xe741d0,0xe741d7,0xe741c9,0xe741ce,0xe75e10,0xe75e17,0xe75e09,0xe75e0e,0xe75ef0,0xe75ef7,0xe75ee9,0xe75eee,0xe75d30,0xe75d37,0xe75d29,0xe75d2e,0xe75dd0,0xe75dd7,0xe75dc9,0xe75dce,0xe72610,0xe72617,0xe72609,0xe7260e,0xe726f0,0xe726f7,0xe726e9,0xe726ee,0xe72530,0xe72537,0xe72529,0xe7252e,0xe725d0,0xe725d7,0xe725c9,0xe725ce,0xe73a10,0xe73a17,0xe73a09,0xe73a0e,0xe73af0,0xe73af7,0xe73ae9,0xe73aee,0xe73930,0xe73937,0xe73929,0xe7392e,0xe739d0,0xe739d7,0xe739c9] + }, + ARUCO_MIP_36h12: { + nBits: 36, + tau: 12, + codeList: [0xd2b63a09d,0x6001134e5,0x1206fbe72,0xff8ad6cb4,0x85da9bc49,0xb461afe9c,0x6db51fe13,0x5248c541f,0x8f34503,0x8ea462ece,0xeac2be76d,0x1af615c44,0xb48a49f27,0x2e4e1283b,0x78b1f2fa8,0x27d34f57e,0x89222fff1,0x4c1669406,0xbf49b3511,0xdc191cd5d,0x11d7c3f85,0x16a130e35,0xe29f27eff,0x428d8ae0c,0x90d548477,0x2319cbc93,0xc3b0c3dfc,0x424bccc9,0x2a081d630,0x762743d96,0xd0645bf19,0xf38d7fd60,0xc6cbf9a10,0x3c1be7c65,0x276f75e63,0x4490a3f63,0xda60acd52,0x3cc68df59,0xab46f9dae,0x88d533d78,0xb6d62ec21,0xb3c02b646,0x22e56d408,0xac5f5770a,0xaaa993f66,0x4caa07c8d,0x5c9b4f7b0,0xaa9ef0e05,0x705c5750,0xac81f545e,0x735b91e74,0x8cc35cee4,0xe44694d04,0xb5e121de0,0x261017d0f,0xf1d439eb5,0xa1a33ac96,0x174c62c02,0x1ee27f716,0x8b1c5ece9,0x6a05b0c6a,0xd0568dfc,0x192d25e5f,0x1adbeccc8,0xcfec87f00,0xd0b9dde7a,0x88dcef81e,0x445681cb9,0xdbb2ffc83,0xa48d96df1,0xb72cc2e7d,0xc295b53f,0xf49832704,0x9968edc29,0x9e4e1af85,0x8683e2d1b,0x810b45c04,0x6ac44bfe2,0x645346615,0x3990bd598,0x1c9ed0f6a,0xc26729d65,0x83993f795,0x3ac05ac5d,0x357adff3b,0xd5c05565,0x2f547ef44,0x86c115041,0x640fd9e5f,0xce08bbcf7,0x109bb343e,0xc21435c92,0x35b4dfce4,0x459752cf2,0xec915b82c,0x51881eed0,0x2dda7dc97,0x2e0142144,0x42e890f99,0x9a8856527,0x8e80d9d80,0x891cbcf34,0x25dd82410,0x239551d34,0x8fe8f0c70,0x94106a970,0x82609b40c,0xfc9caf36,0x688181d11,0x718613c08,0xf1ab7629,0xa357bfc18,0x4c03b7a46,0x204dedce6,0xad6300d37,0x84cc4cd09,0x42160e5c4,0x87d2adfa8,0x7850e7749,0x4e750fc7c,0xbf2e5dfda,0xd88324da5,0x234b52f80,0x378204514,0xabdf2ad53,0x365e78ef9,0x49caa6ca2,0x3c39ddf3,0xc68c5385d,0x5bfcbbf67,0x623241e21,0xabc90d5cc,0x388c6fe85,0xda0e2d62d,0x10855dfe9,0x4d46efd6b,0x76ea12d61,0x9db377d3d,0xeed0efa71,0xe6ec3ae2f,0x441faee83,0xba19c8ff5,0x313035eab,0x6ce8f7625,0x880dab58d,0x8d3409e0d,0x2be92ee21,0xd60302c6c,0x469ffc724,0x87eebeed3,0x42587ef7a,0x7a8cc4e52,0x76a437650,0x999e41ef4,0x7d0969e42,0xc02baf46b,0x9259f3e47,0x2116a1dc0,0x9f2de4d84,0xeffac29,0x7b371ff8c,0x668339da9,0xd010aee3f,0x1cd00b4c0,0x95070fc3b,0xf84c9a770,0x38f863d76,0x3646ff045,0xce1b96412,0x7a5d45da8,0x14e00ef6c,0x5e95abfd8,0xb2e9cb729,0x36c47dd7,0xb8ee97c6b,0xe9e8f657,0xd4ad2ef1a,0x8811c7f32,0x47bde7c31,0x3adadfb64,0x6e5b28574,0x33e67cd91,0x2ab9fdd2d,0x8afa67f2b,0xe6a28fc5e,0x72049cdbd,0xae65dac12,0x1251a4526,0x1089ab841,0xe2f096ee0,0xb0caee573,0xfd6677e86,0x444b3f518,0xbe8b3a56a,0x680a75cfc,0xac02baea8,0x97d815e1c,0x1d4386e08,0x1a14f5b0e,0xe658a8d81,0xa3868efa7,0x3668a9673,0xe8fc53d85,0x2e2b7edd5,0x8b2470f13,0xf69795f32,0x4589ffc8e,0x2e2080c9c,0x64265f7d,0x3d714dd10,0x1692c6ef1,0x3e67f2f49,0x5041dad63,0x1a1503415,0x64c18c742,0xa72eec35,0x1f0f9dc60,0xa9559bc67,0xf32911d0d,0x21c0d4ffc,0xe01cef5b0,0x4e23a3520,0xaa4f04e49,0xe1c4fcc43,0x208e8f6e8,0x8486774a5,0x9e98c7558,0x2c59fb7dc,0x9446a4613,0x8292dcc2e,0x4d61631,0xd05527809,0xa0163852d,0x8f657f639,0xcca6c3e37,0xcb136bc7a,0xfc5a83e53,0x9aa44fc30,0xbdec1bd3c,0xe020b9f7c,0x4b8f35fb0,0xb8165f637,0x33dc88d69,0x10a2f7e4d,0xc8cb5ff53,0xde259ff6b,0x46d070dd4,0x32d3b9741,0x7075f1c04,0x4d58dbea0] + } +}; + +AR.Dictionary = function (dicName) { + this.codes = {}; + this.codeList = []; + this.tau = 0; + this._initialize(dicName); +}; + +AR.Dictionary.prototype._initialize = function (dicName) { + this.codes = {}; + this.codeList = []; + this.tau = 0; + this.nBits = 0; + this.markSize = 0; + this.dicName = dicName; + var dictionary = AR.DICTIONARIES[dicName]; + if (!dictionary) + throw 'The dictionary "' + dicName + '" is not recognized.'; + + this.nBits = dictionary.nBits; + this.markSize = Math.sqrt(dictionary.nBits) + 2; + for (var i = 0; i < dictionary.codeList.length; i++) { + var code = null; + if (typeof dictionary.codeList[i] === 'number') + code = this._hex2bin(dictionary.codeList[i], dictionary.nBits); + if (typeof dictionary.codeList[i] === 'string') + code = this._hex2bin(parseInt(dictionary.codeList[i], 16), dictionary.nBits); + if (Array.isArray(dictionary.codeList[i])) + code = this._bytes2bin(dictionary.codeList[i], dictionary.nBits); + if (code === null) + throw 'Invalid code ' + i + ' in dictionary ' + dicName + ': ' + JSON.stringify(dictionary.codeList[i]); + if (code.length != dictionary.nBits) + throw 'The code ' + i + ' in dictionary ' + dicName + ' is not ' + dictionary.nBits + ' bits long but ' + code.length + ': ' + code; + this.codeList.push(code); + this.codes[code] = { + id: i + }; + } + this.tau = dictionary.tau || this._calculateTau(); +}; + +AR.Dictionary.prototype.find = function (bits) { + var val = '', + i, j; + for (i = 0; i < bits.length; i++) { + var bitRow = bits[i]; + for (j = 0; j < bitRow.length; j++) { + val += bitRow[j]; + } + } + var minFound = this.codes[val]; + if (minFound) + return { + id: minFound.id, + distance: 0 + }; + + for (i = 0; i < this.codeList.length; i++) { + var code = this.codeList[i]; + var distance = this._hammingDistance(val, code); + if (this._hammingDistance(val, code) < this.tau) { + if (!minFound || minFound.distance > distance) { + minFound = { + id: this.codes[code].id, + distance: distance + }; + } + } + } + return minFound; +}; + +AR.Dictionary.prototype._hex2bin = function (hex, nBits) { + return hex.toString(2).padStart(nBits, '0'); +}; + +AR.Dictionary.prototype._bytes2bin = function (byteList, nBits) { + var bits = '', byte; + for (byte of byteList) { + bits += byte.toString(2).padStart(bits.length + 8 > nBits?nBits - bits.length:8, '0'); + } + return bits; +}; + +AR.Dictionary.prototype._hammingDistance = function (str1, str2) { + if (str1.length != str2.length) + throw 'Hamming distance calculation require inputs of the same length'; + var distance = 0, + i; + for (i = 0; i < str1.length; i++) + if (str1[i] !== str2[i]) + distance += 1; + return distance; +}; + +AR.Dictionary.prototype._calculateTau = function () { + var tau = Number.MAX_VALUE; + for(var i=0;i'; + svg += ''; + svg += ''; + for(var y=0;y'; + } + } + svg += ''; + return svg; +}; + +AR.Marker = function (id, corners, hammingDistance) { + this.id = id; + this.corners = corners; + this.hammingDistance = hammingDistance; +}; + +AR.Detector = function (config) { + config = config || {}; + this.grey = new CV.Image(); + this.thres = new CV.Image(); + this.homography = new CV.Image(); + this.binary = []; + this.contours = []; + this.polys = []; + this.candidates = []; + config.dictionaryName = config.dictionaryName || 'ARUCO_MIP_36h12'; + this.dictionary = new AR.Dictionary(config.dictionaryName); + this.dictionary.tau = config.maxHammingDistance != null ? config.maxHammingDistance : this.dictionary.tau; +}; + +AR.Detector.prototype.detectImage = function (width, height, data) { + return this.detect({ + width: width, + height: height, + data: data + }); +}; + +AR.Detector.prototype.detectStreamInit = function (width, height, callback) { + this.streamConfig = {}; + this.streamConfig.width = width; + this.streamConfig.height = height; + this.streamConfig.imageSize = width * height * 4; //provided image must be a sequence of rgba bytes (4 bytes represent a pixel) + this.streamConfig.index = 0; + this.streamConfig.imageData = new Uint8ClampedArray(this.streamConfig.imageSize); + this.streamConfig.callback = callback || function (image, markerList) {}; +}; + +//accept data chunks of different sizes +AR.Detector.prototype.detectStream = function (data) { + for (var i = 0; i < data.length; i++) { + this.streamConfig.imageData[this.streamConfig.index] = data[i]; + this.streamConfig.index = (this.streamConfig.index + 1) % this.streamConfig.imageSize; + if (this.streamConfig.index == 0) { + var image = { + width: this.streamConfig.width, + height: this.streamConfig.height, + data: this.streamConfig.imageData + }; + var markerList = this.detect(image); + this.streamConfig.callback(image, markerList); + } + } +}; + +AR.Detector.prototype.detectMJPEGStreamInit = function (width, height, callback, decoderFn) { + this.mjpeg = { + decoderFn: decoderFn, + chunks: [], + SOI: [0xff, 0xd8], + EOI: [0xff, 0xd9] + }; + this.detectStreamInit(width, height, callback); +}; + +AR.Detector.prototype.detectMJPEGStream = function (chunk) { + var eoiPos = chunk.findIndex(function (element, index, array) { + return this.mjpeg.EOI[0] == element && array.length > index + 1 && this.mjpeg.EOI[1] == array[index + 1]; + }); + var soiPos = chunk.findIndex(function (element, index, array) { + return this.mjpeg.SOI[0] == element && array.length > index + 1 && this.mjpeg.SOI[1] == array[index + 1]; + }); + + if (eoiPos === -1) { + this.mjpeg.chunks.push(chunk); + } else { + var part1 = chunk.slice(0, eoiPos + 2); + if (part1.length) { + this.mjpeg.chunks.push(part1); + } + if (this.mjpeg.chunks.length) { + var jpegImage = this.mjpeg.chunks.flat(); + var rgba = this.mjpeg.decoderFn(jpegImage); + this.detectStream(rgba); + } + this.mjpeg.chunks = []; + } + if (soiPos > -1) { + this.mjpeg.chunks = []; + this.mjpeg.chunks.push(chunk.slice(soiPos)); + } +}; + +AR.Detector.prototype.detect = function (image) { + CV.grayscale(image, this.grey); + CV.adaptiveThreshold(this.grey, this.thres, 2, 7); + + this.contours = CV.findContours(this.thres, this.binary); + //Scale Fix: https://stackoverflow.com/questions/35936397/marker-detection-on-paper-sheet-using-javascript + //this.candidates = this.findCandidates(this.contours, image.width * 0.20, 0.05, 10); + this.candidates = this.findCandidates(this.contours, image.width * 0.01, 0.05, 10); + this.candidates = this.clockwiseCorners(this.candidates); + this.candidates = this.notTooNear(this.candidates, 10); + + return this.findMarkers(this.grey, this.candidates, 49); +}; + +AR.Detector.prototype.findCandidates = function (contours, minSize, epsilon, minLength) { + var candidates = [], + len = contours.length, + contour, poly, i; + + this.polys = []; + + for (i = 0; i < len; ++i) { + contour = contours[i]; + + if (contour.length >= minSize) { + poly = CV.approxPolyDP(contour, contour.length * epsilon); + + this.polys.push(poly); + + if ((4 === poly.length) && (CV.isContourConvex(poly))) { + + if (CV.minEdgeLength(poly) >= minLength) { + candidates.push(poly); + } + } + } + } + + return candidates; +}; + +AR.Detector.prototype.clockwiseCorners = function (candidates) { + var len = candidates.length, + dx1, dx2, dy1, dy2, swap, i; + + for (i = 0; i < len; ++i) { + dx1 = candidates[i][1].x - candidates[i][0].x; + dy1 = candidates[i][1].y - candidates[i][0].y; + dx2 = candidates[i][2].x - candidates[i][0].x; + dy2 = candidates[i][2].y - candidates[i][0].y; + + if ((dx1 * dy2 - dy1 * dx2) < 0) { + swap = candidates[i][1]; + candidates[i][1] = candidates[i][3]; + candidates[i][3] = swap; + } + } + + return candidates; +}; + +AR.Detector.prototype.notTooNear = function (candidates, minDist) { + var notTooNear = [], + len = candidates.length, + dist, dx, dy, i, j, k; + + for (i = 0; i < len; ++i) { + + for (j = i + 1; j < len; ++j) { + dist = 0; + + for (k = 0; k < 4; ++k) { + dx = candidates[i][k].x - candidates[j][k].x; + dy = candidates[i][k].y - candidates[j][k].y; + + dist += dx * dx + dy * dy; + } + + if ((dist / 4) < (minDist * minDist)) { + + if (CV.perimeter(candidates[i]) < CV.perimeter(candidates[j])) { + candidates[i].tooNear = true; + } else { + candidates[j].tooNear = true; + } + } + } + } + + for (i = 0; i < len; ++i) { + if (!candidates[i].tooNear) { + notTooNear.push(candidates[i]); + } + } + + return notTooNear; +}; + +AR.Detector.prototype.findMarkers = function (imageSrc, candidates, warpSize) { + var markers = [], + len = candidates.length, + candidate, marker, i; + + for (i = 0; i < len; ++i) { + candidate = candidates[i]; + + CV.warp(imageSrc, this.homography, candidate, warpSize); + + CV.threshold(this.homography, this.homography, CV.otsu(this.homography)); + + marker = this.getMarker(this.homography, candidate); + if (marker) { + markers.push(marker); + } + } + + return markers; +}; + +AR.Detector.prototype.getMarker = function (imageSrc, candidate) { + var markSize = this.dictionary.markSize; + var width = (imageSrc.width / markSize) >>> 0, + minZero = (width * width) >> 1, + bits = [], + rotations = [], + square, inc, i, j; + + for (i = 0; i < markSize; ++i) { + inc = (0 === i || (markSize - 1) === i) ? 1 : (markSize - 1); + + for (j = 0; j < markSize; j += inc) { + square = { + x: j * width, + y: i * width, + width: width, + height: width + }; + if (CV.countNonZero(imageSrc, square) > minZero) { + return null; + } + } + } + + for (i = 0; i < markSize - 2; ++i) { + bits[i] = []; + + for (j = 0; j < markSize - 2; ++j) { + square = { + x: (j + 1) * width, + y: (i + 1) * width, + width: width, + height: width + }; + + bits[i][j] = CV.countNonZero(imageSrc, square) > minZero ? 1 : 0; + } + } + + rotations[0] = bits; + + var foundMin = null; + var rot = 0; + for (i = 0; i < 4; i++) { + var found = this.dictionary.find(rotations[i]); + if (found && (foundMin === null || found.distance < foundMin.distance)) { + foundMin = found; + rot = i; + if (foundMin.distance === 0) + break; + } + rotations[i + 1] = this.rotate(rotations[i]); + } + + if (foundMin) + return new AR.Marker(foundMin.id, this.rotate2(candidate, 4 - rot), foundMin.distance); + + return null; +}; + +AR.Detector.prototype.rotate = function (src) { + var dst = [], + len = src.length, + i, j; + + for (i = 0; i < len; ++i) { + dst[i] = []; + for (j = 0; j < src[i].length; ++j) { + dst[i][j] = src[src[i].length - j - 1][i]; + } + } + + return dst; +}; + +AR.Detector.prototype.rotate2 = function (src, rotation) { + var dst = [], + len = src.length, + i; + + for (i = 0; i < len; ++i) { + dst[i] = src[(rotation + i) % len]; + } + + return dst; +}; diff --git a/public/vendor/cv.js b/public/vendor/cv.js new file mode 100644 index 0000000..966a3cf --- /dev/null +++ b/public/vendor/cv.js @@ -0,0 +1,737 @@ +/* +Copyright (c) 2011 Juan Mellado + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +/* +References: +- "OpenCV: Open Computer Vision Library" + http://sourceforge.net/projects/opencvlibrary/ +- "Stack Blur: Fast But Goodlooking" + http://incubator.quasimondo.com/processing/fast_blur_deluxe.php +*/ + +var CV = CV || {}; +this.CV = CV; + +CV.Image = function(width, height, data){ + this.width = width || 0; + this.height = height || 0; + this.data = data || []; +}; + +CV.grayscale = function(imageSrc, imageDst){ + var src = imageSrc.data, dst = imageDst.data, len = src.length, + i = 0, j = 0; + + for (; i < len; i += 4){ + dst[j ++] = + (src[i] * 0.299 + src[i + 1] * 0.587 + src[i + 2] * 0.114 + 0.5) & 0xff; + } + + imageDst.width = imageSrc.width; + imageDst.height = imageSrc.height; + + return imageDst; +}; + +CV.threshold = function(imageSrc, imageDst, threshold){ + var src = imageSrc.data, dst = imageDst.data, + len = src.length, tab = [], i; + + for (i = 0; i < 256; ++ i){ + tab[i] = i <= threshold? 0: 255; + } + + for (i = 0; i < len; ++ i){ + dst[i] = tab[ src[i] ]; + } + + imageDst.width = imageSrc.width; + imageDst.height = imageSrc.height; + + return imageDst; +}; + +CV.adaptiveThreshold = function(imageSrc, imageDst, kernelSize, threshold){ + var src = imageSrc.data, dst = imageDst.data, len = src.length, tab = [], i; + + CV.stackBoxBlur(imageSrc, imageDst, kernelSize); + + for (i = 0; i < 768; ++ i){ + tab[i] = (i - 255 <= -threshold)? 255: 0; + } + + for (i = 0; i < len; ++ i){ + dst[i] = tab[ src[i] - dst[i] + 255 ]; + } + + imageDst.width = imageSrc.width; + imageDst.height = imageSrc.height; + + return imageDst; +}; + +CV.otsu = function(imageSrc){ + var src = imageSrc.data, len = src.length, hist = [], + threshold = 0, sum = 0, sumB = 0, wB = 0, wF = 0, max = 0, + mu, between, i; + + for (i = 0; i < 256; ++ i){ + hist[i] = 0; + } + + for (i = 0; i < len; ++ i){ + hist[ src[i] ] ++; + } + + for (i = 0; i < 256; ++ i){ + sum += hist[i] * i; + } + + for (i = 0; i < 256; ++ i){ + wB += hist[i]; + if (0 !== wB){ + + wF = len - wB; + if (0 === wF){ + break; + } + + sumB += hist[i] * i; + + mu = (sumB / wB) - ( (sum - sumB) / wF ); + + between = wB * wF * mu * mu; + + if (between > max){ + max = between; + threshold = i; + } + } + } + + return threshold; +}; + +CV.stackBoxBlurMult = + [1, 171, 205, 293, 57, 373, 79, 137, 241, 27, 391, 357, 41, 19, 283, 265]; + +CV.stackBoxBlurShift = + [0, 9, 10, 11, 9, 12, 10, 11, 12, 9, 13, 13, 10, 9, 13, 13]; + +CV.BlurStack = function(){ + this.color = 0; + this.next = null; +}; + +CV.stackBoxBlur = function(imageSrc, imageDst, kernelSize){ + var src = imageSrc.data, dst = imageDst.data, + height = imageSrc.height, width = imageSrc.width, + heightMinus1 = height - 1, widthMinus1 = width - 1, + size = kernelSize + kernelSize + 1, radius = kernelSize + 1, + mult = CV.stackBoxBlurMult[kernelSize], + shift = CV.stackBoxBlurShift[kernelSize], + stack, stackStart, color, sum, pos, start, p, x, y, i; + + stack = stackStart = new CV.BlurStack(); + for (i = 1; i < size; ++ i){ + stack = stack.next = new CV.BlurStack(); + } + stack.next = stackStart; + + pos = 0; + + for (y = 0; y < height; ++ y){ + start = pos; + + color = src[pos]; + sum = radius * color; + + stack = stackStart; + for (i = 0; i < radius; ++ i){ + stack.color = color; + stack = stack.next; + } + for (i = 1; i < radius; ++ i){ + stack.color = src[pos + i]; + sum += stack.color; + stack = stack.next; + } + + stack = stackStart; + for (x = 0; x < width; ++ x){ + dst[pos ++] = (sum * mult) >>> shift; + + p = x + radius; + p = start + (p < widthMinus1? p: widthMinus1); + sum -= stack.color - src[p]; + + stack.color = src[p]; + stack = stack.next; + } + } + + for (x = 0; x < width; ++ x){ + pos = x; + start = pos + width; + + color = dst[pos]; + sum = radius * color; + + stack = stackStart; + for (i = 0; i < radius; ++ i){ + stack.color = color; + stack = stack.next; + } + for (i = 1; i < radius; ++ i){ + stack.color = dst[start]; + sum += stack.color; + stack = stack.next; + + start += width; + } + + stack = stackStart; + for (y = 0; y < height; ++ y){ + dst[pos] = (sum * mult) >>> shift; + + p = y + radius; + p = x + ( (p < heightMinus1? p: heightMinus1) * width ); + sum -= stack.color - dst[p]; + + stack.color = dst[p]; + stack = stack.next; + + pos += width; + } + } + + return imageDst; +}; + +CV.gaussianBlur = function(imageSrc, imageDst, imageMean, kernelSize){ + var kernel = CV.gaussianKernel(kernelSize); + + imageDst.width = imageSrc.width; + imageDst.height = imageSrc.height; + + imageMean.width = imageSrc.width; + imageMean.height = imageSrc.height; + + CV.gaussianBlurFilter(imageSrc, imageMean, kernel, true); + CV.gaussianBlurFilter(imageMean, imageDst, kernel, false); + + return imageDst; +}; + +CV.gaussianBlurFilter = function(imageSrc, imageDst, kernel, horizontal){ + var src = imageSrc.data, dst = imageDst.data, + height = imageSrc.height, width = imageSrc.width, + pos = 0, limit = kernel.length >> 1, + cur, value, i, j, k; + + for (i = 0; i < height; ++ i){ + + for (j = 0; j < width; ++ j){ + value = 0.0; + + for (k = -limit; k <= limit; ++ k){ + + if (horizontal){ + cur = pos + k; + if (j + k < 0){ + cur = pos; + } + else if (j + k >= width){ + cur = pos; + } + }else{ + cur = pos + (k * width); + if (i + k < 0){ + cur = pos; + } + else if (i + k >= height){ + cur = pos; + } + } + + value += kernel[limit + k] * src[cur]; + } + + dst[pos ++] = horizontal? value: (value + 0.5) & 0xff; + } + } + + return imageDst; +}; + +CV.gaussianKernel = function(kernelSize){ + var tab = + [ [1], + [0.25, 0.5, 0.25], + [0.0625, 0.25, 0.375, 0.25, 0.0625], + [0.03125, 0.109375, 0.21875, 0.28125, 0.21875, 0.109375, 0.03125] ], + kernel = [], center, sigma, scale2X, sum, x, i; + + if ( (kernelSize <= 7) && (kernelSize % 2 === 1) ){ + kernel = tab[kernelSize >> 1]; + }else{ + center = (kernelSize - 1.0) * 0.5; + sigma = 0.8 + (0.3 * (center - 1.0) ); + scale2X = -0.5 / (sigma * sigma); + sum = 0.0; + for (i = 0; i < kernelSize; ++ i){ + x = i - center; + sum += kernel[i] = Math.exp(scale2X * x * x); + } + sum = 1 / sum; + for (i = 0; i < kernelSize; ++ i){ + kernel[i] *= sum; + } + } + + return kernel; +}; + +CV.findContours = function(imageSrc, binary){ + var width = imageSrc.width, height = imageSrc.height, contours = [], + src, deltas, pos, pix, nbd, outer, hole, i, j; + + src = CV.binaryBorder(imageSrc, binary); + + deltas = CV.neighborhoodDeltas(width + 2); + + pos = width + 3; + nbd = 1; + + for (i = 0; i < height; ++ i, pos += 2){ + + for (j = 0; j < width; ++ j, ++ pos){ + pix = src[pos]; + + if (0 !== pix){ + outer = hole = false; + + if (1 === pix && 0 === src[pos - 1]){ + outer = true; + } + else if (pix >= 1 && 0 === src[pos + 1]){ + hole = true; + } + + if (outer || hole){ + ++ nbd; + + contours.push( CV.borderFollowing(src, pos, nbd, {x: j, y: i}, hole, deltas) ); + } + } + } + } + + return contours; +}; + +CV.borderFollowing = function(src, pos, nbd, point, hole, deltas){ + var contour = [], pos1, pos3, pos4, s, s_end, s_prev; + + contour.hole = hole; + + s = s_end = hole? 0: 4; + do{ + s = (s - 1) & 7; + pos1 = pos + deltas[s]; + if (src[pos1] !== 0){ + break; + } + }while(s !== s_end); + + if (s === s_end){ + src[pos] = -nbd; + contour.push( {x: point.x, y: point.y} ); + + }else{ + pos3 = pos; + s_prev = s ^ 4; + + while(true){ + s_end = s; + + do{ + pos4 = pos3 + deltas[++ s]; + }while(src[pos4] === 0); + + s &= 7; + + if ( ( (s - 1) >>> 0) < (s_end >>> 0) ){ + src[pos3] = -nbd; + } + else if (src[pos3] === 1){ + src[pos3] = nbd; + } + + contour.push( {x: point.x, y: point.y} ); + + s_prev = s; + + point.x += CV.neighborhood[s][0]; + point.y += CV.neighborhood[s][1]; + + if ( (pos4 === pos) && (pos3 === pos1) ){ + break; + } + + pos3 = pos4; + s = (s + 4) & 7; + } + } + + return contour; +}; + +CV.neighborhood = + [ [1, 0], [1, -1], [0, -1], [-1, -1], [-1, 0], [-1, 1], [0, 1], [1, 1] ]; + +CV.neighborhoodDeltas = function(width){ + var deltas = [], len = CV.neighborhood.length, i = 0; + + for (; i < len; ++ i){ + deltas[i] = CV.neighborhood[i][0] + (CV.neighborhood[i][1] * width); + } + + return deltas.concat(deltas); +}; + +CV.approxPolyDP = function(contour, epsilon){ + var slice = {start_index: 0, end_index: 0}, + right_slice = {start_index: 0, end_index: 0}, + poly = [], stack = [], len = contour.length, + pt, start_pt, end_pt, dist, max_dist, le_eps, + dx, dy, i, j, k; + + epsilon *= epsilon; + + k = 0; + + for (i = 0; i < 3; ++ i){ + max_dist = 0; + + k = (k + right_slice.start_index) % len; + start_pt = contour[k]; + if (++ k === len) {k = 0;} + + for (j = 1; j < len; ++ j){ + pt = contour[k]; + if (++ k === len) {k = 0;} + + dx = pt.x - start_pt.x; + dy = pt.y - start_pt.y; + dist = dx * dx + dy * dy; + + if (dist > max_dist){ + max_dist = dist; + right_slice.start_index = j; + } + } + } + + if (max_dist <= epsilon){ + poly.push( {x: start_pt.x, y: start_pt.y} ); + + }else{ + slice.start_index = k; + slice.end_index = (right_slice.start_index += slice.start_index); + + right_slice.start_index -= right_slice.start_index >= len? len: 0; + right_slice.end_index = slice.start_index; + if (right_slice.end_index < right_slice.start_index){ + right_slice.end_index += len; + } + + stack.push( {start_index: right_slice.start_index, end_index: right_slice.end_index} ); + stack.push( {start_index: slice.start_index, end_index: slice.end_index} ); + } + + while(stack.length !== 0){ + slice = stack.pop(); + + end_pt = contour[slice.end_index % len]; + start_pt = contour[k = slice.start_index % len]; + if (++ k === len) {k = 0;} + + if (slice.end_index <= slice.start_index + 1){ + le_eps = true; + + }else{ + max_dist = 0; + + dx = end_pt.x - start_pt.x; + dy = end_pt.y - start_pt.y; + + for (i = slice.start_index + 1; i < slice.end_index; ++ i){ + pt = contour[k]; + if (++ k === len) {k = 0;} + + dist = Math.abs( (pt.y - start_pt.y) * dx - (pt.x - start_pt.x) * dy); + + if (dist > max_dist){ + max_dist = dist; + right_slice.start_index = i; + } + } + + le_eps = max_dist * max_dist <= epsilon * (dx * dx + dy * dy); + } + + if (le_eps){ + poly.push( {x: start_pt.x, y: start_pt.y} ); + + }else{ + right_slice.end_index = slice.end_index; + slice.end_index = right_slice.start_index; + + stack.push( {start_index: right_slice.start_index, end_index: right_slice.end_index} ); + stack.push( {start_index: slice.start_index, end_index: slice.end_index} ); + } + } + + return poly; +}; + +CV.warp = function(imageSrc, imageDst, contour, warpSize){ + var src = imageSrc.data, dst = imageDst.data, + width = imageSrc.width, height = imageSrc.height, + pos = 0, + sx1, sx2, dx1, dx2, sy1, sy2, dy1, dy2, p1, p2, p3, p4, + m, r, s, t, u, v, w, x, y, i, j; + + m = CV.getPerspectiveTransform(contour, warpSize - 1); + + r = m[8]; + s = m[2]; + t = m[5]; + + for (i = 0; i < warpSize; ++ i){ + r += m[7]; + s += m[1]; + t += m[4]; + + u = r; + v = s; + w = t; + + for (j = 0; j < warpSize; ++ j){ + u += m[6]; + v += m[0]; + w += m[3]; + + x = v / u; + y = w / u; + + sx1 = x >>> 0; + sx2 = (sx1 === width - 1)? sx1: sx1 + 1; + dx1 = x - sx1; + dx2 = 1.0 - dx1; + + sy1 = y >>> 0; + sy2 = (sy1 === height - 1)? sy1: sy1 + 1; + dy1 = y - sy1; + dy2 = 1.0 - dy1; + + p1 = p2 = sy1 * width; + p3 = p4 = sy2 * width; + + dst[pos ++] = + (dy2 * (dx2 * src[p1 + sx1] + dx1 * src[p2 + sx2]) + + dy1 * (dx2 * src[p3 + sx1] + dx1 * src[p4 + sx2]) ) & 0xff; + + } + } + + imageDst.width = warpSize; + imageDst.height = warpSize; + + return imageDst; +}; + +CV.getPerspectiveTransform = function(src, size){ + var rq = CV.square2quad(src); + + rq[0] /= size; + rq[1] /= size; + rq[3] /= size; + rq[4] /= size; + rq[6] /= size; + rq[7] /= size; + + return rq; +}; + +CV.square2quad = function(src){ + var sq = [], px, py, dx1, dx2, dy1, dy2, den; + + px = src[0].x - src[1].x + src[2].x - src[3].x; + py = src[0].y - src[1].y + src[2].y - src[3].y; + + if (0 === px && 0 === py){ + sq[0] = src[1].x - src[0].x; + sq[1] = src[2].x - src[1].x; + sq[2] = src[0].x; + sq[3] = src[1].y - src[0].y; + sq[4] = src[2].y - src[1].y; + sq[5] = src[0].y; + sq[6] = 0; + sq[7] = 0; + sq[8] = 1; + + }else{ + dx1 = src[1].x - src[2].x; + dx2 = src[3].x - src[2].x; + dy1 = src[1].y - src[2].y; + dy2 = src[3].y - src[2].y; + den = dx1 * dy2 - dx2 * dy1; + + sq[6] = (px * dy2 - dx2 * py) / den; + sq[7] = (dx1 * py - px * dy1) / den; + sq[8] = 1; + sq[0] = src[1].x - src[0].x + sq[6] * src[1].x; + sq[1] = src[3].x - src[0].x + sq[7] * src[3].x; + sq[2] = src[0].x; + sq[3] = src[1].y - src[0].y + sq[6] * src[1].y; + sq[4] = src[3].y - src[0].y + sq[7] * src[3].y; + sq[5] = src[0].y; + } + + return sq; +}; + +CV.isContourConvex = function(contour){ + var orientation = 0, convex = true, + len = contour.length, i = 0, j = 0, + cur_pt, prev_pt, dxdy0, dydx0, dx0, dy0, dx, dy; + + prev_pt = contour[len - 1]; + cur_pt = contour[0]; + + dx0 = cur_pt.x - prev_pt.x; + dy0 = cur_pt.y - prev_pt.y; + + for (; i < len; ++ i){ + if (++ j === len) {j = 0;} + + prev_pt = cur_pt; + cur_pt = contour[j]; + + dx = cur_pt.x - prev_pt.x; + dy = cur_pt.y - prev_pt.y; + dxdy0 = dx * dy0; + dydx0 = dy * dx0; + + orientation |= dydx0 > dxdy0? 1: (dydx0 < dxdy0? 2: 3); + + if (3 === orientation){ + convex = false; + break; + } + + dx0 = dx; + dy0 = dy; + } + + return convex; +}; + +CV.perimeter = function(poly){ + var len = poly.length, i = 0, j = len - 1, + p = 0.0, dx, dy; + + for (; i < len; j = i ++){ + dx = poly[i].x - poly[j].x; + dy = poly[i].y - poly[j].y; + + p += Math.sqrt(dx * dx + dy * dy) ; + } + + return p; +}; + +CV.minEdgeLength = function(poly){ + var len = poly.length, i = 0, j = len - 1, + min = Infinity, d, dx, dy; + + for (; i < len; j = i ++){ + dx = poly[i].x - poly[j].x; + dy = poly[i].y - poly[j].y; + + d = dx * dx + dy * dy; + + if (d < min){ + min = d; + } + } + + return Math.sqrt(min); +}; + +CV.countNonZero = function(imageSrc, square){ + var src = imageSrc.data, height = square.height, width = square.width, + pos = square.x + (square.y * imageSrc.width), + span = imageSrc.width - width, + nz = 0, i, j; + + for (i = 0; i < height; ++ i){ + + for (j = 0; j < width; ++ j){ + + if ( 0 !== src[pos ++] ){ + ++ nz; + } + } + + pos += span; + } + + return nz; +}; + +CV.binaryBorder = function(imageSrc, dst){ + var src = imageSrc.data, height = imageSrc.height, width = imageSrc.width, + posSrc = 0, posDst = 0, i, j; + + for (j = -2; j < width; ++ j){ + dst[posDst ++] = 0; + } + + for (i = 0; i < height; ++ i){ + dst[posDst ++] = 0; + + for (j = 0; j < width; ++ j){ + dst[posDst ++] = (0 === src[posSrc ++]? 0: 1); + } + + dst[posDst ++] = 0; + } + + for (j = -2; j < width; ++ j){ + dst[posDst ++] = 0; + } + + return dst; +}; diff --git a/public/vendor/dictionaries/aruco_4x4_1000.js b/public/vendor/dictionaries/aruco_4x4_1000.js new file mode 100644 index 0000000..2d91dd0 --- /dev/null +++ b/public/vendor/dictionaries/aruco_4x4_1000.js @@ -0,0 +1,39 @@ +/* +By downloading, copying, installing or using the software you agree to this +license. If you do not agree to this license, do not download, install, +copy or use the software. + License Agreement + For Open Source Computer Vision Library + (3-clause BSD License) +Copyright (C) 2013, OpenCV Foundation, all rights reserved. +Third party copyrights are property of their respective owners. +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the names of the copyright holders nor the names of the contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. +This software is provided by the copyright holders and contributors "as is" and +any express or implied warranties, including, but not limited to, the implied +warranties of merchantability and fitness for a particular purpose are +disclaimed. In no event shall copyright holders or contributors be liable for +any direct, indirect, incidental, special, exemplary, or consequential damages +(including, but not limited to, procurement of substitute goods or services; +loss of use, data, or profits; or business interruption) however caused +and on any theory of liability, whether in contract, strict liability, +or tort (including negligence or otherwise) arising in any way out of +the use of this software, even if advised of the possibility of such damage. +*/ + +//Dictionary extracted from https://github.com/opencv/opencv_contrib/blob/4.x/modules/aruco/src/predefined_dictionaries.hpp + +var AR = this.AR || require('../aruco').AR; +AR.DICTIONARIES['ARUCO_4X4_1000'] = { + nBits: 16, + tau: null, + codeList: [[181,50],[15,154],[51,45],[153,70],[84,158],[121,205],[158,46],[196,242],[254,218],[207,86],[249,145],[17,167],[14,183],[42,15],[36,177],[38,62],[70,101],[102,0],[108,94],[118,175],[134,139],[176,43],[204,213],[221,130],[254,71],[148,113],[172,228],[165,84],[33,35],[52,111],[68,21],[87,178],[158,207],[240,203],[8,174],[9,41],[24,117],[4,255],[13,246],[28,90],[23,24],[42,40],[50,140],[56,178],[36,232],[46,235],[45,63],[75,100],[80,46],[80,19],[81,148],[85,104],[93,65],[95,151],[104,1],[104,103],[97,36],[97,233],[107,18],[111,229],[103,223],[126,27],[128,160],[131,68],[139,162],[147,122],[132,108],[133,42],[133,156],[156,137],[159,161],[187,124],[188,4],[182,91],[191,200],[183,171],[202,31],[201,98],[217,88],[211,213],[204,152],[199,160],[197,55],[233,93],[249,37],[251,187],[238,42],[247,77],[53,117],[138,173],[118,23],[10,207],[6,75],[45,193],[73,216],[67,244],[79,54],[79,211],[105,228],[112,199],[122,110],[180,234],[237,79],[252,231],[254,166],[0,37],[0,67],[10,136],[10,134],[2,111],[0,28],[0,151],[8,55],[10,49],[9,198],[11,1],[9,251],[11,88],[16,130],[24,45],[16,120],[16,115],[18,116],[18,177],[26,249],[19,6],[12,14],[12,241],[4,51],[12,159],[14,242],[14,253],[7,76],[15,164],[7,47],[5,181],[15,145],[7,219],[30,228],[20,57],[29,128],[21,200],[31,139],[21,186],[29,177],[32,128],[40,233],[34,162],[40,83],[42,240],[34,247],[41,64],[33,70],[41,185],[43,156],[43,178],[56,202],[56,46],[48,7],[56,231],[58,73],[58,101],[50,93],[59,136],[57,29],[59,211],[38,71],[39,128],[47,170],[45,20],[37,222],[37,83],[47,119],[52,72],[60,168],[60,65],[52,13],[52,251],[54,154],[61,224],[53,106],[61,9],[61,237],[63,196],[63,108],[55,206],[61,92],[61,118],[55,176],[63,23],[63,255],[72,229],[66,104],[74,45],[65,96],[73,81],[65,221],[75,223],[88,79],[90,72],[88,22],[80,93],[90,250],[90,181],[81,35],[91,138],[89,25],[81,53],[76,105],[70,193],[78,11],[68,95],[78,89],[77,131],[77,125],[71,216],[71,115],[92,133],[94,68],[86,43],[92,187],[85,195],[95,110],[95,235],[93,18],[85,94],[98,112],[98,21],[97,194],[107,32],[99,69],[107,92],[107,91],[120,12],[122,207],[120,127],[121,128],[113,229],[113,116],[121,182],[113,211],[123,51],[100,106],[102,168],[110,167],[110,145],[101,34],[109,203],[103,141],[109,49],[126,128],[126,226],[126,141],[116,210],[124,50],[126,53],[117,171],[119,5],[127,43],[125,218],[127,146],[128,117],[128,243],[129,166],[137,237],[129,252],[152,166],[154,32],[145,67],[153,249],[145,147],[155,212],[132,9],[132,107],[134,196],[142,100],[134,26],[133,78],[141,203],[133,103],[133,175],[133,215],[135,179],[156,225],[156,242],[148,23],[149,0],[149,162],[157,35],[159,98],[157,82],[149,218],[160,197],[170,205],[162,216],[162,87],[169,61],[169,87],[171,82],[163,54],[163,89],[176,244],[184,18],[176,191],[178,157],[187,237],[185,114],[185,150],[164,195],[172,210],[174,177],[165,130],[175,101],[165,123],[175,250],[180,100],[188,98],[180,129],[182,160],[190,238],[190,13],[188,217],[190,248],[181,40],[183,9],[183,210],[192,234],[192,25],[192,253],[200,211],[202,90],[193,77],[201,180],[193,87],[195,152],[195,29],[216,128],[216,239],[218,43],[208,30],[209,5],[211,173],[219,167],[196,201],[204,120],[205,69],[197,11],[207,207],[220,172],[212,2],[220,99],[212,39],[212,245],[214,120],[222,184],[221,230],[213,93],[221,189],[223,29],[226,202],[234,107],[224,180],[226,56],[226,212],[227,34],[225,216],[240,3],[242,204],[248,246],[241,73],[243,234],[241,156],[249,245],[241,59],[236,141],[238,201],[230,15],[228,247],[231,96],[239,232],[237,178],[229,21],[239,209],[244,134],[252,1],[246,195],[244,124],[252,147],[245,66],[253,152],[245,61],[2,189],[0,225],[2,226],[2,174],[8,120],[0,116],[8,158],[8,209],[8,125],[10,50],[10,222],[2,81],[1,162],[3,128],[11,131],[11,75],[11,39],[11,239],[9,182],[9,89],[9,147],[11,248],[3,217],[3,241],[16,196],[24,171],[26,160],[26,4],[26,108],[26,174],[18,137],[16,23],[26,243],[25,64],[17,2],[17,43],[17,207],[27,34],[19,46],[17,21],[19,187],[12,32],[12,201],[12,220],[12,54],[6,20],[6,114],[13,97],[5,13],[13,143],[15,224],[15,73],[7,133],[5,144],[13,51],[15,150],[15,118],[20,96],[28,141],[20,218],[28,115],[30,148],[30,186],[22,217],[30,61],[22,251],[29,233],[29,254],[31,159],[40,139],[32,175],[34,14],[34,169],[42,141],[42,163],[42,239],[40,144],[40,59],[42,88],[34,51],[33,160],[33,2],[33,165],[33,199],[43,3],[35,103],[41,48],[41,210],[43,25],[43,155],[43,151],[56,40],[56,165],[58,134],[50,1],[56,159],[50,210],[58,153],[58,213],[57,232],[59,193],[51,67],[59,231],[49,154],[51,144],[59,158],[36,196],[44,74],[44,173],[44,207],[44,103],[38,234],[46,229],[44,112],[46,18],[46,209],[46,57],[37,100],[37,231],[47,204],[45,188],[45,113],[37,213],[37,155],[39,16],[47,124],[39,242],[39,58],[47,182],[39,211],[47,179],[39,31],[60,75],[54,192],[54,238],[62,233],[52,184],[60,20],[60,82],[52,114],[52,126],[52,191],[62,113],[62,83],[61,140],[53,162],[53,46],[53,45],[55,172],[53,112],[55,250],[63,241],[63,219],[72,196],[72,233],[74,194],[74,65],[66,235],[72,19],[74,216],[66,253],[74,23],[73,99],[67,110],[65,58],[73,177],[65,61],[75,146],[75,155],[67,63],[88,34],[80,170],[88,39],[82,200],[82,132],[82,10],[90,15],[88,152],[88,92],[80,219],[80,247],[90,244],[81,236],[81,66],[81,13],[91,3],[83,235],[81,118],[89,113],[81,147],[83,249],[91,179],[83,151],[76,76],[68,75],[76,35],[70,140],[78,39],[70,144],[78,212],[69,206],[69,229],[69,39],[79,193],[71,5],[69,52],[69,114],[92,200],[92,14],[84,235],[86,137],[86,67],[94,231],[92,112],[84,178],[94,121],[86,243],[93,163],[93,242],[85,29],[93,157],[87,252],[87,210],[95,115],[104,45],[104,195],[104,135],[106,74],[98,105],[96,185],[104,255],[106,220],[106,218],[106,62],[106,81],[106,49],[98,215],[97,204],[107,130],[107,227],[105,58],[97,158],[97,149],[97,117],[105,95],[105,55],[99,218],[112,2],[120,99],[112,79],[114,202],[122,173],[112,123],[122,20],[122,249],[122,211],[122,187],[121,226],[113,41],[123,103],[113,208],[121,57],[115,48],[115,185],[115,83],[115,255],[108,136],[100,9],[108,67],[102,6],[102,131],[100,176],[100,218],[110,159],[103,200],[111,238],[109,59],[111,210],[116,128],[124,171],[126,104],[126,2],[124,156],[116,54],[124,17],[126,222],[126,182],[118,219],[125,196],[125,138],[117,109],[119,136],[119,32],[119,65],[117,56],[117,190],[125,155],[119,87],[136,40],[128,172],[136,13],[136,103],[130,78],[138,161],[130,43],[128,24],[136,249],[128,157],[138,156],[130,49],[138,117],[130,151],[129,9],[129,235],[129,7],[139,40],[139,172],[131,46],[131,229],[129,80],[137,50],[139,122],[139,150],[131,125],[144,135],[154,252],[146,245],[145,170],[147,65],[147,37],[155,235],[153,52],[145,247],[155,218],[147,86],[132,66],[140,129],[140,79],[134,72],[134,166],[142,3],[134,227],[134,111],[142,175],[132,94],[132,119],[134,250],[142,30],[142,55],[135,10],[143,138],[143,38],[135,33],[135,13],[133,114],[135,62],[156,67],[158,97],[148,88],[148,248],[156,50],[148,118],[148,177],[148,221],[148,155],[156,219],[158,156],[158,210],[150,25],[158,177],[149,105],[159,109],[151,43],[149,182],[149,185],[157,61],[157,87],[168,236],[168,37],[162,172],[162,2],[170,102],[170,143],[170,231],[168,48],[168,122],[168,246],[168,147],[162,20],[170,52],[162,114],[170,242],[162,241],[161,64],[169,10],[161,38],[169,197],[169,207],[161,52],[169,18],[161,250],[171,152],[163,247],[176,6],[176,69],[184,141],[178,132],[184,240],[184,85],[178,118],[186,145],[178,113],[185,192],[185,66],[185,42],[179,140],[179,202],[187,102],[179,15],[177,218],[187,20],[187,246],[179,19],[164,104],[172,44],[172,161],[172,235],[172,199],[164,103],[166,192],[174,224],[166,35],[173,232],[165,204],[167,236],[173,124],[165,26],[165,145],[173,25],[165,151],[180,109],[190,203],[188,58],[188,245],[190,189],[190,243],[181,37],[181,143],[183,104],[191,228],[189,254],[189,157],[181,245],[181,243],[191,176],[183,90],[191,62],[183,57],[191,213],[183,29],[191,53],[183,127],[200,1],[192,165],[194,130],[200,189],[194,252],[202,145],[194,91],[201,68],[193,42],[195,192],[201,122],[193,185],[201,117],[193,247],[203,177],[208,108],[216,135],[208,175],[218,196],[210,12],[218,9],[208,48],[216,148],[208,58],[208,182],[208,117],[210,118],[218,93],[218,53],[210,23],[217,2],[211,232],[211,229],[209,154],[209,246],[209,81],[219,20],[211,62],[211,211],[196,96],[204,167],[198,66],[198,71],[206,231],[196,92],[204,29],[204,53],[198,188],[205,168],[197,12],[197,228],[197,194],[205,45],[205,89],[205,149],[197,147],[199,95],[212,197],[222,136],[214,36],[222,236],[214,226],[222,198],[222,35],[220,220],[220,26],[212,17],[222,84],[214,148],[222,157],[221,129],[213,165],[215,172],[215,102],[223,169],[213,220],[221,31],[223,240],[226,72],[226,232],[226,7],[224,93],[234,245],[235,38],[235,237],[225,82],[225,126],[233,219],[248,6],[240,238],[248,161],[250,0],[250,194],[240,155],[250,244],[250,60],[242,252],[242,189],[242,147],[241,96],[249,236],[241,70],[249,225],[243,72],[243,174],[243,193],[243,139],[243,167],[241,115],[241,151],[243,244],[251,50],[228,7],[230,77],[236,85],[237,192],[237,133],[239,162],[231,78],[229,213],[239,80],[244,34],[244,137],[244,41],[246,106],[254,11],[254,111],[244,149],[244,53],[244,31],[246,176],[245,232],[245,197],[253,35],[255,192],[247,204],[247,233],[245,188],[253,246],[245,217],[253,151],[253,63],[255,156],[255,90],[247,254],[255,17],[247,191]] +}; \ No newline at end of file diff --git a/public/vendor/posit1.js b/public/vendor/posit1.js new file mode 100644 index 0000000..26fc0f1 --- /dev/null +++ b/public/vendor/posit1.js @@ -0,0 +1,495 @@ +/* +Copyright (c) 2012 Juan Mellado + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +/* +References: +- "Iterative Pose Estimation using Coplanar Feature Points" + Denis Oberkampf, Daniel F. DeMenthon, Larry S. Davis + http://www.cfar.umd.edu/~daniel/daniel_papersfordownload/CoplanarPts.pdf +*/ + +var POS = POS || {}; +this.POS = POS; + +var SVD = this.SVD || require('./svd').SVD; + +POS.Posit = function(modelSize, focalLength){ + this.objectPoints = this.buildModel(modelSize); + this.focalLength = focalLength; + + this.objectVectors = []; + this.objectNormal = []; + this.objectMatrix = [[],[],[]]; + + this.init(); +}; + +POS.Posit.prototype.buildModel = function(modelSize){ + var half = modelSize / 2.0; + + return [ + [-half, half, 0.0], + [ half, half, 0.0], + [ half, -half, 0.0], + [-half, -half, 0.0] ]; +}; + +POS.Posit.prototype.init = function(){ + var np = this.objectPoints.length, + vectors = [], n = [], len = 0.0, row = 2, i; + + for (i = 0; i < np; ++ i){ + this.objectVectors[i] = [this.objectPoints[i][0] - this.objectPoints[0][0], + this.objectPoints[i][1] - this.objectPoints[0][1], + this.objectPoints[i][2] - this.objectPoints[0][2]]; + + vectors[i] = [this.objectVectors[i][0], + this.objectVectors[i][1], + this.objectVectors[i][2]]; + } + + while(0.0 === len){ + n[0] = this.objectVectors[1][1] * this.objectVectors[row][2] - + this.objectVectors[1][2] * this.objectVectors[row][1]; + n[1] = this.objectVectors[1][2] * this.objectVectors[row][0] - + this.objectVectors[1][0] * this.objectVectors[row][2]; + n[2] = this.objectVectors[1][0] * this.objectVectors[row][1] - + this.objectVectors[1][1] * this.objectVectors[row][0]; + + len = Math.sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]); + + ++ row; + } + + for (i = 0; i < 3; ++ i){ + this.objectNormal[i] = n[i] / len; + } + + POS.pseudoInverse(vectors, np, this.objectMatrix); +}; + +POS.Posit.prototype.pose = function(imagePoints){ + var posRotation1 = [[],[],[]], posRotation2 = [[],[],[]], posTranslation = [], + rotation1 = [[],[],[]], rotation2 = [[],[],[]], translation1 = [], translation2 = [], + error1, error2, valid1, valid2, i, j; + + this.pos(imagePoints, posRotation1, posRotation2, posTranslation); + + valid1 = this.isValid(posRotation1, posTranslation); + if (valid1){ + error1 = this.iterate(imagePoints, posRotation1, posTranslation, rotation1, translation1); + }else{ + error1 = {euclidean: -1.0, pixels: -1, maximum: -1.0}; + } + + valid2 = this.isValid(posRotation2, posTranslation); + if (valid2){ + error2 = this.iterate(imagePoints, posRotation2, posTranslation, rotation2, translation2); + }else{ + error2 = {euclidean: -1.0, pixels: -1, maximum: -1.0}; + } + + for (i = 0; i < 3; ++ i){ + for (j = 0; j < 3; ++ j){ + if (valid1){ + translation1[i] -= rotation1[i][j] * this.objectPoints[0][j]; + } + if (valid2){ + translation2[i] -= rotation2[i][j] * this.objectPoints[0][j]; + } + } + } + + return error1.euclidean < error2.euclidean? + new POS.Pose(error1.pixels, rotation1, translation1, error2.pixels, rotation2, translation2): + new POS.Pose(error2.pixels, rotation2, translation2, error1.pixels, rotation1, translation1); +}; + +POS.Posit.prototype.pos = function(imagePoints, rotation1, rotation2, translation){ + var np = this.objectPoints.length, imageVectors = [], + i0 = [], j0 = [], ivec = [], jvec = [], row1 = [], row2 = [], row3 = [], + i0i0, j0j0, i0j0, delta, q, lambda, mu, scale, i, j; + + for (i = 0; i < np; ++ i){ + imageVectors[i] = [imagePoints[i].x - imagePoints[0].x, + imagePoints[i].y - imagePoints[0].y]; + } + + //i0 and j0 + for (i = 0; i < 3; ++ i){ + i0[i] = 0.0; + j0[i] = 0.0; + for (j = 0; j < np; ++ j){ + i0[i] += this.objectMatrix[i][j] * imageVectors[j][0]; + j0[i] += this.objectMatrix[i][j] * imageVectors[j][1]; + } + } + + i0i0 = i0[0] * i0[0] + i0[1] * i0[1] + i0[2] * i0[2]; + j0j0 = j0[0] * j0[0] + j0[1] * j0[1] + j0[2] * j0[2]; + i0j0 = i0[0] * j0[0] + i0[1] * j0[1] + i0[2] * j0[2]; + + //Lambda and mu + delta = (j0j0 - i0i0) * (j0j0 - i0i0) + 4.0 * (i0j0 * i0j0); + + if (j0j0 - i0i0 >= 0.0){ + q = (j0j0 - i0i0 + Math.sqrt(delta) ) / 2.0; + }else{ + q = (j0j0 - i0i0 - Math.sqrt(delta) ) / 2.0; + } + + if (q >= 0.0){ + lambda = Math.sqrt(q); + if (0.0 === lambda){ + mu = 0.0; + }else{ + mu = -i0j0 / lambda; + } + }else{ + lambda = Math.sqrt( -(i0j0 * i0j0) / q); + if (0.0 === lambda){ + mu = Math.sqrt(i0i0 - j0j0); + }else{ + mu = -i0j0 / lambda; + } + } + + //First rotation + for (i = 0; i < 3; ++ i){ + ivec[i] = i0[i] + lambda * this.objectNormal[i]; + jvec[i] = j0[i] + mu * this.objectNormal[i]; + } + + scale = Math.sqrt(ivec[0] * ivec[0] + ivec[1] * ivec[1] + ivec[2] * ivec[2]); + + for (i = 0; i < 3; ++ i){ + row1[i] = ivec[i] / scale; + row2[i] = jvec[i] / scale; + } + + row3[0] = row1[1] * row2[2] - row1[2] * row2[1]; + row3[1] = row1[2] * row2[0] - row1[0] * row2[2]; + row3[2] = row1[0] * row2[1] - row1[1] * row2[0]; + + for (i = 0; i < 3; ++ i){ + rotation1[0][i] = row1[i]; + rotation1[1][i] = row2[i]; + rotation1[2][i] = row3[i]; + } + + //Second rotation + for (i = 0; i < 3; ++ i){ + ivec[i] = i0[i] - lambda * this.objectNormal[i]; + jvec[i] = j0[i] - mu * this.objectNormal[i]; + } + + for (i = 0; i < 3; ++ i){ + row1[i] = ivec[i] / scale; + row2[i] = jvec[i] / scale; + } + + row3[0] = row1[1] * row2[2] - row1[2] * row2[1]; + row3[1] = row1[2] * row2[0] - row1[0] * row2[2]; + row3[2] = row1[0] * row2[1] - row1[1] * row2[0]; + + for (i = 0; i < 3; ++ i){ + rotation2[0][i] = row1[i]; + rotation2[1][i] = row2[i]; + rotation2[2][i] = row3[i]; + } + + //Translation + translation[0] = imagePoints[0].x / scale; + translation[1] = imagePoints[0].y / scale; + translation[2] = this.focalLength / scale; +}; + +POS.Posit.prototype.isValid = function(rotation, translation){ + var np = this.objectPoints.length, zmin = Infinity, i = 0, zi; + + for (; i < np; ++ i){ + zi = translation[2] + + (rotation[2][0] * this.objectVectors[i][0] + + rotation[2][1] * this.objectVectors[i][1] + + rotation[2][2] * this.objectVectors[i][2]); + if (zi < zmin){ + zmin = zi; + } + } + + return zmin >= 0.0; +}; + +POS.Posit.prototype.iterate = function(imagePoints, posRotation, posTranslation, rotation, translation){ + var np = this.objectPoints.length, + oldSopImagePoints = [], sopImagePoints = [], + rotation1 = [[],[],[]], rotation2 = [[],[],[]], + translation1 = [], translation2 = [], + converged = false, iteration = 0, + oldImageDifference, imageDifference, factor, + error, error1, error2, delta, i, j; + + for (i = 0; i < np; ++ i){ + oldSopImagePoints[i] = {x: imagePoints[i].x, + y: imagePoints[i].y}; + } + + for (i = 0; i < 3; ++ i){ + for (j = 0; j < 3; ++ j){ + rotation[i][j] = posRotation[i][j]; + } + translation[i] = posTranslation[i]; + } + + for (i = 0; i < np; ++ i){ + factor = 0.0; + for (j = 0; j < 3; ++ j){ + factor += this.objectVectors[i][j] * rotation[2][j] / translation[2]; + } + sopImagePoints[i] = {x: (1.0 + factor) * imagePoints[i].x, + y: (1.0 + factor) * imagePoints[i].y}; + } + + imageDifference = 0.0; + + for (i = 0; i < np; ++ i){ + imageDifference += Math.abs(sopImagePoints[i].x - oldSopImagePoints[i].x); + imageDifference += Math.abs(sopImagePoints[i].y - oldSopImagePoints[i].y); + } + + for (i = 0; i < 3; ++ i){ + translation1[i] = translation[i] - + (rotation[i][0] * this.objectPoints[0][0] + + rotation[i][1] * this.objectPoints[0][1] + + rotation[i][2] * this.objectPoints[0][2]); + } + + error = error1 = this.error(imagePoints, rotation, translation1); + + //Convergence + converged = (0.0 === error1.pixels) || (imageDifference < 0.01); + + while( iteration ++ < 100 && !converged ){ + + for (i = 0; i < np; ++ i){ + oldSopImagePoints[i].x = sopImagePoints[i].x; + oldSopImagePoints[i].y = sopImagePoints[i].y; + } + + this.pos(sopImagePoints, rotation1, rotation2, translation); + + for (i = 0; i < 3; ++ i){ + translation1[i] = translation[i] - + (rotation1[i][0] * this.objectPoints[0][0] + + rotation1[i][1] * this.objectPoints[0][1] + + rotation1[i][2] * this.objectPoints[0][2]); + + translation2[i] = translation[i] - + (rotation2[i][0] * this.objectPoints[0][0] + + rotation2[i][1] * this.objectPoints[0][1] + + rotation2[i][2] * this.objectPoints[0][2]); + } + + error1 = this.error(imagePoints, rotation1, translation1); + error2 = this.error(imagePoints, rotation2, translation2); + + if ( (error1.euclidean >= 0.0) && (error2.euclidean >= 0.0) ){ + if (error2.euclidean < error1.euclidean){ + error = error2; + for (i = 0; i < 3; ++ i){ + for (j = 0; j < 3; ++ j){ + rotation[i][j] = rotation2[i][j]; + } + } + }else{ + error = error1; + for (i = 0; i < 3; ++ i){ + for (j = 0; j < 3; ++ j){ + rotation[i][j] = rotation1[i][j]; + } + } + } + } + + if ( (error1.euclidean < 0.0) && (error2.euclidean >= 0.0) ){ + error = error2; + for (i = 0; i < 3; ++ i){ + for (j = 0; j < 3; ++ j){ + rotation[i][j] = rotation2[i][j]; + } + } + } + + if ( (error2.euclidean < 0.0) && (error1.euclidean >= 0.0) ){ + error = error1; + for (i = 0; i < 3; ++ i){ + for (j = 0; j < 3; ++ j){ + rotation[i][j] = rotation1[i][j]; + } + } + } + + for (i = 0; i < np; ++ i){ + factor = 0.0; + for (j = 0; j < 3; ++ j){ + factor += this.objectVectors[i][j] * rotation[2][j] / translation[2]; + } + sopImagePoints[i].x = (1.0 + factor) * imagePoints[i].x; + sopImagePoints[i].y = (1.0 + factor) * imagePoints[i].y; + } + + oldImageDifference = imageDifference; + imageDifference = 0.0; + + for (i = 0; i < np; ++ i){ + imageDifference += Math.abs(sopImagePoints[i].x - oldSopImagePoints[i].x); + imageDifference += Math.abs(sopImagePoints[i].y - oldSopImagePoints[i].y); + } + + delta = Math.abs(imageDifference - oldImageDifference); + + converged = (0.0 === error.pixels) || (delta < 0.01); + } + + return error; +}; + +POS.Posit.prototype.error = function(imagePoints, rotation, translation){ + var np = this.objectPoints.length, + move = [], projection = [], errorvec = [], + euclidean = 0.0, pixels = 0.0, maximum = 0.0, + i, j, k; + + if ( !this.isValid(rotation, translation) ){ + return {euclidean: -1.0, pixels: -1, maximum: -1.0}; + } + + for (i = 0; i < np; ++ i){ + move[i] = []; + for (j = 0; j < 3; ++ j){ + move[i][j] = translation[j]; + } + } + + for (i = 0; i < np; ++ i){ + for (j = 0; j < 3; ++ j){ + for (k = 0; k < 3; ++ k){ + move[i][j] += rotation[j][k] * this.objectPoints[i][k]; + } + } + } + + for (i = 0; i < np; ++ i){ + projection[i] = []; + for (j = 0; j < 2; ++ j){ + projection[i][j] = this.focalLength * move[i][j] / move[i][2]; + } + } + + for (i = 0; i < np; ++ i){ + errorvec[i] = [projection[i][0] - imagePoints[i].x, + projection[i][1] - imagePoints[i].y]; + } + + for (i = 0; i < np; ++ i){ + euclidean += Math.sqrt(errorvec[i][0] * errorvec[i][0] + + errorvec[i][1] * errorvec[i][1]); + + pixels += Math.abs( Math.round(projection[i][0]) - Math.round(imagePoints[i].x) ) + + Math.abs( Math.round(projection[i][1]) - Math.round(imagePoints[i].y) ); + + if (Math.abs(errorvec[i][0]) > maximum){ + maximum = Math.abs(errorvec[i][0]); + } + if (Math.abs(errorvec[i][1]) > maximum){ + maximum = Math.abs(errorvec[i][1]); + } + } + + return {euclidean: euclidean / np, pixels: pixels, maximum: maximum}; +}; + +POS.pseudoInverse = function(a, n, b){ + var w = [], v = [[],[],[]], s = [[],[],[]], + wmax = 0.0, cn = 0, + i, j, k; + + SVD.svdcmp(a, n, 3, w, v); + + for (i = 0; i < 3; ++ i){ + if (w[i] > wmax){ + wmax = w[i]; + } + } + + wmax *= 0.01; + + for (i = 0; i < 3; ++ i){ + if (w[i] < wmax){ + w[i] = 0.0; + } + } + + for (j = 0; j < 3; ++ j){ + if (0.0 === w[j]){ + ++ cn; + for (k = j; k < 2; ++ k){ + for (i = 0; i < n; ++ i){ + a[i][k] = a[i][k + 1]; + } + for (i = 0; i < 3; ++ i){ + v[i][k] = v[i][k + 1]; + } + } + } + } + + for (j = 0; j < 2; ++ j){ + if (0.0 === w[j]){ + w[j] = w[j + 1]; + } + } + + for (i = 0; i < 3; ++ i){ + for (j = 0; j < 3 - cn; ++ j){ + s[i][j] = v[i][j] / w[j]; + } + } + + for (i = 0; i < 3; ++ i){ + for (j = 0; j < n; ++ j){ + b[i][j] = 0.0; + for (k = 0; k < 3 - cn; ++ k){ + b[i][j] += s[i][k] * a[j][k]; + } + } + } +}; + +POS.Pose = function(error1, rotation1, translation1, error2, rotation2, translation2){ + this.bestError = error1; + this.bestRotation = rotation1; + this.bestTranslation = translation1; + this.alternativeError = error2; + this.alternativeRotation = rotation2; + this.alternativeTranslation = translation2; +}; diff --git a/public/vendor/svd.js b/public/vendor/svd.js new file mode 100644 index 0000000..b4ab6b2 --- /dev/null +++ b/public/vendor/svd.js @@ -0,0 +1,284 @@ +/* +Copyright (c) 2012 Juan Mellado + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +/* +References: +- "Numerical Recipes in C - Second Edition" + http://www.nr.com/ +*/ + +var SVD = SVD || {}; +this.SVD = SVD; + +SVD.svdcmp = function(a, m, n, w, v){ + var flag, i, its, j, jj, k, l, nm, + anorm = 0.0, c, f, g = 0.0, h, s, scale = 0.0, x, y, z, rv1 = []; + + //Householder reduction to bidiagonal form + for (i = 0; i < n; ++ i){ + l = i + 1; + rv1[i] = scale * g; + g = s = scale = 0.0; + if (i < m){ + for (k = i; k < m; ++ k){ + scale += Math.abs( a[k][i] ); + } + if (0.0 !== scale){ + for (k = i; k < m; ++ k){ + a[k][i] /= scale; + s += a[k][i] * a[k][i]; + } + f = a[i][i]; + g = -SVD.sign( Math.sqrt(s), f ); + h = f * g - s; + a[i][i] = f - g; + for (j = l; j < n; ++ j){ + for (s = 0.0, k = i; k < m; ++ k){ + s += a[k][i] * a[k][j]; + } + f = s / h; + for (k = i; k < m; ++ k){ + a[k][j] += f * a[k][i]; + } + } + for (k = i; k < m; ++ k){ + a[k][i] *= scale; + } + } + } + w[i] = scale * g; + g = s = scale = 0.0; + if ( (i < m) && (i !== n - 1) ){ + for (k = l; k < n; ++ k){ + scale += Math.abs( a[i][k] ); + } + if (0.0 !== scale){ + for (k = l; k < n; ++ k){ + a[i][k] /= scale; + s += a[i][k] * a[i][k]; + } + f = a[i][l]; + g = -SVD.sign( Math.sqrt(s), f ); + h = f * g - s; + a[i][l] = f - g; + for (k = l; k < n; ++ k){ + rv1[k] = a[i][k] / h; + } + for (j = l; j < m; ++ j){ + for (s = 0.0, k = l; k < n; ++ k){ + s += a[j][k] * a[i][k]; + } + for (k = l; k < n; ++ k){ + a[j][k] += s * rv1[k]; + } + } + for (k = l; k < n; ++ k){ + a[i][k] *= scale; + } + } + } + anorm = Math.max(anorm, ( Math.abs( w[i] ) + Math.abs( rv1[i] ) ) ); + } + + //Acumulation of right-hand transformation + for (i = n - 1; i >= 0; -- i){ + if (i < n - 1){ + if (0.0 !== g){ + for (j = l; j < n; ++ j){ + v[j][i] = ( a[i][j] / a[i][l] ) / g; + } + for (j = l; j < n; ++ j){ + for (s = 0.0, k = l; k < n; ++ k){ + s += a[i][k] * v[k][j]; + } + for (k = l; k < n; ++ k){ + v[k][j] += s * v[k][i]; + } + } + } + for (j = l; j < n; ++ j){ + v[i][j] = v[j][i] = 0.0; + } + } + v[i][i] = 1.0; + g = rv1[i]; + l = i; + } + + //Acumulation of left-hand transformation + for (i = Math.min(n, m) - 1; i >= 0; -- i){ + l = i + 1; + g = w[i]; + for (j = l; j < n; ++ j){ + a[i][j] = 0.0; + } + if (0.0 !== g){ + g = 1.0 / g; + for (j = l; j < n; ++ j){ + for (s = 0.0, k = l; k < m; ++ k){ + s += a[k][i] * a[k][j]; + } + f = (s / a[i][i]) * g; + for (k = i; k < m; ++ k){ + a[k][j] += f * a[k][i]; + } + } + for (j = i; j < m; ++ j){ + a[j][i] *= g; + } + }else{ + for (j = i; j < m; ++ j){ + a[j][i] = 0.0; + } + } + ++ a[i][i]; + } + + //Diagonalization of the bidiagonal form + for (k = n - 1; k >= 0; -- k){ + for (its = 1; its <= 30; ++ its){ + flag = true; + for (l = k; l >= 0; -- l){ + nm = l - 1; + if ( Math.abs( rv1[l] ) + anorm === anorm ){ + flag = false; + break; + } + if ( Math.abs( w[nm] ) + anorm === anorm ){ + break; + } + } + if (flag){ + c = 0.0; + s = 1.0; + for (i = l; i <= k; ++ i){ + f = s * rv1[i]; + if ( Math.abs(f) + anorm === anorm ){ + break; + } + g = w[i]; + h = SVD.pythag(f, g); + w[i] = h; + h = 1.0 / h; + c = g * h; + s = -f * h; + for (j = 1; j <= m; ++ j){ + y = a[j][nm]; + z = a[j][i]; + a[j][nm] = y * c + z * s; + a[j][i] = z * c - y * s; + } + } + } + + //Convergence + z = w[k]; + if (l === k){ + if (z < 0.0){ + w[k] = -z; + for (j = 0; j < n; ++ j){ + v[j][k] = -v[j][k]; + } + } + break; + } + + if (30 === its){ + return false; + } + + //Shift from bottom 2-by-2 minor + x = w[l]; + nm = k - 1; + y = w[nm]; + g = rv1[nm]; + h = rv1[k]; + f = ( (y - z) * (y + z) + (g - h) * (g + h) ) / (2.0 * h * y); + g = SVD.pythag( f, 1.0 ); + f = ( (x - z) * (x + z) + h * ( (y / (f + SVD.sign(g, f) ) ) - h) ) / x; + + //Next QR transformation + c = s = 1.0; + for (j = l; j <= nm; ++ j){ + i = j + 1; + g = rv1[i]; + y = w[i]; + h = s * g; + g = c * g; + z = SVD.pythag(f, h); + rv1[j] = z; + c = f / z; + s = h / z; + f = x * c + g * s; + g = g * c - x * s; + h = y * s; + y *= c; + for (jj = 0; jj < n; ++ jj){ + x = v[jj][j]; + z = v[jj][i]; + v[jj][j] = x * c + z * s; + v[jj][i] = z * c - x * s; + } + z = SVD.pythag(f, h); + w[j] = z; + if (0.0 !== z){ + z = 1.0 / z; + c = f * z; + s = h * z; + } + f = c * g + s * y; + x = c * y - s * g; + for (jj = 0; jj < m; ++ jj){ + y = a[jj][j]; + z = a[jj][i]; + a[jj][j] = y * c + z * s; + a[jj][i] = z * c - y * s; + } + } + rv1[l] = 0.0; + rv1[k] = f; + w[k] = x; + } + } + + return true; +}; + +SVD.pythag = function(a, b){ + var at = Math.abs(a), bt = Math.abs(b), ct; + + if (at > bt){ + ct = bt / at; + return at * Math.sqrt(1.0 + ct * ct); + } + + if (0.0 === bt){ + return 0.0; + } + + ct = at / bt; + return bt * Math.sqrt(1.0 + ct * ct); +}; + +SVD.sign = function(a, b){ + return b >= 0.0? Math.abs(a): -Math.abs(a); +}; diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..3d0398e --- /dev/null +++ b/server/auth.js @@ -0,0 +1,21 @@ +/* Simple token auth for final mode. */ +const crypto = require('crypto'); +const tokens = new Map(); // token -> expiry +const TTL = 12 * 60 * 60 * 1000; + +module.exports = { + login(password) { + const expected = process.env.ADMIN_PASSWORD; + if (!expected || password !== expected) return null; + const t = crypto.randomBytes(24).toString('hex'); + tokens.set(t, Date.now() + TTL); + return t; + }, + check(t) { + if (!t) return false; + const exp = tokens.get(t); + if (!exp) return false; + if (Date.now() > exp) { tokens.delete(t); return false; } + return true; + }, +}; diff --git a/server/playlist.js b/server/playlist.js new file mode 100644 index 0000000..e43586a --- /dev/null +++ b/server/playlist.js @@ -0,0 +1,146 @@ +/* Playlist engine — server-driven so every viewer sees the same ghosts. + * + * Config: + * { + * slots: 5, // concurrent visible ghosts + * dwellSeconds: 120, // how long each ghost stays before rotating out + * crossfadeSeconds: 3, // fade out/in overlap hint for clients + * order: 'shuffle' | 'roster', // rotation order through the roster + * include: { colors:[], rarities:[], ids:[] }, // empty = all + * behaviors: { staticChance: 0.5, wanderRadius: 0.6, wanderSpeed: 0.15 }, + * timeWindows: [] // e.g. [{ start:"09:00", end:"17:00" }] — empty = always on + * } + * + * Broadcasts: + * { type:'spawn', ghost:{ uid, id, name, color, spawnId, pos, behavior, until } } + * { type:'despawn', uid } + * { type:'active', ghosts:[...] } (full snapshot, sent on join + config change) + */ +class Playlist { + constructor(state, broadcast) { + this.state = state; + this.broadcast = broadcast; + this.cfg = state.getPlaylistConfig({ + slots: 5, dwellSeconds: 120, crossfadeSeconds: 3, order: 'shuffle', + include: { colors: [], rarities: [], ids: [] }, + behaviors: { staticChance: 0.5, wanderRadius: 0.6, wanderSpeed: 0.15 }, + timeWindows: [], + }); + this.active = new Map(); // uid -> ghost record + this.queue = []; + this.uidCounter = 1; + this.timer = null; + } + + getConfig() { return this.cfg; } + + setConfig(cfg) { + if (!cfg || typeof cfg !== 'object') throw new Error('bad config'); + this.cfg = { ...this.cfg, ...cfg }; + this.state.putPlaylistConfig(this.cfg); + // rebuild world: clear and respawn under new rules + for (const uid of [...this.active.keys()]) this.despawn(uid); + this.queue = []; + this.tick(); + this.broadcast({ type: 'active', ghosts: this.activeSnapshot() }); + return this.cfg; + } + + roster() { + const { ghosts } = this.state.getGhosts(); + const inc = this.cfg.include || {}; + return ghosts.filter(g => + (!inc.colors?.length || inc.colors.includes(g.color)) && + (!inc.rarities?.length || inc.rarities.includes(g.rarity)) && + (!inc.ids?.length || inc.ids.includes(g.id))); + } + + refillQueue() { + const r = this.roster().map(g => g.id); + if (!r.length) return; + if (this.cfg.order === 'shuffle') { + for (let i = r.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [r[i], r[j]] = [r[j], r[i]]; + } + } + // avoid immediate repeat of currently-active ghosts at queue head + const activeIds = new Set([...this.active.values()].map(g => g.id)); + this.queue.push(...r.filter(id => !activeIds.has(id)), ...r.filter(id => activeIds.has(id))); + } + + inTimeWindow() { + const w = this.cfg.timeWindows || []; + if (!w.length) return true; + const now = new Date(); + const mins = now.getHours() * 60 + now.getMinutes(); + return w.some(({ start, end }) => { + const [sh, sm] = String(start).split(':').map(Number); + const [eh, em] = String(end).split(':').map(Number); + const s = sh * 60 + (sm || 0), e = eh * 60 + (em || 0); + return s <= e ? (mins >= s && mins < e) : (mins >= s || mins < e); // handles overnight + }); + } + + freeSpawn() { + const scene = this.state.getScene(); + const used = new Set([...this.active.values()].map(g => g.spawnId)); + const free = (scene.spawns || []).filter(s => s.enabled !== false && !used.has(s.id)); + if (!free.length) return null; + return free[Math.floor(Math.random() * free.length)]; + } + + spawnOne() { + if (!this.queue.length) this.refillQueue(); + const id = this.queue.shift(); + if (!id) return; + const g = this.roster().find(x => x.id === id); + const spawn = this.freeSpawn(); + if (!g || !spawn) return; + const b = this.cfg.behaviors || {}; + const isStatic = Math.random() < (b.staticChance ?? 0.5); + const uid = 'g' + (this.uidCounter++); + const rec = { + uid, id: g.id, name: g.name, color: g.color, rarity: g.rarity, + spawnId: spawn.id, pos: spawn.position, + behavior: isStatic + ? { type: 'static', bobAmp: 0.05 + Math.random() * 0.05, bobHz: 0.3 + Math.random() * 0.3 } + : { type: 'wander', radius: b.wanderRadius ?? 0.6, speed: b.wanderSpeed ?? 0.15, seed: Math.floor(Math.random() * 1e6) }, + spawnedAt: Date.now(), + until: Date.now() + (this.cfg.dwellSeconds || 120) * 1000, + crossfade: this.cfg.crossfadeSeconds || 3, + }; + this.active.set(uid, rec); + this.broadcast({ type: 'spawn', ghost: rec }); + } + + despawn(uid) { + if (!this.active.has(uid)) return; + this.active.delete(uid); + this.broadcast({ type: 'despawn', uid }); + } + + activeSnapshot() { return [...this.active.values()]; } + + tick() { + const now = Date.now(); + if (!this.inTimeWindow()) { + for (const uid of [...this.active.keys()]) this.despawn(uid); + return; + } + for (const [uid, g] of this.active) if (now >= g.until) this.despawn(uid); + const want = Math.min(this.cfg.slots || 5, (this.state.getScene().spawns || []).filter(s => s.enabled !== false).length); + while (this.active.size < want) { + const before = this.active.size; + this.spawnOne(); + if (this.active.size === before) break; // no roster/spawns available + } + } + + start() { + this.tick(); + this.timer = setInterval(() => this.tick(), 1000); + } +} + +module.exports = Playlist; diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..acba871 --- /dev/null +++ b/server/server.js @@ -0,0 +1,108 @@ +/* Newbury Exhibit v2 — server + * Express static host + WebSocket sync + admin API + playlist engine. + * Modes: EXHIBIT_MODE=dev -> admin open, verbose client error overlay enabled + * EXHIBIT_MODE=final -> admin password-protected (ADMIN_PASSWORD env) + */ +const express = require('express'); +const http = require('http'); +const path = require('path'); +const { WebSocketServer } = require('ws'); + +const state = require('./state'); +const auth = require('./auth'); +const Playlist = require('./playlist'); + +const PORT = process.env.PORT || 33044; +const MODE = process.env.EXHIBIT_MODE || 'dev'; + +const app = express(); +app.use(express.json({ limit: '2mb' })); + +// ---------- public static ---------- +app.use(express.static(path.join(__dirname, '..', 'public'))); +app.use('/data/ghosts.json', express.static(path.join(__dirname, '..', 'data', 'ghosts.json'))); +app.use('/data/gradients.json', express.static(path.join(__dirname, '..', 'data', 'gradients.json'))); + +// ---------- runtime info ---------- +app.get('/api/info', (req, res) => { + res.json({ mode: MODE, authRequired: MODE === 'final', version: 2 }); +}); + +// ---------- auth ---------- +app.post('/api/login', (req, res) => { + if (MODE !== 'final') return res.json({ token: 'dev' }); + const t = auth.login(req.body && req.body.password); + if (!t) return res.status(401).json({ error: 'bad password' }); + res.json({ token: t }); +}); + +function guard(req, res, next) { + if (MODE !== 'final') return next(); + if (auth.check(req.headers['x-exhibit-token'])) return next(); + res.status(401).json({ error: 'unauthorized' }); +} + +// ---------- scene (anchors, buildings, spawns) ---------- +app.get('/api/scene', (req, res) => res.json(state.getScene())); +app.put('/api/scene', guard, (req, res) => { + try { res.json(state.putScene(req.body)); } + catch (e) { res.status(400).json({ error: String(e.message || e) }); } +}); +app.post('/api/scene/reset', guard, (req, res) => res.json(state.resetToSeed())); + +// ---------- playlist config ---------- +app.get('/api/playlist', (req, res) => res.json(playlist.getConfig())); +app.put('/api/playlist', guard, (req, res) => { + try { res.json(playlist.setConfig(req.body)); } + catch (e) { res.status(400).json({ error: String(e.message || e) }); } +}); + +// ---------- ghost roster ---------- +app.get('/api/ghosts', (req, res) => res.json(state.getGhosts())); + +// ---------- model manifests (multi-part OBJ ghosts) ---------- +app.get('/api/models', (req, res) => res.json(state.getModels())); +app.put('/api/models', guard, (req, res) => { + try { res.json(state.putModels(req.body)); } + catch (e) { res.status(400).json({ error: String(e.message || e) }); } +}); + +// ---------- client error reporting (dev mode) ---------- +const clientErrors = []; +app.post('/api/client-error', (req, res) => { + clientErrors.push({ t: Date.now(), ...req.body }); + if (clientErrors.length > 500) clientErrors.shift(); + console.warn('[client-error]', JSON.stringify(req.body).slice(0, 500)); + res.json({ ok: true }); +}); +app.get('/api/client-errors', guard, (req, res) => res.json(clientErrors)); + +// ---------- server + websocket ---------- +const server = http.createServer(app); +const wss = new WebSocketServer({ server, path: '/ws' }); + +function broadcast(obj) { + const msg = JSON.stringify(obj); + for (const c of wss.clients) if (c.readyState === 1) c.send(msg); +} + +const playlist = new Playlist(state, broadcast); +playlist.start(); + +wss.on('connection', (ws) => { + ws.send(JSON.stringify({ type: 'scene', scene: state.getScene() })); + ws.send(JSON.stringify({ type: 'active', ghosts: playlist.activeSnapshot() })); + ws.on('message', (raw) => { + let m; try { m = JSON.parse(raw); } catch { return; } + if (m.type === 'ping') ws.send(JSON.stringify({ type: 'pong', t: m.t, server: Date.now() })); + }); +}); + +// push scene updates to viewers when admin saves +state.onChange(() => broadcast({ type: 'scene', scene: state.getScene() })); + +server.listen(PORT, () => { + console.log(`Newbury Exhibit v2 [${MODE}] on :${PORT}`); + if (MODE === 'final' && !process.env.ADMIN_PASSWORD) + console.warn('WARNING: final mode with no ADMIN_PASSWORD set — admin is locked out.'); +}); diff --git a/server/state.js b/server/state.js new file mode 100644 index 0000000..01454c9 --- /dev/null +++ b/server/state.js @@ -0,0 +1,73 @@ +/* Seed/live persistence. + * data/scene.json — committed seed (survives in git) + * data/live/scene.live.json — runtime edits, volume-mounted in Docker, survives redeploys + */ +const fs = require('fs'); +const path = require('path'); + +const DATA = path.join(__dirname, '..', 'data'); +const LIVE_DIR = process.env.LIVE_DIR || path.join(DATA, 'live'); +const SEED = path.join(DATA, 'scene.json'); +const LIVE = path.join(LIVE_DIR, 'scene.live.json'); +const PLAYLIST_LIVE = path.join(LIVE_DIR, 'playlist.live.json'); +const MODELS_LIVE = path.join(LIVE_DIR, 'models.live.json'); +const MODELS_SEED = path.join(DATA, 'models.json'); + +fs.mkdirSync(LIVE_DIR, { recursive: true }); + +function readJSON(p, fallback) { + try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return fallback; } +} +function writeJSON(p, obj) { + fs.writeFileSync(p, JSON.stringify(obj, null, 2)); +} + +let scene = readJSON(LIVE, null) || readJSON(SEED, { anchors: [], buildings: [], spawns: [], world: { units: 'm' } }); +let ghosts = readJSON(path.join(DATA, 'ghosts.json'), []); +let gradients = readJSON(path.join(DATA, 'gradients.json'), {}); +let models = readJSON(MODELS_LIVE, null) || readJSON(MODELS_SEED, { models: [] }); + +const listeners = []; + +function validateScene(s) { + if (!s || typeof s !== 'object') throw new Error('scene must be object'); + for (const k of ['anchors', 'buildings', 'spawns']) { + if (!Array.isArray(s[k])) throw new Error(`scene.${k} must be array`); + } + for (const a of s.anchors) { + if (typeof a.markerId !== 'number') throw new Error('anchor.markerId required'); + if (!Array.isArray(a.position) || a.position.length !== 3) throw new Error('anchor.position [x,y,z] required'); + a.mount = a.mount || 'flat'; // 'flat' | 'wall' | 'custom' + a.yawDeg = Number(a.yawDeg || 0); + a.pitchDeg = Number(a.pitchDeg || 0); + a.rollDeg = Number(a.rollDeg || 0); + a.sizeMM = Number(a.sizeMM || 60); // printed marker size + a.enabled = a.enabled !== false; + } + return s; +} + +module.exports = { + getScene: () => scene, + putScene(s) { + scene = validateScene(s); + writeJSON(LIVE, scene); + listeners.forEach(f => f()); + return scene; + }, + resetToSeed() { + scene = readJSON(SEED, scene); + writeJSON(LIVE, scene); + listeners.forEach(f => f()); + return scene; + }, + getGhosts: () => ({ ghosts, gradients }), + getModels: () => models, + putModels(m) { + if (!m || !Array.isArray(m.models)) throw new Error('models.models must be array'); + models = m; writeJSON(MODELS_LIVE, models); return models; + }, + getPlaylistConfig: (fallback) => readJSON(PLAYLIST_LIVE, null) || readJSON(path.join(DATA, 'playlist.json'), fallback), + putPlaylistConfig(cfg) { writeJSON(PLAYLIST_LIVE, cfg); return cfg; }, + onChange: (f) => listeners.push(f), +}; diff --git a/upload-to-gitea.bat b/upload-to-gitea.bat new file mode 100644 index 0000000..aaf3efe --- /dev/null +++ b/upload-to-gitea.bat @@ -0,0 +1,14 @@ +@echo off +REM upload-to-gitea.bat — push F:\HiddenSide\newbury-exhibit-v2 to Gitea. +REM First run initialises the repo; later runs just commit + push everything. +cd /d F:\HiddenSide\newbury-exhibit-v2 + +if not exist .git ( + git init -b main + git remote add origin https://gitea.hideawaygaming.com.au/jessikitty/newbury-exhibit-v2.git +) + +git add -A +git commit -m "update %date% %time%" || echo Nothing to commit. +git push -u origin main +pause