update Tue 07/14/2026 11:38:16.92
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
# copy to .env and edit
|
||||||
|
EXHIBIT_MODE=dev
|
||||||
|
# required when EXHIBIT_MODE=final:
|
||||||
|
ADMIN_PASSWORD=change-me
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
data/live/
|
||||||
|
.env
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
COPY server ./server
|
||||||
|
COPY data ./data
|
||||||
|
COPY public ./public
|
||||||
|
ENV PORT=33044
|
||||||
|
ENV LIVE_DIR=/app/data/live
|
||||||
|
EXPOSE 33044
|
||||||
|
CMD ["node", "server/server.js"]
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Newbury Exhibit v2
|
||||||
|
|
||||||
|
Fan-made shared multi-viewer AR experience for a physical LEGO® Hidden Side™ town
|
||||||
|
display. Multiple phones point at printed ArUco markers ("Newbury Crests") and see
|
||||||
|
the same server-driven ghosts anchored to real-world positions, each from their own
|
||||||
|
viewpoint. Tribute project — not affiliated with or endorsed by the LEGO Group.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
Node 20 (Express + ws) in Docker · Three.js r160 (CDN import map) · js-aruco2
|
||||||
|
(ARUCO_4X4_1000, vendored) · vanilla ES-module client.
|
||||||
|
|
||||||
|
## Baked-in AR fixes (hard-won, do not "simplify" these)
|
||||||
|
- **POS-IT pose**: rotation used **as-is**; translation **Y and Z negated** (`-t[1]`, `-t[2]`).
|
||||||
|
- **Planar-ambiguity flips**: per-marker temporal consistency — pick the POS-IT solution
|
||||||
|
closest to the previous frame (`public/js/ar/pose.js`).
|
||||||
|
- **Phantom marker IDs**: `maxHammingDistance: 0` (`public/js/ar/detect.js`).
|
||||||
|
- **Multi-marker flicker**: confidence-weighted quaternion slerp fusion, hemisphere-aligned,
|
||||||
|
weighted by marker screen area (`public/js/ar/fuse.js`). Place anchors densely so ≥2
|
||||||
|
crests are visible from any viewing angle.
|
||||||
|
- **Vendor load order matters**: `cv → svd → posit1 → aruco → dictionary`.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- **Playlist engine** (server-driven, all viewers in sync): N concurrent ghosts,
|
||||||
|
per-ghost dwell time then rotate through the whole roster, shuffle/roster order,
|
||||||
|
color/rarity filters, optional time-of-day windows, crossfade in/out.
|
||||||
|
- **Behaviors**: static float (bob + idle sway) or wander (deterministic orbit-drift —
|
||||||
|
a pure function of server spawn record + wall clock, so every phone computes the
|
||||||
|
same motion with zero position streaming).
|
||||||
|
- **Anchors ("Newbury Crests")**: flat (horizontal) / wall (vertical) / custom mounts,
|
||||||
|
fully editable — position, yaw/pitch/roll trim, printed size, enable toggle.
|
||||||
|
- **Layout editor** (`/admin/layout.html`): to-scale top-down canvas; drag anchors,
|
||||||
|
occlusion buildings, and spawn points; property panel; 5 mm snap; save/reset-to-seed.
|
||||||
|
- **Playlist manager** (`/admin/playlist.html`), **crest print sheet** (`/admin/print.html`,
|
||||||
|
true-to-size), **calibration** (`/admin/calibrate.html`: live distance/angle/jitter +
|
||||||
|
fused pose), **client error log** (`/admin/errors.html`).
|
||||||
|
- **Occlusion**: buildings render as invisible depth-only volumes so ghosts pass behind them.
|
||||||
|
- **Multi-part OBJ ghosts**: legs/wisp + torso + head + headpiece assembled and tinted with
|
||||||
|
the recovered Hidden Side gradient shader (Red/Angry, Yellow/Crazy, Blue/Sad). Procedural
|
||||||
|
wisp fallback until models are dropped into `public/models/` and listed in the model
|
||||||
|
manifest (`GET/PUT /api/models`).
|
||||||
|
|
||||||
|
## Modes
|
||||||
|
- `EXHIBIT_MODE=dev` — admin pages open, on-screen client error overlay, errors reported
|
||||||
|
to `/api/client-errors`.
|
||||||
|
- `EXHIBIT_MODE=final` — admin + setup pages require `ADMIN_PASSWORD` (12 h tokens).
|
||||||
|
|
||||||
|
## Run
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # set mode + password
|
||||||
|
docker compose up -d --build
|
||||||
|
# http://host:33044 → viewer
|
||||||
|
# http://host:33044/admin/layout.html → admin
|
||||||
|
```
|
||||||
|
Live edits (layout, playlist, models) persist in the `exhibit-live` volume and survive
|
||||||
|
rebuilds; `POST /api/scene/reset` restores the committed seed.
|
||||||
|
|
||||||
|
## Deploy on blackpearl
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.hideawaygaming.com.au/jessikitty/newbury-exhibit-v2.git
|
||||||
|
cd newbury-exhibit-v2 && cp .env.example .env && bash deploy.sh
|
||||||
|
```
|
||||||
|
Then nginx-proxy 33044 with HTTPS (camera + sensors require a secure context).
|
||||||
|
|
||||||
|
## Adding your OBJ ghost models
|
||||||
|
1. Drop parts into `public/models/<id>/` (e.g. `legs.obj`, `torso.obj`, `head.obj`, `headpiece.obj`).
|
||||||
|
2. Register via the models API (or edit `data/models.json` seed):
|
||||||
|
```json
|
||||||
|
{ "models": [ { "id": "minifig", "scale": 1.0, "parts": {
|
||||||
|
"legs": "/models/minifig/legs.obj", "torso": "/models/minifig/torso.obj",
|
||||||
|
"head": "/models/minifig/head.obj", "headpiece": "/models/minifig/headpiece.obj" } } ] }
|
||||||
|
```
|
||||||
|
The gradient shader recolors every part by the ghost's color type automatically.
|
||||||
+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,9 @@
|
|||||||
|
{
|
||||||
|
"slots": 5,
|
||||||
|
"dwellSeconds": 120,
|
||||||
|
"crossfadeSeconds": 3,
|
||||||
|
"order": "shuffle",
|
||||||
|
"include": { "colors": [], "rarities": [], "ids": [] },
|
||||||
|
"behaviors": { "staticChance": 0.5, "wanderRadius": 0.6, "wanderSpeed": 0.15 },
|
||||||
|
"timeWindows": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"world": { "units": "m", "note": "origin = front-left corner of the table; +X right, +Z toward the back, +Y up" },
|
||||||
|
"anchors": [
|
||||||
|
{ "markerId": 0, "position": [0.20, 0.00, 0.20], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true },
|
||||||
|
{ "markerId": 1, "position": [0.90, 0.00, 0.20], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true },
|
||||||
|
{ "markerId": 2, "position": [0.20, 0.00, 0.90], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true },
|
||||||
|
{ "markerId": 3, "position": [0.90, 0.00, 0.90], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true },
|
||||||
|
{ "markerId": 4, "position": [0.55, 0.12, 0.00], "mount": "wall", "yawDeg": 180, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true }
|
||||||
|
],
|
||||||
|
"buildings": [
|
||||||
|
{ "name": "Newbury High", "position": [0.55, 0.00, 0.55], "size": [0.35, 0.30, 0.25], "yawDeg": 0 }
|
||||||
|
],
|
||||||
|
"spawns": [
|
||||||
|
{ "id": "spawn-1", "position": [0.35, 0.30, 0.40], "enabled": true },
|
||||||
|
{ "id": "spawn-2", "position": [0.75, 0.35, 0.40], "enabled": true },
|
||||||
|
{ "id": "spawn-3", "position": [0.55, 0.45, 0.70], "enabled": true },
|
||||||
|
{ "id": "spawn-4", "position": [0.25, 0.25, 0.75], "enabled": true },
|
||||||
|
{ "id": "spawn-5", "position": [0.85, 0.28, 0.75], "enabled": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
exhibit:
|
||||||
|
build: .
|
||||||
|
container_name: newbury-exhibit
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "33044:33044"
|
||||||
|
environment:
|
||||||
|
# dev = admin open + on-screen error overlay; final = password-protected admin
|
||||||
|
EXHIBIT_MODE: ${EXHIBIT_MODE:-dev}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||||
|
volumes:
|
||||||
|
# live scene/playlist edits survive rebuilds and redeploys
|
||||||
|
- exhibit-live:/app/data/live
|
||||||
|
# drop OBJ ghost models here without rebuilding the image
|
||||||
|
- ./public/models:/app/public/models
|
||||||
|
volumes:
|
||||||
|
exhibit-live:
|
||||||
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,12 @@
|
|||||||
|
{
|
||||||
|
"name": "newbury-exhibit-v2",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Fan-made shared multi-viewer AR exhibit for a LEGO Hidden Side town display (tribute project)",
|
||||||
|
"main": "server/server.js",
|
||||||
|
"scripts": { "start": "node server/server.js" },
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"ws": "^8.17.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
${['layout', 'playlist', '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(3)).join(', ')} m (${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,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,237 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Layout Editor — 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}
|
||||||
|
#wrap { display:grid; grid-template-columns: 1fr 300px; height: calc(100vh - 44px); }
|
||||||
|
#cv { background:#0d1226; 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; }
|
||||||
|
.badge { font-size:.7rem; opacity:.6; }
|
||||||
|
</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 data-add="spawn">+ Spawn point</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">
|
||||||
|
<canvas id="cv"></canvas>
|
||||||
|
<div id="side"><em style="opacity:.6">Select an item to edit it. Drag on the canvas to move. Scroll to zoom.</em></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
let scene = await api('/api/scene');
|
||||||
|
scene.anchors ||= []; scene.buildings ||= []; scene.spawns ||= [];
|
||||||
|
|
||||||
|
const cv = document.getElementById('cv');
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
let view = { x: 0, z: 0, scale: 120 }; // px per metre
|
||||||
|
let sel = null; // { kind, index }
|
||||||
|
let drag = null;
|
||||||
|
|
||||||
|
function fit() {
|
||||||
|
cv.width = cv.clientWidth * devicePixelRatio;
|
||||||
|
cv.height = cv.clientHeight * devicePixelRatio;
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
new ResizeObserver(fit).observe(cv);
|
||||||
|
|
||||||
|
const w2s = (x, z) => [cv.width / 2 + (x - view.x) * view.scale * devicePixelRatio,
|
||||||
|
cv.height / 2 + (z - view.z) * view.scale * devicePixelRatio];
|
||||||
|
const s2w = (px, py) => [(px * devicePixelRatio - cv.width / 2) / (view.scale * devicePixelRatio) + view.x,
|
||||||
|
(py * devicePixelRatio - cv.height / 2) / (view.scale * devicePixelRatio) + view.z];
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||||
|
// grid (0.25 m)
|
||||||
|
ctx.strokeStyle = '#1a2244'; ctx.lineWidth = 1;
|
||||||
|
const step = 0.25 * view.scale * devicePixelRatio;
|
||||||
|
const ox = (cv.width / 2 - view.x * view.scale * devicePixelRatio) % step;
|
||||||
|
const oz = (cv.height / 2 - view.z * view.scale * devicePixelRatio) % step;
|
||||||
|
for (let x = ox; x < cv.width; x += step) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, cv.height); ctx.stroke(); }
|
||||||
|
for (let y = oz; y < cv.height; y += step) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cv.width, y); ctx.stroke(); }
|
||||||
|
|
||||||
|
// buildings (occluders)
|
||||||
|
for (const [i, b] of scene.buildings.entries()) {
|
||||||
|
const [sx, sy] = w2s(b.position[0], b.position[2]);
|
||||||
|
const w = b.size[0] * view.scale * devicePixelRatio, d = b.size[2] * view.scale * devicePixelRatio;
|
||||||
|
ctx.save(); ctx.translate(sx, sy); ctx.rotate(-(b.yawDeg || 0) * Math.PI / 180);
|
||||||
|
ctx.fillStyle = isSel('building', i) ? 'rgba(82,158,255,.45)' : 'rgba(82,158,255,.22)';
|
||||||
|
ctx.strokeStyle = '#529eff';
|
||||||
|
ctx.fillRect(-w / 2, -d / 2, w, d); ctx.strokeRect(-w / 2, -d / 2, w, d);
|
||||||
|
ctx.restore();
|
||||||
|
label(b.name || `bldg ${i}`, sx, sy, '#9cc4ff');
|
||||||
|
}
|
||||||
|
// spawns
|
||||||
|
for (const [i, s] of scene.spawns.entries()) {
|
||||||
|
const [sx, sy] = w2s(s.position[0], s.position[2]);
|
||||||
|
ctx.beginPath(); ctx.arc(sx, sy, 9 * devicePixelRatio, 0, 7);
|
||||||
|
ctx.fillStyle = isSel('spawn', i) ? '#fff35d' : (s.enabled === false ? '#665' : '#b57f0b');
|
||||||
|
ctx.fill();
|
||||||
|
label(s.id || `spawn ${i}`, sx, sy - 14 * devicePixelRatio, '#ffe9a3');
|
||||||
|
}
|
||||||
|
// anchors
|
||||||
|
for (const [i, a] of scene.anchors.entries()) {
|
||||||
|
const [sx, sy] = w2s(a.position[0], a.position[2]);
|
||||||
|
ctx.save(); ctx.translate(sx, sy); ctx.rotate(-(a.yawDeg || 0) * Math.PI / 180);
|
||||||
|
const r = Math.max(8, (a.sizeMM / 1000) * view.scale) * devicePixelRatio;
|
||||||
|
ctx.fillStyle = isSel('anchor', i) ? '#51eaf1' : (a.enabled === false ? '#456' : (a.mount === 'wall' ? '#f65151' : '#3bc9a7'));
|
||||||
|
if (a.mount === 'wall') { ctx.fillRect(-r, -2.5 * devicePixelRatio, 2 * r, 5 * devicePixelRatio); // edge-on line
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, -r); ctx.strokeStyle = ctx.fillStyle; ctx.lineWidth = 2; ctx.stroke(); }
|
||||||
|
else ctx.fillRect(-r, -r, 2 * r, 2 * r);
|
||||||
|
ctx.restore();
|
||||||
|
label(`#${a.markerId}${a.mount === 'wall' ? ' ⊥' : ''}`, sx, sy + 16 * devicePixelRatio, '#a6fff0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function label(txt, x, y, col) {
|
||||||
|
ctx.fillStyle = col; ctx.font = `${11 * devicePixelRatio}px system-ui`; ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(txt, x, y - 12 * devicePixelRatio);
|
||||||
|
}
|
||||||
|
const isSel = (k, i) => sel && sel.kind === k && sel.index === i;
|
||||||
|
|
||||||
|
function hit(px, py) {
|
||||||
|
const [wx, wz] = s2w(px, py);
|
||||||
|
const near = (x, z, r) => Math.hypot(wx - x, wz - z) < r;
|
||||||
|
for (const [i, a] of scene.anchors.entries()) if (near(a.position[0], a.position[2], 0.12)) return { kind: 'anchor', index: i };
|
||||||
|
for (const [i, s] of scene.spawns.entries()) if (near(s.position[0], s.position[2], 0.12)) return { kind: 'spawn', index: i };
|
||||||
|
for (const [i, b] of scene.buildings.entries())
|
||||||
|
if (Math.abs(wx - b.position[0]) < b.size[0] / 2 + 0.05 && Math.abs(wz - b.position[2]) < b.size[2] / 2 + 0.05)
|
||||||
|
return { kind: 'building', index: i };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const objOf = (s) => s && ({ anchor: scene.anchors, building: scene.buildings, spawn: scene.spawns })[s.kind][s.index];
|
||||||
|
|
||||||
|
cv.addEventListener('pointerdown', (e) => {
|
||||||
|
cv.setPointerCapture(e.pointerId);
|
||||||
|
const h = hit(e.offsetX, e.offsetY);
|
||||||
|
sel = h;
|
||||||
|
drag = h ? { kind: 'move' } : { kind: 'pan', sx: e.offsetX, sy: e.offsetY, vx: view.x, vz: view.z };
|
||||||
|
panel(); draw();
|
||||||
|
});
|
||||||
|
cv.addEventListener('pointermove', (e) => {
|
||||||
|
if (!drag) return;
|
||||||
|
if (drag.kind === 'move' && sel) {
|
||||||
|
const [wx, wz] = s2w(e.offsetX, e.offsetY);
|
||||||
|
const o = objOf(sel);
|
||||||
|
o.position[0] = Math.round(wx * 200) / 200; // 5 mm snap
|
||||||
|
o.position[2] = Math.round(wz * 200) / 200;
|
||||||
|
panel(false); draw();
|
||||||
|
} else if (drag.kind === 'pan') {
|
||||||
|
view.x = drag.vx - (e.offsetX - drag.sx) / view.scale;
|
||||||
|
view.z = drag.vz - (e.offsetY - drag.sy) / view.scale;
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cv.addEventListener('pointerup', () => drag = null);
|
||||||
|
cv.addEventListener('wheel', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
view.scale = Math.min(600, Math.max(30, view.scale * (e.deltaY < 0 ? 1.12 : 0.89)));
|
||||||
|
draw();
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// ---------- add / delete ----------
|
||||||
|
document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => {
|
||||||
|
const [wx, wz] = [view.x, view.z];
|
||||||
|
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: [wx, 0, wz], 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: [wx, 0, wz], size: [0.4, 0.3, 0.3], yawDeg: 0 });
|
||||||
|
sel = { kind: 'building', index: scene.buildings.length - 1 };
|
||||||
|
} else {
|
||||||
|
scene.spawns.push({ id: 'spawn-' + (scene.spawns.length + 1), position: [wx, 0.25, wz], enabled: true });
|
||||||
|
sel = { kind: 'spawn', index: scene.spawns.length - 1 };
|
||||||
|
}
|
||||||
|
panel(); draw();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 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)); panel(false); draw(); };
|
||||||
|
});
|
||||||
|
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.01}" 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); panel(false); draw(); }; });
|
||||||
|
return `<div class="row"><label>${label}</label><input id="${id}" value="${val}"></div>`;
|
||||||
|
}
|
||||||
|
function panel(rebuild = true) {
|
||||||
|
if (!rebuild) return;
|
||||||
|
const o = objOf(sel);
|
||||||
|
if (!o) { side.innerHTML = '<em style="opacity:.6">Select an item to edit it.</em>'; 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 (m)', o.position[0], v => o.position[0] = v);
|
||||||
|
h += field('y (m)', o.position[1], v => o.position[1] = v);
|
||||||
|
h += field('z (m)', 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">flat = face-up on a surface · wall = vertical, yaw sets facing · custom = full yaw/pitch/roll</p>`;
|
||||||
|
} else if (sel.kind === 'building') {
|
||||||
|
h += textField('name', o.name || '', v => o.name = v);
|
||||||
|
h += field('x (m)', o.position[0], v => o.position[0] = v);
|
||||||
|
h += field('z (m)', 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 });
|
||||||
|
} else {
|
||||||
|
h += textField('id', o.id, v => o.id = v);
|
||||||
|
h += field('x (m)', o.position[0], v => o.position[0] = v);
|
||||||
|
h += field('y (m)', o.position[1], v => o.position[1] = v);
|
||||||
|
h += field('z (m)', 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" style="margin-top:12px"><button id="del" class="danger">Delete</button></div>`;
|
||||||
|
side.innerHTML = h;
|
||||||
|
document.getElementById('del').onclick = () => {
|
||||||
|
({ anchor: scene.anchors, building: scene.buildings, spawn: scene.spawns })[sel.kind].splice(sel.index, 1);
|
||||||
|
sel = null; panel(); draw();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- save / reset ----------
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
document.getElementById('save').onclick = async () => {
|
||||||
|
try { scene = await api('/api/scene', 'PUT', scene); 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; panel(); draw();
|
||||||
|
};
|
||||||
|
|
||||||
|
fit(); panel();
|
||||||
|
</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,99 @@
|
|||||||
|
<!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:640px; margin:20px auto; padding:0 16px; }
|
||||||
|
.card { background:#111730; border-radius:10px; padding:16px; margin-bottom:16px; }
|
||||||
|
.row { display:flex; gap:10px; align-items:center; margin:8px 0; }
|
||||||
|
.row label { width:170px; }
|
||||||
|
.chips { display:flex; flex-wrap:wrap; gap:6px; }
|
||||||
|
.chip { padding:4px 12px; border-radius:999px; background:#1a2244; cursor:pointer; font-size:.85rem; user-select:none; }
|
||||||
|
.chip.on { background:#2a6ea0; }
|
||||||
|
table { width:100%; font-size:.85rem; }
|
||||||
|
</style>`);
|
||||||
|
await requireAuth();
|
||||||
|
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
let cfg = await api('/api/playlist');
|
||||||
|
const { ghosts } = await api('/api/ghosts');
|
||||||
|
|
||||||
|
const colors = ['Red', 'Yellow', 'Blue'];
|
||||||
|
const rarities = ['Common', 'Rare', 'Epic', 'Legendary'];
|
||||||
|
|
||||||
|
function chips(list, selected, onToggle) {
|
||||||
|
return list.map(v => `<span class="chip ${selected.includes(v) ? 'on' : ''}" data-v="${v}">${v}</span>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rosterCount() {
|
||||||
|
const inc = cfg.include || {};
|
||||||
|
return ghosts.filter(g =>
|
||||||
|
(!inc.colors?.length || inc.colors.includes(g.color)) &&
|
||||||
|
(!inc.rarities?.length || inc.rarities.includes(g.rarity))).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
app.innerHTML = `${nav('playlist')}<main>
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0">Rotation</h2>
|
||||||
|
<div class="row"><label>Concurrent ghosts (slots)</label><input id="slots" type="number" min="1" max="20" value="${cfg.slots}"></div>
|
||||||
|
<div class="row"><label>Dwell time (seconds)</label><input id="dwell" type="number" min="10" value="${cfg.dwellSeconds}"></div>
|
||||||
|
<div class="row"><label>Crossfade (seconds)</label><input id="fade" type="number" min="0" value="${cfg.crossfadeSeconds}"></div>
|
||||||
|
<div class="row"><label>Order</label>
|
||||||
|
<select id="order"><option ${cfg.order === 'shuffle' ? 'selected' : ''}>shuffle</option><option ${cfg.order === 'roster' ? 'selected' : ''}>roster</option></select></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0">Roster filter <span style="opacity:.6;font-size:.8rem">(${rosterCount()} ghosts match — empty = all)</span></h2>
|
||||||
|
<div class="row"><label>Colors</label><div class="chips" id="colors">${chips(colors, cfg.include?.colors || [])}</div></div>
|
||||||
|
<div class="row"><label>Rarities</label><div class="chips" id="rarities">${chips(rarities, cfg.include?.rarities || [])}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0">Behavior</h2>
|
||||||
|
<div class="row"><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="row"><label>Wander radius (m)</label><input id="wanderRadius" type="number" step="0.05" value="${cfg.behaviors.wanderRadius}"></div>
|
||||||
|
<div class="row"><label>Wander speed</label><input id="wanderSpeed" type="number" step="0.05" value="${cfg.behaviors.wanderSpeed}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0">Time windows <span style="opacity:.6;font-size:.8rem">(ghosts only appear inside 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-del="${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 rotation)</button>
|
||||||
|
<div id="status" style="margin:10px 0;opacity:.7;font-size:.85rem"></div>
|
||||||
|
</main>`;
|
||||||
|
|
||||||
|
for (const grp of ['colors', 'rarities']) {
|
||||||
|
document.getElementById(grp).querySelectorAll('.chip').forEach(c => c.onclick = () => {
|
||||||
|
const arr = cfg.include[grp] = cfg.include[grp] || [];
|
||||||
|
const i = arr.indexOf(c.dataset.v);
|
||||||
|
i >= 0 ? arr.splice(i, 1) : arr.push(c.dataset.v);
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('addWin').onclick = () => { (cfg.timeWindows ||= []).push({ start: '09:00', end: '17:00' }); render(); };
|
||||||
|
document.querySelectorAll('[data-del]').forEach(b => b.onclick = () => { cfg.timeWindows.splice(+b.dataset.del, 1); render(); });
|
||||||
|
document.querySelectorAll('#windows input').forEach(inp => inp.onchange = () => cfg.timeWindows[+inp.dataset.i][inp.dataset.k] = inp.value);
|
||||||
|
|
||||||
|
document.getElementById('save').onclick = async () => {
|
||||||
|
cfg.slots = +document.getElementById('slots').value;
|
||||||
|
cfg.dwellSeconds = +document.getElementById('dwell').value;
|
||||||
|
cfg.crossfadeSeconds = +document.getElementById('fade').value;
|
||||||
|
cfg.order = document.getElementById('order').value;
|
||||||
|
cfg.behaviors = {
|
||||||
|
staticChance: +document.getElementById('staticChance').value,
|
||||||
|
wanderRadius: +document.getElementById('wanderRadius').value,
|
||||||
|
wanderSpeed: +document.getElementById('wanderSpeed').value,
|
||||||
|
};
|
||||||
|
try { cfg = await api('/api/playlist', 'PUT', cfg); document.getElementById('status').textContent = 'Applied ' + new Date().toLocaleTimeString(); }
|
||||||
|
catch (e) { document.getElementById('status').textContent = 'Failed: ' + e.message; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<!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 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);
|
||||||
|
div.insertAdjacentHTML('beforeend',
|
||||||
|
`<div class="cap"><b>Crest #${a.markerId}</b> · ${a.sizeMM} mm · ${a.mount}` +
|
||||||
|
`${a.mount === 'wall' ? ` · yaw ${a.yawDeg}°` : ''}<br>` +
|
||||||
|
`pos ${a.position.map(v => v.toFixed(2)).join(', ')} m</div>`);
|
||||||
|
sheet.appendChild(div);
|
||||||
|
}
|
||||||
|
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,65 @@
|
|||||||
|
<!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 Exhibit</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
html, body { margin:0; height:100%; overflow:hidden; background:#06080f; font-family:system-ui, sans-serif; color:#e8ecff; }
|
||||||
|
#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; }
|
||||||
|
|
||||||
|
#startScreen { position:fixed; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
|
||||||
|
background:radial-gradient(ellipse at 50% 30%, #12203f 0%, #06080f 70%); text-align:center; padding:24px; z-index:10; }
|
||||||
|
#startScreen h1 { font-size:2rem; margin:0 0 4px; letter-spacing:.06em; }
|
||||||
|
#startScreen .sub { opacity:.7; margin-bottom:28px; }
|
||||||
|
#start { font-size:1.2rem; padding:14px 42px; border-radius:999px; border:0; cursor:pointer;
|
||||||
|
background:linear-gradient(135deg,#51eaf1,#529eff); color:#04121a; font-weight:700; }
|
||||||
|
.disclaimer { position:absolute; bottom:14px; left:16px; right:16px; font-size:.68rem; opacity:.55; line-height:1.4; }
|
||||||
|
|
||||||
|
#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; }
|
||||||
|
|
||||||
|
#toast { position:fixed; bottom:32px; left:50%; transform:translateX(-50%) translateY(20px);
|
||||||
|
background:rgba(20,28,55,.9); 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">
|
||||||
|
<h1>NEWBURY EXHIBIT</h1>
|
||||||
|
<div class="sub">Point your phone at the Newbury Crests to reveal the hidden side of the town.</div>
|
||||||
|
<button id="start">Start Ghost Watching</button>
|
||||||
|
<div class="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.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="hud" class="hidden">
|
||||||
|
<span id="trk">—</span>
|
||||||
|
<span id="cnt">—</span>
|
||||||
|
</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,88 @@
|
|||||||
|
/* fuse.js — fuseWorld: combine per-marker camera pose estimates into one world pose.
|
||||||
|
*
|
||||||
|
* Each detected marker yields a camera-in-world estimate:
|
||||||
|
* cameraWorld = anchorWorld * inverse(markerPoseInCamera)
|
||||||
|
* Estimates are fused by confidence-weighted quaternion slerp (incremental
|
||||||
|
* weighted average) and weighted position mean. Confidence = marker screen area
|
||||||
|
* (bigger/closer markers dominate). A light temporal smooth removes residual jitter.
|
||||||
|
*
|
||||||
|
* Requiring >= 2 visible markers is handled upstream by anchor placement density;
|
||||||
|
* fuseWorld itself works with 1..N.
|
||||||
|
*/
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { anchorWorldMatrix } from './pose.js';
|
||||||
|
|
||||||
|
const _inv = new THREE.Matrix4();
|
||||||
|
const _m = new THREE.Matrix4();
|
||||||
|
const _p = new THREE.Vector3();
|
||||||
|
const _q = new THREE.Quaternion();
|
||||||
|
const _s = new THREE.Vector3();
|
||||||
|
|
||||||
|
export class WorldFuser {
|
||||||
|
constructor() {
|
||||||
|
this.anchorMats = new Map(); // markerId -> Matrix4
|
||||||
|
this.smoothPos = null;
|
||||||
|
this.smoothQuat = null;
|
||||||
|
this.posAlpha = 0.35; // smoothing factors (higher = snappier)
|
||||||
|
this.quatAlpha = 0.35;
|
||||||
|
this.lastFuseT = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
setScene(scene) {
|
||||||
|
this.anchorMats.clear();
|
||||||
|
for (const a of scene.anchors || []) {
|
||||||
|
if (a.enabled === false) continue;
|
||||||
|
this.anchorMats.set(a.markerId, { mat: anchorWorldMatrix(a), sizeMM: a.sizeMM || 60 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sizeFor(markerId) { return this.anchorMats.get(markerId)?.sizeMM || 60; }
|
||||||
|
knownIds() { return new Set(this.anchorMats.keys()); }
|
||||||
|
|
||||||
|
/** estimates: [{ markerId, position(mm), quaternion, area }] */
|
||||||
|
fuse(estimates) {
|
||||||
|
const MM = 0.001;
|
||||||
|
const cams = [];
|
||||||
|
for (const e of estimates) {
|
||||||
|
const entry = this.anchorMats.get(e.markerId);
|
||||||
|
if (!entry) continue;
|
||||||
|
// marker pose in camera space -> matrix (translate mm->m)
|
||||||
|
_m.compose(_p.copy(e.position).multiplyScalar(MM), e.quaternion, _s.set(1, 1, 1));
|
||||||
|
_inv.copy(_m).invert(); // camera in marker space
|
||||||
|
const camWorld = new THREE.Matrix4().multiplyMatrices(entry.mat, _inv);
|
||||||
|
const pos = new THREE.Vector3();
|
||||||
|
const quat = new THREE.Quaternion();
|
||||||
|
camWorld.decompose(pos, quat, _s);
|
||||||
|
cams.push({ pos, quat, w: Math.max(1, e.area) });
|
||||||
|
}
|
||||||
|
if (!cams.length) return null;
|
||||||
|
|
||||||
|
// weighted position mean + incremental weighted slerp for orientation
|
||||||
|
let wSum = cams[0].w;
|
||||||
|
const pos = cams[0].pos.clone().multiplyScalar(cams[0].w);
|
||||||
|
const quat = cams[0].quat.clone();
|
||||||
|
for (let i = 1; i < cams.length; i++) {
|
||||||
|
const c = cams[i];
|
||||||
|
// hemisphere alignment before slerp (quaternion double-cover)
|
||||||
|
if (quat.dot(c.quat) < 0) c.quat.set(-c.quat.x, -c.quat.y, -c.quat.z, -c.quat.w);
|
||||||
|
const t = c.w / (wSum + c.w);
|
||||||
|
quat.slerp(c.quat, t);
|
||||||
|
pos.add(c.pos.clone().multiplyScalar(c.w));
|
||||||
|
wSum += c.w;
|
||||||
|
}
|
||||||
|
pos.multiplyScalar(1 / wSum);
|
||||||
|
|
||||||
|
// temporal smoothing
|
||||||
|
const now = performance.now();
|
||||||
|
if (this.smoothPos && now - this.lastFuseT < 500) {
|
||||||
|
this.smoothPos.lerp(pos, this.posAlpha);
|
||||||
|
if (this.smoothQuat.dot(quat) < 0) quat.set(-quat.x, -quat.y, -quat.z, -quat.w);
|
||||||
|
this.smoothQuat.slerp(quat, this.quatAlpha);
|
||||||
|
} else {
|
||||||
|
this.smoothPos = pos.clone();
|
||||||
|
this.smoothQuat = quat.clone();
|
||||||
|
}
|
||||||
|
this.lastFuseT = now;
|
||||||
|
return { position: this.smoothPos.clone(), quaternion: this.smoothQuat.clone(), markerCount: cams.length };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/* pose.js — marker pose estimation with the confirmed fixes:
|
||||||
|
* 1. POS-IT rotation used AS-IS; translation Y and Z negated (-t[1], -t[2]).
|
||||||
|
* 2. POS-IT planar ambiguity resolved by temporal consistency:
|
||||||
|
* pick the solution (bestError vs alternativeError) closest to the previous frame.
|
||||||
|
*
|
||||||
|
* Requires vendor chain loaded in order: cv -> svd -> posit1 -> aruco -> dictionary.
|
||||||
|
*/
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
const _m = new THREE.Matrix4();
|
||||||
|
const _q = new THREE.Quaternion();
|
||||||
|
|
||||||
|
export class PoseEstimator {
|
||||||
|
constructor(focalLength) {
|
||||||
|
this.focal = focalLength;
|
||||||
|
this.posits = new Map(); // sizeMM -> POS.Posit
|
||||||
|
this.prev = new Map(); // markerId -> { quat, pos, t }
|
||||||
|
this.prevTTL = 1500; // ms before history is considered stale
|
||||||
|
}
|
||||||
|
|
||||||
|
positFor(sizeMM) {
|
||||||
|
if (!this.posits.has(sizeMM)) this.posits.set(sizeMM, new POS.Posit(sizeMM, this.focal));
|
||||||
|
return this.posits.get(sizeMM);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** corners: aruco marker corners, image-space; cx/cy: image center.
|
||||||
|
* Returns { position: THREE.Vector3 (mm, marker->camera), quaternion, error } */
|
||||||
|
estimate(markerId, corners, cx, cy, sizeMM) {
|
||||||
|
const centered = corners.map(c => ({ x: c.x - cx, y: (cy - c.y) }));
|
||||||
|
const pose = this.positFor(sizeMM).pose(centered);
|
||||||
|
if (!pose) return null;
|
||||||
|
|
||||||
|
const cand = [
|
||||||
|
this.candidate(pose.bestRotation, pose.bestTranslation, pose.bestError),
|
||||||
|
this.candidate(pose.alternativeRotation, pose.alternativeTranslation, pose.alternativeError),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Temporal consistency: prefer the solution nearest the previous frame's quat.
|
||||||
|
const prev = this.prev.get(markerId);
|
||||||
|
let pick;
|
||||||
|
if (prev && (performance.now() - prev.t) < this.prevTTL) {
|
||||||
|
const d0 = Math.abs(cand[0].quaternion.dot(prev.quat));
|
||||||
|
const d1 = Math.abs(cand[1].quaternion.dot(prev.quat));
|
||||||
|
// only override error-order if the alternative is clearly more consistent
|
||||||
|
pick = (d1 > d0 + 0.05) ? cand[1] : (d0 > d1 + 0.05 ? cand[0] : (cand[0].error <= cand[1].error ? cand[0] : cand[1]));
|
||||||
|
} else {
|
||||||
|
pick = cand[0].error <= cand[1].error ? cand[0] : cand[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.prev.set(markerId, { quat: pick.quaternion.clone(), pos: pick.position.clone(), t: performance.now() });
|
||||||
|
return pick;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate(rot, t, error) {
|
||||||
|
// Rotation as-is (row-major 3x3 -> Matrix4)
|
||||||
|
_m.set(
|
||||||
|
rot[0][0], rot[0][1], rot[0][2], 0,
|
||||||
|
rot[1][0], rot[1][1], rot[1][2], 0,
|
||||||
|
rot[2][0], rot[2][1], rot[2][2], 0,
|
||||||
|
0, 0, 0, 1
|
||||||
|
);
|
||||||
|
const quaternion = new THREE.Quaternion().setFromRotationMatrix(_m);
|
||||||
|
// Translation: negate Y and Z only (confirmed fix)
|
||||||
|
const position = new THREE.Vector3(t[0], -t[1], -t[2]);
|
||||||
|
return { position, quaternion, error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the marker->world transform for an anchor.
|
||||||
|
* mount 'flat': marker printed face-up on a horizontal surface.
|
||||||
|
* mount 'wall': marker on a vertical surface; yawDeg = facing direction.
|
||||||
|
* mount 'custom': explicit yaw/pitch/roll (deg) applied in YXZ order.
|
||||||
|
* All mounts additionally honour yaw/pitch/roll offsets for fine trim.
|
||||||
|
*/
|
||||||
|
export function anchorWorldMatrix(anchor) {
|
||||||
|
const pos = new THREE.Vector3(...anchor.position);
|
||||||
|
const yaw = THREE.MathUtils.degToRad(anchor.yawDeg || 0);
|
||||||
|
const pitch = THREE.MathUtils.degToRad(anchor.pitchDeg || 0);
|
||||||
|
const roll = THREE.MathUtils.degToRad(anchor.rollDeg || 0);
|
||||||
|
|
||||||
|
// Base orientation by mount:
|
||||||
|
// flat: marker face-up — marker +Z (out of print face) -> world +Y
|
||||||
|
// wall: marker vertical — marker +Z faces world +Z when yawDeg = 0
|
||||||
|
const base = new THREE.Quaternion();
|
||||||
|
if (anchor.mount !== 'wall' && anchor.mount !== 'custom') {
|
||||||
|
base.setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim (fully editable): yaw about world Y, then pitch/roll fine adjustment
|
||||||
|
const trim = new THREE.Quaternion().setFromEuler(new THREE.Euler(pitch, yaw, roll, 'YXZ'));
|
||||||
|
const q = trim.multiply(base);
|
||||||
|
return new THREE.Matrix4().compose(pos, q, new THREE.Vector3(1, 1, 1));
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/* exhibit.js — main viewer.
|
||||||
|
* Pipeline: camera video -> ArUco detect (maxHamming 0) -> per-marker POS-IT pose
|
||||||
|
* (rotation as-is, -t[1] -t[2]) -> fuseWorld (confidence-weighted slerp) -> Three.js
|
||||||
|
* camera placed in world -> server-driven ghosts + building occlusion meshes.
|
||||||
|
*/
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { createDetector, detectMarkers, quadArea } from './ar/detect.js';
|
||||||
|
import { PoseEstimator } from './ar/pose.js';
|
||||||
|
import { WorldFuser } from './ar/fuse.js';
|
||||||
|
import { buildGhost } from './ghosts/loader.js';
|
||||||
|
import { ghostTransform } from './ghosts/behavior.js';
|
||||||
|
import { ExhibitNet, installErrorReporter } from './net.js';
|
||||||
|
|
||||||
|
const $ = (s) => document.querySelector(s);
|
||||||
|
let info = { mode: 'dev' };
|
||||||
|
let scene3, camera3, renderer, video, canvas2d, ctx2d;
|
||||||
|
let detector, poseEst, fuser;
|
||||||
|
let gradients = {}, modelManifest = null;
|
||||||
|
const activeGhosts = new Map(); // uid -> { rec, group }
|
||||||
|
let tracking = { markers: 0, lastSeen: 0 };
|
||||||
|
const clockOffsetSamples = [];
|
||||||
|
let clockOffset = 0; // serverNow - clientNow
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
info = await (await fetch('/api/info')).json();
|
||||||
|
installErrorReporter(info.mode === 'dev');
|
||||||
|
|
||||||
|
const g = await (await fetch('/api/ghosts')).json();
|
||||||
|
gradients = g.gradients.gradients || g.gradients;
|
||||||
|
modelManifest = await (await fetch('/api/models')).json();
|
||||||
|
|
||||||
|
$('#start').addEventListener('click', start, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
$('#startScreen').classList.add('hidden');
|
||||||
|
$('#hud').classList.remove('hidden');
|
||||||
|
|
||||||
|
// camera
|
||||||
|
video = $('#cam');
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } },
|
||||||
|
audio: false,
|
||||||
|
});
|
||||||
|
video.srcObject = stream;
|
||||||
|
await video.play();
|
||||||
|
|
||||||
|
// detection canvas
|
||||||
|
canvas2d = document.createElement('canvas');
|
||||||
|
ctx2d = canvas2d.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
|
// three.js
|
||||||
|
renderer = new THREE.WebGLRenderer({ canvas: $('#gl'), alpha: true, antialias: true });
|
||||||
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||||
|
scene3 = new THREE.Scene();
|
||||||
|
camera3 = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.02, 50);
|
||||||
|
scene3.add(new THREE.AmbientLight(0xffffff, 1.2));
|
||||||
|
onResize(); addEventListener('resize', onResize);
|
||||||
|
|
||||||
|
detector = createDetector();
|
||||||
|
fuser = new WorldFuser();
|
||||||
|
|
||||||
|
// network
|
||||||
|
const net = new ExhibitNet();
|
||||||
|
net.on('scene', (m) => { fuser.setScene(m.scene); buildOcclusion(m.scene); })
|
||||||
|
.on('active', async (m) => {
|
||||||
|
for (const uid of [...activeGhosts.keys()]) removeGhost(uid);
|
||||||
|
for (const rec of m.ghosts) await addGhost(rec);
|
||||||
|
})
|
||||||
|
.on('spawn', (m) => addGhost(m.ghost))
|
||||||
|
.on('despawn', (m) => scheduleRemove(m.uid))
|
||||||
|
.on('pong', (m) => {
|
||||||
|
const rtt = performance.now() - m.t;
|
||||||
|
clockOffsetSamples.push(m.server + rtt / 2 - Date.now());
|
||||||
|
if (clockOffsetSamples.length > 5) clockOffsetSamples.shift();
|
||||||
|
clockOffset = clockOffsetSamples.reduce((a, b) => a + b, 0) / clockOffsetSamples.length;
|
||||||
|
});
|
||||||
|
net.connect();
|
||||||
|
setInterval(() => { try { net.ws.send(JSON.stringify({ type: 'ping', t: performance.now() })); } catch {} }, 5000);
|
||||||
|
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize() {
|
||||||
|
renderer.setSize(innerWidth, innerHeight);
|
||||||
|
camera3.aspect = innerWidth / innerHeight;
|
||||||
|
camera3.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- occlusion: invisible depth-only building volumes ----------
|
||||||
|
const occlusionGroup = new THREE.Group();
|
||||||
|
function buildOcclusion(scene) {
|
||||||
|
occlusionGroup.clear();
|
||||||
|
const mat = new THREE.MeshBasicMaterial({ colorWrite: false }); // depth-only
|
||||||
|
for (const b of scene.buildings || []) {
|
||||||
|
const geo = new THREE.BoxGeometry(b.size[0], b.size[1], b.size[2]);
|
||||||
|
const mesh = new THREE.Mesh(geo, mat);
|
||||||
|
mesh.position.set(b.position[0], b.position[1] + b.size[1] / 2, b.position[2]);
|
||||||
|
mesh.rotation.y = THREE.MathUtils.degToRad(b.yawDeg || 0);
|
||||||
|
mesh.renderOrder = -1;
|
||||||
|
occlusionGroup.add(mesh);
|
||||||
|
}
|
||||||
|
if (!occlusionGroup.parent) scene3.add(occlusionGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- ghosts ----------
|
||||||
|
async function addGhost(rec) {
|
||||||
|
if (activeGhosts.has(rec.uid)) return;
|
||||||
|
const group = await buildGhost(rec, gradients, modelManifest);
|
||||||
|
scene3.add(group);
|
||||||
|
activeGhosts.set(rec.uid, { rec, group });
|
||||||
|
toast(`${rec.name} appeared`);
|
||||||
|
}
|
||||||
|
function removeGhost(uid) {
|
||||||
|
const e = activeGhosts.get(uid);
|
||||||
|
if (!e) return;
|
||||||
|
scene3.remove(e.group);
|
||||||
|
activeGhosts.delete(uid);
|
||||||
|
}
|
||||||
|
function scheduleRemove(uid) {
|
||||||
|
const e = activeGhosts.get(uid);
|
||||||
|
if (!e) return removeGhost(uid);
|
||||||
|
// let the client-side crossfade (driven by rec.until) finish, then remove
|
||||||
|
const wait = Math.max(0, e.rec.until - (Date.now() + clockOffset)) + (e.rec.crossfade || 3) * 1000;
|
||||||
|
setTimeout(() => removeGhost(uid), Math.min(wait, 8000));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- main loop ----------
|
||||||
|
const _tmp = { position: new THREE.Vector3(), rotationY: 0, opacity: 1 };
|
||||||
|
let frameCount = 0;
|
||||||
|
|
||||||
|
function loop(t) {
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
if (video && video.readyState >= 2) {
|
||||||
|
// downscale for detection speed
|
||||||
|
const W = 640;
|
||||||
|
const H = Math.round(W * video.videoHeight / video.videoWidth);
|
||||||
|
if (canvas2d.width !== W) { canvas2d.width = W; canvas2d.height = H; }
|
||||||
|
ctx2d.drawImage(video, 0, 0, W, H);
|
||||||
|
const img = ctx2d.getImageData(0, 0, W, H);
|
||||||
|
|
||||||
|
if (!poseEst) {
|
||||||
|
// focal length in detection-canvas pixels from camera FOV assumption (~60° h-fov)
|
||||||
|
poseEst = new PoseEstimator(W / (2 * Math.tan(THREE.MathUtils.degToRad(60) / 2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const markers = detectMarkers(detector, img, fuser.knownIds());
|
||||||
|
tracking.markers = markers.length;
|
||||||
|
if (markers.length) {
|
||||||
|
tracking.lastSeen = performance.now();
|
||||||
|
const estimates = markers.map(m => {
|
||||||
|
const e = poseEst.estimate(m.id, m.corners, W / 2, H / 2, fuser.sizeFor(m.id));
|
||||||
|
return e && { markerId: m.id, position: e.position, quaternion: e.quaternion, area: quadArea(m.corners) };
|
||||||
|
}).filter(Boolean);
|
||||||
|
const fused = fuser.fuse(estimates);
|
||||||
|
if (fused) {
|
||||||
|
camera3.position.copy(fused.position);
|
||||||
|
camera3.quaternion.copy(fused.quaternion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ghosts: deterministic motion on synced clock
|
||||||
|
const now = Date.now() + clockOffset;
|
||||||
|
for (const { rec, group } of activeGhosts.values()) {
|
||||||
|
ghostTransform(rec, now, _tmp);
|
||||||
|
group.position.copy(_tmp.position);
|
||||||
|
group.rotation.y = _tmp.rotationY;
|
||||||
|
group.userData.setOpacity(_tmp.opacity * 0.92);
|
||||||
|
group.userData.tick(t / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HUD
|
||||||
|
if ((frameCount++ & 15) === 0) {
|
||||||
|
const stale = performance.now() - tracking.lastSeen > 1500;
|
||||||
|
$('#trk').textContent = stale ? 'Point at a Newbury Crest' : `Tracking ${tracking.markers} crest${tracking.markers === 1 ? '' : 's'}`;
|
||||||
|
$('#trk').classList.toggle('warn', stale || tracking.markers < 2);
|
||||||
|
$('#cnt').textContent = `${activeGhosts.size} ghost${activeGhosts.size === 1 ? '' : 's'} nearby`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.render(scene3, camera3);
|
||||||
|
}
|
||||||
|
|
||||||
|
let toastTimer = null;
|
||||||
|
function toast(msg) {
|
||||||
|
const el = $('#toast');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.classList.add('show');
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => el.classList.remove('show'), 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
boot().catch(e => { console.error(e); alert('Failed to start: ' + e.message); });
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/* behavior.js — deterministic ghost motion so all viewers see the same movement.
|
||||||
|
* Motion is a pure function of (server spawn record, wall-clock time): no per-client
|
||||||
|
* randomness, so phones stay in sync without streaming positions.
|
||||||
|
*/
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
function hashNoise(seed, k) {
|
||||||
|
// cheap deterministic pseudo-noise in [-1, 1]
|
||||||
|
const x = Math.sin(seed * 127.1 + k * 311.7) * 43758.5453;
|
||||||
|
return (x - Math.floor(x)) * 2 - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ghostTransform(rec, nowMs, out) {
|
||||||
|
const t = (nowMs - rec.spawnedAt) / 1000;
|
||||||
|
const base = new THREE.Vector3(...rec.pos);
|
||||||
|
const b = rec.behavior || { type: 'static' };
|
||||||
|
|
||||||
|
if (b.type === 'wander') {
|
||||||
|
const s = b.seed || 1;
|
||||||
|
const R = b.radius ?? 0.6;
|
||||||
|
const v = b.speed ?? 0.15;
|
||||||
|
// smooth pseudo-random orbit-drift: two incommensurate sines per axis
|
||||||
|
const ph = t * v;
|
||||||
|
out.position.set(
|
||||||
|
base.x + R * 0.9 * Math.sin(ph * 1.0 + hashNoise(s, 1) * 6.28) * 0.7
|
||||||
|
+ R * 0.4 * Math.sin(ph * 2.3 + hashNoise(s, 2) * 6.28) * 0.3,
|
||||||
|
base.y + 0.06 * Math.sin(t * 1.7 + hashNoise(s, 3) * 6.28),
|
||||||
|
base.z + R * 0.9 * Math.cos(ph * 0.8 + hashNoise(s, 4) * 6.28) * 0.7
|
||||||
|
+ R * 0.4 * Math.cos(ph * 1.9 + hashNoise(s, 5) * 6.28) * 0.3
|
||||||
|
);
|
||||||
|
// face travel direction (finite difference)
|
||||||
|
const eps = 0.05;
|
||||||
|
const ahead = (t2) => new THREE.Vector3(
|
||||||
|
base.x + R * 0.9 * Math.sin(t2 * v + hashNoise(s, 1) * 6.28) * 0.7,
|
||||||
|
0,
|
||||||
|
base.z + R * 0.9 * Math.cos(t2 * v * 0.8 + hashNoise(s, 4) * 6.28) * 0.7);
|
||||||
|
const dir = ahead(t + eps).sub(ahead(t));
|
||||||
|
out.rotationY = Math.atan2(dir.x, dir.z);
|
||||||
|
} else {
|
||||||
|
const amp = b.bobAmp ?? 0.06;
|
||||||
|
const hz = b.bobHz ?? 0.4;
|
||||||
|
out.position.set(base.x, base.y + amp * Math.sin(t * hz * Math.PI * 2), base.z);
|
||||||
|
out.rotationY = 0.25 * Math.sin(t * 0.3); // slow idle sway
|
||||||
|
}
|
||||||
|
|
||||||
|
// crossfade opacity: fade in on spawn, fade out approaching `until`
|
||||||
|
const fade = (rec.crossfade || 3) * 1000;
|
||||||
|
const inA = Math.min(1, (nowMs - rec.spawnedAt) / fade);
|
||||||
|
const outA = Math.min(1, Math.max(0, (rec.until - nowMs) / fade));
|
||||||
|
out.opacity = Math.min(inA, outA);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/* loader.js — ghost visuals.
|
||||||
|
* Multi-part OBJ ghosts: { legs|wisp, torso, head, headpiece } assembled into one Group,
|
||||||
|
* every part rendered with the recovered Hidden Side gradient shader (top->bottom tint
|
||||||
|
* by GhostColor: Red / Yellow / Blue). Until models exist, a procedural wisp fallback
|
||||||
|
* is used so the exhibit always has something to show.
|
||||||
|
*
|
||||||
|
* Model manifest (data/models.json, editable via /api/models):
|
||||||
|
* { "models": [ { "id": "classic", "scale": 1.0,
|
||||||
|
* "parts": { "legs": "/models/classic/legs.obj", "torso": "...", "head": "...", "headpiece": "..." } } ] }
|
||||||
|
*/
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
||||||
|
|
||||||
|
const objLoader = new OBJLoader();
|
||||||
|
const objCache = new Map();
|
||||||
|
|
||||||
|
function gradientMaterial(top, bottom, opacity = 0.92) {
|
||||||
|
return new THREE.ShaderMaterial({
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
uniforms: {
|
||||||
|
topColor: { value: new THREE.Color(top) },
|
||||||
|
bottomColor: { value: new THREE.Color(bottom) },
|
||||||
|
opacity: { value: opacity },
|
||||||
|
uMinY: { value: 0 },
|
||||||
|
uMaxY: { value: 1 },
|
||||||
|
uTime: { value: 0 },
|
||||||
|
},
|
||||||
|
vertexShader: `
|
||||||
|
varying float vY;
|
||||||
|
varying vec3 vNormal;
|
||||||
|
uniform float uMinY, uMaxY;
|
||||||
|
void main() {
|
||||||
|
vY = clamp((position.y - uMinY) / max(uMaxY - uMinY, 0.001), 0.0, 1.0);
|
||||||
|
vNormal = normalize(normalMatrix * normal);
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
|
}`,
|
||||||
|
fragmentShader: `
|
||||||
|
varying float vY;
|
||||||
|
varying vec3 vNormal;
|
||||||
|
uniform vec3 topColor, bottomColor;
|
||||||
|
uniform float opacity, uTime;
|
||||||
|
void main() {
|
||||||
|
vec3 c = mix(bottomColor, topColor, vY);
|
||||||
|
float rim = pow(1.0 - abs(dot(vNormal, vec3(0.0, 0.0, 1.0))), 2.0);
|
||||||
|
c += rim * 0.35;
|
||||||
|
float pulse = 0.92 + 0.08 * sin(uTime * 2.2);
|
||||||
|
gl_FragColor = vec4(c, opacity * pulse);
|
||||||
|
}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOBJ(url) {
|
||||||
|
if (objCache.has(url)) return objCache.get(url).clone();
|
||||||
|
const obj = await objLoader.loadAsync(url);
|
||||||
|
objCache.set(url, obj);
|
||||||
|
return obj.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
function proceduralWisp() {
|
||||||
|
const g = new THREE.Group();
|
||||||
|
const body = new THREE.Mesh(new THREE.SphereGeometry(0.09, 20, 16));
|
||||||
|
body.scale.set(1, 1.35, 1);
|
||||||
|
body.position.y = 0.14;
|
||||||
|
const tail = new THREE.Mesh(new THREE.ConeGeometry(0.07, 0.16, 16));
|
||||||
|
tail.rotation.x = Math.PI;
|
||||||
|
tail.position.y = 0.0;
|
||||||
|
g.add(body, tail);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildGhost(ghost, gradients, manifest) {
|
||||||
|
const grad = gradients[ghost.color] || gradients.Blue;
|
||||||
|
const mat = gradientMaterial(grad.top, grad.bottom);
|
||||||
|
|
||||||
|
let group;
|
||||||
|
const model = (manifest?.models || [])[0]; // default model; per-ghost mapping can extend later
|
||||||
|
if (model && model.parts) {
|
||||||
|
group = new THREE.Group();
|
||||||
|
try {
|
||||||
|
for (const key of ['legs', 'wisp', 'torso', 'head', 'headpiece']) {
|
||||||
|
const url = model.parts[key];
|
||||||
|
if (!url) continue;
|
||||||
|
group.add(await loadOBJ(url));
|
||||||
|
}
|
||||||
|
if (model.scale) group.scale.setScalar(model.scale);
|
||||||
|
if (!group.children.length) group = proceduralWisp();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('ghost model load failed, using wisp fallback', e);
|
||||||
|
group = proceduralWisp();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
group = proceduralWisp();
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply gradient material to every mesh; compute Y-range for gradient mapping
|
||||||
|
const box = new THREE.Box3().setFromObject(group);
|
||||||
|
group.traverse(o => {
|
||||||
|
if (o.isMesh) {
|
||||||
|
o.material = mat;
|
||||||
|
o.material.uniforms.uMinY.value = box.min.y;
|
||||||
|
o.material.uniforms.uMaxY.value = box.max.y;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
group.userData.material = mat;
|
||||||
|
group.userData.setOpacity = (v) => { mat.uniforms.opacity.value = v; };
|
||||||
|
group.userData.tick = (t) => { mat.uniforms.uTime.value = t; };
|
||||||
|
return group;
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
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,146 @@
|
|||||||
|
/* Playlist engine — server-driven so every viewer sees the same ghosts.
|
||||||
|
*
|
||||||
|
* Config:
|
||||||
|
* {
|
||||||
|
* slots: 5, // concurrent visible ghosts
|
||||||
|
* dwellSeconds: 120, // how long each ghost stays before rotating out
|
||||||
|
* crossfadeSeconds: 3, // fade out/in overlap hint for clients
|
||||||
|
* order: 'shuffle' | 'roster', // rotation order through the roster
|
||||||
|
* include: { colors:[], rarities:[], ids:[] }, // empty = all
|
||||||
|
* behaviors: { staticChance: 0.5, wanderRadius: 0.6, wanderSpeed: 0.15 },
|
||||||
|
* timeWindows: [] // e.g. [{ start:"09:00", end:"17:00" }] — empty = always on
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Broadcasts:
|
||||||
|
* { type:'spawn', ghost:{ uid, id, name, color, spawnId, pos, behavior, until } }
|
||||||
|
* { type:'despawn', uid }
|
||||||
|
* { type:'active', ghosts:[...] } (full snapshot, sent on join + config change)
|
||||||
|
*/
|
||||||
|
class Playlist {
|
||||||
|
constructor(state, broadcast) {
|
||||||
|
this.state = state;
|
||||||
|
this.broadcast = broadcast;
|
||||||
|
this.cfg = state.getPlaylistConfig({
|
||||||
|
slots: 5, dwellSeconds: 120, crossfadeSeconds: 3, order: 'shuffle',
|
||||||
|
include: { colors: [], rarities: [], ids: [] },
|
||||||
|
behaviors: { staticChance: 0.5, wanderRadius: 0.6, wanderSpeed: 0.15 },
|
||||||
|
timeWindows: [],
|
||||||
|
});
|
||||||
|
this.active = new Map(); // uid -> ghost record
|
||||||
|
this.queue = [];
|
||||||
|
this.uidCounter = 1;
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfig() { return this.cfg; }
|
||||||
|
|
||||||
|
setConfig(cfg) {
|
||||||
|
if (!cfg || typeof cfg !== 'object') throw new Error('bad config');
|
||||||
|
this.cfg = { ...this.cfg, ...cfg };
|
||||||
|
this.state.putPlaylistConfig(this.cfg);
|
||||||
|
// rebuild world: clear and respawn under new rules
|
||||||
|
for (const uid of [...this.active.keys()]) this.despawn(uid);
|
||||||
|
this.queue = [];
|
||||||
|
this.tick();
|
||||||
|
this.broadcast({ type: 'active', ghosts: this.activeSnapshot() });
|
||||||
|
return this.cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
roster() {
|
||||||
|
const { ghosts } = this.state.getGhosts();
|
||||||
|
const inc = this.cfg.include || {};
|
||||||
|
return ghosts.filter(g =>
|
||||||
|
(!inc.colors?.length || inc.colors.includes(g.color)) &&
|
||||||
|
(!inc.rarities?.length || inc.rarities.includes(g.rarity)) &&
|
||||||
|
(!inc.ids?.length || inc.ids.includes(g.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
refillQueue() {
|
||||||
|
const r = this.roster().map(g => g.id);
|
||||||
|
if (!r.length) return;
|
||||||
|
if (this.cfg.order === 'shuffle') {
|
||||||
|
for (let i = r.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[r[i], r[j]] = [r[j], r[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// avoid immediate repeat of currently-active ghosts at queue head
|
||||||
|
const activeIds = new Set([...this.active.values()].map(g => g.id));
|
||||||
|
this.queue.push(...r.filter(id => !activeIds.has(id)), ...r.filter(id => activeIds.has(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
inTimeWindow() {
|
||||||
|
const w = this.cfg.timeWindows || [];
|
||||||
|
if (!w.length) return true;
|
||||||
|
const now = new Date();
|
||||||
|
const mins = now.getHours() * 60 + now.getMinutes();
|
||||||
|
return w.some(({ start, end }) => {
|
||||||
|
const [sh, sm] = String(start).split(':').map(Number);
|
||||||
|
const [eh, em] = String(end).split(':').map(Number);
|
||||||
|
const s = sh * 60 + (sm || 0), e = eh * 60 + (em || 0);
|
||||||
|
return s <= e ? (mins >= s && mins < e) : (mins >= s || mins < e); // handles overnight
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
freeSpawn() {
|
||||||
|
const scene = this.state.getScene();
|
||||||
|
const used = new Set([...this.active.values()].map(g => g.spawnId));
|
||||||
|
const free = (scene.spawns || []).filter(s => s.enabled !== false && !used.has(s.id));
|
||||||
|
if (!free.length) return null;
|
||||||
|
return free[Math.floor(Math.random() * free.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnOne() {
|
||||||
|
if (!this.queue.length) this.refillQueue();
|
||||||
|
const id = this.queue.shift();
|
||||||
|
if (!id) return;
|
||||||
|
const g = this.roster().find(x => x.id === id);
|
||||||
|
const spawn = this.freeSpawn();
|
||||||
|
if (!g || !spawn) return;
|
||||||
|
const b = this.cfg.behaviors || {};
|
||||||
|
const isStatic = Math.random() < (b.staticChance ?? 0.5);
|
||||||
|
const uid = 'g' + (this.uidCounter++);
|
||||||
|
const rec = {
|
||||||
|
uid, id: g.id, name: g.name, color: g.color, rarity: g.rarity,
|
||||||
|
spawnId: spawn.id, pos: spawn.position,
|
||||||
|
behavior: isStatic
|
||||||
|
? { type: 'static', bobAmp: 0.05 + Math.random() * 0.05, bobHz: 0.3 + Math.random() * 0.3 }
|
||||||
|
: { type: 'wander', radius: b.wanderRadius ?? 0.6, speed: b.wanderSpeed ?? 0.15, seed: Math.floor(Math.random() * 1e6) },
|
||||||
|
spawnedAt: Date.now(),
|
||||||
|
until: Date.now() + (this.cfg.dwellSeconds || 120) * 1000,
|
||||||
|
crossfade: this.cfg.crossfadeSeconds || 3,
|
||||||
|
};
|
||||||
|
this.active.set(uid, rec);
|
||||||
|
this.broadcast({ type: 'spawn', ghost: rec });
|
||||||
|
}
|
||||||
|
|
||||||
|
despawn(uid) {
|
||||||
|
if (!this.active.has(uid)) return;
|
||||||
|
this.active.delete(uid);
|
||||||
|
this.broadcast({ type: 'despawn', uid });
|
||||||
|
}
|
||||||
|
|
||||||
|
activeSnapshot() { return [...this.active.values()]; }
|
||||||
|
|
||||||
|
tick() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (!this.inTimeWindow()) {
|
||||||
|
for (const uid of [...this.active.keys()]) this.despawn(uid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [uid, g] of this.active) if (now >= g.until) this.despawn(uid);
|
||||||
|
const want = Math.min(this.cfg.slots || 5, (this.state.getScene().spawns || []).filter(s => s.enabled !== false).length);
|
||||||
|
while (this.active.size < want) {
|
||||||
|
const before = this.active.size;
|
||||||
|
this.spawnOne();
|
||||||
|
if (this.active.size === before) break; // no roster/spawns available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.tick();
|
||||||
|
this.timer = setInterval(() => this.tick(), 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Playlist;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/* Newbury Exhibit v2 — server
|
||||||
|
* Express static host + WebSocket sync + admin API + playlist engine.
|
||||||
|
* Modes: EXHIBIT_MODE=dev -> admin open, verbose client error overlay enabled
|
||||||
|
* EXHIBIT_MODE=final -> admin password-protected (ADMIN_PASSWORD env)
|
||||||
|
*/
|
||||||
|
const express = require('express');
|
||||||
|
const http = require('http');
|
||||||
|
const path = require('path');
|
||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
|
||||||
|
const state = require('./state');
|
||||||
|
const auth = require('./auth');
|
||||||
|
const Playlist = require('./playlist');
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 33044;
|
||||||
|
const MODE = process.env.EXHIBIT_MODE || 'dev';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json({ limit: '2mb' }));
|
||||||
|
|
||||||
|
// ---------- public static ----------
|
||||||
|
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||||
|
app.use('/data/ghosts.json', express.static(path.join(__dirname, '..', 'data', 'ghosts.json')));
|
||||||
|
app.use('/data/gradients.json', express.static(path.join(__dirname, '..', 'data', 'gradients.json')));
|
||||||
|
|
||||||
|
// ---------- runtime info ----------
|
||||||
|
app.get('/api/info', (req, res) => {
|
||||||
|
res.json({ mode: MODE, authRequired: MODE === 'final', version: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- auth ----------
|
||||||
|
app.post('/api/login', (req, res) => {
|
||||||
|
if (MODE !== 'final') return res.json({ token: 'dev' });
|
||||||
|
const t = auth.login(req.body && req.body.password);
|
||||||
|
if (!t) return res.status(401).json({ error: 'bad password' });
|
||||||
|
res.json({ token: t });
|
||||||
|
});
|
||||||
|
|
||||||
|
function guard(req, res, next) {
|
||||||
|
if (MODE !== 'final') return next();
|
||||||
|
if (auth.check(req.headers['x-exhibit-token'])) return next();
|
||||||
|
res.status(401).json({ error: 'unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- scene (anchors, buildings, spawns) ----------
|
||||||
|
app.get('/api/scene', (req, res) => res.json(state.getScene()));
|
||||||
|
app.put('/api/scene', guard, (req, res) => {
|
||||||
|
try { res.json(state.putScene(req.body)); }
|
||||||
|
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
|
||||||
|
});
|
||||||
|
app.post('/api/scene/reset', guard, (req, res) => res.json(state.resetToSeed()));
|
||||||
|
|
||||||
|
// ---------- playlist config ----------
|
||||||
|
app.get('/api/playlist', (req, res) => res.json(playlist.getConfig()));
|
||||||
|
app.put('/api/playlist', guard, (req, res) => {
|
||||||
|
try { res.json(playlist.setConfig(req.body)); }
|
||||||
|
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- ghost roster ----------
|
||||||
|
app.get('/api/ghosts', (req, res) => res.json(state.getGhosts()));
|
||||||
|
|
||||||
|
// ---------- model manifests (multi-part OBJ ghosts) ----------
|
||||||
|
app.get('/api/models', (req, res) => res.json(state.getModels()));
|
||||||
|
app.put('/api/models', guard, (req, res) => {
|
||||||
|
try { res.json(state.putModels(req.body)); }
|
||||||
|
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- client error reporting (dev mode) ----------
|
||||||
|
const clientErrors = [];
|
||||||
|
app.post('/api/client-error', (req, res) => {
|
||||||
|
clientErrors.push({ t: Date.now(), ...req.body });
|
||||||
|
if (clientErrors.length > 500) clientErrors.shift();
|
||||||
|
console.warn('[client-error]', JSON.stringify(req.body).slice(0, 500));
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
app.get('/api/client-errors', guard, (req, res) => res.json(clientErrors));
|
||||||
|
|
||||||
|
// ---------- server + websocket ----------
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||||
|
|
||||||
|
function broadcast(obj) {
|
||||||
|
const msg = JSON.stringify(obj);
|
||||||
|
for (const c of wss.clients) if (c.readyState === 1) c.send(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlist = new Playlist(state, broadcast);
|
||||||
|
playlist.start();
|
||||||
|
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
ws.send(JSON.stringify({ type: 'scene', scene: state.getScene() }));
|
||||||
|
ws.send(JSON.stringify({ type: 'active', ghosts: playlist.activeSnapshot() }));
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
let m; try { m = JSON.parse(raw); } catch { return; }
|
||||||
|
if (m.type === 'ping') ws.send(JSON.stringify({ type: 'pong', t: m.t, server: Date.now() }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// push scene updates to viewers when admin saves
|
||||||
|
state.onChange(() => broadcast({ type: 'scene', scene: state.getScene() }));
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Newbury Exhibit v2 [${MODE}] on :${PORT}`);
|
||||||
|
if (MODE === 'final' && !process.env.ADMIN_PASSWORD)
|
||||||
|
console.warn('WARNING: final mode with no ADMIN_PASSWORD set — admin is locked out.');
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/* Seed/live persistence.
|
||||||
|
* data/scene.json — committed seed (survives in git)
|
||||||
|
* data/live/scene.live.json — runtime edits, volume-mounted in Docker, survives redeploys
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DATA = path.join(__dirname, '..', 'data');
|
||||||
|
const LIVE_DIR = process.env.LIVE_DIR || path.join(DATA, 'live');
|
||||||
|
const SEED = path.join(DATA, 'scene.json');
|
||||||
|
const LIVE = path.join(LIVE_DIR, 'scene.live.json');
|
||||||
|
const PLAYLIST_LIVE = path.join(LIVE_DIR, 'playlist.live.json');
|
||||||
|
const MODELS_LIVE = path.join(LIVE_DIR, 'models.live.json');
|
||||||
|
const MODELS_SEED = path.join(DATA, 'models.json');
|
||||||
|
|
||||||
|
fs.mkdirSync(LIVE_DIR, { recursive: true });
|
||||||
|
|
||||||
|
function readJSON(p, fallback) {
|
||||||
|
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return fallback; }
|
||||||
|
}
|
||||||
|
function writeJSON(p, obj) {
|
||||||
|
fs.writeFileSync(p, JSON.stringify(obj, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
let scene = readJSON(LIVE, null) || readJSON(SEED, { anchors: [], buildings: [], spawns: [], world: { units: 'm' } });
|
||||||
|
let ghosts = readJSON(path.join(DATA, 'ghosts.json'), []);
|
||||||
|
let gradients = readJSON(path.join(DATA, 'gradients.json'), {});
|
||||||
|
let models = readJSON(MODELS_LIVE, null) || readJSON(MODELS_SEED, { models: [] });
|
||||||
|
|
||||||
|
const listeners = [];
|
||||||
|
|
||||||
|
function validateScene(s) {
|
||||||
|
if (!s || typeof s !== 'object') throw new Error('scene must be object');
|
||||||
|
for (const k of ['anchors', 'buildings', 'spawns']) {
|
||||||
|
if (!Array.isArray(s[k])) throw new Error(`scene.${k} must be array`);
|
||||||
|
}
|
||||||
|
for (const a of s.anchors) {
|
||||||
|
if (typeof a.markerId !== 'number') throw new Error('anchor.markerId required');
|
||||||
|
if (!Array.isArray(a.position) || a.position.length !== 3) throw new Error('anchor.position [x,y,z] required');
|
||||||
|
a.mount = a.mount || 'flat'; // 'flat' | 'wall' | 'custom'
|
||||||
|
a.yawDeg = Number(a.yawDeg || 0);
|
||||||
|
a.pitchDeg = Number(a.pitchDeg || 0);
|
||||||
|
a.rollDeg = Number(a.rollDeg || 0);
|
||||||
|
a.sizeMM = Number(a.sizeMM || 60); // printed marker size
|
||||||
|
a.enabled = a.enabled !== false;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getScene: () => scene,
|
||||||
|
putScene(s) {
|
||||||
|
scene = validateScene(s);
|
||||||
|
writeJSON(LIVE, scene);
|
||||||
|
listeners.forEach(f => f());
|
||||||
|
return scene;
|
||||||
|
},
|
||||||
|
resetToSeed() {
|
||||||
|
scene = readJSON(SEED, scene);
|
||||||
|
writeJSON(LIVE, scene);
|
||||||
|
listeners.forEach(f => f());
|
||||||
|
return scene;
|
||||||
|
},
|
||||||
|
getGhosts: () => ({ ghosts, gradients }),
|
||||||
|
getModels: () => models,
|
||||||
|
putModels(m) {
|
||||||
|
if (!m || !Array.isArray(m.models)) throw new Error('models.models must be array');
|
||||||
|
models = m; writeJSON(MODELS_LIVE, models); return models;
|
||||||
|
},
|
||||||
|
getPlaylistConfig: (fallback) => readJSON(PLAYLIST_LIVE, null) || readJSON(path.join(DATA, 'playlist.json'), fallback),
|
||||||
|
putPlaylistConfig(cfg) { writeJSON(PLAYLIST_LIVE, cfg); return cfg; },
|
||||||
|
onChange: (f) => listeners.push(f),
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
@echo off
|
||||||
|
REM upload-to-gitea.bat — push F:\HiddenSide\newbury-exhibit-v2 to Gitea.
|
||||||
|
REM First run initialises the repo; later runs just commit + push everything.
|
||||||
|
cd /d F:\HiddenSide\newbury-exhibit-v2
|
||||||
|
|
||||||
|
if not exist .git (
|
||||||
|
git init -b main
|
||||||
|
git remote add origin https://gitea.hideawaygaming.com.au/jessikitty/newbury-exhibit-v2.git
|
||||||
|
)
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
git commit -m "update %date% %time%" || echo Nothing to commit.
|
||||||
|
git push -u origin main
|
||||||
|
pause
|
||||||
Reference in New Issue
Block a user