Import v2 base (unchanged files)

This commit is contained in:
load-v3
2026-08-13 12:45:29 +00:00
parent 81f2168717
commit 290976a6cc
39 changed files with 9948 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# copy to .env and edit
EXHIBIT_MODE=dev
# required when EXHIBIT_MODE=final:
ADMIN_PASSWORD=change-me
# public port on the host (container always listens on 33044 internally)
HOST_PORT=33044
+3
View File
@@ -0,0 +1,3 @@
node_modules/
data/live/
.env
+2666
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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]
}
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"_note": "Multi-part OBJ ghost models. Drop OBJs into public/models/<id>/ and list parts here (legs|wisp, torso, head, headpiece). While 'models' is empty the exhibit uses the procedural wisp fallback.",
"models": []
}
+35
View File
@@ -0,0 +1,35 @@
{
"slots": 5,
"dwellSeconds": 120,
"crossfadeSeconds": 3,
"order": "weighted",
"include": {
"colors": [],
"rarities": [],
"ids": []
},
"behaviors": {
"staticChance": 0.5,
"wanderRadius": 60,
"wanderSpeed": 0.15,
"wanderVertical": 10,
"pathSpeed": 8,
"pathChance": 0.4
},
"timeWindows": [],
"rarity": {
"weights": {
"Common": 1.0,
"Rare": 0.5,
"Epic": 0.25,
"Legendary": 0.1
},
"movementScale": {
"Common": 1.0,
"Rare": 1.0,
"Epic": 0.7,
"Legendary": 0.4
}
},
"residents": []
}
+182
View File
@@ -0,0 +1,182 @@
{
"world": {
"units": "cm",
"note": "origin = front-left corner of the table; +X right, +Z toward the back, +Y up. All positions/sizes in centimetres; marker print size stays in mm (sizeMM)."
},
"anchors": [
{
"markerId": 0,
"position": [
20.0,
0.0,
20.0
],
"mount": "flat",
"yawDeg": 0,
"pitchDeg": 0,
"rollDeg": 0,
"sizeMM": 60,
"enabled": true
},
{
"markerId": 1,
"position": [
90.0,
0.0,
20.0
],
"mount": "flat",
"yawDeg": 0,
"pitchDeg": 0,
"rollDeg": 0,
"sizeMM": 60,
"enabled": true
},
{
"markerId": 2,
"position": [
20.0,
0.0,
90.0
],
"mount": "flat",
"yawDeg": 0,
"pitchDeg": 0,
"rollDeg": 0,
"sizeMM": 60,
"enabled": true
},
{
"markerId": 3,
"position": [
90.0,
0.0,
90.0
],
"mount": "flat",
"yawDeg": 0,
"pitchDeg": 0,
"rollDeg": 0,
"sizeMM": 60,
"enabled": true
},
{
"markerId": 4,
"position": [
55.0,
12.0,
0.0
],
"mount": "wall",
"yawDeg": 180,
"pitchDeg": 0,
"rollDeg": 0,
"sizeMM": 60,
"enabled": true
}
],
"buildings": [
{
"name": "Newbury High",
"position": [
55.0,
0.0,
55.0
],
"size": [
35.0,
30.0,
25.0
],
"yawDeg": 0
}
],
"spawns": [
{
"id": "spawn-1",
"position": [
35.0,
30.0,
40.0
],
"enabled": true
},
{
"id": "spawn-2",
"position": [
75.0,
35.0,
40.0
],
"enabled": true
},
{
"id": "spawn-3",
"position": [
55.0,
45.0,
70.0
],
"enabled": true
},
{
"id": "spawn-4",
"position": [
25.0,
25.0,
75.0
],
"enabled": true
},
{
"id": "spawn-5",
"position": [
85.0,
28.0,
75.0
],
"enabled": true
}
],
"paths": [
{
"id": "main-street",
"mode": "loop",
"enabled": true,
"points": [
[
20,
20,
35
],
[
90,
20,
35
],
[
95,
25,
75
],
[
55,
30,
95
],
[
15,
25,
70
]
]
}
],
"table": {
"width": 110,
"depth": 110,
"offsetX": 0,
"offsetZ": 0,
"show": true
}
}
+9
View File
@@ -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"
+850
View File
@@ -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
}
}
}
}
}
+46
View File
@@ -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 `<header>
<h1>Newbury Exhibit — Admin</h1>
${['preview', 'layout', 'plan', 'playlist', 'characters', 'landing', 'print', 'calibrate', 'errors'].map(p =>
`<a href="/admin/${p}.html" class="${p === active ? 'active' : ''}">${p}</a>`).join('')}
<a href="/">viewer ↗</a>
</header>`;
}
+92
View File
@@ -0,0 +1,92 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Calibrate — Newbury Exhibit</title>
<style>
html,body{margin:0;height:100%;overflow:hidden;background:#000;color:#fff;font-family:monospace}
#cam{position:fixed;inset:0;width:100%;height:100%;object-fit:cover;opacity:.85}
#ov{position:fixed;inset:0;pointer-events:none}
#out{position:fixed;top:0;left:0;right:0;background:rgba(0,0,0,.65);padding:8px 10px;font-size:12px;white-space:pre;line-height:1.5}
</style></head>
<body>
<video id="cam" playsinline muted></video>
<canvas id="ov"></canvas>
<div id="out">Starting camera…</div>
<script src="/vendor/cv.js"></script>
<script src="/vendor/svd.js"></script>
<script src="/vendor/posit1.js"></script>
<script src="/vendor/aruco.js"></script>
<script src="/vendor/dictionaries/aruco_4x4_1000.js"></script>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
} }
</script>
<script type="module">
import * as THREE from 'three';
import { createDetector, detectMarkers, quadArea } from '/js/ar/detect.js';
import { PoseEstimator } from '/js/ar/pose.js';
import { WorldFuser } from '/js/ar/fuse.js';
const scene = await (await fetch('/api/scene')).json();
const fuser = new WorldFuser(); fuser.setScene(scene);
const detector = createDetector();
const video = document.getElementById('cam');
const ov = document.getElementById('ov'); const octx = ov.getContext('2d');
const out = document.getElementById('out');
const cv2 = document.createElement('canvas'); const ctx = cv2.getContext('2d', { willReadFrequently: true });
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', width: { ideal: 1280 } }, audio: false });
video.srcObject = stream; await video.play();
let poseEst = null;
const jitterBuf = new Map(); // markerId -> recent positions
function loop() {
requestAnimationFrame(loop);
if (video.readyState < 2) return;
const W = 640, H = Math.round(W * video.videoHeight / video.videoWidth);
if (cv2.width !== W) { cv2.width = W; cv2.height = H; }
ov.width = innerWidth; ov.height = innerHeight;
ctx.drawImage(video, 0, 0, W, H);
const img = ctx.getImageData(0, 0, W, H);
if (!poseEst) poseEst = new PoseEstimator(W / (2 * Math.tan(30 * Math.PI / 180)));
const markers = detectMarkers(detector, img, fuser.knownIds());
const sx = innerWidth / W, sy = innerHeight / H;
octx.clearRect(0, 0, ov.width, ov.height);
const lines = [];
const estimates = [];
for (const m of markers) {
// outline
octx.strokeStyle = '#51eaf1'; octx.lineWidth = 3; octx.beginPath();
m.corners.forEach((c, i) => i ? octx.lineTo(c.x * sx, c.y * sy) : octx.moveTo(c.x * sx, c.y * sy));
octx.closePath(); octx.stroke();
const e = poseEst.estimate(m.id, m.corners, W / 2, H / 2, fuser.sizeFor(m.id));
if (!e) continue;
estimates.push({ markerId: m.id, position: e.position, quaternion: e.quaternion, area: quadArea(m.corners) });
const distMM = e.position.length();
const eul = new THREE.Euler().setFromQuaternion(e.quaternion, 'YXZ');
const buf = jitterBuf.get(m.id) || []; buf.push(e.position.clone()); if (buf.length > 30) buf.shift(); jitterBuf.set(m.id, buf);
const mean = buf.reduce((a, p) => a.add(p), new THREE.Vector3()).multiplyScalar(1 / buf.length);
const jit = Math.sqrt(buf.reduce((a, p) => a + p.distanceToSquared(mean), 0) / buf.length);
lines.push(`crest #${String(m.id).padStart(3)} dist ${(distMM / 10).toFixed(1)} cm` +
` yaw ${THREE.MathUtils.radToDeg(eul.y).toFixed(0).padStart(4)}°` +
` pitch ${THREE.MathUtils.radToDeg(eul.x).toFixed(0).padStart(4)}°` +
` jitter ${jit.toFixed(1)} mm err ${e.error.toFixed(1)}`);
}
const fused = fuser.fuse(estimates);
if (fused) lines.push('', `FUSED cam @ ${fused.position.toArray().map(v => v.toFixed(1)).join(', ')} cm (${fused.markerCount} crest${fused.markerCount === 1 ? '' : 's'})`);
out.textContent = lines.join('\n') || 'No crests in view — show a printed Newbury Crest to the camera.';
}
loop();
</script>
</body></html>
+556
View File
@@ -0,0 +1,556 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Characters — Newbury Exhibit</title></head>
<body>
<div id="app"></div>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
} }
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { TransformControls } from 'three/addons/controls/TransformControls.js';
import { buildGhost, PART_KEYS, normalizePart, mergePart } from '/js/ghosts/loader.js';
import { api, requireAuth, styles, nav } from './admin.js';
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
#wrap { display:grid; grid-template-columns: 240px 1fr 320px; height:calc(100vh - 44px); }
#list { background:#0d1226; overflow:auto; padding:8px; }
#list .gh { padding:6px 9px; border-radius:6px; cursor:pointer; font-size:.82rem; display:flex; gap:7px; align-items:center; }
#list .gh:hover { background:#1a2244; }
#list .gh.sel { background:#2a3a6e; }
#list .gh .dot { width:9px; height:9px; border-radius:50%; flex:none; }
#list .gh .cust { margin-left:auto; font-size:.65rem; color:#51eaf1; }
#list input.search { width:100%; box-sizing:border-box; margin-bottom:8px; }
#stage { position:relative; background:#0a0e1c; }
#gl { width:100%; height:100%; display:block; }
#side { background:#111730; padding:12px; overflow:auto; }
#side h2 { font-size:.9rem; margin:12px 0 6px; color:#8fb8ff; }
#side h2:first-child { margin-top:0; }
.row { display:flex; gap:8px; align-items:center; margin:5px 0; }
.row label { width:96px; font-size:.75rem; }
.row input, .row select { flex:1; min-width:0; }
.row input[type=color] { padding:1px; height:26px; }
.row input[type=range] { padding:0; }
.row.tri input { width:0; flex:1; text-align:center; }
.row.tri label { width:60px; }
.assets { display:flex; flex-wrap:wrap; gap:6px; margin:6px 0; }
.asset { background:#0d1226; border-radius:6px; padding:5px 8px; font-size:.72rem; display:flex; gap:6px; align-items:center; }
.asset img { width:26px; height:26px; object-fit:cover; border-radius:3px; }
.asset button { padding:1px 6px; font-size:.7rem; }
.badge { font-size:.7rem; opacity:.62; line-height:1.45; }
.drop { border:1px dashed #2a3a6e; border-radius:8px; padding:10px; text-align:center; font-size:.75rem; opacity:.8; }
.drop.over { border-color:#51eaf1; background:#141c3a; }
.parttabs { display:flex; flex-wrap:wrap; gap:4px; margin:6px 0; }
.parttab { padding:3px 9px; border-radius:6px; font-size:.72rem; cursor:pointer; background:#0d1226; border:1px solid #2a3a6e; }
.parttab.active { background:#2a3a6e; border-color:#51eaf1; color:#cfe6ff; }
.parttab.absent { opacity:.4; cursor:default; }
.parttab .m { color:#51eaf1; margin-left:4px; font-size:.62rem; }
.seg { display:flex; gap:0; margin:6px 0; border:1px solid #2a3a6e; border-radius:6px; overflow:hidden; }
.seg button { flex:1; border-radius:0; background:#0d1226; font-size:.72rem; padding:5px; }
.seg button.on { background:#2a3a6e; color:#51eaf1; }
.minibtn { padding:3px 8px; font-size:.7rem; }
#overlay { position:absolute; left:10px; bottom:10px; font-size:.72rem; background:rgba(6,8,15,.6); padding:6px 10px; border-radius:8px; max-width:60%; }
#gizhint { position:absolute; right:10px; top:10px; font-size:.72rem; background:rgba(6,8,15,.7); padding:6px 10px; border-radius:8px; display:none; }
</style>`);
await requireAuth();
const COLORS = { Red: '#ff2678', Yellow: '#fff35d', Blue: '#51eaf1' };
const app = document.getElementById('app');
app.innerHTML = `${nav('characters')}
<div id="wrap">
<div id="list"><input class="search" id="search" placeholder="Search ghosts…"><div id="ghosts"></div></div>
<div id="stage"><canvas id="gl"></canvas>
<div id="overlay">Live preview · drag to orbit · changes apply to all viewers on Save</div>
<div id="gizhint"></div></div>
<div id="side"></div>
</div>`;
// ---------- data ----------
const gdata = await api('/api/ghosts');
const ghosts = gdata.ghosts;
const gradients = gdata.gradients.gradients || gdata.gradients;
let manifest = await api('/api/models');
manifest.models ||= [];
let assets = await api('/api/assets');
let chars = await api('/api/characters');
chars.byId ||= {}; chars.defaults ||= {};
const scene = await api('/api/scene');
const baseHeight = scene.ghostHeightCm ?? 4;
let selId = ghosts[0]?.id;
let filter = '';
let activePart = null; // which part key the gizmo/fields target
let partScope = 'model'; // 'model' (shared default) | 'ghost' (this ghost's override)
let gizmoMode = 'translate'; // 'translate' | 'rotate'
let modelsDirty = false; // model manifest changed but not yet PUT
// ---------- preview ----------
const cvs = document.getElementById('gl');
const renderer = new THREE.WebGLRenderer({ canvas: cvs, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
const scn = new THREE.Scene();
scn.background = new THREE.Color(0x0a0e1c);
const cam = new THREE.PerspectiveCamera(45, 1, 0.1, 500);
cam.position.set(0, 4, 12);
scn.add(new THREE.AmbientLight(0xffffff, 1.2));
const dl = new THREE.DirectionalLight(0xffffff, 0.7); dl.position.set(4, 10, 8); scn.add(dl);
const orbit = new OrbitControls(cam, cvs);
orbit.target.set(0, 2.2, 0);
const grid = new THREE.GridHelper(20, 20, 0x2a3a6e, 0x151b36); scn.add(grid);
// Gizmo for direct part manipulation, attached to a part's node inside the preview.
const gizmo = new TransformControls(cam, cvs);
gizmo.setSize(0.7);
gizmo.addEventListener('dragging-changed', e => orbit.enabled = !e.value);
gizmo.addEventListener('objectChange', onGizmoMove);
scn.add(gizmo);
let previewObj = null;
let partNodes = {}; // partKey -> Object3D holding that part
async function refreshPreview(keepGizmo = false) {
const priorPart = activePart;
if (gizmo.object) gizmo.detach();
if (previewObj) { scn.remove(previewObj); previewObj = null; }
partNodes = {};
const g = ghosts.find(x => x.id === selId);
if (!g) return;
previewObj = await buildGhost(g, gradients, manifest, chars);
previewObj.userData.setHeight(baseHeight);
scn.add(previewObj);
previewObj.traverse(o => { if (o.userData && o.userData.partKey) partNodes[o.userData.partKey] = o; });
const h = chars.byId[selId]?.heightCm || baseHeight;
orbit.target.set(0, h / 2, 0);
if (!keepGizmo) cam.position.set(0, h * 0.75, h * 3);
if (keepGizmo && priorPart && partNodes[priorPart]) attachGizmo(priorPart);
}
function attachGizmo(key) {
const node = partNodes[key];
const hint = document.getElementById('gizhint');
if (!node) { gizmo.detach(); if (hint) hint.style.display = 'none'; return; }
gizmo.setMode(gizmoMode);
gizmo.attach(node);
if (hint) {
hint.style.display = 'block';
hint.textContent = `${key} · ${gizmoMode} · ${partScope === 'model' ? 'model default' : 'this ghost'}`;
}
}
// When the gizmo is dragged, read the node transform and persist it.
function onGizmoMove() {
if (!activePart) return;
const node = gizmo.object;
if (!node) return;
writePartTransform(activePart, {
offset: [round(node.position.x), round(node.position.y), round(node.position.z)],
rotation: [round(THREE.MathUtils.radToDeg(node.rotation.x)),
round(THREE.MathUtils.radToDeg(node.rotation.y)),
round(THREE.MathUtils.radToDeg(node.rotation.z))],
scale: round(node.scale.x),
});
syncPartFields();
}
const round = v => Math.round(v * 1000) / 1000;
function fit() {
renderer.setSize(cvs.clientWidth, cvs.clientHeight, false);
cam.aspect = cvs.clientWidth / cvs.clientHeight; cam.updateProjectionMatrix();
}
new ResizeObserver(fit).observe(cvs);
(function loop(t) {
requestAnimationFrame(loop);
// Don't auto-spin while placing a part — rotation makes the gizmo unusable.
if (previewObj) { previewObj.userData.tick(t / 1000); if (!activePart) previewObj.rotation.y += 0.004; }
orbit.update(); renderer.render(scn, cam);
})(0);
// ---------- ghost list ----------
function renderList() {
const q = filter.toLowerCase();
document.getElementById('ghosts').innerHTML = ghosts
.filter(g => !q || g.name.toLowerCase().includes(q))
.map(g => `<div class="gh ${g.id === selId ? 'sel' : ''}" data-id="${g.id}">
<span class="dot" style="background:${COLORS[g.color]}"></span>${g.name}
${chars.byId[g.id] ? '<span class="cust">●</span>' : ''}</div>`).join('');
document.querySelectorAll('.gh').forEach(el => el.onclick = async () => {
selId = el.dataset.id; activePart = null; gizmo.detach();
if (previewObj) previewObj.rotation.y = 0;
renderList(); renderSide(); await refreshPreview();
});
}
document.getElementById('search').oninput = e => { filter = e.target.value; renderList(); };
// ---------- override helpers ----------
const side = document.getElementById('side');
const ov = () => (chars.byId[selId] ||= {});
const cur = (k, dflt) => { const v = chars.byId[selId]?.[k]; return v != null ? v : (chars.defaults[k] != null ? chars.defaults[k] : dflt); };
function activeModel() {
const id = cur('modelId', '');
const models = manifest.models || [];
if (!models.length) return null;
return (id && models.find(m => m.id === id)) || models[0];
}
/* Effective transform for a part = model default, plus ghost override when that scope is active. */
function effectivePart(key) {
const model = activeModel();
if (!model || !model.parts || !model.parts[key]) return null;
const ghostOv = chars.byId[selId]?.partOverrides?.[key];
return mergePart(model.parts[key], partScope === 'ghost' ? ghostOv : null);
}
/* Persist a transform into whichever scope is active. */
function writePartTransform(key, tf) {
const model = activeModel();
if (!model) return;
if (partScope === 'model') {
const base = normalizePart(model.parts[key]) || { url: model.parts[key] };
model.parts[key] = { url: base.url, offset: tf.offset, rotation: tf.rotation, scale: tf.scale };
modelsDirty = true; setSaveDirty();
} else {
const o = ov();
o.partOverrides ||= {};
o.partOverrides[key] = { ...(o.partOverrides[key] || {}), ...tf };
}
}
// ---------- side panel ----------
function renderSide() {
const g = ghosts.find(x => x.id === selId);
if (!g) { side.innerHTML = ''; return; }
const grad = gradients[g.color] || gradients.Blue;
const models = (manifest.models || []);
const textures = assets.assets.filter(a => a.kind === 'texture');
const model = activeModel();
const hasParts = model && model.parts && Object.keys(model.parts).length;
side.innerHTML = `
<h2>${g.name}</h2>
<div class="badge">${g.rarity} · ${g.color} · id <code>${g.id}</code></div>
<h2>Model</h2>
<div class="row"><label>model</label>
<select id="modelId">
<option value="">— default (${models[0]?.id || 'procedural wisp'}) —</option>
${models.map(m => `<option value="${m.id}" ${cur('modelId', '') === m.id ? 'selected' : ''}>${m.id}</option>`).join('')}
</select></div>
<div class="row"><label>height (cm)</label>
<input type="number" step="0.5" min="0.5" id="heightCm" value="${cur('heightCm', baseHeight)}"></div>
<div class="row"><label>extra scale</label>
<input type="number" step="0.05" min="0.05" id="scale" value="${cur('scale', 1)}"></div>
${hasParts ? partPlacementSection(model, g) : `<div class="badge" style="margin-top:8px">
This ghost uses the ${model ? 'models single mesh' : 'procedural wisp'} — build a multi-part model (Assets box) to place parts individually.</div>`}
<h2>Appearance</h2>
<div class="row"><label>opacity</label>
<input type="range" min="0.1" max="1" step="0.02" id="opacity" value="${cur('opacity', 0.92)}">
<span id="opv" style="width:32px;font-size:.72rem">${(+cur('opacity', 0.92)).toFixed(2)}</span></div>
<div class="row"><label>top colour</label>
<input type="color" id="topColor" value="${cur('topColor', grad.top)}">
<button id="resetTop" class="minibtn">reset</button></div>
<div class="row"><label>bottom</label>
<input type="color" id="bottomColor" value="${cur('bottomColor', grad.bottom)}">
<button id="resetBottom" class="minibtn">reset</button></div>
<h2>Textures</h2>
<div class="badge">Front-projected decals — no UV mapping needed. Use PNGs with transparency.</div>
<div class="row"><label>face</label>
<select id="faceTextureUrl"><option value="">— none —</option>
${textures.map(a => `<option value="${a.url}" ${cur('faceTextureUrl', '') === a.url ? 'selected' : ''}>${a.name}</option>`).join('')}
</select></div>
<div class="row"><label>face Y</label><input type="range" min="0" max="1" step="0.01" id="faceY" value="${cur('faceY', 0.82)}"></div>
<div class="row"><label>face size</label><input type="range" min="0.04" max="0.5" step="0.01" id="faceSize" value="${cur('faceSize', 0.16)}"></div>
<div class="row"><label>torso</label>
<select id="torsoTextureUrl"><option value="">— none —</option>
${textures.map(a => `<option value="${a.url}" ${cur('torsoTextureUrl', '') === a.url ? 'selected' : ''}>${a.name}</option>`).join('')}
</select></div>
<div class="row"><label>torso Y</label><input type="range" min="0" max="1" step="0.01" id="torsoY" value="${cur('torsoY', 0.52)}"></div>
<div class="row"><label>torso size</label><input type="range" min="0.04" max="0.6" step="0.01" id="torsoSize" value="${cur('torsoSize', 0.22)}"></div>
<div class="row" style="margin-top:12px">
<button id="clearChar" class="danger">Reset this ghost</button>
<button id="applyAll">Apply to all</button>
</div>
<button id="save" class="primary" style="width:100%;padding:10px;margin-top:8px">Save characters${modelsDirty ? ' + model' : ''}</button>
<div id="status" class="badge" style="margin-top:6px"></div>`;
bindCommon();
if (hasParts) bindPartPlacement(model);
}
function partPlacementSection(model, g) {
const present = PART_KEYS.filter(k => model.parts[k]);
if (!activePart || !model.parts[activePart]) activePart = present[0] || null;
const p = activePart ? effectivePart(activePart) : null;
const ghostHasOv = !!(activePart && chars.byId[selId]?.partOverrides?.[activePart]);
return `
<h2>Part placement</h2>
<div class="seg" id="scopeSeg">
<button data-scope="model" class="${partScope === 'model' ? 'on' : ''}">Model default</button>
<button data-scope="ghost" class="${partScope === 'ghost' ? 'on' : ''}">This ghost${ghostHasOv ? ' ●' : ''}</button>
</div>
<div class="badge">${partScope === 'model'
? 'Editing the shared model — affects every ghost using it.'
: 'Editing only ' + g.name + ' — layered over the model default.'}</div>
<div class="parttabs" id="partTabs">
${PART_KEYS.map(k => `<span class="parttab ${k === activePart ? 'active' : ''} ${model.parts[k] ? '' : 'absent'}" data-part="${k}">
${k}${chars.byId[selId]?.partOverrides?.[k] ? '<span class="m">●</span>' : ''}</span>`).join('')}
</div>
${!activePart ? '<div class="badge">No parts in this model.</div>' : `
<div class="seg" id="modeSeg">
<button data-mode="translate" class="${gizmoMode === 'translate' ? 'on' : ''}">Move</button>
<button data-mode="rotate" class="${gizmoMode === 'rotate' ? 'on' : ''}">Rotate</button>
</div>
<div class="row tri"><label>offset</label>
<input type="number" step="0.05" id="pofx" value="${p.offset[0]}" title="X">
<input type="number" step="0.05" id="pofy" value="${p.offset[1]}" title="Y">
<input type="number" step="0.05" id="pofz" value="${p.offset[2]}" title="Z"></div>
<div class="row tri"><label>rotation°</label>
<input type="number" step="5" id="prox" value="${p.rotation[0]}" title="X">
<input type="number" step="5" id="proy" value="${p.rotation[1]}" title="Y">
<input type="number" step="5" id="proz" value="${p.rotation[2]}" title="Z"></div>
<div class="row"><label>part scale</label>
<input type="number" step="0.02" min="0.02" id="pscale" value="${p.scale}"></div>
<div class="row">
<button id="partReset" class="minibtn danger">Reset ${partScope === 'ghost' ? 'override' : 'part'}</button>
<span class="badge">drag the gizmo or type values</span>
</div>`}`;
}
function bindPartPlacement(model) {
document.querySelectorAll('#scopeSeg button').forEach(b => b.onclick = () => {
partScope = b.dataset.scope; renderSide();
if (activePart) attachGizmo(activePart);
});
document.querySelectorAll('#partTabs .parttab').forEach(el => el.onclick = () => {
const k = el.dataset.part;
if (!model.parts[k]) return;
activePart = k; renderSide(); attachGizmo(k);
});
document.querySelectorAll('#modeSeg button').forEach(b => b.onclick = () => {
gizmoMode = b.dataset.mode;
document.querySelectorAll('#modeSeg button').forEach(x => x.classList.toggle('on', x === b));
if (activePart) attachGizmo(activePart);
});
if (!activePart) return;
const readFields = () => ({
offset: [num('pofx'), num('pofy'), num('pofz')],
rotation: [num('prox'), num('proy'), num('proz')],
scale: num('pscale', 1),
});
const onField = async () => { writePartTransform(activePart, readFields()); await refreshPreview(true); };
['pofx','pofy','pofz','prox','proy','proz','pscale'].forEach(id => {
const el = document.getElementById(id); if (el) el.oninput = onField;
});
document.getElementById('partReset').onclick = async () => {
if (partScope === 'ghost') {
const po = chars.byId[selId]?.partOverrides;
if (po) { delete po[activePart]; if (!Object.keys(po).length) delete ov().partOverrides; }
if (!Object.keys(chars.byId[selId] || {}).length) delete chars.byId[selId];
} else {
const base = normalizePart(model.parts[activePart]);
if (base) { model.parts[activePart] = base.url; modelsDirty = true; setSaveDirty(); }
}
renderSide(); await refreshPreview(true);
};
if (partNodes[activePart]) attachGizmo(activePart);
}
const num = (id, dflt = 0) => { const v = parseFloat(document.getElementById(id)?.value); return Number.isFinite(v) ? v : dflt; };
function syncPartFields() {
if (!activePart) return;
const p = effectivePart(activePart);
if (!p) return;
const set = (id, v) => { const el = document.getElementById(id); if (el && document.activeElement !== el) el.value = v; };
set('pofx', p.offset[0]); set('pofy', p.offset[1]); set('pofz', p.offset[2]);
set('prox', p.rotation[0]); set('proy', p.rotation[1]); set('proz', p.rotation[2]);
set('pscale', p.scale);
}
function setSaveDirty() {
const s = document.getElementById('save');
if (s) s.textContent = 'Save characters + model';
}
function bindCommon() {
const bind = (id, key, transform = v => v) => {
const el = document.getElementById(id);
if (!el) return;
el.oninput = el.onchange = async () => {
const v = transform(el.value);
if (v === '' || v == null) delete ov()[key]; else ov()[key] = v;
if (id === 'opacity') document.getElementById('opv').textContent = (+v).toFixed(2);
if (!Object.keys(chars.byId[selId] || {}).length) delete chars.byId[selId];
if (id === 'modelId') { activePart = null; renderSide(); }
await refreshPreview(id !== 'modelId'); renderList();
};
};
bind('modelId', 'modelId');
bind('heightCm', 'heightCm', v => parseFloat(v) || baseHeight);
bind('scale', 'scale', v => parseFloat(v) || 1);
bind('opacity', 'opacity', v => parseFloat(v));
bind('topColor', 'topColor');
bind('bottomColor', 'bottomColor');
bind('faceTextureUrl', 'faceTextureUrl');
bind('torsoTextureUrl', 'torsoTextureUrl');
for (const k of ['faceY', 'faceSize', 'torsoY', 'torsoSize']) bind(k, k, v => parseFloat(v));
document.getElementById('resetTop').onclick = async () => { delete ov().topColor; await refreshPreview(true); renderSide(); };
document.getElementById('resetBottom').onclick = async () => { delete ov().bottomColor; await refreshPreview(true); renderSide(); };
document.getElementById('clearChar').onclick = async () => {
delete chars.byId[selId]; activePart = null; await refreshPreview(); renderList(); renderSide();
};
document.getElementById('applyAll').onclick = async () => {
const src = { ...(chars.byId[selId] || {}) };
delete src.faceTextureUrl; // per-ghost faces stay per-ghost
delete src.partOverrides; // per-ghost nudges stay per-ghost
if (!confirm('Apply this ghost\'s model/appearance settings as the default for every ghost?')) return;
chars.defaults = { ...chars.defaults, ...src };
renderList(); renderSide();
};
document.getElementById('save').onclick = onSave;
}
async function onSave() {
const st = document.getElementById('status');
try {
if (modelsDirty) { manifest = await api('/api/models', 'PUT', manifest); manifest.models ||= []; modelsDirty = false; }
chars = await api('/api/characters', 'PUT', chars); chars.byId ||= {}; chars.defaults ||= {};
st.textContent = 'Saved ' + new Date().toLocaleTimeString(); renderList(); renderSide();
} catch (e) { st.textContent = 'Save failed: ' + e.message; }
}
// ---------- asset manager ----------
function renderAssets() {
const models = assets.assets.filter(a => a.kind === 'model');
const textures = assets.assets.filter(a => a.kind === 'texture');
let box = document.getElementById('assetBox');
if (!box) {
box = document.createElement('div');
box.id = 'assetBox';
box.style.cssText = 'margin-top:12px;border-top:1px solid #1a2244;padding-top:10px';
document.getElementById('list').appendChild(box);
}
box.innerHTML = `
<h2 style="font-size:.85rem;color:#8fb8ff;margin:0 0 6px">Assets</h2>
<div class="drop" id="drop">Drop .obj / .png here<br><span style="opacity:.6">or</span>
<input type="file" id="file" multiple accept=".obj,.mtl,.png,.jpg,.jpeg,.webp" style="width:100%;margin-top:5px"></div>
<div class="badge" style="margin-top:6px">Models (${models.length})</div>
<div class="assets">${models.map(a => `<span class="asset">${a.name}
<button data-delasset="${a.id}" class="danger">✕</button></span>`).join('') || '<span class="badge">none</span>'}</div>
<div class="badge">Textures (${textures.length})</div>
<div class="assets">${textures.map(a => `<span class="asset"><img src="${a.url}">${a.name}
<button data-delasset="${a.id}" class="danger">✕</button></span>`).join('') || '<span class="badge">none</span>'}</div>
<button id="buildModel" style="width:100%;margin-top:6px">${manifest.models.length ? 'Build / edit model…' : 'Build model from parts…'}</button>`;
const drop = document.getElementById('drop');
drop.ondragover = e => { e.preventDefault(); drop.classList.add('over'); };
drop.ondragleave = () => drop.classList.remove('over');
drop.ondrop = e => { e.preventDefault(); drop.classList.remove('over'); upload([...e.dataTransfer.files]); };
document.getElementById('file').onchange = e => upload([...e.target.files]);
document.querySelectorAll('[data-delasset]').forEach(b => b.onclick = async () => {
if (!confirm('Delete this asset? Any character using it falls back to default.')) return;
await api('/api/assets/' + b.dataset.delasset, 'DELETE');
assets = await api('/api/assets'); renderAssets(); renderSide(); await refreshPreview();
});
document.getElementById('buildModel').onclick = () => openBuilder(activeModel()?.id || null);
}
async function upload(files) {
for (const f of files) {
const ext = f.name.toLowerCase().split('.').pop();
const kind = ['obj', 'mtl'].includes(ext) ? 'model' : 'texture';
const dataBase64 = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(String(r.result).split(',')[1]);
r.onerror = () => rej(new Error('read failed'));
r.readAsDataURL(f);
});
try { await api('/api/assets', 'POST', { name: f.name, kind, dataBase64 }); }
catch (e) { alert(`${f.name}: ${e.message}`); }
}
assets = await api('/api/assets');
renderAssets(); renderSide();
}
// ---------- inline model builder (replaces sequential prompts) ----------
let builderEl = null;
function openBuilder(editId = null) {
const objs = assets.assets.filter(a => a.kind === 'model' && a.name.toLowerCase().endsWith('.obj'));
if (!objs.length) return alert('Upload some .obj parts first (drop them in the Assets box).');
const existing = editId ? manifest.models.find(m => m.id === editId) : null;
if (builderEl) builderEl.remove();
builderEl = document.createElement('div');
builderEl.style.cssText = 'position:fixed;inset:0;background:rgba(4,6,14,.72);display:flex;align-items:center;justify-content:center;z-index:50';
const partsInit = existing ? existing.parts : {};
const urlToIdx = entry => { const u = typeof entry === 'string' ? entry : entry?.url; return objs.findIndex(o => o.url === u); };
builderEl.innerHTML = `<div style="background:#111730;border:1px solid #2a3a6e;border-radius:12px;padding:18px;width:420px;max-width:92vw;max-height:88vh;overflow:auto">
<h2 style="margin:0 0 4px;color:#8fb8ff;font-size:1rem">${existing ? 'Edit model' : 'Build model from parts'}</h2>
<div class="badge" style="margin-bottom:10px">Assign an uploaded .obj to each slot. Leave a slot as “— none —” to skip it. Fine placement is tuned afterward in the Part placement panel.</div>
<div class="row"><label>model id</label>
<input id="bmId" value="${existing ? existing.id : ''}" placeholder="e.g. minifig" ${existing ? 'readonly' : ''}></div>
${PART_KEYS.map(k => `<div class="row"><label>${k}</label>
<select data-slot="${k}"><option value="">— none —</option>
${objs.map((o, i) => `<option value="${i}" ${urlToIdx(partsInit[k]) === i ? 'selected' : ''}>${o.name}</option>`).join('')}
</select></div>`).join('')}
<div class="row"><label>base scale</label>
<input type="number" step="0.05" min="0.05" id="bmScale" value="${existing?.scale ?? 1}"></div>
<div class="row" style="justify-content:flex-end;margin-top:12px">
${existing ? `<button id="bmDelete" class="danger">Delete</button>` : ''}
<button id="bmCancel">Cancel</button>
<button id="bmSave" class="primary">${existing ? 'Update' : 'Create'}</button>
</div>
<div class="badge" id="bmStatus" style="margin-top:6px"></div>
</div>`;
document.body.appendChild(builderEl);
document.getElementById('bmCancel').onclick = () => { builderEl.remove(); builderEl = null; };
const del = document.getElementById('bmDelete');
if (del) del.onclick = async () => {
if (!confirm(`Delete model "${existing.id}"? Ghosts using it fall back to the first model / wisp.`)) return;
manifest.models = manifest.models.filter(m => m.id !== existing.id);
manifest = await api('/api/models', 'PUT', manifest); manifest.models ||= [];
builderEl.remove(); builderEl = null; renderAssets(); renderSide(); await refreshPreview();
};
document.getElementById('bmSave').onclick = async () => {
const status = document.getElementById('bmStatus');
const id = document.getElementById('bmId').value.trim();
if (!id) return status.textContent = 'Give the model an id.';
const newParts = {};
document.querySelectorAll('[data-slot]').forEach(sel => {
if (sel.value !== '' && objs[+sel.value]) {
const url = objs[+sel.value].url;
const prev = existing?.parts?.[sel.dataset.slot]; // keep placement if the file is unchanged
newParts[sel.dataset.slot] = (prev && normalizePart(prev)?.url === url) ? prev : url;
}
});
if (!Object.keys(newParts).length) return status.textContent = 'Pick at least one part.';
const scale = parseFloat(document.getElementById('bmScale').value) || 1;
manifest.models = manifest.models.filter(m => m.id !== id);
manifest.models.push({ id, scale, parts: newParts });
manifest = await api('/api/models', 'PUT', manifest); manifest.models ||= [];
ov().modelId = id;
if (!Object.keys(chars.byId[selId] || {}).length) delete chars.byId[selId];
builderEl.remove(); builderEl = null;
activePart = null; renderAssets(); renderList(); renderSide(); await refreshPreview();
};
}
renderList(); renderSide(); renderAssets(); fit(); await refreshPreview();
</script>
</body></html>
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Client Errors — Newbury Exhibit</title></head>
<body>
<div id="app"></div>
<script type="module">
import { api, requireAuth, styles, nav } from './admin.js';
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
main{max-width:900px;margin:16px auto;padding:0 16px}
.err{background:#111730;border-radius:8px;padding:10px 12px;margin:8px 0;font:12px monospace;white-space:pre-wrap}
.err .meta{opacity:.55;margin-bottom:4px}
</style>`);
await requireAuth();
const app = document.getElementById('app');
async function render() {
const errs = await api('/api/client-errors');
app.innerHTML = `${nav('errors')}<main>
<div style="display:flex;align-items:center;gap:12px;margin:12px 0">
<h2 style="margin:0;font-size:1rem">Client errors (${errs.length})</h2>
<button id="refresh">Refresh</button>
</div>
${errs.slice().reverse().map(e => `<div class="err">
<div class="meta">${new Date(e.t).toLocaleString()} · ${e.page || '?'} · ${(e.ua || '').slice(0, 80)}</div>
[${e.kind}] ${e.msg}${e.src ? ` (${e.src}:${e.line})` : ''}</div>`).join('') ||
'<em style="opacity:.6">No errors reported. Lovely.</em>'}
</main>`;
document.getElementById('refresh').onclick = render;
}
render();
</script>
</body></html>
+181
View File
@@ -0,0 +1,181 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Landing Page — Newbury Exhibit</title></head>
<body>
<div id="app"></div>
<script type="module">
import { api, requireAuth, styles, nav } from './admin.js';
import { renderMarkdown } from '/js/md.js';
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
#wrap { display:grid; grid-template-columns: 1fr 420px; height:calc(100vh - 44px); }
#form { padding:14px 18px; overflow:auto; }
#form h2 { font-size:.9rem; color:#8fb8ff; margin:16px 0 6px; }
#form h2:first-child { margin-top:0; }
.row { display:flex; gap:8px; align-items:center; margin:6px 0; }
.row label { width:120px; font-size:.78rem; }
.row input, .row select, .row textarea { flex:1; min-width:0; font-family:inherit; }
.row input[type=color] { padding:1px; height:28px; max-width:60px; }
textarea { min-height:190px; background:#141a33; color:#fff; border:1px solid #2a3a6e;
border-radius:5px; padding:8px; line-height:1.5; resize:vertical; }
.badge { font-size:.72rem; opacity:.62; line-height:1.5; }
.imgpick { display:flex; gap:8px; align-items:center; }
.imgpick img { width:44px; height:44px; object-fit:contain; background:#0d1226; border-radius:5px; }
#preview { background:#080b16; border-left:1px solid #1e2748; overflow:auto; padding:14px; }
.phone { width:340px; height:680px; margin:0 auto; border-radius:26px; border:8px solid #222a44;
overflow:hidden; position:relative; background:#06080f; }
.scr { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center;
justify-content:center; text-align:center; padding:22px; background-size:cover; background-position:center; }
.scr::before { content:''; position:absolute; inset:0; background:#06080f; }
.scr > * { position:relative; z-index:1; }
.scr img.logo { max-width:74%; max-height:30%; margin-bottom:14px; }
.scr h1 { font-size:1.6rem; margin:0 0 8px; letter-spacing:.05em; }
.scr p.sub { font-size:.85rem; opacity:.85; margin:0 0 22px; line-height:1.5; }
.scr .b1 { padding:11px 30px; border-radius:999px; border:0; font-weight:700; font-size:.95rem; }
.scr .b2 { margin-top:11px; padding:7px 18px; border-radius:999px; background:transparent;
border:1px solid rgba(255,255,255,.35); font-size:.8rem; }
.scr .disc { position:absolute; bottom:12px; left:14px; right:14px; font-size:.58rem; opacity:.55; line-height:1.35; z-index:1; }
.dscr { position:absolute; inset:0; overflow:auto; padding:18px; background:#080b16; }
.dscr h1 { font-size:1.25rem; margin:0 0 10px; }
.dscr p, .dscr li { font-size:.82rem; line-height:1.6; opacity:.9; }
.tabs { display:flex; gap:6px; justify-content:center; margin-bottom:10px; }
</style>`);
await requireAuth();
const app = document.getElementById('app');
let L = await api('/api/landing');
let assets = await api('/api/assets');
let tab = 'start';
const images = () => assets.assets.filter(a => a.kind === 'texture');
function render() {
app.innerHTML = `${nav('landing')}
<div id="wrap">
<div id="form">
<h2>Artwork</h2>
<div class="row"><label>logo</label>
<div class="imgpick" style="flex:1">
<img src="${L.logoUrl || ''}" onerror="this.style.visibility='hidden'">
<select id="logoUrl" style="flex:1"><option value="">— none (show text title) —</option>
${images().map(a => `<option value="${a.url}" ${L.logoUrl === a.url ? 'selected' : ''}>${a.name}</option>`).join('')}
</select></div></div>
<div class="row"><label>background</label>
<div class="imgpick" style="flex:1">
<img src="${L.backgroundUrl || ''}" onerror="this.style.visibility='hidden'">
<select id="backgroundUrl" style="flex:1"><option value="">— none —</option>
${images().map(a => `<option value="${a.url}" ${L.backgroundUrl === a.url ? 'selected' : ''}>${a.name}</option>`).join('')}
</select></div></div>
<div class="row"><label>upload</label><input type="file" id="file" accept=".png,.jpg,.jpeg,.webp" multiple></div>
<div class="badge">Uploads share the asset library with the character manager.</div>
<h2>Landing page</h2>
<div class="row"><label>title</label><input id="title" value="${esc(L.title)}"></div>
<div class="badge">Shown only when no logo is selected.</div>
<div class="row"><label>subtitle</label><input id="subtitle" value="${esc(L.subtitle)}"></div>
<div class="row"><label>start button</label><input id="startButton" value="${esc(L.startButton)}"></div>
<div class="row"><label>about button</label><input id="detailsButton" value="${esc(L.detailsButton)}"></div>
<h2>Details page</h2>
<div class="row"><label>heading</label><input id="detailsTitle" value="${esc(L.detailsTitle)}"></div>
<div class="row" style="align-items:flex-start"><label>body</label>
<textarea id="detailsMarkdown">${esc(L.detailsMarkdown)}</textarea></div>
<div class="badge">Markdown: <code>#</code> heading · <code>**bold**</code> · <code>*italic*</code> ·
<code>- list</code> · <code>[text](https://…)</code> · blank line = new paragraph.
Leave empty to hide the About button entirely.</div>
<h2>Theme</h2>
<div class="row"><label>accent</label><input type="color" id="accent" value="${L.accent}">
<label style="width:auto">text</label><input type="color" id="textColor" value="${L.textColor}"></div>
<div class="row"><label>bg dimming</label>
<input type="range" min="0" max="1" step="0.05" id="overlay" value="${L.overlay}">
<span class="badge" id="ovv">${L.overlay}</span></div>
<h2>Disclaimer</h2>
<div class="row" style="align-items:flex-start"><label>text</label>
<textarea id="disclaimer" style="min-height:80px">${esc(L.disclaimer)}</textarea></div>
<div class="badge">Always shown on both screens — wording is yours to edit, but it can't be removed.
If left blank it reverts to the default wording.</div>
<button id="save" class="primary" style="width:100%;padding:11px;margin:16px 0 6px">Save landing page</button>
<div id="status" class="badge"></div>
</div>
<div id="preview">
<div class="tabs">
<button data-tab="start" class="${tab === 'start' ? 'primary' : ''}">Landing</button>
<button data-tab="details" class="${tab === 'details' ? 'primary' : ''}">Details</button>
</div>
<div class="phone">${tab === 'start' ? startPreview() : detailsPreview()}</div>
<div class="badge" style="text-align:center;margin-top:8px">Live preview · approximate phone size</div>
</div>
</div>`;
wire();
}
function esc(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function startPreview() {
return `<div class="scr" style="background-image:${L.backgroundUrl ? `url('${L.backgroundUrl}')` : 'none'};color:${L.textColor}">
<style>.phone .scr::before{opacity:${L.overlay}}</style>
${L.logoUrl ? `<img class="logo" src="${L.logoUrl}">` : `<h1>${esc(L.title)}</h1>`}
<p class="sub">${esc(L.subtitle)}</p>
<button class="b1" style="background:linear-gradient(135deg,${L.accent},#529eff);color:#04121a">${esc(L.startButton)}</button>
${L.detailsMarkdown ? `<button class="b2" style="color:${L.textColor}">${esc(L.detailsButton)}</button>` : ''}
<div class="disc">${esc(L.disclaimer)}</div>
</div>`;
}
function detailsPreview() {
return `<div class="dscr" style="color:${L.textColor}">
<h1 style="color:${L.accent}">${esc(L.detailsTitle)}</h1>
${renderMarkdown(L.detailsMarkdown)}
<div class="disc" style="position:static;margin-top:24px;font-size:.6rem;opacity:.55">${esc(L.disclaimer)}</div>
</div>`;
}
function wire() {
const live = (id, key) => {
const el = document.getElementById(id);
if (!el) return;
el.oninput = () => {
L[key] = el.type === 'range' ? parseFloat(el.value) : el.value;
if (id === 'overlay') document.getElementById('ovv').textContent = L.overlay;
// repaint preview only — keeps focus/caret in the field being edited
document.querySelector('.phone').innerHTML = tab === 'start' ? startPreview() : detailsPreview();
};
el.onchange = () => { if (['logoUrl', 'backgroundUrl'].includes(id)) render(); };
};
['title', 'subtitle', 'startButton', 'detailsButton', 'detailsTitle', 'detailsMarkdown',
'disclaimer', 'accent', 'textColor', 'overlay', 'logoUrl', 'backgroundUrl'].forEach(k => live(k, k));
document.querySelectorAll('[data-tab]').forEach(b => b.onclick = () => { tab = b.dataset.tab; render(); });
document.getElementById('file').onchange = async e => {
for (const f of [...e.target.files]) {
const dataBase64 = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(String(r.result).split(',')[1]);
r.onerror = () => rej(new Error('read failed'));
r.readAsDataURL(f);
});
try { await api('/api/assets', 'POST', { name: f.name, kind: 'texture', dataBase64 }); }
catch (err) { alert(`${f.name}: ${err.message}`); }
}
assets = await api('/api/assets');
render();
};
document.getElementById('save').onclick = async () => {
const st = document.getElementById('status');
try { L = await api('/api/landing', 'PUT', L); render();
document.getElementById('status').textContent = 'Saved ' + new Date().toLocaleTimeString() +
' — reload the viewer to see it.';
} catch (e) { st.textContent = 'Save failed: ' + e.message; }
};
}
render();
</script>
</body></html>
+611
View File
@@ -0,0 +1,611 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Layout Editor (3D) — Newbury Exhibit</title></head>
<body>
<div id="app"></div>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
} }
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { TransformControls } from 'three/addons/controls/TransformControls.js';
import { anchorWorldMatrix } from '/js/ar/pose.js';
import { SET_FOOTPRINTS, footprintLabel, footprintToBuilding } from '/js/set-footprints.js';
import { api, requireAuth, styles, nav } from './admin.js';
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
#wrap { display:grid; grid-template-columns: 1fr 300px; height: calc(100vh - 88px); }
#view { position:relative; }
#gl { width:100%; height:100%; display:block; touch-action:none; }
#side { background:#111730; padding:12px; overflow:auto; }
#side h2 { font-size:.9rem; margin:14px 0 6px; color:#8fb8ff; }
.row { display:flex; gap:6px; align-items:center; margin:4px 0; }
.row label { width:70px; }
.row input, .row select { flex:1; min-width:0; }
.tools { display:flex; gap:6px; padding:8px 12px; background:#0d1226; flex-wrap:wrap; align-items:center; }
.badge { font-size:.7rem; opacity:.6; }
#legend { position:absolute; left:10px; bottom:10px; font-size:.72rem; background:rgba(6,8,15,.6);
padding:6px 10px; border-radius:8px; line-height:1.5; pointer-events:none; }
.modal { position:fixed; inset:0; background:rgba(4,6,12,.7); display:none; z-index:50;
align-items:center; justify-content:center; }
.modal.open { display:flex; }
.modalCard { background:#111730; border:1px solid #2a3a6e; border-radius:10px; padding:16px;
width:min(420px,92vw); max-height:80vh; display:flex; flex-direction:column; }
.modalCard h2 { margin:0 0 4px; font-size:1rem; color:#8fb8ff; }
.modalCard .hint { font-size:.72rem; opacity:.6; margin:0 0 10px; }
.modalList { overflow:auto; display:flex; flex-direction:column; gap:4px; }
#setList button { text-align:left; background:#141a33; }
#setList button:hover { background:#1c2650; }
.modalCard .close { margin-top:12px; align-self:flex-end; }
.layoutRow { display:flex; gap:6px; align-items:center; background:#141a33; border-radius:6px; padding:4px 6px; }
.layoutRow .nm { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.layoutRow .active { color:#51eaf1; font-size:.7rem; }
.layoutRow button { padding:4px 10px; }
#layoutSaveRow { display:flex; gap:6px; margin:10px 0 4px; }
#layoutSaveRow input { flex:1; min-width:0; }
</style>`);
await requireAuth();
const app = document.getElementById('app');
app.innerHTML = `${nav('layout')}
<div class="tools">
<button data-add="anchor">+ Anchor</button>
<button data-add="building">+ Building</button>
<button id="importSet">Import set…</button>
<button data-add="spawn">+ Spawn</button>
<button data-add="path">+ Path</button>
<button id="tableBtn">Table…</button>
<button id="layoutsBtn">Layouts…</button>
<button id="topView">Top view</button>
<button onclick="location.href='/admin/plan.html'">Print plan ↗</button>
<span style="flex:1"></span>
<button id="reset" class="danger">Reset to seed</button>
<button id="save" class="primary">Save layout</button>
<span id="status" class="badge"></span>
</div>
<div id="wrap">
<div id="view">
<canvas id="gl"></canvas>
<div id="legend"><b>N = +Z (back of table)</b> · grid 25 cm · drag gizmo to move (0.5 cm snap)<br>
flat crest arrow = top edge of print · wall crest arrow = direction the print faces<br>
purple = ghost paths: drag the spheres, cone shows walk direction</div>
</div>
<div id="side"></div>
</div>
<div id="setModal" class="modal"><div class="modalCard">
<h2>Import Hidden Side set</h2>
<p class="hint">Adds a to-scale footprint (a building box) at the view centre. Drag it into place,
then set its height/yaw in the panel. Footprints in cm; not affiliated with the LEGO Group.</p>
<div id="setList" class="modalList"></div>
<button class="close">Close</button>
</div></div>
<div id="layoutsModal" class="modal"><div class="modalCard">
<h2>Layouts</h2>
<p class="hint">Each layout stores this scene <b>and</b> its playlist. Save the current arrangement
under a name, then load any saved layout to switch the live exhibit (all viewers update).
Save your current edits first — loading replaces the live scene.</p>
<div id="layoutSaveRow">
<input id="layoutName" placeholder="Layout name (e.g. Small demo)">
<button id="layoutSave" class="primary">Save current</button>
</div>
<div id="layoutList" class="modalList"></div>
<button class="close">Close</button>
</div></div>`;
let scene = await api('/api/scene');
scene.anchors ||= []; scene.buildings ||= []; scene.spawns ||= []; scene.paths ||= [];
scene.table = Object.assign({ width: 120, depth: 100, offsetX: 0, offsetZ: 0, show: true }, scene.table || {});
// Active layout slug drives spawn/path ID prefixes (e.g. small-demo -> "sd-sp1").
// Fetched here and refreshed whenever a layout is saved or loaded.
let activeLayoutSlug = null;
try { activeLayoutSlug = (await api('/api/layouts')).activeSlug || null; } catch { activeLayoutSlug = null; }
// Short prefix from a slug: initials of its words. "small-demo" -> "sd",
// "full-build" -> "fb". Null slug (unsaved scene) -> null (use legacy names).
function layoutPrefix() {
if (!activeLayoutSlug) return null;
const initials = activeLayoutSlug.split('-').filter(Boolean).map(w => w[0]).join('');
return initials || activeLayoutSlug.slice(0, 3);
}
// Next free "<prefix>-sp<n>" (or "spawn-<n>" when no layout is active),
// scanning existing spawn IDs so numbers don't collide after deletes.
function nextSpawnId() {
const p = layoutPrefix();
const base = p ? `${p}-sp` : 'spawn-';
const used = new Set(scene.spawns.map(s => s.id));
let n = 1; while (used.has(base + n)) n++;
return base + n;
}
// ---------- three.js setup ----------
const cvs = document.getElementById('gl');
const renderer = new THREE.WebGLRenderer({ canvas: cvs, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
const scn = new THREE.Scene();
scn.background = new THREE.Color(0x0d1226);
const cam = new THREE.PerspectiveCamera(55, 1, 1, 20000);
cam.position.set(90, 120, -110);
scn.add(new THREE.AmbientLight(0xffffff, 0.9));
const dl = new THREE.DirectionalLight(0xffffff, 0.9); dl.position.set(100, 250, -100); scn.add(dl);
const orbit = new OrbitControls(cam, cvs);
orbit.target.set(55, 0, 55);
const gizmo = new TransformControls(cam, cvs);
gizmo.setTranslationSnap(0.5); // 0.5 cm
gizmo.setSize(0.8);
gizmo.addEventListener('dragging-changed', e => orbit.enabled = !e.value);
gizmo.addEventListener('objectChange', onGizmoMove);
scn.add(gizmo);
// grid + axes + compass
scn.add(new THREE.GridHelper(400, 16, 0x2a3a6e, 0x1a2244)); // 400 cm, 25 cm cells
function textSprite(txt, color = '#e8ecff', size = 26) {
const c = document.createElement('canvas'); c.width = 128; c.height = 64;
const x = c.getContext('2d'); x.font = `bold ${size * 2}px system-ui`; x.fillStyle = color;
x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText(txt, 64, 32);
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c), transparent: true, depthTest: false }));
sp.scale.set(24, 12, 1); return sp;
}
const compass = new THREE.Group();
compass.add(new THREE.ArrowHelper(new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0.5, 0), 40, 0x51eaf1, 10, 6));
const nL = textSprite('N', '#51eaf1'); nL.position.set(0, 4, 52); compass.add(nL);
const sL = textSprite('S', '#8899bb'); sL.position.set(0, 4, -52); compass.add(sL);
const eL = textSprite('E', '#8899bb'); eL.position.set(52, 4, 0); compass.add(eL);
const wL = textSprite('W', '#8899bb'); wL.position.set(-52, 4, 0); compass.add(wL);
scn.add(compass);
// ---------- object meshes ----------
const objRoot = new THREE.Group(); scn.add(objRoot);
let sel = null; // { kind, index, obj3d }
const matAnchorFlat = new THREE.MeshStandardMaterial({ color: 0x3bc9a7, side: THREE.DoubleSide });
const matAnchorWall = new THREE.MeshStandardMaterial({ color: 0xf65151, side: THREE.DoubleSide });
const matBuilding = new THREE.MeshStandardMaterial({ color: 0x529eff, transparent: true, opacity: 0.35 });
const matSpawn = new THREE.MeshStandardMaterial({ color: 0xb57f0b });
const matPath = new THREE.LineBasicMaterial({ color: 0xc77dff });
const matPathPt = new THREE.MeshStandardMaterial({ color: 0xc77dff });
const tableGroup = new THREE.Group(); scn.add(tableGroup);
function buildTable() {
tableGroup.clear();
const t = scene.table;
if (!t || t.show === false) return;
const x0 = t.offsetX || 0, z0 = t.offsetZ || 0, w = t.width, d = t.depth;
// surface tint
const top = new THREE.Mesh(new THREE.PlaneGeometry(w, d),
new THREE.MeshBasicMaterial({ color: 0xffb84d, transparent: true, opacity: 0.07, side: THREE.DoubleSide, depthWrite: false }));
top.rotation.x = -Math.PI / 2;
top.position.set(x0 + w / 2, -0.2, z0 + d / 2);
tableGroup.add(top);
// edge outline
const pts = [[x0, z0], [x0 + w, z0], [x0 + w, z0 + d], [x0, z0 + d], [x0, z0]]
.map(([x, z]) => new THREE.Vector3(x, 0.3, z));
tableGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts),
new THREE.LineBasicMaterial({ color: 0xffb84d })));
// corner posts
for (const [x, z] of [[x0, z0], [x0 + w, z0], [x0 + w, z0 + d], [x0, z0 + d]]) {
const c = new THREE.Mesh(new THREE.CylinderGeometry(1, 1, 3, 8),
new THREE.MeshBasicMaterial({ color: 0xffb84d }));
c.position.set(x, 1.5, z);
tableGroup.add(c);
}
// dimension labels: width along the front (S) edge, depth along the left (W) edge
const wl = textSprite(`${w} cm`, '#ffb84d'); wl.position.set(x0 + w / 2, 5, z0 - 7); tableGroup.add(wl);
const dl2 = textSprite(`${d} cm`, '#ffb84d'); dl2.position.set(x0 - 9, 5, z0 + d / 2); tableGroup.add(dl2);
}
function buildAll() {
gizmo.detach();
buildTable();
objRoot.clear();
scene.anchors.forEach((a, i) => {
const size = (a.sizeMM || 60) / 10; // cm
const g = new THREE.Group();
const plane = new THREE.Mesh(new THREE.PlaneGeometry(size, size),
(a.mount === 'wall' ? matAnchorWall : matAnchorFlat).clone());
plane.material.opacity = a.enabled === false ? 0.25 : 1;
plane.material.transparent = a.enabled === false;
g.add(plane);
// orientation arrow: marker local +Y = top edge of the print
g.add(new THREE.ArrowHelper(new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 0.2), size * 1.1, 0xffffff, size * 0.35, size * 0.2));
const idL = textSprite('#' + a.markerId, '#a6fff0', 20); idL.position.set(0, 0, 3); idL.scale.set(14, 7, 1); g.add(idL);
// pose from the SAME transform the AR pipeline uses
anchorWorldMatrix(a).decompose(g.position, g.quaternion, g.scale);
g.userData = { kind: 'anchor', index: i };
objRoot.add(g);
});
scene.buildings.forEach((b, i) => {
const m = new THREE.Mesh(new THREE.BoxGeometry(...b.size), matBuilding.clone());
m.position.set(b.position[0], b.position[1] + b.size[1] / 2, b.position[2]);
m.rotation.y = THREE.MathUtils.degToRad(b.yawDeg || 0);
m.userData = { kind: 'building', index: i };
objRoot.add(m);
});
scene.spawns.forEach((s, i) => {
const m = new THREE.Mesh(new THREE.SphereGeometry(4, 16, 12), matSpawn.clone());
if (s.enabled === false) { m.material.transparent = true; m.material.opacity = 0.3; }
m.position.set(...s.position);
m.userData = { kind: 'spawn', index: i };
// drop line to the ground so height is readable
const line = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, -s.position[1], 0)]),
new THREE.LineDashedMaterial({ color: 0xb57f0b, dashSize: 2, gapSize: 2 }));
line.computeLineDistances(); m.add(line);
objRoot.add(m);
});
scene.paths.forEach((pth, i) => {
const pts = (pth.points || []).map(q => new THREE.Vector3(...q));
if (pts.length >= 2) {
const lp = pth.mode === 'loop' ? [...pts, pts[0]] : pts;
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(lp), matPath.clone());
if (pth.enabled === false) { line.material.transparent = true; line.material.opacity = 0.3; }
objRoot.add(line);
const dir = lp[1].clone().sub(lp[0]);
if (dir.lengthSq() > 1e-4) {
const cone = new THREE.Mesh(new THREE.ConeGeometry(2.2, 6, 10), matPathPt);
cone.position.copy(lp[0]).addScaledVector(dir, 0.5);
cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.clone().normalize());
objRoot.add(cone);
}
}
pts.forEach((q, wi) => {
const h = new THREE.Mesh(new THREE.SphereGeometry(3, 14, 10), matPathPt.clone());
h.position.copy(q);
h.userData = { kind: 'pathpoint', index: i, sub: wi };
const drop = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, -q.y, 0)]),
new THREE.LineDashedMaterial({ color: 0xc77dff, dashSize: 2, gapSize: 2 }));
drop.computeLineDistances(); h.add(drop);
objRoot.add(h);
});
});
reselect();
}
function dataOf(s) {
if (s.kind === 'table') return scene.table;
if (s.kind === 'pathpoint') return scene.paths[s.index];
return ({ anchor: scene.anchors, building: scene.buildings, spawn: scene.spawns })[s.kind][s.index];
}
function reselect() {
if (!sel) return;
if (sel.kind === 'table') return;
const o = objRoot.children.find(c => c.userData.kind === sel.kind && c.userData.index === sel.index && (sel.sub === undefined || c.userData.sub === sel.sub));
if (o) { sel.obj3d = o; gizmo.attach(o); } else { sel = null; gizmo.detach(); }
}
// ---------- selection ----------
const ray = new THREE.Raycaster(); const ptr = new THREE.Vector2();
let downAt = null;
cvs.addEventListener('pointerdown', e => downAt = [e.clientX, e.clientY]);
cvs.addEventListener('pointerup', e => {
if (!downAt || Math.hypot(e.clientX - downAt[0], e.clientY - downAt[1]) > 6) return; // it was a drag
if (gizmo.dragging) return;
const r = cvs.getBoundingClientRect();
ptr.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
ray.setFromCamera(ptr, cam);
const hits = ray.intersectObjects(objRoot.children, true);
let top = hits.find(h => { let o = h.object; while (o && !o.userData.kind) o = o.parent; return o && o.userData.kind; });
if (top) {
let o = top.object; while (!o.userData.kind) o = o.parent;
sel = { kind: o.userData.kind, index: o.userData.index, sub: o.userData.sub, obj3d: o };
gizmo.attach(o);
} else { sel = null; gizmo.detach(); }
panel();
});
function onGizmoMove() {
if (!sel) return;
const d = dataOf(sel);
const p = sel.obj3d.position;
if (sel.kind === 'pathpoint') d.points[sel.sub] = [+p.x.toFixed(1), +p.y.toFixed(1), +p.z.toFixed(1)];
else if (sel.kind === 'building') d.position = [+p.x.toFixed(1), +(p.y - d.size[1] / 2).toFixed(1), +p.z.toFixed(1)];
else d.position = [+p.x.toFixed(1), +p.y.toFixed(1), +p.z.toFixed(1)];
panel(false);
}
// path lines follow their points only after the drag ends (cheap + smooth)
gizmo.addEventListener('mouseUp', () => { if (sel && sel.kind === 'pathpoint') buildAll(); });
// ---------- add / delete ----------
document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => {
const t = orbit.target;
if (b.dataset.add === 'anchor') {
const used = new Set(scene.anchors.map(a => a.markerId));
let id = 0; while (used.has(id)) id++;
scene.anchors.push({ markerId: id, position: [t.x, 0, t.z], mount: 'flat', yawDeg: 0, pitchDeg: 0, rollDeg: 0, sizeMM: 60, enabled: true });
sel = { kind: 'anchor', index: scene.anchors.length - 1 };
} else if (b.dataset.add === 'building') {
scene.buildings.push({ name: 'building', position: [t.x, 0, t.z], size: [40, 30, 30], yawDeg: 0 });
sel = { kind: 'building', index: scene.buildings.length - 1 };
} else if (b.dataset.add === 'path') {
scene.paths.push({ id: 'path-' + (scene.paths.length + 1), mode: 'loop', enabled: true,
points: [[t.x - 25, 25, t.z], [t.x + 25, 25, t.z], [t.x, 25, t.z + 30]] });
sel = { kind: 'pathpoint', index: scene.paths.length - 1, sub: 0 };
} else {
scene.spawns.push({ id: nextSpawnId(), position: [t.x, 25, t.z], enabled: true });
sel = { kind: 'spawn', index: scene.spawns.length - 1 };
}
buildAll(); panel();
});
// ---------- import set footprint ----------
const setModal = document.getElementById('setModal');
const setList = document.getElementById('setList');
setList.innerHTML = SET_FOOTPRINTS
.map((fp, i) => `<button data-fp="${i}">${footprintLabel(fp)}</button>`).join('');
document.getElementById('importSet').onclick = () => setModal.classList.add('open');
setModal.querySelector('.close').onclick = () => setModal.classList.remove('open');
setModal.onclick = e => { if (e.target === setModal) setModal.classList.remove('open'); };
setList.querySelectorAll('[data-fp]').forEach(b => b.onclick = () => {
const fp = SET_FOOTPRINTS[+b.dataset.fp];
const t = orbit.target;
scene.buildings.push(footprintToBuilding(fp, [t.x, 0, t.z]));
sel = { kind: 'building', index: scene.buildings.length - 1 };
setModal.classList.remove('open');
buildAll(); panel();
});
// ---------- layouts (save / load / delete) ----------
const layoutsModal = document.getElementById('layoutsModal');
const layoutList = document.getElementById('layoutList');
async function refreshLayouts() {
let data;
try { data = await api('/api/layouts'); }
catch { layoutList.innerHTML = '<em style="opacity:.6">Could not load layouts.</em>'; return; }
const { layouts, activeSlug } = data;
if (!layouts.length) { layoutList.innerHTML = '<em style="opacity:.6">No saved layouts yet. Name one above and Save current.</em>'; return; }
layoutList.innerHTML = layouts.map(l => `<div class="layoutRow" data-slug="${l.slug}">
<span class="nm">${l.name}${l.slug === activeSlug ? ' <span class="active">● active</span>' : ''}</span>
<button data-act="load">Load</button>
<button data-act="del" class="danger">✕</button>
</div>`).join('');
layoutList.querySelectorAll('.layoutRow').forEach(row => {
const slug = row.dataset.slug;
row.querySelector('[data-act="load"]').onclick = async () => {
if (!confirm('Load this layout? The current live scene will be replaced (save it first if unsaved).')) return;
try {
const r = await api('/api/layouts/' + slug + '/load', 'POST');
scene = r.scene;
scene.anchors ||= []; scene.buildings ||= []; scene.spawns ||= []; scene.paths ||= [];
scene.table = Object.assign({ width: 120, depth: 100, offsetX: 0, offsetZ: 0, show: true }, scene.table || {});
activeLayoutSlug = (r.layouts && r.layouts.activeSlug) || slug;
sel = null; gizmo.detach(); buildAll(); panel();
status.textContent = 'Loaded layout';
refreshLayouts();
} catch (e) { alert('Load failed: ' + e.message); }
};
row.querySelector('[data-act="del"]').onclick = async () => {
if (!confirm('Delete this saved layout? (Does not affect the live scene.)')) return;
try { await api('/api/layouts/' + slug, 'DELETE'); refreshLayouts(); }
catch (e) { alert('Delete failed: ' + e.message); }
};
});
}
document.getElementById('layoutsBtn').onclick = () => { layoutsModal.classList.add('open'); refreshLayouts(); };
layoutsModal.querySelector('.close').onclick = () => layoutsModal.classList.remove('open');
layoutsModal.onclick = e => { if (e.target === layoutsModal) layoutsModal.classList.remove('open'); };
document.getElementById('layoutSave').onclick = async () => {
const name = document.getElementById('layoutName').value.trim();
if (!name) return alert('Give the layout a name first.');
try {
// persist current edits into the live scene, then snapshot as a layout
scene = await api('/api/scene', 'PUT', scene);
const saved = await api('/api/layouts', 'POST', { name });
activeLayoutSlug = saved.activeSlug || activeLayoutSlug;
document.getElementById('layoutName').value = '';
status.textContent = 'Saved layout "' + name + '"';
refreshLayouts();
} catch (e) { alert('Save failed: ' + e.message); }
};
// ---------- spawn ID rename (keeps playlist references in sync) ----------
// Given a { oldId: newId } map, rewrite scene.spawns, then load the playlist
// config, remap track.spawnPoint (bare spawn refs only) and resident.spawnId,
// and save both. Persists immediately so live viewers stay consistent.
async function applySpawnRename(map) {
const changed = Object.keys(map).filter(k => map[k] && map[k] !== k);
if (!changed.length) return;
for (const s of scene.spawns) if (map[s.id]) s.id = map[s.id];
scene = await api('/api/scene', 'PUT', scene);
try {
const cfg = await api('/api/playlist');
let touched = false;
for (const t of cfg.tracks || []) {
// spawnPoint is 'random', a bare spawn id, or 'path:<id>' — only remap bare spawn ids
if (t.spawnPoint && !String(t.spawnPoint).startsWith('path:') && map[t.spawnPoint]) {
t.spawnPoint = map[t.spawnPoint]; touched = true;
}
}
for (const r of cfg.residents || []) {
if (r.spawnId && map[r.spawnId]) { r.spawnId = map[r.spawnId]; touched = true; }
}
if (touched) await api('/api/playlist', 'PUT', cfg);
} catch (e) { console.warn('playlist remap skipped:', e.message); }
buildAll();
}
// Renumber every spawn to the active layout's prefix: sp-prefix-1, -2, … in
// current array order. Rewrites playlist refs via applySpawnRename.
async function renumberSpawns() {
if (!scene.spawns.length) return alert('No spawns to renumber.');
const p = layoutPrefix();
const base = p ? `${p}-sp` : 'spawn-';
const preview = scene.spawns.map((s, i) => `${s.id}${base}${i + 1}`).join('\n');
if (!confirm(`Renumber ${scene.spawns.length} spawn(s) to "${base}N"?\n\n${preview}\n\nPlaylist references will be updated too.`)) return;
const map = {}; scene.spawns.forEach((s, i) => { map[s.id] = `${base}${i + 1}`; });
try { await applySpawnRename(map); status.textContent = 'Renumbered spawns'; panel(); }
catch (e) { alert('Renumber failed: ' + e.message); }
}
// ---------- property panel ----------
const side = document.getElementById('side');
function field(label, val, oncommit, opts) {
const id = 'f' + Math.random().toString(36).slice(2, 8);
setTimeout(() => {
const el = document.getElementById(id);
el.onchange = () => { oncommit(opts?.select ? el.value : parseFloat(el.value)); buildAll(); panel(false); };
});
if (opts?.select)
return `<div class="row"><label>${label}</label><select id="${id}">${opts.select.map(o => `<option ${o === val ? 'selected' : ''}>${o}</option>`).join('')}</select></div>`;
return `<div class="row"><label>${label}</label><input id="${id}" type="number" step="${opts?.step ?? 0.5}" value="${val}"></div>`;
}
function textField(label, val, oncommit) {
const id = 'f' + Math.random().toString(36).slice(2, 8);
setTimeout(() => { const el = document.getElementById(id); el.onchange = () => { oncommit(el.value); buildAll(); panel(false); }; });
return `<div class="row"><label>${label}</label><input id="${id}" value="${val}"></div>`;
}
const bearing = a => a.mount === 'wall'
? `${((a.yawDeg % 360) + 360) % 360}°`
: `${(((180 + a.yawDeg) % 360) + 360) % 360}°`;
function panel(rebuild = true) {
if (!rebuild) return;
const o = sel && dataOf(sel);
if (!o) { side.innerHTML = '<em style="opacity:.6">Tap an object to select it, then drag the gizmo arrows (X red, Y green = height, Z blue). All values in cm.</em>'; return; }
if (sel.kind === 'table') {
let h = `<h2>table &amp; scale</h2>`;
h += field('width (cm)', o.width, v => o.width = v, { step: 1 });
h += field('depth (cm)', o.depth, v => o.depth = v, { step: 1 });
h += field('offset x', o.offsetX || 0, v => o.offsetX = v, { step: 1 });
h += field('offset z', o.offsetZ || 0, v => o.offsetZ = v, { step: 1 });
h += field('show', o.show === false ? 'no' : 'yes', v => o.show = v === 'yes', { select: ['yes', 'no'] });
h += field('ghost height (cm)', scene.ghostHeightCm ?? 4, v => scene.ghostHeightCm = v, { step: 0.5 });
h += `<p class="badge">Ghost height scales every ghost (wisp or OBJ model) to that many cm tall —
minifigure ≈ 4 cm. Models auto-normalize by bounding box, so imported OBJs land at this size
regardless of their source units; per-model fine-tune lives in the models manifest. Applies
live to all viewers on Save.</p>`;
h += `<p class="badge">Orange outline = your physical table, measured from the world origin
(front-left corner, N = +Z = back). Width runs W→E, depth runs S→N. Offsets shift the
table if your origin isn't at its corner. Saved with the layout; also printed on the
placement plan so you can sanity-check everything fits before taping down.</p>`;
side.innerHTML = h;
return;
}
let h = `<h2>${sel.kind} ${sel.index}</h2>`;
if (sel.kind === 'anchor') {
h += field('marker ID', o.markerId, v => o.markerId = v | 0, { step: 1 });
h += field('x (cm)', o.position[0], v => o.position[0] = v);
h += field('y (cm)', o.position[1], v => o.position[1] = v);
h += field('z (cm)', o.position[2], v => o.position[2] = v);
h += field('mount', o.mount, v => o.mount = v, { select: ['flat', 'wall', 'custom'] });
h += field('yaw °', o.yawDeg, v => o.yawDeg = v, { step: 1 });
h += field('pitch °', o.pitchDeg, v => o.pitchDeg = v, { step: 1 });
h += field('roll °', o.rollDeg, v => o.rollDeg = v, { step: 1 });
h += field('size mm', o.sizeMM, v => o.sizeMM = v, { step: 1 });
h += field('enabled', o.enabled ? 'yes' : 'no', v => o.enabled = v === 'yes', { select: ['yes', 'no'] });
h += `<p class="badge">Compass: ${o.mount === 'wall' ? 'print faces' : 'top edge of print points'} <b>${bearing(o)}</b>
(0° = N = +Z back). The white arrow in the 3D view and on the print sheet show the same direction.</p>`;
} else if (sel.kind === 'building') {
h += textField('name', o.name || '', v => o.name = v);
h += field('x (cm)', o.position[0], v => o.position[0] = v);
h += field('y (cm)', o.position[1], v => o.position[1] = v);
h += field('z (cm)', o.position[2], v => o.position[2] = v);
h += field('width', o.size[0], v => o.size[0] = v);
h += field('height', o.size[1], v => o.size[1] = v);
h += field('depth', o.size[2], v => o.size[2] = v);
h += field('yaw °', o.yawDeg || 0, v => o.yawDeg = v, { step: 1 });
if (o.setNumber) h += `<p class="badge">Imported footprint · Hidden Side set <b>${o.setNumber}</b>.
Size is the published build; adjust height/yaw freely.</p>`;
} else if (sel.kind === 'pathpoint') {
h = `<h2>path ${sel.index} · point ${sel.sub + 1}/${o.points.length}</h2>`;
h += textField('path id', o.id, v => o.id = v);
h += field('mode', o.mode || 'loop', v => o.mode = v, { select: ['loop', 'pingpong'] });
h += field('enabled', o.enabled === false ? 'no' : 'yes', v => o.enabled = v === 'yes', { select: ['yes', 'no'] });
const pt = o.points[sel.sub];
h += field('x (cm)', pt[0], v => pt[0] = v);
h += field('y (cm)', pt[1], v => pt[1] = v);
h += field('z (cm)', pt[2], v => pt[2] = v);
h += `<div class="row"><button id="addPt">+ point after</button><button id="delPt" class="danger">✕ point</button></div>`;
h += `<p class="badge">Ghosts walk point→point at path speed, turning to face each new direction.
loop = circles forever · pingpong = there and back. Tap other spheres to edit them;
the cone marks walk direction. Delete below removes the whole path.</p>`;
} else {
h += `<div class="row"><label>id</label><input id="spawnId" value="${o.id}"><button id="spawnRename">Rename</button></div>`;
h += field('x (cm)', o.position[0], v => o.position[0] = v);
h += field('y (cm)', o.position[1], v => o.position[1] = v);
h += field('z (cm)', o.position[2], v => o.position[2] = v);
h += field('enabled', o.enabled === false ? 'no' : 'yes', v => o.enabled = v === 'yes', { select: ['yes', 'no'] });
h += `<div class="row"><button id="spawnRenumber">Renumber all spawns</button></div>`;
h += `<p class="badge">Editing the id is a failsafe — hit Rename to change it safely; any playlist
references (track spawn points, pinned residents) are updated to match. Renumber all spawns retags
every spawn to this layout's prefix (${layoutPrefix() ? layoutPrefix() + '-sp1, -sp2…' : 'spawn-1, -2…'}).
Drag the green (Y) arrow to set spawn height — ghosts wander around this point in 3D.</p>`;
}
h += `<div class="row" style="margin-top:12px"><button id="del" class="danger">Delete</button></div>`;
side.innerHTML = h;
document.getElementById('del').onclick = () => {
if (sel.kind === 'pathpoint') scene.paths.splice(sel.index, 1);
else ({ anchor: scene.anchors, building: scene.buildings, spawn: scene.spawns })[sel.kind].splice(sel.index, 1);
sel = null; gizmo.detach(); buildAll(); panel();
};
wirePathExtras();
wireSpawnExtras();
}
function wireSpawnExtras() {
const renameBtn = document.getElementById('spawnRename');
const renumBtn = document.getElementById('spawnRenumber');
if (renameBtn) renameBtn.onclick = async () => {
const o = dataOf(sel);
const newId = document.getElementById('spawnId').value.trim();
if (!newId || newId === o.id) return;
if (scene.spawns.some(s => s !== o && s.id === newId)) return alert('That id is already used by another spawn.');
try { await applySpawnRename({ [o.id]: newId }); status.textContent = 'Renamed spawn'; panel(); }
catch (e) { alert('Rename failed: ' + e.message); }
};
if (renumBtn) renumBtn.onclick = renumberSpawns;
}
function wirePathExtras() {
const addBtn = document.getElementById('addPt'), delBtn = document.getElementById('delPt');
if (addBtn) addBtn.onclick = () => {
const pth = dataOf(sel);
const a = pth.points[sel.sub], b = pth.points[(sel.sub + 1) % pth.points.length];
pth.points.splice(sel.sub + 1, 0, [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2]);
sel.sub += 1; buildAll(); panel();
};
if (delBtn) delBtn.onclick = () => {
const pth = dataOf(sel);
if (pth.points.length <= 2) return alert('A path needs at least 2 points — delete the whole path instead.');
pth.points.splice(sel.sub, 1);
sel.sub = Math.max(0, sel.sub - 1); buildAll(); panel();
};
}
// ---------- toolbar ----------
document.getElementById('tableBtn').onclick = () => {
sel = { kind: 'table' }; gizmo.detach(); panel();
};
document.getElementById('topView').onclick = () => {
cam.position.set(orbit.target.x, 320, orbit.target.z + 0.01);
cam.lookAt(orbit.target);
};
const status = document.getElementById('status');
document.getElementById('save').onclick = async () => {
try { scene = await api('/api/scene', 'PUT', scene); buildAll(); status.textContent = 'Saved ' + new Date().toLocaleTimeString(); }
catch (e) { status.textContent = 'Save failed: ' + e.message; }
};
document.getElementById('reset').onclick = async () => {
if (!confirm('Reset layout to committed seed? Live edits will be lost.')) return;
scene = await api('/api/scene/reset', 'POST');
sel = null; gizmo.detach(); buildAll(); panel();
};
// ---------- render loop ----------
function fit() {
const w = cvs.clientWidth, h = cvs.clientHeight;
renderer.setSize(w, h, false);
cam.aspect = w / h; cam.updateProjectionMatrix();
}
new ResizeObserver(fit).observe(cvs);
fit(); buildAll(); panel();
(function loop() { requestAnimationFrame(loop); orbit.update(); renderer.render(scn, cam); })();
</script>
</body></html>
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin Login — Newbury Exhibit</title></head>
<body>
<div style="max-width:340px;margin:18vh auto;text-align:center">
<h1 style="font-size:1.3rem">Exhibit Admin</h1>
<input id="pw" type="password" placeholder="Admin password" style="width:100%;padding:10px;margin:12px 0;font-size:1rem">
<button id="go" class="primary" style="width:100%;padding:10px;font-size:1rem">Sign in</button>
<div id="err" style="color:#ff8b8b;margin-top:10px;font-size:.85rem"></div>
</div>
<script type="module">
import { styles } from './admin.js';
document.head.insertAdjacentHTML('beforeend', `<style>${styles}</style>`);
const go = async () => {
const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: document.getElementById('pw').value }) });
if (!res.ok) { document.getElementById('err').textContent = 'Wrong password.'; return; }
localStorage.setItem('exhibitToken', (await res.json()).token);
location.href = new URLSearchParams(location.search).get('next') || '/admin/layout.html';
};
document.getElementById('go').onclick = go;
document.getElementById('pw').onkeydown = (e) => e.key === 'Enter' && go();
</script>
</body></html>
+254
View File
@@ -0,0 +1,254 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Placement Plan — Newbury Exhibit</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; background: #666; }
#controls { padding: 10px 16px; display: flex; gap: 14px; flex-wrap: wrap; align-items: center; background: #0d1226; color: #e8ecff; }
.page { background: #fff; color: #000; margin: 10px auto; box-shadow: 0 2px 10px rgba(0,0,0,.5); position: relative; overflow: hidden; }
.page svg { display: block; }
.pgHead { position: absolute; top: 2mm; left: 5mm; right: 5mm; font-size: 8pt; display: flex; justify-content: space-between; }
@media print {
body { background: #fff; }
#hdr, #controls { display: none !important; }
.page { margin: 0; box-shadow: none; page-break-after: always; }
}
</style></head>
<body>
<div id="hdr"></div>
<div id="controls"></div>
<div id="pages"></div>
<script src="/vendor/cv.js"></script>
<script src="/vendor/svd.js"></script>
<script src="/vendor/posit1.js"></script>
<script src="/vendor/aruco.js"></script>
<script src="/vendor/dictionaries/aruco_4x4_1000.js"></script>
<script type="module">
import { requireAuth, styles, nav } from './admin.js';
document.getElementById('hdr').innerHTML = nav('plan');
document.head.insertAdjacentHTML('beforeend', `<style>@media screen {${styles}}</style>`);
await requireAuth();
const scene = await (await fetch('/api/scene')).json();
const dic = new AR.Dictionary('ARUCO_4X4_1000');
const PAPER = { A4: [210, 297], A3: [297, 420] };
const MARGIN = 10; // mm printable margin
const OVERLAP = 10; // mm tile overlap for joining
const opts = { scale: '1', paper: 'A4', table: true, anchors: true, buildings: true, spawns: true, paths: true };
document.getElementById('controls').innerHTML = `
<label>Scale <select id="scale">
<option value="1">1:1 (true size — tape it down)</option>
<option value="2">1:2</option>
<option value="5">1:5</option>
<option value="fit">Fit one page</option>
</select></label>
<label>Paper <select id="paper"><option>A4</option><option>A3</option></select></label>
${['table','anchors','buildings','spawns','paths'].map(k =>
`<label><input type="checkbox" id="ly-${k}" checked> ${k}</label>`).join('')}
<span style="flex:1"></span>
<button class="primary" onclick="print()">Print…</button>
<span id="pgCount" style="opacity:.7;font-size:.85rem"></span>`;
['scale','paper'].forEach(id => document.getElementById(id).onchange = e => { opts[id] = e.target.value; build(); });
['table','anchors','buildings','spawns','paths'].forEach(k =>
document.getElementById('ly-' + k).onchange = e => { opts[k] = e.target.checked; build(); });
// ---------- geometry ----------
function extent() {
let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
const eat = (x, z, pad = 10) => {
minX = Math.min(minX, x - pad); maxX = Math.max(maxX, x + pad);
minZ = Math.min(minZ, z - pad); maxZ = Math.max(maxZ, z + pad);
};
const t = scene.table;
if (t && t.show !== false) { eat(t.offsetX || 0, t.offsetZ || 0, 6); eat((t.offsetX || 0) + t.width, (t.offsetZ || 0) + t.depth, 6); }
for (const a of scene.anchors || []) eat(a.position[0], a.position[2]);
for (const b of scene.buildings || []) eat(b.position[0], b.position[2], Math.max(b.size[0], b.size[2]) / 2 + 5);
for (const s of scene.spawns || []) eat(s.position[0], s.position[2]);
for (const p of scene.paths || []) for (const q of p.points || []) eat(q[0], q[2]);
// nothing to draw → fall back to a small default page area
if (!isFinite(minX)) { minX = 0; minZ = 0; maxX = 100; maxZ = 100; }
return { minX, minZ, maxX, maxZ, w: maxX - minX, h: maxZ - minZ };
}
const brgOf = a => a.mount === 'wall'
? ((a.yawDeg % 360) + 360) % 360
: (((180 + (a.yawDeg || 0)) % 360) + 360) % 360;
/* Build the plan as SVG inner markup in mm units at scale k (world cm * 10 / k).
* Page mapping: +X east = right, N (+Z) = UP the page. */
function planSVG(ex, k) {
const S = 10 / k; // mm per world-cm
const X = x => (x - ex.minX) * S;
const Y = z => (ex.maxZ - z) * S; // N up
const px = v => +v.toFixed(2);
let g = '';
// grid: 10 cm light, 50 cm dark, labels every 50 cm
for (let x = Math.ceil(ex.minX / 10) * 10; x <= ex.maxX; x += 10) {
const major = x % 50 === 0;
g += `<line x1="${px(X(x))}" y1="0" x2="${px(X(x))}" y2="${px(Y(ex.minZ))}" stroke="${major ? '#999' : '#ddd'}" stroke-width="${major ? 0.3 : 0.15}"/>`;
if (major) g += `<text x="${px(X(x))}" y="${px(Y(ex.minZ)) - 1}" font-size="3" text-anchor="middle" fill="#666">${x}</text>`;
}
for (let z = Math.ceil(ex.minZ / 10) * 10; z <= ex.maxZ; z += 10) {
const major = z % 50 === 0;
g += `<line x1="0" y1="${px(Y(z))}" x2="${px(X(ex.maxX))}" y2="${px(Y(z))}" stroke="${major ? '#999' : '#ddd'}" stroke-width="${major ? 0.3 : 0.15}"/>`;
if (major) g += `<text x="1" y="${px(Y(z)) - 0.8}" font-size="3" fill="#666">${z}</text>`;
}
// compass rose (top-left)
g += `<g transform="translate(12,14)">
<circle r="8" fill="none" stroke="#000" stroke-width="0.4"/>
<path d="M0,2 L0,-6 M-1.6,-3.6 L0,-6 L1.6,-3.6" stroke="#000" stroke-width="0.7" fill="none"/>
<text y="-9.5" font-size="4" text-anchor="middle" font-weight="bold">N (+Z, back)</text>
<text y="13" font-size="3" text-anchor="middle" fill="#444">grid 10 cm</text></g>`;
if (opts.table && scene.table && scene.table.show !== false) {
const t = scene.table;
const x0 = t.offsetX || 0, z0 = t.offsetZ || 0;
const tx = X(x0), ty = Y(z0 + t.depth), tw = t.width * S, th = t.depth * S;
g += `<rect x="${px(tx)}" y="${px(ty)}" width="${px(tw)}" height="${px(th)}"
fill="none" stroke="#e67e00" stroke-width="0.7"/>`;
// dimension labels with arrows: width along the front (bottom) edge, depth along the left edge
g += `<text x="${px(tx + tw / 2)}" y="${px(ty + th + 5)}" font-size="4" text-anchor="middle" fill="#e67e00" font-weight="bold">← ${t.width} cm →</text>`;
g += `<text x="${px(tx - 3)}" y="${px(ty + th / 2)}" font-size="4" text-anchor="middle" fill="#e67e00" font-weight="bold"
transform="rotate(-90 ${px(tx - 3)} ${px(ty + th / 2)})">← ${t.depth} cm →</text>`;
g += `<text x="${px(tx + tw / 2)}" y="${px(ty - 2)}" font-size="3" text-anchor="middle" fill="#e67e00">TABLE ${t.width} × ${t.depth} cm — back edge (N)</text>`;
}
if (opts.paths) for (const p of scene.paths || []) {
const pts = (p.points || []);
if (pts.length < 2) continue;
const lp = p.mode === 'loop' ? [...pts, pts[0]] : pts;
g += `<polyline points="${lp.map(q => `${px(X(q[0]))},${px(Y(q[2]))}`).join(' ')}" fill="none" stroke="#8a2be2" stroke-width="0.5" stroke-dasharray="2 1.2"/>`;
for (let i = 0; i < lp.length - 1; i++) { // direction arrow per segment midpoint
const mx = (X(lp[i][0]) + X(lp[i + 1][0])) / 2, my = (Y(lp[i][2]) + Y(lp[i + 1][2])) / 2;
const ang = Math.atan2(Y(lp[i + 1][2]) - Y(lp[i][2]), X(lp[i + 1][0]) - X(lp[i][0])) * 180 / Math.PI;
g += `<path d="M-1.8,-1.2 L1.8,0 L-1.8,1.2 Z" transform="translate(${px(mx)},${px(my)}) rotate(${px(ang)})" fill="#8a2be2"/>`;
}
pts.forEach((q, i) => {
g += `<circle cx="${px(X(q[0]))}" cy="${px(Y(q[2]))}" r="1.4" fill="#fff" stroke="#8a2be2" stroke-width="0.5"/>
<text x="${px(X(q[0]) + 2)}" y="${px(Y(q[2]) - 1.5)}" font-size="2.6" fill="#8a2be2">${p.id}·${i + 1} (y=${q[1]})</text>`;
});
}
if (opts.buildings) for (const b of scene.buildings || []) {
const w = b.size[0] * S, d = b.size[2] * S;
g += `<g transform="translate(${px(X(b.position[0]))},${px(Y(b.position[2]))}) rotate(${px(-(b.yawDeg || 0))})">
<rect x="${px(-w / 2)}" y="${px(-d / 2)}" width="${px(w)}" height="${px(d)}" fill="#dbe8ff" fill-opacity="0.6" stroke="#4a7fd0" stroke-width="0.4"/>
<text font-size="3.2" text-anchor="middle" fill="#2a4f90">${b.name || 'building'}</text>
<text y="4" font-size="2.6" text-anchor="middle" fill="#2a4f90">h=${b.size[1]} cm</text></g>`;
}
if (opts.spawns) for (const s of scene.spawns || []) {
g += `<circle cx="${px(X(s.position[0]))}" cy="${px(Y(s.position[2]))}" r="2.2" fill="#ffd166" fill-opacity="0.7" stroke="#b57f0b" stroke-width="0.4"/>
<text x="${px(X(s.position[0]) + 3)}" y="${px(Y(s.position[2]) + 1)}" font-size="2.8" fill="#7a5500">${s.id} (y=${s.position[1]})</text>`;
}
if (opts.anchors) for (const a of scene.anchors || []) {
const cx = X(a.position[0]), cy = Y(a.position[2]);
const brg = brgOf(a);
if (a.mount === 'wall') {
// vertical marker: footprint line perpendicular to facing, arrow shows facing
const half = (a.sizeMM / 10) * S / 2;
g += `<g transform="translate(${px(cx)},${px(cy)}) rotate(${px(brg)})">
<line x1="${px(-half)}" y1="0" x2="${px(half)}" y2="0" stroke="#c0392b" stroke-width="1"/>
<path d="M0,0 L0,${px(-half)} M-1.5,${px(-half + 2)} L0,${px(-half)} L1.5,${px(-half + 2)}" stroke="#c0392b" stroke-width="0.5" fill="none"/></g>
<text x="${px(cx + half + 1)}" y="${px(cy)}" font-size="3" fill="#c0392b">#${a.markerId} WALL faces ${brg}° · y=${a.position[1]}</text>`;
continue;
}
// flat marker: true-size crest pattern rotated so its top edge faces brg (clockwise from N=up)
const sz = (a.sizeMM / 10) * S; // mm on page
const cell = sz / 6;
const code = dic.codeList[a.markerId] || '';
let cells = `<rect x="${px(-sz / 2)}" y="${px(-sz / 2)}" width="${px(sz)}" height="${px(sz)}" fill="#000"/>`;
for (let yy = 0; yy < 4; yy++) for (let xx = 0; xx < 4; xx++)
if (code[yy * 4 + xx] === '1')
cells += `<rect x="${px(-sz / 2 + (xx + 1) * cell)}" y="${px(-sz / 2 + (yy + 1) * cell)}" width="${px(cell)}" height="${px(cell)}" fill="#fff"/>`;
g += `<g transform="translate(${px(cx)},${px(cy)}) rotate(${px(brg)})">${cells}
<path d="M0,${px(-sz / 2 - 1)} L0,${px(-sz / 2 - 5)} M-1.5,${px(-sz / 2 - 3.2)} L0,${px(-sz / 2 - 5)} L1.5,${px(-sz / 2 - 3.2)}" stroke="#0a8f6b" stroke-width="0.6" fill="none"/></g>
<text x="${px(cx + sz / 2 + 1.5)}" y="${px(cy + 1)}" font-size="3" fill="#0a8f6b" font-weight="bold">#${a.markerId}</text>
<text x="${px(cx + sz / 2 + 1.5)}" y="${px(cy + 4.5)}" font-size="2.4" fill="#0a8f6b">top→${brg}°</text>`;
}
return g;
}
// ---------- pages ----------
function build() {
const ex = extent();
const [pw, ph] = PAPER[opts.paper];
const availW = pw - 2 * MARGIN, availH = ph - 2 * MARGIN;
let k;
if (opts.scale === 'fit') k = Math.max((ex.w * 10) / availW, (ex.h * 10) / availH, 1);
else k = parseFloat(opts.scale);
const S = 10 / k;
const totW = ex.w * S, totH = ex.h * S; // mm
const inner = planSVG(ex, k);
const stepW = availW - OVERLAP, stepH = availH - OVERLAP;
const cols = Math.max(1, Math.ceil((totW - OVERLAP) / stepW));
const rows = Math.max(1, Math.ceil((totH - OVERLAP) / stepH));
const pages = document.getElementById('pages');
pages.innerHTML = '';
const scaleLabel = opts.scale === 'fit' ? `1:${k.toFixed(1)}` : `1:${k}` + (k === 1 ? ' — TRUE SIZE: crest patterns are usable, tape sheet down' : '');
// overview page (always first): whole plan fit on one page with tile grid
const fitK = Math.max((ex.w * 10) / availW, (ex.h * 10) / availH, 1);
const ovW = ex.w * 10 / fitK, ovH = ex.h * 10 / fitK;
let tileGrid = '';
if (cols * rows > 1) {
const rr = (10 / fitK) / S; // convert tile mm to overview mm
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
const tx = c * stepW * rr, ty = r * stepH * rr;
tileGrid += `<rect x="${tx.toFixed(1)}" y="${ty.toFixed(1)}" width="${(availW * rr).toFixed(1)}" height="${(availH * rr).toFixed(1)}"
fill="none" stroke="#e74c3c" stroke-width="0.4" stroke-dasharray="2 1"/>
<text x="${(tx + 2).toFixed(1)}" y="${(ty + 5).toFixed(1)}" font-size="5" fill="#e74c3c" font-weight="bold">${String.fromCharCode(65 + r)}${c + 1}</text>`;
}
}
pages.insertAdjacentHTML('beforeend', `
<div class="page" style="width:${pw}mm;height:${ph}mm">
<div class="pgHead"><b>Newbury Exhibit — placement plan (overview, 1:${fitK.toFixed(1)})</b>
<span>${cols * rows > 1 ? `${cols * rows} tile pages follow at ${scaleLabel} — red boxes show tiles` : `scale ${scaleLabel}`}</span></div>
<svg width="${ovW}mm" height="${ovH}mm" viewBox="0 0 ${ex.w * 10 / fitK} ${ex.h * 10 / fitK}"
style="margin:${MARGIN + 4}mm ${MARGIN}mm">
<g transform="scale(${(10 / fitK) / S})">${inner}</g>${tileGrid}
</svg>
</div>`);
// tile pages
if (!(opts.scale === 'fit')) {
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
const vx = c * stepW, vy = r * stepH;
const label = `${String.fromCharCode(65 + r)}${c + 1}`;
const joins = [
c + 1 < cols ? `right joins ${String.fromCharCode(65 + r)}${c + 2}` : '',
r + 1 < rows ? `bottom joins ${String.fromCharCode(66 + r)}${c + 1}` : '',
].filter(Boolean).join(' · ');
// crop marks at the overlap boundary
const cm = `<g stroke="#e74c3c" stroke-width="0.3">
${[[OVERLAP / 2, 0], [availW - OVERLAP / 2, 0]].map(([x]) =>
`<line x1="${vx + x}" y1="${vy}" x2="${vx + x}" y2="${vy + 4}"/><line x1="${vx + x}" y1="${vy + availH - 4}" x2="${vx + x}" y2="${vy + availH}"/>`).join('')}
${[[0, OVERLAP / 2], [0, availH - OVERLAP / 2]].map(([, y]) =>
`<line x1="${vx}" y1="${vy + y}" x2="${vx + 4}" y2="${vy + y}"/><line x1="${vx + availW - 4}" y1="${vy + y}" x2="${vx + availW}" y2="${vy + y}"/>`).join('')}
</g>`;
pages.insertAdjacentHTML('beforeend', `
<div class="page" style="width:${pw}mm;height:${ph}mm">
<div class="pgHead"><b>Tile ${label}</b><span>${scaleLabel}${joins ? ' · ' + joins : ''} · overlap ${OVERLAP} mm</span></div>
<svg width="${availW}mm" height="${availH}mm" viewBox="${vx} ${vy} ${availW} ${availH}"
style="margin:${MARGIN + 4}mm ${MARGIN}mm ${MARGIN - 4}mm">
${inner}${cm}
</svg>
</div>`);
}
}
document.getElementById('pgCount').textContent =
`${pages.children.length} page${pages.children.length === 1 ? '' : 's'} · plan ${ex.w}×${ex.h} cm`;
}
build();
</script>
</body></html>
+289
View File
@@ -0,0 +1,289 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Playlist — Newbury Exhibit</title></head>
<body>
<div id="app"></div>
<script type="module">
import { api, requireAuth, styles, nav } from './admin.js';
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
main { max-width:1080px; margin:18px auto; padding:0 16px; }
.card { background:#111730; border-radius:10px; padding:14px 16px; margin-bottom:14px; }
.card h2 { margin:0 0 10px; font-size:.95rem; }
.row { display:flex; gap:10px; align-items:center; margin:8px 0; }
.row label { width:170px; }
.trk { background:#0d1226; border-radius:10px; padding:12px 14px; margin-bottom:10px; border-left:3px solid #2a3a6e; }
.trk.sched { border-left-color:#51eaf1; }
.trk.off { opacity:.45; }
.thead { display:flex; gap:10px; align-items:center; margin-bottom:8px; }
.thead input.nm { flex:1; font-weight:600; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:8px 12px; }
.f label { display:block; font-size:.72rem; opacity:.7; margin-bottom:2px; }
.f input, .f select { width:100%; box-sizing:border-box; }
.rt { margin-top:8px; font-size:.8rem; color:#51eaf1; }
.rt .warn { color:#ffd166; }
.setline { font-size:.75rem; opacity:.7; margin-top:6px; }
.chips { display:flex; flex-wrap:wrap; gap:5px; margin-top:6px; max-height:150px; overflow:auto; }
.chip { padding:3px 9px; border-radius:999px; background:#1a2244; cursor:pointer; font-size:.75rem; }
.chip.on { background:#2a6ea0; }
table { width:100%; font-size:.85rem; border-collapse:collapse; }
td { padding:3px 8px 3px 0; }
</style>`);
await requireAuth();
const app = document.getElementById('app');
let cfg = await api('/api/playlist');
const { ghosts } = await api('/api/ghosts');
const scene = await api('/api/scene');
const rarities = ['Common', 'Rare', 'Epic', 'Legendary'];
const colors = ['Red', 'Yellow', 'Blue'];
let expanded = null; // trackId whose ghost-picker is open
const fmt = (s) => s >= 3600 ? `${(s / 3600).toFixed(1)} hr` : s >= 60 ? `~${Math.round(s / 60)} min` : `${Math.round(s)} s`;
const locOptions = (sel) => ['random']
.concat((scene.spawns || []).map(s => s.id))
.concat((scene.paths || []).map(p => 'path:' + p.id))
.map(v => `<option value="${v}" ${v === sel ? 'selected' : ''}>${v}</option>`).join('');
function setSize(t) {
const resIds = new Set((cfg.residents || []).map(r => r.id));
const all = ghosts.filter(g => !resIds.has(g.id));
if (!t.set || t.set === 'all') return all.length;
if (t.set.ids?.length) return t.set.ids.length;
return all.filter(g => (!t.set.colors?.length || t.set.colors.includes(g.color)) &&
(!t.set.rarities?.length || t.set.rarities.includes(g.rarity))).length;
}
function approx(t) {
const n = setSize(t);
return (t.spawnTime === 'random' || t.spawnTime == null)
? n * (t.runEach || 30)
: (Number(t.spawnTime) || 0) + (t.runEach || 30);
}
function setLabel(t) {
if (!t.set || t.set === 'all') return 'All ghosts';
if (t.set.ids?.length) {
const names = t.set.ids.map(id => ghosts.find(g => g.id === id)?.name || id);
return names.length <= 3 ? names.join(' and ') : `${names.length} ghosts`;
}
const bits = [...(t.set.colors || []), ...(t.set.rarities || [])];
return bits.length ? bits.join(' / ') : 'All ghosts';
}
function trackCard(t, i) {
const timed = !(t.spawnTime === 'random' || t.spawnTime == null);
const n = setSize(t);
const rt = approx(t);
const tooMany = t.concurrent > n;
return `<div class="trk ${timed ? 'sched' : ''} ${t.enabled === false ? 'off' : ''}" data-i="${i}">
<div class="thead">
<input class="nm" data-k="name" value="${t.name || t.id}">
<label><input type="checkbox" data-k="enabled" ${t.enabled !== false ? 'checked' : ''}> enabled</label>
<button data-up="${i}" ${i === 0 ? 'disabled' : ''}>↑</button>
<button data-down="${i}">↓</button>
<button data-del="${i}" class="danger">✕</button>
</div>
<div class="grid">
<div class="f"><label>Ghosts</label>
<button data-pick="${t.id}" style="width:100%;text-align:left">${setLabel(t)} (${n})</button></div>
<div class="f"><label>SpawnTime</label>
<select data-k="spawnMode">
<option value="random" ${!timed ? 'selected' : ''}>random (continuous)</option>
<option value="timed" ${timed ? 'selected' : ''}>every…</option>
</select></div>
<div class="f"><label>${timed ? 'interval (minutes)' : '—'}</label>
<input type="number" step="0.5" min="0" data-k="spawnTime" value="${timed ? (Number(t.spawnTime) / 60) : ''}" ${timed ? '' : 'disabled'}></div>
<div class="f"><label>SpawnPoint</label>
<select data-k="spawnPoint">${locOptions(t.spawnPoint || 'random')}</select></div>
<div class="f"><label>RunEach (seconds)</label>
<input type="number" step="1" min="1" data-k="runEach" value="${t.runEach || 30}"></div>
<div class="f"><label>ConcurrentSpawns</label>
<input type="number" step="1" min="1" data-k="concurrent" value="${t.concurrent || 1}"></div>
<div class="f"><label>Priority</label>
<input type="number" step="1" data-k="priority" value="${t.priority ?? (timed ? 10 : 0)}"></div>
</div>
<div class="rt"><b>ApproxRuntime: ${fmt(rt)}</b>
<span style="opacity:.7">— ${timed
? `one cycle: waits ${fmt(Number(t.spawnTime) || 0)}, then runs ${fmt(t.runEach)}`
: `one full pass through ${n} ghost${n === 1 ? '' : 's'} at ${t.runEach}s each`}</span>
${tooMany ? `<div class="warn">⚠ ConcurrentSpawns (${t.concurrent}) exceeds the ${n}-ghost set — only ${n} can show at once.</div>` : ''}
${timed && t.runEach > Number(t.spawnTime) ? `<div class="warn">⚠ RunEach is longer than the interval — this track is effectively always on screen.</div>` : ''}
</div>
${expanded === t.id ? ghostPicker(t) : ''}
</div>`;
}
function ghostPicker(t) {
const ids = (t.set && t.set.ids) ? t.set.ids : [];
const cols = (t.set && t.set.colors) ? t.set.colors : [];
const rars = (t.set && t.set.rarities) ? t.set.rarities : [];
return `<div class="setline" style="margin-top:10px">
<b>Set:</b> pick individual ghosts, or filter by colour/rarity. Nothing selected = all ghosts.
<button data-clear="${t.id}" style="margin-left:8px">clear</button>
<div style="margin-top:6px">Colours: ${colors.map(c =>
`<span class="chip ${cols.includes(c) ? 'on' : ''}" data-col="${t.id}|${c}">${c}</span>`).join(' ')}
&nbsp; Rarities: ${rarities.map(r =>
`<span class="chip ${rars.includes(r) ? 'on' : ''}" data-rar="${t.id}|${r}">${r}</span>`).join(' ')}</div>
<div class="chips">${ghosts.map(g =>
`<span class="chip ${ids.includes(g.id) ? 'on' : ''}" data-gid="${t.id}|${g.id}">${g.name}</span>`).join('')}</div>
</div>`;
}
function residentRow(r, i) {
const gOpts = ghosts.map(g => `<option value="${g.id}" ${g.id === r.id ? 'selected' : ''}>${g.name} (${g.rarity} ${g.color})</option>`).join('');
const locSel = r.pathId ? 'path:' + r.pathId : (r.spawnId || '');
const locOpts = ['<option value="">auto</option>']
.concat((scene.spawns || []).map(sp => `<option value="${sp.id}" ${locSel === sp.id ? 'selected' : ''}>spawn ${sp.id}</option>`))
.concat((scene.paths || []).map(pt => `<option value="path:${pt.id}" ${locSel === 'path:' + pt.id ? 'selected' : ''}>path ${pt.id}</option>`)).join('');
const behOpts = ['static', 'wander', 'path'].map(b => `<option ${(r.behavior || 'static') === b ? 'selected' : ''}>${b}</option>`).join('');
return `<div class="row" data-res="${i}">
<select data-rk="id" style="flex:2;min-width:0">${gOpts}</select>
<select data-rk="loc" style="flex:1;min-width:0">${locOpts}</select>
<select data-rk="behavior" style="width:90px">${behOpts}</select>
<button data-resdel="${i}" class="danger">✕</button></div>`;
}
function render() {
const tracks = cfg.tracks || [];
const longest = tracks.filter(t => t.enabled !== false).map(approx);
app.innerHTML = `${nav('playlist')}<main>
<div class="card">
<h2>Playlist tracks <span style="opacity:.6;font-weight:400;font-size:.8rem">— run simultaneously; scheduled tracks (cyan) outrank ambient ones and will take a spawn point from them</span></h2>
<div id="tracks">${tracks.map(trackCard).join('') || '<em style="opacity:.6">No tracks yet</em>'}</div>
<button id="addTrack" style="margin-top:6px">+ Add track</button>
${longest.length ? `<div class="rt" style="margin-top:10px">Full show loops roughly every <b>${fmt(Math.max(...longest))}</b>
(longest enabled track) · ${tracks.filter(t => t.enabled !== false).length} track(s) active</div>` : ''}
</div>
<div class="card">
<h2>Residents <span style="opacity:.6;font-weight:400;font-size:.8rem">— permanent, never rotate out, outrank every track</span></h2>
<div id="residents">${(cfg.residents || []).map(residentRow).join('') || '<em style="opacity:.6">No residents</em>'}</div>
<button id="addRes" style="margin-top:8px">+ Add resident</button>
</div>
<div class="card">
<h2>Movement &amp; rarity</h2>
<div class="grid">
<div class="f"><label>Static chance (01)</label><input id="staticChance" type="number" step="0.05" min="0" max="1" value="${cfg.behaviors.staticChance}"></div>
<div class="f"><label>Wander radius (cm)</label><input id="wanderRadius" type="number" step="1" value="${cfg.behaviors.wanderRadius}"></div>
<div class="f"><label>Wander speed</label><input id="wanderSpeed" type="number" step="0.05" value="${cfg.behaviors.wanderSpeed}"></div>
<div class="f"><label>Vertical wander (cm)</label><input id="wanderVertical" type="number" step="1" value="${cfg.behaviors.wanderVertical ?? 10}"></div>
<div class="f"><label>Path speed (cm/s)</label><input id="pathSpeed" type="number" step="0.5" value="${cfg.behaviors.pathSpeed ?? 8}"></div>
<div class="f"><label>Path chance (01)</label><input id="pathChance" type="number" step="0.05" min="0" max="1" value="${cfg.behaviors.pathChance ?? 0.4}"></div>
<div class="f"><label>Crossfade (seconds)</label><input id="fade" type="number" step="0.5" min="0" value="${cfg.crossfadeSeconds}"></div>
</div>
<table style="margin-top:10px">
<tr style="opacity:.6"><td></td><td>appearance weight</td><td>movement scale</td></tr>
${rarities.map(r => `<tr><td>${r}</td>
<td><input data-rw="${r}" type="number" step="0.05" min="0" style="width:90px" value="${cfg.rarity?.weights?.[r] ?? 1}"></td>
<td><input data-rm="${r}" type="number" step="0.05" min="0" style="width:90px" value="${cfg.rarity?.movementScale?.[r] ?? 1}"></td></tr>`).join('')}
</table>
<p style="opacity:.6;font-size:.78rem">Weights bias which ghost an <b>ambient</b> track picks next; movement scale shrinks wander/path speed for rarer ghosts.</p>
</div>
<div class="card">
<h2>Time windows <span style="opacity:.6;font-weight:400;font-size:.8rem">— whole exhibit sleeps outside these; empty = always on</span></h2>
<div id="windows">${(cfg.timeWindows || []).map((w, i) =>
`<div class="row"><input type="time" value="${w.start}" data-i="${i}" data-k="start"> →
<input type="time" value="${w.end}" data-i="${i}" data-k="end">
<button data-wdel="${i}" class="danger">✕</button></div>`).join('') || '<em style="opacity:.6">Always on</em>'}</div>
<button id="addWin" style="margin-top:8px">+ Add window</button>
</div>
<button id="save" class="primary" style="width:100%;padding:12px;font-size:1rem">Apply playlist (restarts all tracks)</button>
<div id="status" style="margin:10px 0;opacity:.75;font-size:.85rem"></div>
</main>`;
wire();
}
function wire() {
// per-track fields
document.querySelectorAll('#tracks .trk').forEach(el => {
const i = +el.dataset.i, t = cfg.tracks[i];
el.querySelectorAll('[data-k]').forEach(inp => inp.onchange = () => {
const k = inp.dataset.k;
if (k === 'enabled') t.enabled = inp.checked;
else if (k === 'name') t.name = inp.value;
else if (k === 'spawnMode') t.spawnTime = inp.value === 'random' ? 'random' : 60;
else if (k === 'spawnTime') t.spawnTime = Math.max(0, (parseFloat(inp.value) || 0) * 60);
else if (k === 'spawnPoint') t.spawnPoint = inp.value;
else t[k] = parseFloat(inp.value) || 0;
render();
});
});
document.querySelectorAll('[data-pick]').forEach(b => b.onclick = () => {
expanded = expanded === b.dataset.pick ? null : b.dataset.pick; render();
});
const trackById = id => cfg.tracks.find(t => t.id === id);
const ensureSet = t => { if (!t.set || t.set === 'all') t.set = { ids: [], colors: [], rarities: [] }; return t.set; };
document.querySelectorAll('[data-gid]').forEach(c => c.onclick = () => {
const [tid, gid] = c.dataset.gid.split('|'); const s = ensureSet(trackById(tid));
s.ids ||= []; const k = s.ids.indexOf(gid); k >= 0 ? s.ids.splice(k, 1) : s.ids.push(gid); render();
});
document.querySelectorAll('[data-col]').forEach(c => c.onclick = () => {
const [tid, v] = c.dataset.col.split('|'); const s = ensureSet(trackById(tid));
s.colors ||= []; const k = s.colors.indexOf(v); k >= 0 ? s.colors.splice(k, 1) : s.colors.push(v); render();
});
document.querySelectorAll('[data-rar]').forEach(c => c.onclick = () => {
const [tid, v] = c.dataset.rar.split('|'); const s = ensureSet(trackById(tid));
s.rarities ||= []; const k = s.rarities.indexOf(v); k >= 0 ? s.rarities.splice(k, 1) : s.rarities.push(v); render();
});
document.querySelectorAll('[data-clear]').forEach(b => b.onclick = () => { trackById(b.dataset.clear).set = 'all'; render(); });
document.querySelectorAll('[data-del]').forEach(b => b.onclick = () => { cfg.tracks.splice(+b.dataset.del, 1); render(); });
document.querySelectorAll('[data-up]').forEach(b => b.onclick = () => {
const i = +b.dataset.up; [cfg.tracks[i - 1], cfg.tracks[i]] = [cfg.tracks[i], cfg.tracks[i - 1]]; render();
});
document.querySelectorAll('[data-down]').forEach(b => b.onclick = () => {
const i = +b.dataset.down; if (i + 1 >= cfg.tracks.length) return;
[cfg.tracks[i + 1], cfg.tracks[i]] = [cfg.tracks[i], cfg.tracks[i + 1]]; render();
});
document.getElementById('addTrack').onclick = () => {
(cfg.tracks ||= []).push({ id: 'track-' + Date.now().toString(36), name: 'New track', set: 'all',
spawnTime: 600, spawnPoint: 'random', runEach: 120, concurrent: 1, priority: 10, enabled: true });
render();
};
// residents
document.getElementById('addRes').onclick = () => { (cfg.residents ||= []).push({ id: ghosts[0].id, behavior: 'static' }); render(); };
document.querySelectorAll('[data-resdel]').forEach(b => b.onclick = () => { cfg.residents.splice(+b.dataset.resdel, 1); render(); });
document.querySelectorAll('#residents [data-rk]').forEach(el => el.onchange = () => {
const i = +el.closest('[data-res]').dataset.res, r = cfg.residents[i];
if (el.dataset.rk === 'id') r.id = el.value;
else if (el.dataset.rk === 'behavior') r.behavior = el.value;
else {
delete r.spawnId; delete r.pathId;
if (el.value.startsWith('path:')) { r.pathId = el.value.slice(5); r.behavior = 'path'; }
else if (el.value) r.spawnId = el.value;
}
render();
});
// windows
document.getElementById('addWin').onclick = () => { (cfg.timeWindows ||= []).push({ start: '09:00', end: '17:00' }); render(); };
document.querySelectorAll('[data-wdel]').forEach(b => b.onclick = () => { cfg.timeWindows.splice(+b.dataset.wdel, 1); render(); });
document.querySelectorAll('#windows input').forEach(inp => inp.onchange = () => cfg.timeWindows[+inp.dataset.i][inp.dataset.k] = inp.value);
// rarity table
document.querySelectorAll('[data-rw],[data-rm]').forEach(el => el.onchange = () => {
cfg.rarity ||= { weights: {}, movementScale: {} };
if (el.dataset.rw) (cfg.rarity.weights ||= {})[el.dataset.rw] = +el.value;
if (el.dataset.rm) (cfg.rarity.movementScale ||= {})[el.dataset.rm] = +el.value;
});
document.getElementById('save').onclick = async () => {
cfg.crossfadeSeconds = +document.getElementById('fade').value;
cfg.behaviors = {
staticChance: +document.getElementById('staticChance').value,
wanderRadius: +document.getElementById('wanderRadius').value,
wanderSpeed: +document.getElementById('wanderSpeed').value,
wanderVertical: +document.getElementById('wanderVertical').value,
pathSpeed: +document.getElementById('pathSpeed').value,
pathChance: +document.getElementById('pathChance').value,
};
try { cfg = await api('/api/playlist', 'PUT', cfg); render();
document.getElementById('status').textContent = 'Applied ' + new Date().toLocaleTimeString();
} catch (e) { document.getElementById('status').textContent = 'Failed: ' + e.message; }
};
}
render();
</script>
</body></html>
+259
View File
@@ -0,0 +1,259 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Virtual Viewport — Newbury Exhibit</title></head>
<body>
<div id="app"></div>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
} }
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { anchorWorldMatrix } from '/js/ar/pose.js';
import { buildGhost } from '/js/ghosts/loader.js';
import { ghostTransform } from '/js/ghosts/behavior.js';
import { ExhibitNet } from '/js/net.js';
import { requireAuth, styles, nav } from './admin.js';
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
#wrap { display:grid; grid-template-columns: 1fr 260px; height: calc(100vh - 44px); }
#view { position:relative; }
#gl { width:100%; height:100%; display:block; touch-action:none; }
#side { background:#111730; padding:12px; overflow:auto; }
#side h2 { font-size:.9rem; margin:0 0 8px; color:#8fb8ff; }
.g { background:#0d1226; border-radius:8px; padding:8px 10px; margin-bottom:8px; font-size:.8rem; cursor:pointer; }
.g:hover { background:#1a2244; }
.g .nm { font-weight:600; }
.g .meta { opacity:.65; margin-top:2px; }
.dot { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:6px; }
#legend { position:absolute; left:10px; bottom:10px; font-size:.72rem; background:rgba(6,8,15,.6);
padding:6px 10px; border-radius:8px; pointer-events:none; line-height:1.5; }
</style>`);
await requireAuth();
// Surface client-side errors on screen — a silent throw here used to leave the
// panel stuck on "Waiting for server…" with no clue why.
function showError(msg) {
let el = document.getElementById('perr');
if (!el) {
el = document.createElement('div');
el.id = 'perr';
el.style.cssText = 'position:fixed;bottom:0;left:0;right:0;max-height:35vh;overflow:auto;' +
'background:rgba(120,0,0,.9);color:#fff;font:12px monospace;padding:8px;z-index:9999;white-space:pre-wrap';
document.body.appendChild(el);
}
el.textContent += msg + '\n';
}
window.addEventListener('error', e => showError('[error] ' + e.message + (e.filename ? ` (${e.filename}:${e.lineno})` : '')));
window.addEventListener('unhandledrejection', e => showError('[rejection] ' + (e.reason?.message || e.reason)));
const COLORS = { Red: '#ff2678', Yellow: '#fff35d', Blue: '#51eaf1' };
const app = document.getElementById('app');
app.innerHTML = `${nav('preview')}
<div id="wrap">
<div id="view">
<canvas id="gl"></canvas>
<div id="legend"><b>Virtual viewport</b> — live playlist, same deterministic motion the phones compute.<br>
Drag to orbit · scroll to zoom · click a ghost in the list to follow it</div>
</div>
<div id="side"><h2>Active ghosts</h2><div id="list"><em style="opacity:.6">Waiting for server…</em></div></div>
</div>`;
// ---------- data ----------
const g = await (await fetch('/api/ghosts')).json();
const gradients = g.gradients.gradients || g.gradients;
const modelManifest = await (await fetch('/api/models')).json();
let characters = await (await fetch('/api/characters')).json();
// ---------- three.js ----------
const cvs = document.getElementById('gl');
const renderer = new THREE.WebGLRenderer({ canvas: cvs, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
const scn = new THREE.Scene();
scn.background = new THREE.Color(0x0a0e1c);
scn.fog = new THREE.Fog(0x0a0e1c, 400, 1200);
const cam = new THREE.PerspectiveCamera(55, 1, 1, 5000);
cam.position.set(160, 110, -80);
scn.add(new THREE.AmbientLight(0xffffff, 1.1));
const dl = new THREE.DirectionalLight(0x9db8ff, 0.8); dl.position.set(100, 250, -100); scn.add(dl);
const orbit = new OrbitControls(cam, cvs);
orbit.target.set(55, 15, 55);
orbit.enableDamping = true;
function textSprite(txt, color = '#e8ecff', px = 44) {
const c = document.createElement('canvas'); c.width = 512; c.height = 128;
const x = c.getContext('2d'); x.font = `bold ${px}px system-ui`; x.fillStyle = color;
x.shadowColor = '#000'; x.shadowBlur = 8;
x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText(txt, 256, 64);
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c), transparent: true, depthTest: false }));
sp.scale.set(40, 10, 1); return sp;
}
// ---------- static scene ----------
const statics = new THREE.Group(); scn.add(statics);
function buildStatics(scene) {
statics.clear();
statics.add(new THREE.GridHelper(400, 16, 0x2a3a6e, 0x151b36));
const t = scene.table;
if (t && t.show !== false) {
const x0 = t.offsetX || 0, z0 = t.offsetZ || 0;
const slab = new THREE.Mesh(new THREE.BoxGeometry(t.width, 2, t.depth),
new THREE.MeshStandardMaterial({ color: 0x5a4630, roughness: 0.9 }));
slab.position.set(x0 + t.width / 2, -1.2, z0 + t.depth / 2);
statics.add(slab);
const nLbl = textSprite('N ↑ back', '#51eaf1'); nLbl.position.set(x0 + t.width / 2, 4, z0 + t.depth + 8); statics.add(nLbl);
}
for (const b of scene.buildings || []) {
const m = new THREE.Mesh(new THREE.BoxGeometry(...b.size),
new THREE.MeshStandardMaterial({ color: 0x3a4f80, transparent: true, opacity: 0.85, roughness: 0.7 }));
m.position.set(b.position[0], b.position[1] + b.size[1] / 2, b.position[2]);
m.rotation.y = THREE.MathUtils.degToRad(b.yawDeg || 0);
statics.add(m);
const e = new THREE.LineSegments(new THREE.EdgesGeometry(m.geometry),
new THREE.LineBasicMaterial({ color: 0x7fa4e8 }));
e.position.copy(m.position); e.rotation.copy(m.rotation);
statics.add(e);
}
for (const a of scene.anchors || []) {
if (a.enabled === false) continue;
const size = (a.sizeMM || 60) / 10;
const p = new THREE.Mesh(new THREE.PlaneGeometry(size, size),
new THREE.MeshBasicMaterial({ color: a.mount === 'wall' ? 0xf65151 : 0x3bc9a7, side: THREE.DoubleSide }));
anchorWorldMatrix(a).decompose(p.position, p.quaternion, p.scale);
statics.add(p);
}
for (const pth of scene.paths || []) {
if (pth.enabled === false || (pth.points || []).length < 2) continue;
const pts = pth.points.map(q => new THREE.Vector3(...q));
const lp = pth.mode === 'loop' ? [...pts, pts[0]] : pts;
statics.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(lp),
new THREE.LineBasicMaterial({ color: 0xc77dff, transparent: true, opacity: 0.5 })));
}
for (const s of scene.spawns || []) {
if (s.enabled === false) continue;
const m = new THREE.Mesh(new THREE.SphereGeometry(1.5, 10, 8),
new THREE.MeshBasicMaterial({ color: 0xb57f0b, transparent: true, opacity: 0.35 }));
m.position.set(...s.position);
statics.add(m);
}
}
// ---------- live ghosts ----------
const active = new Map(); // uid -> { rec, group, label }
let followUid = null;
let ghostHeight = 4; // cm, from scene.ghostHeightCm
const clockSamples = []; let clockOffset = 0;
async function addGhost(rec) {
if (active.has(rec.uid)) return;
let group;
try {
group = await buildGhost(rec, gradients, modelManifest, characters);
} catch (e) {
showError(`ghost "${rec.name}" failed to build: ${e.message}`);
return;
}
group.userData.setHeight(ghostHeight);
const label = textSprite(rec.name, COLORS[rec.color] || '#fff');
label.scale.set(20, 5, 1);
scn.add(group); scn.add(label); // label stays world-scale, outside the scaled group
active.set(rec.uid, { rec, group, label });
renderList();
}
function removeGhost(uid) {
const e = active.get(uid);
if (!e) return;
scn.remove(e.group); scn.remove(e.label);
active.delete(uid);
if (followUid === uid) followUid = null;
renderList();
}
const net = new ExhibitNet();
net.on('scene', m => {
buildStatics(m.scene);
ghostHeight = m.scene.ghostHeightCm || 4;
for (const e of active.values()) e.group.userData.setHeight(ghostHeight);
})
.on('characters', async m => {
characters = m.characters || characters;
const recs = [...active.values()].map(e => e.rec);
for (const uid of [...active.keys()]) removeGhost(uid);
for (const rec of recs) await addGhost(rec);
})
.on('active', async m => {
for (const uid of [...active.keys()]) removeGhost(uid);
for (const rec of m.ghosts) await addGhost(rec);
})
.on('spawn', m => addGhost(m.ghost))
.on('despawn', m => {
const e = active.get(m.uid);
const until = e?.rec.until ?? Date.now() + clockOffset;
const wait = Math.max(0, until - (Date.now() + clockOffset)) + ((e?.rec.crossfade || 3) * 1000);
setTimeout(() => removeGhost(m.uid), Math.min(wait, 8000));
})
.on('pong', m => {
const rtt = performance.now() - m.t;
clockSamples.push(m.server + rtt / 2 - Date.now());
if (clockSamples.length > 5) clockSamples.shift();
clockOffset = clockSamples.reduce((a, b) => a + b, 0) / clockSamples.length;
});
net.connect();
setInterval(() => { try { net.ws.send(JSON.stringify({ type: 'ping', t: performance.now() })); } catch {} }, 5000);
// ---------- side list ----------
const list = document.getElementById('list');
function renderList() {
if (!active.size) { list.innerHTML = '<em style="opacity:.6">No ghosts active</em>'; return; }
list.innerHTML = [...active.values()].map(({ rec }) => `
<div class="g" data-uid="${rec.uid}">
<div class="nm"><span class="dot" style="background:${COLORS[rec.color] || '#fff'}"></span>${rec.name}</div>
<div class="meta">${rec.rarity} · ${rec.behavior.type}${rec.pathId ? ' on ' + rec.pathId : rec.spawnId ? ' @ ' + rec.spawnId : ''}
· <span class="ttl" data-uid="${rec.uid}"></span>${followUid === rec.uid ? ' · <b>following</b>' : ''}</div>
</div>`).join('');
list.querySelectorAll('.g').forEach(el => el.onclick = () => {
followUid = followUid === el.dataset.uid ? null : el.dataset.uid;
renderList();
});
}
setInterval(() => {
const now = Date.now() + clockOffset;
document.querySelectorAll('.ttl').forEach(el => {
const e = active.get(el.dataset.uid);
if (!e) return;
el.textContent = e.rec.until == null ? 'resident ∞' : Math.max(0, Math.ceil((e.rec.until - now) / 1000)) + 's left';
});
}, 1000);
// ---------- render loop ----------
const _tmp = { position: new THREE.Vector3(), rotationY: 0, opacity: 1 };
function fit() {
renderer.setSize(cvs.clientWidth, cvs.clientHeight, false);
cam.aspect = cvs.clientWidth / cvs.clientHeight; cam.updateProjectionMatrix();
}
new ResizeObserver(fit).observe(cvs);
fit();
(function loop(t) {
requestAnimationFrame(loop);
const now = Date.now() + clockOffset;
for (const { rec, group, label } of active.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);
label.position.set(_tmp.position.x, _tmp.position.y + ghostHeight + 3, _tmp.position.z);
label.material.opacity = _tmp.opacity;
}
if (followUid && active.has(followUid)) orbit.target.lerp(active.get(followUid).group.position, 0.08);
orbit.update();
renderer.render(scn, cam);
})(0);
</script>
</body></html>
+88
View File
@@ -0,0 +1,88 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Print Crests — Newbury Exhibit</title>
<style>
body { font-family:system-ui,sans-serif; margin:0; }
.sheet { display:flex; flex-wrap:wrap; gap:8mm; padding:10mm; }
.crest { text-align:center; page-break-inside:avoid; }
.crest canvas { image-rendering:pixelated; border:0.5mm solid #000; }
.crest .cap { font-size:9pt; margin-top:2mm; }
@media print { header { display:none } }
</style></head>
<body>
<div id="hdr"></div>
<div id="rose" style="padding:8mm 10mm 0"></div>
<div class="sheet" id="sheet"></div>
<script src="/vendor/cv.js"></script>
<script src="/vendor/svd.js"></script>
<script src="/vendor/posit1.js"></script>
<script src="/vendor/aruco.js"></script>
<script src="/vendor/dictionaries/aruco_4x4_1000.js"></script>
<script type="module">
import { requireAuth, styles, nav } from './admin.js';
document.getElementById('hdr').innerHTML = nav('print');
document.head.insertAdjacentHTML('beforeend', `<style>@media screen {${styles}}</style>`);
await requireAuth();
const scene = await (await fetch('/api/scene')).json();
const dic = new AR.Dictionary('ARUCO_4X4_1000');
const MM2PX = 96 / 25.4; // CSS px per mm
const sheet = document.getElementById('sheet');
for (const a of scene.anchors) {
const div = document.createElement('div');
div.className = 'crest';
const cv = document.createElement('canvas');
const bits = 4 + 2; // 4x4 payload + 1-cell black border each side
const px = Math.round(a.sizeMM * MM2PX);
cv.width = cv.height = bits * 10;
cv.style.width = cv.style.height = px + 'px';
const ctx = cv.getContext('2d');
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, cv.width, cv.height);
const code = dic.codeList[a.markerId]; // 16-char '0'/'1' string, row-major
ctx.fillStyle = '#fff';
for (let y = 0; y < 4; y++) for (let x = 0; x < 4; x++) {
if (code[y * 4 + x] === '1') ctx.fillRect((x + 1) * 10, (y + 1) * 10, 10, 10);
}
div.appendChild(cv);
// Compass: bearing the print's TOP edge (flat) or FACE (wall) must point.
// 0° = N = +Z (back of table), 90° = E = +X, 180° = S (front), 270° = W.
const brg = a.mount === 'wall'
? ((a.yawDeg % 360) + 360) % 360
: (((180 + a.yawDeg) % 360) + 360) % 360;
const dirName = ['N','NE','E','SE','S','SW','W','NW'][Math.round(brg / 45) % 8];
const rose = document.createElement('canvas');
rose.width = rose.height = 120;
rose.style.width = rose.style.height = '18mm';
const rc = rose.getContext('2d');
rc.translate(60, 60);
rc.strokeStyle = '#000'; rc.lineWidth = 2;
rc.beginPath(); rc.arc(0, 0, 52, 0, 7); rc.stroke();
rc.font = 'bold 16px system-ui'; rc.textAlign = 'center'; rc.textBaseline = 'middle'; rc.fillStyle = '#000';
rc.fillText('N', 0, -40); rc.font = '12px system-ui';
rc.fillText('E', 40, 0); rc.fillText('S', 0, 40); rc.fillText('W', -40, 0);
rc.save(); rc.rotate(brg * Math.PI / 180); // needle at target bearing
rc.beginPath(); rc.moveTo(0, 10); rc.lineTo(0, -46);
rc.lineTo(-6, -34); rc.moveTo(0, -46); rc.lineTo(6, -34);
rc.lineWidth = 3; rc.stroke(); rc.restore();
rose.style.verticalAlign = 'middle'; rose.style.marginLeft = '3mm';
div.appendChild(rose);
div.insertAdjacentHTML('beforeend',
`<div class="cap"><b>Crest #${a.markerId}</b> · ${a.sizeMM} mm · ${a.mount}<br>` +
(a.mount === 'wall'
? `mount vertically, this way up · print <b>faces ${brg}° ${dirName}</b>`
: `lay flat · <b>top edge of print → ${brg}° ${dirName}</b>`) +
`<br>pos ${a.position.map(v => v.toFixed(1)).join(', ')} cm</div>`);
sheet.appendChild(div);
}
document.getElementById('rose').innerHTML =
`<b>Table compass:</b> N (0°) = +Z, the <u>back</u> of the table · E (90°) = +X, the right ·
S (180°) = front · W (270°) = left. Each crest below shows the bearing its arrow must point —
align the needle with the table compass and the AR maths will line up exactly.`;
if (!scene.anchors.length) sheet.innerHTML = '<p style="padding:20px">No anchors in the scene yet — add some in the Layout editor.</p>';
</script>
</body></html>
+117
View File
@@ -0,0 +1,117 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Newbury Nights</title>
<style>
:root { color-scheme: dark; --accent:#51eaf1; --text:#e8ecff; }
html, body { margin:0; height:100%; overflow:hidden; background:#06080f; font-family:system-ui, sans-serif; color:var(--text); }
#cam { position:fixed; inset:0; width:100%; height:100%; object-fit:cover; }
#gl { position:fixed; inset:0; width:100%; height:100%; }
.hidden { display:none !important; }
/* Flow layout, not absolute positioning: the disclaimer is a normal flex child pinned
to the bottom by margin-top:auto, so it can never overlap the buttons on short
screens. Content scrolls if the viewport is too small for it. */
#startScreen { position:fixed; inset:0; display:flex; flex-direction:column; align-items:center;
text-align:center; z-index:10; background-size:cover; background-position:center;
padding:max(18px, env(safe-area-inset-top)) 20px max(16px, env(safe-area-inset-bottom));
overflow-y:auto; overscroll-behavior:contain; }
#startScreen::before { content:''; position:fixed; inset:0; background:#06080f; opacity:var(--ov,.55); }
#startScreen > * { position:relative; flex:none; }
/* centres the block when there's room, without ever squashing the disclaimer */
#startInner { margin:auto 0; display:flex; flex-direction:column; align-items:center; width:100%; }
#logo { max-width:min(78vw, 420px); max-height:min(28vh, 240px); margin-bottom:16px; object-fit:contain; }
#title { font-size:clamp(1.6rem,6.5vw,2.8rem); margin:0 0 10px; letter-spacing:.06em; }
#subtitle { opacity:.85; margin:0 0 26px; max-width:34rem; line-height:1.5; font-size:clamp(.9rem,3.6vw,1rem); }
.btn { font-size:1.1rem; padding:14px 38px; border-radius:999px; border:0; cursor:pointer;
background:linear-gradient(135deg, var(--accent), #529eff); color:#04121a; font-weight:700; }
.btn.ghost { background:transparent; color:var(--text); border:1px solid rgba(255,255,255,.35);
font-size:.92rem; padding:9px 22px; font-weight:500; margin-top:12px; }
.disclaimer { font-size:.66rem; opacity:.55; line-height:1.4; max-width:36rem;
margin-top:auto; padding-top:22px; }
/* details page */
#details { position:fixed; inset:0; z-index:20; overflow:auto; background:#080b16;
padding:0 0 calc(90px + env(safe-area-inset-bottom)); }
#details .inner { max-width:44rem; margin:0 auto;
padding:max(24px, env(safe-area-inset-top)) 20px 0; }
#details h1 { font-size:1.6rem; color:var(--accent); }
#details h2, #details h3 { color:var(--accent); margin-top:1.4em; }
#details p, #details li { line-height:1.65; opacity:.9; }
#details a { color:var(--accent); }
#backBtn { position:fixed; bottom:0; left:0; right:0; border:0; font-size:1rem;
padding:14px 14px calc(14px + env(safe-area-inset-bottom));
background:#111730; color:var(--text); cursor:pointer; border-top:1px solid #1e2748; }
#hud { position:fixed; top:0; left:0; right:0; display:flex; justify-content:space-between; padding:10px 14px;
pointer-events:none; z-index:5; text-shadow:0 1px 3px #000; }
#hud span { background:rgba(6,8,15,.55); border-radius:8px; padding:5px 10px; font-size:.85rem; }
#trk.warn { color:#ffd166; }
/* shutter */
#shutter { position:fixed; bottom:calc(28px + env(safe-area-inset-bottom)); left:50%;
transform:translateX(-50%); z-index:6;
width:68px; height:68px; border-radius:50%; border:4px solid rgba(255,255,255,.85);
background:rgba(255,255,255,.22); cursor:pointer; backdrop-filter:blur(2px); }
#shutter:active { transform:translateX(-50%) scale(.92); background:rgba(255,255,255,.5); }
#flash { position:fixed; inset:0; background:#fff; opacity:0; pointer-events:none; z-index:30; transition:opacity .35s; }
#flash.on { opacity:.85; transition:opacity .05s; }
#toast { position:fixed; bottom:110px; left:50%; transform:translateX(-50%) translateY(20px);
background:rgba(20,28,55,.92); padding:10px 20px; border-radius:999px; opacity:0; transition:all .3s; z-index:6; }
#toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
</style>
</head>
<body>
<video id="cam" playsinline muted></video>
<canvas id="gl"></canvas>
<div id="startScreen">
<div id="startInner">
<img id="logo" class="hidden" alt="">
<h1 id="title"></h1>
<p id="subtitle"></p>
<button id="start" class="btn"></button>
<button id="detailsBtn" class="btn ghost"></button>
</div>
<div class="disclaimer" id="disc1"></div>
</div>
<div id="details" class="hidden">
<div class="inner">
<h1 id="detailsTitle"></h1>
<div id="detailsBody"></div>
<div class="disclaimer" style="position:static;margin-top:32px" id="disc2"></div>
</div>
<button id="backBtn">← Back</button>
</div>
<div id="hud" class="hidden">
<span id="trk"></span>
<span id="cnt"></span>
</div>
<pre id="dbg" style="position:fixed;top:8px;left:8px;margin:0;padding:6px 8px;z-index:9998;
background:rgba(6,8,15,.72);color:#8fffd0;font:11px/1.45 monospace;border-radius:6px;
pointer-events:none;white-space:pre;display:none"></pre>
<button id="shutter" class="hidden" aria-label="Take photo"></button>
<div id="flash"></div>
<div id="toast"></div>
<!-- js-aruco2 vendor chain — load order matters: cv -> svd -> posit -> aruco -> dictionary -->
<script src="/vendor/cv.js"></script>
<script src="/vendor/svd.js"></script>
<script src="/vendor/posit1.js"></script>
<script src="/vendor/aruco.js"></script>
<script src="/vendor/dictionaries/aruco_4x4_1000.js"></script>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
} }
</script>
<script type="module" src="/js/exhibit.js"></script>
</body>
</html>
+22
View File
@@ -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;
}
+151
View File
@@ -0,0 +1,151 @@
/* 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;
/* 1€ (OneEuro) filter — the standard cure for marker-pose jitter. It low-passes
* hard when the signal is slow (phone still => kills jitter) and eases off as
* speed rises (phone moving => stays responsive, no lag). Two knobs:
* minCutoff — lower = steadier at rest (more smoothing when still)
* beta — higher = snappier when moving (less lag during motion)
* dCutoff is the cutoff for the internal speed estimate; 1.0 is standard. */
this.oe = {
minCutoffPos: 0.6, betaPos: 0.05,
minCutoffAng: 0.7, betaAng: 0.06,
dCutoff: 1.0,
prevPos: null, dPos: new THREE.Vector3(),
prevQuat: null, dAngRate: 0,
};
this.lastFuseT = 0;
}
// low-pass alpha from a cutoff frequency (Hz) and timestep dt (s)
static alpha(cutoff, dt) {
const tau = 1 / (2 * Math.PI * cutoff);
return 1 / (1 + tau / dt);
}
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 MM2CM = 0.1; // POS-IT translation is in mm; world units are cm
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->cm)
_m.compose(_p.copy(e.position).multiplyScalar(MM2CM), 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({ markerId: e.markerId, pos, quat, w: Math.max(1, e.area) });
}
if (!cams.length) return null;
/* Multi-marker fusion. Two visible markers each give an independent camera-in-world
* estimate; if they disagree (pose noise, or real-table spacing not matching the
* editor), naive area-weighting oscillates frame to frame because the areas jitter
* — this is the "steady on one marker, jittery on two" symptom. Fixes:
* 1. Deterministic order (sort by markerId) so the slerp base can't flip.
* 2. Robust weights: quantise area into coarse buckets so tiny per-frame area
* wobble doesn't shift the blend; a marker only dominates when genuinely much
* closer. Equal-ish markers then average to a STABLE midpoint, not a moving one. */
cams.sort((a, b) => a.markerId - b.markerId);
// Quantise confidence into coarse buckets from marker size (sqrt(area) ~ linear
// size). Near-equal markers land in the same bucket => equal weight => stable
// midpoint. A marker only outweighs another when it's a full bucket closer, so
// per-frame area jitter no longer shifts the blend.
for (const c of cams) c.w = Math.max(1, Math.round(Math.sqrt(c.w) / 8));
// weighted position mean + incremental weighted slerp (now order-stable)
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);
// spread = how far apart the individual marker estimates are (0 for one marker).
// Large spread (>~2cm) means the markers disagree — usually real-table spacing
// not matching the editor, which no fusion can fully hide; surfaced in dev HUD.
let spread = 0;
for (let i = 0; i < cams.length; i++)
for (let j = i + 1; j < cams.length; j++)
spread = Math.max(spread, cams[i].pos.distanceTo(cams[j].pos));
// ---- 1€ filter (position + orientation) ----
const now = performance.now();
const gap = now - this.lastFuseT;
const dt = this.smoothPos ? Math.min(0.1, Math.max(0.001, gap / 1000)) : 1 / 30;
this.lastFuseT = now;
const oe = this.oe;
// reset on first frame or after a real tracking gap (>1.5s)
if (!this.smoothPos || gap > 1500) {
this.smoothPos = pos.clone(); oe.prevPos = pos.clone(); oe.dPos.set(0, 0, 0);
this.smoothQuat = quat.clone(); oe.prevQuat = quat.clone(); oe.dAngRate = 0;
return { position: this.smoothPos.clone(), quaternion: this.smoothQuat.clone(), markerCount: cams.length, spread };
}
// POSITION: derivative -> speed -> dynamic cutoff -> low-pass
const dPosRaw = pos.clone().sub(oe.prevPos).multiplyScalar(1 / dt); // cm/s
const aD = WorldFuser.alpha(oe.dCutoff, dt);
oe.dPos.lerp(dPosRaw, aD);
const speed = oe.dPos.length();
const cutoffP = oe.minCutoffPos + oe.betaPos * speed;
const aP = WorldFuser.alpha(cutoffP, dt);
this.smoothPos.lerp(pos, aP);
oe.prevPos.copy(pos);
// ORIENTATION: angular speed -> dynamic cutoff -> slerp
if (this.smoothQuat.dot(quat) < 0) quat.set(-quat.x, -quat.y, -quat.z, -quat.w);
if (oe.prevQuat.dot(quat) < 0) oe.prevQuat.set(-oe.prevQuat.x, -oe.prevQuat.y, -oe.prevQuat.z, -oe.prevQuat.w);
const angDelta = 2 * Math.acos(Math.min(1, Math.abs(oe.prevQuat.dot(quat)))) / dt; // rad/s
oe.dAngRate += aD * (angDelta - oe.dAngRate);
const cutoffA = oe.minCutoffAng + oe.betaAng * oe.dAngRate;
const aA = WorldFuser.alpha(cutoffA, dt);
this.smoothQuat.slerp(quat, aA);
oe.prevQuat.copy(quat);
return { position: this.smoothPos.clone(), quaternion: this.smoothQuat.clone(), markerCount: cams.length, spread };
}
}
+127
View File
@@ -0,0 +1,127 @@
/* 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();
/* Rotation frame correction. The translation is converted from POS-IT's frame to
* the Three.js frame by F = diag(1,-1,-1) (the -t[1]/-t[2] negation). The rotation
* needs a matching left-multiply by F: R' = F R. Confirmed on-device — orientation
* then survives phone rotation and viewing angle. (Leaving rotation as-is ('asis')
* puts position and orientation in different frames so pitch leaks into yaw/roll;
* the full conjugation F R F^T ('frf') over-corrects for this pipeline.) Modes stay
* runtime-switchable for future re-tuning; the debug overlay cycles ROT_MODES. */
const F = new THREE.Matrix4().set(1,0,0,0, 0,-1,0,0, 0,0,-1,0, 0,0,0,1);
const Ft = F.clone().transpose();
export const ROT_MODES = ['fr', 'frf', 'asis', 'rf'];
let rotMode = 'fr'; // F R — confirmed correct on-device (orientation survives rotation/angle)
export function setRotMode(m) { if (ROT_MODES.includes(m)) rotMode = m; }
export function getRotMode() { return rotMode; }
function correctRotation(R) {
// R is a Matrix4 holding the raw POS-IT rotation.
switch (rotMode) {
case 'asis': return R;
case 'fr': return _m2.multiplyMatrices(F, R);
case 'rf': return _m2.multiplyMatrices(R, F);
case 'frf':
default: return _m2.multiplyMatrices(F, R).multiply(Ft);
}
}
const _m2 = new THREE.Matrix4();
/* Input convention (matches the v1-validated fix): raw centered image coords are
* passed to POS-IT — NO Y pre-flip. The -t[1]/-t[2] negation below is what converts
* to the Three.js camera frame. Pre-flipping Y here double-flips the vertical axis
* and inverts pitch response (ghosts move opposite when tilting the phone).
* If tilt ever reads inverted on a device, toggle this for a quick A/B test. */
const FLIP_INPUT_Y = false;
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: FLIP_INPUT_Y ? (cy - c.y) : (c.y - cy) }));
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) {
// Raw POS-IT rotation (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
);
// Conjugate into the Three.js frame so rotation matches the flipped translation
const quaternion = new THREE.Quaternion().setFromRotationMatrix(correctRotation(_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));
}
+96
View File
@@ -0,0 +1,96 @@
/* 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 === 'path') {
pathTransform(b, t, out);
} else if (b.type === 'wander') {
const s = b.seed || 1;
const R = b.radius ?? 60; // cm
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 + (b.vertical ?? 10) * Math.sin(t * v * 1.3 + hashNoise(s, 3) * 6.28)
+ 3 * Math.sin(t * 1.7 + hashNoise(s, 6) * 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 ?? 6; // cm
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; permanent residents (until == null) never fade out
const fade = (rec.crossfade || 3) * 1000;
const inA = Math.min(1, (nowMs - rec.spawnedAt) / fade);
const outA = rec.until == null ? 1 : Math.min(1, Math.max(0, (rec.until - nowMs) / fade));
out.opacity = Math.min(inA, outA);
return out;
}
/* Walk a waypoint path at constant speed (cm/s), turning to face travel direction.
* Deterministic: position is a pure function of elapsed time, so all viewers agree.
* b: { points:[[x,y,z],...], mode:'loop'|'pingpong', speed, phase, seed } */
function pathTransform(b, t, out) {
const raw = b.points || [];
if (raw.length < 2) { out.position.set(...(raw[0] || [0, 0, 0])); out.rotationY = 0; return; }
const pts = b.mode === 'loop' ? [...raw, raw[0]] : raw;
// cumulative segment lengths
const cum = [0];
for (let i = 1; i < pts.length; i++) {
const dx = pts[i][0] - pts[i - 1][0], dy = pts[i][1] - pts[i - 1][1], dz = pts[i][2] - pts[i - 1][2];
cum.push(cum[i - 1] + Math.hypot(dx, dy, dz));
}
const total = cum[cum.length - 1] || 1;
const wrap = (d) => {
if (b.mode === 'pingpong') { const m = ((d % (2 * total)) + 2 * total) % (2 * total); return m < total ? m : 2 * total - m; }
return ((d % total) + total) % total;
};
const sample = (d, v) => {
let i = 1; while (i < cum.length - 1 && cum[i] < d) i++;
const f = (d - cum[i - 1]) / Math.max(cum[i] - cum[i - 1], 1e-6);
v.set(
pts[i - 1][0] + (pts[i][0] - pts[i - 1][0]) * f,
pts[i - 1][1] + (pts[i][1] - pts[i - 1][1]) * f,
pts[i - 1][2] + (pts[i][2] - pts[i - 1][2]) * f);
return v;
};
const d = wrap(t * (b.speed ?? 8) + (b.phase || 0));
sample(d, out.position);
// face where we're heading: sample a few cm ahead so corners turn smoothly
const ahead = sample(wrap(t * (b.speed ?? 8) + (b.phase || 0) + 6), _look);
const dx = ahead.x - out.position.x, dz = ahead.z - out.position.z;
if (dx * dx + dz * dz > 1e-4) out.rotationY = Math.atan2(dx, dz);
// gentle float so walking still reads ghostly
out.position.y += 2 * Math.sin(t * 1.9 + (b.seed || 0));
}
const _look = new THREE.Vector3();
+280
View File
@@ -0,0 +1,280 @@
/* loader.js — ghost visuals.
*
* Multi-part OBJ ghosts: { legs|wisp, torso, head, headpiece } assembled into one Group,
* rendered with the recovered Hidden Side gradient shader (top->bottom tint by GhostColor:
* Red / Yellow / Blue). Procedural wisp fallback until models are uploaded.
*
* Per-character overrides come from /api/characters (managed in /admin/characters.html):
* { modelId, opacity, topColor, bottomColor, heightCm, scale,
* faceTextureUrl, torsoTextureUrl,
* partOverrides: { <partKey>: { offset:[x,y,z], rotation:[x,y,z], scale } } }
*
* Part placement: each part in a model may be a bare URL string, or an object
* { url, offset:[x,y,z], rotation:[x,y,z](degrees), scale }
* The model manifest holds the shared default placement; a character's
* partOverrides[key] is merged on top so an individual ghost can be nudged
* without disturbing every other ghost that shares the same model.
*
* Textures: face and torso images are projected planar-front onto the mesh (UV-independent,
* so untextured OBJs still work). Each is placed by a normalized Y band with adjustable
* centre/size, blended over the gradient — a simple decal without needing UV-mapped models.
*/
import * as THREE from 'three';
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
/* Canonical part order (also the assembly/z-order). Shared with the editor. */
export const PART_KEYS = ['legs', 'wisp', 'torso', 'head', 'headpiece'];
const objLoader = new OBJLoader();
const objCache = new Map();
const texLoader = new THREE.TextureLoader();
const texCache = new Map();
/* A sampler2D uniform must ALWAYS be bound to a real texture: leaving it null makes
* the shader program fail to link on many GPUs, which would break every untextured
* ghost. This 1x1 fully-transparent pixel is the safe no-op binding. */
const EMPTY_TEX = (() => {
const t = new THREE.DataTexture(new Uint8Array([0, 0, 0, 0]), 1, 1, THREE.RGBAFormat);
t.needsUpdate = true;
return t;
})();
function loadTexture(url) {
if (!url) return null;
if (!texCache.has(url)) {
const t = texLoader.load(url, undefined, undefined,
() => console.warn('texture failed to load:', url)); // keeps the ghost alive
t.colorSpace = THREE.SRGBColorSpace;
texCache.set(url, t);
}
return texCache.get(url);
}
function gradientMaterial(top, bottom, opacity, faceTex, torsoTex, bands) {
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 },
uToGroup: { value: new THREE.Matrix4() }, // mesh-local -> group-local (bakes part transform)
uTime: { value: 0 },
faceMap: { value: faceTex || EMPTY_TEX },
torsoMap: { value: torsoTex || EMPTY_TEX },
hasFace: { value: faceTex ? 1 : 0 },
hasTorso: { value: torsoTex ? 1 : 0 },
// [centreY, halfHeight, halfWidth] in normalized model space
faceBand: { value: new THREE.Vector3(bands.faceY, bands.faceSize, bands.faceSize) },
torsoBand: { value: new THREE.Vector3(bands.torsoY, bands.torsoSize, bands.torsoSize) },
},
vertexShader: `
varying float vY;
varying vec3 vNormal;
varying vec3 vLocal;
uniform float uMinY, uMaxY;
uniform mat4 uToGroup;
void main() {
// Position within the assembled group (accounts for per-part offset/rotation/scale),
// so the gradient + decal bands span the whole figure, not each part's raw origin.
vec3 gp = (uToGroup * vec4(position, 1.0)).xyz;
float h = max(uMaxY - uMinY, 0.001);
vY = clamp((gp.y - uMinY) / h, 0.0, 1.0);
vLocal = vec3(gp.x / h, vY, gp.z / h);
vNormal = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `
varying float vY;
varying vec3 vNormal;
varying vec3 vLocal;
uniform vec3 topColor, bottomColor;
uniform float opacity, uTime;
uniform sampler2D faceMap, torsoMap;
uniform int hasFace, hasTorso;
uniform vec3 faceBand, torsoBand;
// planar-front decal: map local X/Y into the band's UV box
vec4 decal(sampler2D map, vec3 band) {
vec2 uv = vec2((vLocal.x / band.z) * 0.5 + 0.5,
((vLocal.y - band.x) / band.y) * 0.5 + 0.5);
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return vec4(0.0);
return texture2D(map, uv);
}
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;
// only decal the front-facing side so images don't mirror onto the back
float front = smoothstep(0.0, 0.35, vNormal.z);
if (hasTorso == 1) {
vec4 t = decal(torsoMap, torsoBand);
c = mix(c, t.rgb, t.a * front);
}
if (hasFace == 1) {
vec4 f = decal(faceMap, faceBand);
c = mix(c, f.rgb, f.a * front);
}
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();
}
const DEG = Math.PI / 180;
/* A part entry may be a bare URL string or { url, offset, rotation, scale }.
* Return a normalized { url, offset:[x,y,z], rotation:[x,y,z], scale } or null. */
export function normalizePart(entry) {
if (!entry) return null;
if (typeof entry === 'string') {
return { url: entry, offset: [0, 0, 0], rotation: [0, 0, 0], scale: 1 };
}
if (!entry.url) return null;
const o = Array.isArray(entry.offset) ? entry.offset : [0, 0, 0];
const r = Array.isArray(entry.rotation) ? entry.rotation : [0, 0, 0];
return {
url: entry.url,
offset: [+o[0] || 0, +o[1] || 0, +o[2] || 0],
rotation: [+r[0] || 0, +r[1] || 0, +r[2] || 0],
scale: entry.scale != null ? (+entry.scale || 1) : 1,
};
}
/* Merge a per-ghost override on top of the model's default placement.
* Only fields present in the override replace the base; url always wins from base. */
export function mergePart(base, override) {
const b = normalizePart(base);
if (!b) return null;
if (!override) return b;
const ov = override;
return {
url: b.url,
offset: Array.isArray(ov.offset) ? ov.offset.map((v, i) => (v != null ? +v : b.offset[i])) : b.offset,
rotation: Array.isArray(ov.rotation) ? ov.rotation.map((v, i) => (v != null ? +v : b.rotation[i])) : b.rotation,
scale: ov.scale != null ? +ov.scale : b.scale,
};
}
/* Apply a normalized transform to a freshly-loaded part group. */
function applyPartTransform(obj, p) {
obj.position.set(p.offset[0], p.offset[1], p.offset[2]);
obj.rotation.set(p.rotation[0] * DEG, p.rotation[1] * DEG, p.rotation[2] * DEG);
obj.scale.setScalar(p.scale);
obj.userData.partKey = p.key;
return obj;
}
function proceduralWisp() {
const g = new THREE.Group();
const body = new THREE.Mesh(new THREE.SphereGeometry(9, 20, 16));
body.scale.set(1, 1.35, 1);
body.position.y = 14;
const tail = new THREE.Mesh(new THREE.ConeGeometry(7, 16, 16));
tail.rotation.x = Math.PI;
tail.position.y = 0;
g.add(body, tail);
return g;
}
/* Resolve the override chain: character-specific -> defaults -> built-in. */
function resolveOverrides(ghost, characters) {
const d = (characters && characters.defaults) || {};
const c = (characters && characters.byId && characters.byId[ghost.id]) || {};
return { ...d, ...c };
}
function pickModel(manifest, ov) {
const models = (manifest && manifest.models) || [];
if (!models.length) return null;
if (ov.modelId) return models.find(m => m.id === ov.modelId) || models[0];
return models[0];
}
export async function buildGhost(ghost, gradients, manifest, characters) {
const ov = resolveOverrides(ghost, characters);
const grad = (gradients && (gradients[ghost.color] || gradients.Blue))
|| { top: '#51eaf1', bottom: '#529eff' }; // survive a missing/!oddly-shaped gradient payload
const top = ov.topColor || grad.top;
const bottom = ov.bottomColor || grad.bottom;
const opacity = ov.opacity != null ? Number(ov.opacity) : 0.92;
const bands = {
faceY: ov.faceY != null ? Number(ov.faceY) : 0.82,
faceSize: ov.faceSize != null ? Number(ov.faceSize) : 0.16,
torsoY: ov.torsoY != null ? Number(ov.torsoY) : 0.52,
torsoSize: ov.torsoSize != null ? Number(ov.torsoSize) : 0.22,
};
const mat = gradientMaterial(top, bottom, opacity,
loadTexture(ov.faceTextureUrl), loadTexture(ov.torsoTextureUrl), bands);
let group;
const model = pickModel(manifest, ov);
const partOv = ov.partOverrides || {};
if (model && model.parts) {
group = new THREE.Group();
try {
for (const key of PART_KEYS) {
const merged = mergePart(model.parts[key], partOv[key]);
if (!merged) continue;
merged.key = key;
const partObj = await loadOBJ(merged.url);
applyPartTransform(partObj, merged);
group.add(partObj);
}
if (!group.children.length) group = proceduralWisp();
} catch (e) {
console.warn('ghost model load failed, using wisp fallback', e);
group = proceduralWisp();
}
} else {
group = proceduralWisp();
}
// gradient/decal mapping needs the model's Y range (group-local, post part-transform)
group.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(group);
const groupInv = new THREE.Matrix4().copy(group.matrixWorld).invert();
const mats = [];
group.traverse(o => {
if (o.isMesh) {
// Each mesh needs its own material instance to carry a per-mesh group-local matrix,
// but they all share the same tunable uniforms via the tick/opacity setters below.
const m = mat.clone();
m.uniforms.uMinY.value = box.min.y;
m.uniforms.uMaxY.value = box.max.y;
m.uniforms.uToGroup.value = new THREE.Matrix4().multiplyMatrices(groupInv, o.matrixWorld);
o.material = m;
mats.push(m);
}
});
if (!mats.length) mats.push(mat);
// Normalize: feet at origin, then setHeight(cm) scales any source units to world height.
const outer = new THREE.Group();
const inner = new THREE.Group();
inner.position.y = -box.min.y;
inner.add(group);
outer.add(inner);
const rawH = Math.max(box.max.y - box.min.y, 1e-6);
const modelScale = (ov.scale || model?.scale || 1);
outer.userData.setHeight = (cm) => outer.scale.setScalar(((ov.heightCm || cm) / rawH) * modelScale);
outer.userData.setHeight(4);
outer.userData.material = mats[0];
outer.userData.materials = mats;
outer.userData.setOpacity = (v) => { for (const m of mats) m.uniforms.opacity.value = v * opacity; };
outer.userData.tick = (t) => { for (const m of mats) m.uniforms.uTime.value = t; };
return outer;
}
+41
View File
@@ -0,0 +1,41 @@
/* md.js — tiny, safe markdown subset for user-authored copy.
* Supports: # headings, **bold**, *italic*, [links](url), - lists, blank-line paragraphs.
* Everything is HTML-escaped FIRST, so authored text can never inject markup.
*/
const esc = (s) => String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
export function renderMarkdown(src) {
const lines = esc(src || '').split(/\r?\n/);
const out = [];
let para = [], list = null;
const flushPara = () => { if (para.length) { out.push(`<p>${inline(para.join(' '))}</p>`); para = []; } };
const flushList = () => { if (list) { out.push(`<ul>${list.join('')}</ul>`); list = null; } };
for (const raw of lines) {
const line = raw.trim();
if (!line) { flushPara(); flushList(); continue; }
const h = line.match(/^(#{1,3})\s+(.*)$/);
if (h) { flushPara(); flushList(); const n = h[1].length + 1; out.push(`<h${n}>${inline(h[2])}</h${n}>`); continue; }
const li = line.match(/^[-*]\s+(.*)$/);
if (li) { flushPara(); (list ||= []).push(`<li>${inline(li[1])}</li>`); continue; }
flushList();
para.push(line);
}
flushPara(); flushList();
return out.join('\n');
}
function inline(t) {
return t
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
// links: only http(s) and site-relative, so no javascript: URLs
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
}
+51
View File
@@ -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`;
}
+45
View File
@@ -0,0 +1,45 @@
/* set-footprints.js — LEGO Hidden Side set footprint presets for the layout editor.
Units: centimetres (1 Three.js unit = 1 cm). width = X extent, depth = Z extent,
height = Y extent. Footprint = the two horizontal axes of each build.
Fan/tribute project; not affiliated with or endorsed by the LEGO Group.
Sizes derived from Brickset-published model dimensions (brickset.com/sets/theme-Hidden-Side). */
export const SET_FOOTPRINTS = [
{ setNumber: "70418", name: "J.B.'s Ghost Lab", width: 13, depth: 18, height: 9, year: 2019 },
{ setNumber: "70420", name: "Graveyard Mystery", width: 14, depth: 32, height: 10, year: 2019 },
{ setNumber: "70421", name: "El Fuego's Stunt Truck", width: 10, depth: 10, height: 17, year: 2019 },
{ setNumber: "70422", name: "Shrimp Shack Attack", width: 19, depth: 32, height: 10, year: 2019 },
{ setNumber: "70424", name: "Ghost Train Express", width: 14, depth: 61, height: 15, year: 2019 },
{ setNumber: "70425", name: "Newbury Haunted High School", width: 30, depth: 43, height: 26, year: 2019 },
{ setNumber: "70427", name: "Welcome to the Hidden Side", width: 17, depth: 22, height: 13, year: 2020 },
{ setNumber: "70428", name: "Jack's Beach Buggy", width: 10, depth: 11, height: 8, year: 2020 },
{ setNumber: "70429", name: "El Fuego's Stunt Plane", width: 22, depth: 22, height: 8, year: 2020 },
{ setNumber: "70430", name: "Newbury Subway", width: 15, depth: 26, height: 14, year: 2020 },
{ setNumber: "70431", name: "The Lighthouse of Darkness", width: 29, depth: 22, height: 18, year: 2020 },
{ setNumber: "70432", name: "Haunted Fairground", width: 28, depth: 35, height: 27, year: 2020 },
{ setNumber: "70433", name: "J.B.'s Submarine", width: 21, depth: 19, height: 10, year: 2020 },
{ setNumber: "70434", name: "Supernatural Race Car", width: 19, depth: 11, height: 7, year: 2020 },
{ setNumber: "70435", name: "Newbury Abandoned Prison", width: 19, depth: 30, height: 15, year: 2020 },
{ setNumber: "70437", name: "Mystery Castle (closed)", width: 34, depth: 31, height: 27, year: 2020 },
{ setNumber: "70437o", name: "Mystery Castle (open)", width: 45.5, depth: 26, height: 27, year: 2020 }
];
export function getFootprint(setNumber) {
return SET_FOOTPRINTS.find(s => s.setNumber === setNumber) || null;
}
export function footprintLabel(fp) {
return `${fp.setNumber}${fp.name} (${fp.width} × ${fp.depth} cm)`;
}
/* Turn a preset into a building record the layout editor already understands.
Buildings store size:[w,h,d]; footprint width→X, height→Y, depth→Z. */
export function footprintToBuilding(fp, at = [0, 0, 0]) {
return {
name: `${fp.setNumber} ${fp.name}`,
setNumber: fp.setNumber,
position: [at[0], at[1], at[2]],
size: [fp.width, fp.height, fp.depth],
yawDeg: 0
};
}
+468
View File
File diff suppressed because one or more lines are too long
+737
View File
@@ -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;
};
File diff suppressed because one or more lines are too long
+495
View File
@@ -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;
};
+284
View File
@@ -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);
};
+21
View File
@@ -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;
},
};
+334
View File
@@ -0,0 +1,334 @@
/* Playlist engine — server-driven so every viewer sees the same ghosts.
*
* THREE LAYERS, running simultaneously:
* 1. residents — permanent, never rotate out, own their spot
* 2. tracks — scheduled/ambient groups (the playlist proper)
* 3. (legacy) — global settings kept for behavior tuning
*
* A TRACK:
* {
* id, name,
* set: 'all' | { ids: [...] } | { colors:[], rarities:[] }, // who can appear
* spawnTime: 'random' | seconds, // 'random' = continuous ambient; number = interval between cycles
* spawnPoint: 'random' | 'spawn-1' | 'path:main-street',
* runEach: seconds, // how long each ghost stays visible
* concurrent: n, // how many of this track's set are visible at once
* priority: n, // higher wins a contested spawn point (scheduled default 10, ambient 0)
* enabled: bool
* }
*
* ApproxRuntime (computed, read-only):
* spawnTime 'random' -> setSize * runEach (one full pass through the set)
* spawnTime number -> spawnTime + runEach (one complete cycle: wait, then run)
*
* Scheduled tracks outrank ambient ones: when a timed track fires and its spawn point is
* held by a lower-priority ghost, that ghost is faded out and replaced.
*
* Broadcasts: spawn / despawn / active. Permanent records have until: null.
*/
class Playlist {
constructor(state, broadcast) {
this.state = state;
this.broadcast = broadcast;
const saved = state.getPlaylistConfig(Playlist.defaults());
this.cfg = Playlist.migrate({ ...Playlist.defaults(), ...saved });
this.active = new Map();
this.trackState = new Map(); // trackId -> { nextFireAt, cursor }
this.uidCounter = 1;
this.timer = null;
}
static defaults() {
return {
crossfadeSeconds: 3,
behaviors: { staticChance: 0.5, wanderRadius: 60, wanderSpeed: 0.15, wanderVertical: 10, pathSpeed: 8, pathChance: 0.4 },
rarity: {
weights: { Common: 1.0, Rare: 0.5, Epic: 0.25, Legendary: 0.1 },
movementScale: { Common: 1.0, Rare: 1.0, Epic: 0.7, Legendary: 0.4 },
},
residents: [],
tracks: [{
id: 'ambient', name: 'All ghosts (ambient)', set: 'all',
spawnTime: 'random', spawnPoint: 'random', runEach: 30,
concurrent: 5, priority: 0, enabled: true,
}],
timeWindows: [],
};
}
/* Old single-rotation configs become the ambient track. */
static migrate(cfg) {
if (!Array.isArray(cfg.tracks) || !cfg.tracks.length) {
cfg.tracks = [{
id: 'ambient', name: 'All ghosts (ambient)',
set: (cfg.include && (cfg.include.ids?.length || cfg.include.colors?.length || cfg.include.rarities?.length))
? { ids: cfg.include.ids || [], colors: cfg.include.colors || [], rarities: cfg.include.rarities || [] }
: 'all',
spawnTime: 'random', spawnPoint: 'random',
runEach: cfg.dwellSeconds || 30,
concurrent: cfg.slots || 5, priority: 0, enabled: true,
}];
}
delete cfg.slots; delete cfg.dwellSeconds; delete cfg.order; delete cfg.include;
return cfg;
}
getConfig() {
return { ...this.cfg, tracks: this.cfg.tracks.map(t => ({ ...t, approxRuntimeSeconds: this.approxRuntime(t) })) };
}
setConfig(cfg) {
if (!cfg || typeof cfg !== 'object') throw new Error('bad config');
const merged = Playlist.migrate({ ...Playlist.defaults(), ...this.cfg, ...cfg });
for (const t of merged.tracks || []) {
if (!t.id) throw new Error('track.id required');
t.runEach = Math.max(1, Number(t.runEach) || 30);
t.concurrent = Math.max(1, Number(t.concurrent) || 1);
t.priority = Number(t.priority ?? (t.spawnTime === 'random' ? 0 : 10));
t.enabled = t.enabled !== false;
}
this.cfg = merged;
this.state.putPlaylistConfig(this.cfg);
for (const uid of [...this.active.keys()]) this.despawn(uid);
this.trackState.clear();
this.tick();
this.broadcast({ type: 'active', ghosts: this.activeSnapshot() });
return this.getConfig();
}
/* Re-read the persisted playlist config from state (used after a layout load
* swaps playlist.live.json) and do the same clean reset as setConfig. */
reloadFromState() {
const saved = this.state.getPlaylistConfig(Playlist.defaults());
this.cfg = Playlist.migrate({ ...Playlist.defaults(), ...saved });
for (const uid of [...this.active.keys()]) this.despawn(uid);
this.trackState.clear();
this.tick();
this.broadcast({ type: 'active', ghosts: this.activeSnapshot() });
return this.getConfig();
}
allGhosts() { return this.state.getGhosts().ghosts; }
/* Ghosts a track may use, minus those pinned as residents. */
trackSet(track) {
const residentIds = new Set((this.cfg.residents || []).map(r => r.id));
const all = this.allGhosts().filter(g => !residentIds.has(g.id));
if (!track.set || track.set === 'all') return all;
const s = track.set;
if (s.ids && s.ids.length) return all.filter(g => s.ids.includes(g.id));
return all.filter(g =>
(!s.colors?.length || s.colors.includes(g.color)) &&
(!s.rarities?.length || s.rarities.includes(g.rarity)));
}
approxRuntime(track) {
const n = this.trackSet(track).length;
if (track.spawnTime === 'random' || track.spawnTime == null) return n * (track.runEach || 30);
return (Number(track.spawnTime) || 0) + (track.runEach || 30);
}
scale(g) { return this.cfg.rarity?.movementScale?.[g.rarity] ?? 1; }
// ---------- locations ----------
holderOf(spawnId, pathId) {
for (const [uid, g] of this.active)
if ((spawnId && g.spawnId === spawnId) || (pathId && g.pathId === pathId)) return { uid, g };
return null;
}
/* Resolve a track's spawnPoint into { spawn, path } or null if unavailable.
* Scheduled tracks may evict a lower-priority occupant. */
resolveLocation(track) {
const scene = this.state.getScene();
const sp = track.spawnPoint || 'random';
if (sp !== 'random') {
let spawn = null, path = null;
if (String(sp).startsWith('path:')) path = (scene.paths || []).find(p => p.id === String(sp).slice(5));
else spawn = (scene.spawns || []).find(s => s.id === sp);
if (!spawn && !path) return null;
const held = this.holderOf(spawn?.id, path?.id);
if (held) {
if (held.g.permanent) return null; // never evict a resident
if ((held.g.priority ?? 0) >= (track.priority ?? 0)) return null;
this.despawn(held.uid); // scheduled beats ambient
}
return { spawn, path };
}
// random: prefer a free spawn point, else a free path
const usedS = new Set([...this.active.values()].map(g => g.spawnId).filter(Boolean));
const usedP = new Set([...this.active.values()].map(g => g.pathId).filter(Boolean));
const b = this.cfg.behaviors || {};
const freeP = (scene.paths || []).filter(p => p.enabled !== false && (p.points || []).length >= 2 && !usedP.has(p.id));
const freeS = (scene.spawns || []).filter(s => s.enabled !== false && !usedS.has(s.id));
if (freeP.length && Math.random() < (b.pathChance ?? 0.4)) return { path: freeP[Math.floor(Math.random() * freeP.length)] };
if (freeS.length) return { spawn: freeS[Math.floor(Math.random() * freeS.length)] };
if (freeP.length) return { path: freeP[Math.floor(Math.random() * freeP.length)] };
return null;
}
// ---------- behavior ----------
makeBehavior(kind, g, path) {
const b = this.cfg.behaviors || {};
const k = this.scale(g);
if (kind === 'path' && path) {
return { type: 'path', points: path.points.map(p => [...p]), mode: path.mode || 'loop',
speed: (b.pathSpeed ?? 8) * k, phase: Math.random() * 1000, seed: Math.floor(Math.random() * 1e6) };
}
if (kind === 'wander') {
return { type: 'wander', radius: (b.wanderRadius ?? 60) * k, speed: (b.wanderSpeed ?? 0.15) * k,
vertical: (b.wanderVertical ?? 10) * k, seed: Math.floor(Math.random() * 1e6) };
}
return { type: 'static', bobAmp: (5 + Math.random() * 5) * k, bobHz: 0.3 + Math.random() * 0.3 };
}
makeRecord(g, { spawn, path }, behavior, opts = {}) {
const perm = !!opts.permanent;
return {
uid: perm ? 'res-' + g.id : 'g' + (this.uidCounter++),
id: g.id, name: g.name, color: g.color, rarity: g.rarity,
trackId: opts.trackId || null,
priority: opts.priority ?? 0,
spawnId: spawn ? spawn.id : null,
pathId: path ? path.id : null,
pos: spawn ? [...spawn.position] : [...path.points[0]],
behavior,
spawnedAt: Date.now(),
until: perm ? null : Date.now() + (opts.runEach || 30) * 1000,
crossfade: this.cfg.crossfadeSeconds || 3,
permanent: perm,
};
}
// ---------- residents (layer 1) ----------
ensureResidents() {
for (const r of this.cfg.residents || []) {
const uid = 'res-' + r.id;
if (this.active.has(uid)) continue;
const g = this.allGhosts().find(x => x.id === r.id);
if (!g) continue;
const scene = this.state.getScene();
let spawn = null, path = null;
if (r.pathId) path = (scene.paths || []).find(p => p.id === r.pathId && (p.points || []).length >= 2);
if (!path && r.spawnId) spawn = (scene.spawns || []).find(s => s.id === r.spawnId);
if (!path && !spawn) {
const loc = this.resolveLocation({ spawnPoint: 'random', priority: 999 });
if (!loc) continue;
spawn = loc.spawn; path = loc.path;
} else {
const held = this.holderOf(spawn?.id, path?.id);
if (held && !held.g.permanent) this.despawn(held.uid); // residents outrank everything
else if (held) continue;
}
const kind = r.behavior || (path ? 'path' : 'static');
const rec = this.makeRecord(g, { spawn, path }, this.makeBehavior(kind, g, path), { permanent: true, priority: 999 });
this.active.set(uid, rec);
this.broadcast({ type: 'spawn', ghost: rec });
}
const wanted = new Set((this.cfg.residents || []).map(r => 'res-' + r.id));
for (const uid of [...this.active.keys()])
if (uid.startsWith('res-') && !wanted.has(uid)) this.despawn(uid);
}
// ---------- tracks (layer 2) ----------
pickFromTrack(track, st) {
const pool = this.trackSet(track);
if (!pool.length) return null;
const activeIds = new Set([...this.active.values()].map(x => x.id));
if (track.spawnTime === 'random' || track.spawnTime == null) {
// ambient: rarity-weighted pick from whoever isn't already out
const avail = pool.filter(g => !activeIds.has(g.id));
if (!avail.length) return null;
const w = this.cfg.rarity?.weights || {};
const weights = avail.map(g => Math.max(0, w[g.rarity] ?? 1));
const sum = weights.reduce((a, b) => a + b, 0);
if (sum <= 0) return avail[Math.floor(Math.random() * avail.length)];
let r = Math.random() * sum;
for (let i = 0; i < avail.length; i++) { r -= weights[i]; if (r <= 0) return avail[i]; }
return avail[avail.length - 1];
}
// scheduled: strict round-robin so "Jimothy and Joe Rotten" alternate at concurrent 1
for (let i = 0; i < pool.length; i++) {
const g = pool[(st.cursor + i) % pool.length];
if (!activeIds.has(g.id)) { st.cursor = (st.cursor + i + 1) % pool.length; return g; }
}
return null;
}
runTrack(track, now) {
if (!this.trackState.has(track.id)) this.trackState.set(track.id, { nextFireAt: 0, cursor: 0 });
const st = this.trackState.get(track.id);
const mine = [...this.active.values()].filter(g => g.trackId === track.id);
const timed = !(track.spawnTime === 'random' || track.spawnTime == null);
if (timed) {
if (now < st.nextFireAt) return;
if (mine.length >= track.concurrent) return; // cycle still on screen
// fire a full batch, then wait spawnTime from the END of this appearance
let placed = 0;
for (let i = mine.length; i < track.concurrent; i++) if (this.spawnFor(track, st)) placed++;
if (placed) st.nextFireAt = now + (Number(track.spawnTime) * 1000) + (track.runEach * 1000);
return;
}
// ambient: keep topped up continuously
for (let i = mine.length; i < track.concurrent; i++) if (!this.spawnFor(track, st)) break;
}
spawnFor(track, st) {
const g = this.pickFromTrack(track, st);
if (!g) return false;
const loc = this.resolveLocation(track);
if (!loc) return false;
const b = this.cfg.behaviors || {};
const kind = loc.path ? 'path' : (Math.random() < (b.staticChance ?? 0.5) ? 'static' : 'wander');
const rec = this.makeRecord(g, loc, this.makeBehavior(kind, g, loc.path),
{ trackId: track.id, priority: track.priority ?? 0, runEach: track.runEach });
this.active.set(rec.uid, rec);
this.broadcast({ type: 'spawn', ghost: rec });
return true;
}
despawn(uid) {
if (!this.active.has(uid)) return;
this.active.delete(uid);
this.broadcast({ type: 'despawn', uid });
}
activeSnapshot() { return [...this.active.values()]; }
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);
});
}
tick() {
const now = Date.now();
if (!this.inTimeWindow()) {
for (const uid of [...this.active.keys()]) this.despawn(uid);
return;
}
this.ensureResidents();
for (const [uid, g] of this.active) if (!g.permanent && g.until != null && now >= g.until) this.despawn(uid);
// scheduled tracks first so they can claim/evict before ambient fills up
const tracks = (this.cfg.tracks || []).filter(t => t.enabled !== false)
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
for (const t of tracks) this.runTrack(t, now);
}
start() {
this.tick();
this.timer = setInterval(() => this.tick(), 1000);
}
}
module.exports = Playlist;
+162
View File
@@ -0,0 +1,162 @@
/* 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 { Assets } = require('./uploads');
const PORT = process.env.PORT || 33044;
const MODE = process.env.EXHIBIT_MODE || 'dev';
const app = express();
app.use(express.json({ limit: '20mb' })); // base64 asset uploads
// ---------- 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')));
// ---------- uploaded assets (models + textures) ----------
const assets = new Assets(state.liveDir);
app.use('/assets', express.static(path.join(state.liveDir, 'assets'), { maxAge: '1h' }));
app.get('/api/assets', (req, res) => res.json(assets.list()));
app.post('/api/assets', guard, (req, res) => {
try { res.json(assets.save(req.body)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
app.delete('/api/assets/:id', guard, (req, res) => {
try { res.json(assets.remove(req.params.id)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
// ---------- landing page ----------
app.get('/api/landing', (req, res) => res.json(state.getLanding()));
app.put('/api/landing', guard, (req, res) => {
try { res.json(state.putLanding(req.body)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
// ---------- character overrides ----------
app.get('/api/characters', (req, res) => res.json(state.getCharacters()));
app.put('/api/characters', guard, (req, res) => {
try { res.json(state.putCharacters(req.body)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
// ---------- 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()));
// ---------- named layouts (scene + its playlist) ----------
app.get('/api/layouts', (req, res) => res.json(state.listLayouts()));
app.post('/api/layouts', guard, (req, res) => {
try {
const name = (req.body && req.body.name || '').trim();
if (!name) return res.status(400).json({ error: 'name required' });
res.json(state.saveLayout(name, playlist.getConfig()));
} catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
app.post('/api/layouts/:slug/load', guard, (req, res) => {
try {
const { scene, playlist: pl } = state.loadLayout(req.params.slug);
if (pl) playlist.reloadFromState();
res.json({ scene, layouts: state.listLayouts() });
} catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
app.delete('/api/layouts/:slug', guard, (req, res) => {
try { res.json(state.deleteLayout(req.params.slug)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
// ---------- 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: 'characters', characters: state.getCharacters() }));
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() });
broadcast({ type: 'characters', characters: state.getCharacters() });
});
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.');
});
+202
View File
@@ -0,0 +1,202 @@
/* 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');
const CHARS_LIVE = path.join(LIVE_DIR, 'characters.live.json');
const LANDING_LIVE = path.join(LIVE_DIR, 'landing.live.json');
/* Named layouts: each is a self-contained { name, scene, playlist } saved under
* layouts/<slug>.json. layouts.index.json tracks the list + which slug is active.
* The active layout's scene/playlist are mirrored into scene.live.json /
* playlist.live.json so viewers and the playlist engine need no changes. */
const LAYOUTS_DIR = path.join(LIVE_DIR, 'layouts');
const LAYOUTS_INDEX = path.join(LIVE_DIR, 'layouts.index.json');
fs.mkdirSync(LIVE_DIR, { recursive: true });
fs.mkdirSync(LAYOUTS_DIR, { recursive: true });
function slugify(name) {
const s = String(name || '').toLowerCase().trim()
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
return s || 'layout';
}
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: [], paths: [], world: { units: 'cm' } });
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: [] });
/* characters: per-ghost visual overrides keyed by ghost id, plus defaults.
* { defaults: {...}, byId: { 'mad-fenton': { modelId, opacity, topColor, bottomColor,
* faceTextureUrl, torsoTextureUrl, heightCm, scale } } } */
let characters = readJSON(CHARS_LIVE, null) || { defaults: {}, byId: {} };
/* landing: end-user facing start screen + details page. All text is editable except
* that the disclaimer is ALWAYS rendered (wording editable, presence is not). */
const LANDING_DEFAULTS = {
title: 'NEWBURY NIGHTS',
subtitle: 'Point your phone at the Newbury Crests to reveal the hidden side of the town.',
logoUrl: '',
backgroundUrl: '',
startButton: 'Start Ghost Watching',
detailsButton: 'About this build',
detailsTitle: 'About Newbury Nights',
detailsMarkdown: 'Write about your build here.\n\nUse **bold**, *italic*, and blank lines for paragraphs.',
disclaimer: 'Fan-made tribute experience. Not affiliated with, sponsored, or endorsed by the LEGO Group. LEGO® and Hidden Side™ are trademarks of the LEGO Group.',
accent: '#51eaf1',
textColor: '#e8ecff',
overlay: 0.55,
};
let landing = Object.assign({}, LANDING_DEFAULTS, readJSON(LANDING_LIVE, null) || {});
const listeners = [];
/* ---------- named layouts ---------- */
function readLayoutIndex() {
const idx = readJSON(LAYOUTS_INDEX, null);
if (idx && Array.isArray(idx.layouts)) return idx;
return { layouts: [], activeSlug: null };
}
function writeLayoutIndex(idx) { writeJSON(LAYOUTS_INDEX, idx); }
function layoutPath(slug) { return path.join(LAYOUTS_DIR, `${slug}.json`); }
function listLayouts() {
const idx = readLayoutIndex();
return { layouts: idx.layouts, activeSlug: idx.activeSlug };
}
/* Snapshot the CURRENT live scene + playlist under a name. Overwrites if the
* slug already exists (same name). Returns the updated index. */
function saveLayout(name, playlistCfg) {
const slug = slugify(name);
const record = {
name: String(name || slug),
slug,
scene,
playlist: playlistCfg || readJSON(PLAYLIST_LIVE, null) || readJSON(path.join(DATA, 'playlist.json'), {}),
savedAt: Date.now(),
};
writeJSON(layoutPath(slug), record);
const idx = readLayoutIndex();
if (!idx.layouts.some(l => l.slug === slug)) idx.layouts.push({ slug, name: record.name });
else idx.layouts = idx.layouts.map(l => l.slug === slug ? { slug, name: record.name } : l);
idx.activeSlug = slug;
writeLayoutIndex(idx);
return listLayouts();
}
/* Make a saved layout active: mirror its scene into the live scene (so viewers
* update) and return its playlist so the caller can reload the engine. */
function loadLayout(slug) {
const rec = readJSON(layoutPath(slug), null);
if (!rec) throw new Error('layout not found: ' + slug);
scene = validateScene(rec.scene);
writeJSON(LIVE, scene);
if (rec.playlist) writeJSON(PLAYLIST_LIVE, rec.playlist);
const idx = readLayoutIndex();
idx.activeSlug = slug;
writeLayoutIndex(idx);
listeners.forEach(f => f());
return { scene, playlist: rec.playlist || null };
}
function deleteLayout(slug) {
try { fs.unlinkSync(layoutPath(slug)); } catch {}
const idx = readLayoutIndex();
idx.layouts = idx.layouts.filter(l => l.slug !== slug);
if (idx.activeSlug === slug) idx.activeSlug = null;
writeLayoutIndex(idx);
return listLayouts();
}
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`);
}
s.table = Object.assign({ width: 120, depth: 100, offsetX: 0, offsetZ: 0, show: true }, s.table || {});
s.ghostHeightCm = Number(s.ghostHeightCm || 4); // minifigure-scale default
s.paths = Array.isArray(s.paths) ? s.paths : [];
for (const pth of s.paths) {
if (!pth.id) throw new Error('path.id required');
if (!Array.isArray(pth.points) || pth.points.length < 2) throw new Error(`path ${pth.id} needs >= 2 points`);
pth.mode = pth.mode === 'pingpong' ? 'pingpong' : 'loop';
pth.enabled = pth.enabled !== false;
}
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 }),
getLanding: () => landing,
putLanding(l) {
if (!l || typeof l !== 'object') throw new Error('landing must be object');
const next = Object.assign({}, LANDING_DEFAULTS, l);
// disclaimer may be reworded but never emptied — it protects the project
if (!String(next.disclaimer || '').trim()) next.disclaimer = LANDING_DEFAULTS.disclaimer;
landing = next;
writeJSON(LANDING_LIVE, landing);
listeners.forEach(f => f());
return landing;
},
getCharacters: () => characters,
putCharacters(c) {
if (!c || typeof c !== 'object') throw new Error('characters must be object');
characters = { defaults: c.defaults || {}, byId: c.byId || {} };
writeJSON(CHARS_LIVE, characters);
listeners.forEach(f => f());
return characters;
},
liveDir: LIVE_DIR,
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; },
listLayouts,
saveLayout,
loadLayout,
deleteLayout,
onChange: (f) => listeners.push(f),
};
+64
View File
@@ -0,0 +1,64 @@
/* uploads.js — model/texture asset handling for the character manager.
* Files land in LIVE_DIR/assets (Docker volume) and are served at /assets/...
* so uploads survive image rebuilds without being baked into the repo.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const ALLOWED = {
'.obj': 'model/obj', '.mtl': 'model/mtl',
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp',
};
const MAX_BYTES = 12 * 1024 * 1024;
function safeName(name) {
const base = path.basename(String(name || 'file')).replace(/[^a-zA-Z0-9._-]/g, '_');
return base.slice(-80) || 'file';
}
class Assets {
constructor(liveDir) {
this.dir = path.join(liveDir, 'assets');
this.indexPath = path.join(liveDir, 'assets.json');
fs.mkdirSync(this.dir, { recursive: true });
this.index = this.read();
}
read() {
try { return JSON.parse(fs.readFileSync(this.indexPath, 'utf8')); } catch { return { assets: [] }; }
}
write() { fs.writeFileSync(this.indexPath, JSON.stringify(this.index, null, 2)); }
list() { return this.index; }
/* body: { name, kind: 'model'|'texture', dataBase64 } */
save({ name, kind, dataBase64 }) {
const clean = safeName(name);
const ext = path.extname(clean).toLowerCase();
if (!ALLOWED[ext]) throw new Error(`unsupported file type ${ext || '(none)'} — allowed: ${Object.keys(ALLOWED).join(', ')}`);
const buf = Buffer.from(String(dataBase64 || ''), 'base64');
if (!buf.length) throw new Error('empty file');
if (buf.length > MAX_BYTES) throw new Error(`file too large (${(buf.length / 1048576).toFixed(1)} MB, max 12 MB)`);
const id = crypto.randomBytes(6).toString('hex');
const stored = `${id}${ext}`;
fs.writeFileSync(path.join(this.dir, stored), buf);
const rec = {
id, name: clean, kind: kind === 'texture' ? 'texture' : 'model',
url: `/assets/${stored}`, bytes: buf.length, uploadedAt: Date.now(),
};
this.index.assets.push(rec);
this.write();
return rec;
}
remove(id) {
const i = this.index.assets.findIndex(a => a.id === id);
if (i < 0) throw new Error('asset not found');
const [rec] = this.index.assets.splice(i, 1);
try { fs.unlinkSync(path.join(this.dir, path.basename(rec.url))); } catch {}
this.write();
return rec;
}
}
module.exports = { Assets, ALLOWED, MAX_BYTES };