Import v2 base (unchanged files)
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
data/live/
|
||||
.env
|
||||
+2666
File diff suppressed because it is too large
Load Diff
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
Generated
+850
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 ? 'model’s 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>
|
||||
@@ -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>
|
||||
@@ -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, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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(' ')}
|
||||
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 & rarity</h2>
|
||||
<div class="grid">
|
||||
<div class="f"><label>Static chance (0–1)</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 (0–1)</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
|
||||
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>');
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Vendored
+468
File diff suppressed because one or more lines are too long
Vendored
+737
@@ -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;
|
||||
};
|
||||
+39
File diff suppressed because one or more lines are too long
Vendored
+495
@@ -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;
|
||||
};
|
||||
Vendored
+284
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user