diff --git a/README.md b/README.md index caaa109..c04fb3e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,110 @@ -# newbury-exhibit +# Newbury Exhibit -Shared multi-device AR exhibit viewer for a physical Newbury LEGO build. View-only, fiducial-anchored, WebSocket-synchronized ghosts. Fan tribute to LEGO Hidden Side. \ No newline at end of file +Shared, view-only AR experience for a **physical Newbury LEGO display** (max 700 × 1500 mm). +Multiple phones/tablets see the *same* ghosts, in the *same* real-world spots on the build, +each from their own viewpoint — the "Hidden Side" made visible to a room full of people at once. + +This is a sibling project to **newbury-nights** (the single-player hunt). The architecture is +deliberately different: instead of ghosts spawning around one player, ghosts live at fixed +**world anchors** on the build, and every device localises itself against printed/LEGO markers +so they all agree on where those anchors are. + +--- + +## How it fits together + +``` + physical marker ──► world origin ──► anchors ──► ghosts ──► broadcast ──► every phone + (Newbury Crest) (one corner) (scene.json) (motion) (WebSocket) (renders identically) +``` + +1. **Localisation.** Each device's camera detects an ArUco 4×4 marker of known size and known + world position. From the marker's four corners it solves where the device is in the build's + coordinate frame. (Proof of concept: `public/ar-test.html`.) +2. **Authoritative state.** `server/sync-server.js` runs one motion loop and broadcasts ghost + poses at 20 Hz over WebSocket (`/sync`). The server is the single source of truth, so all + viewers see synchronised motion. +3. **Rendering.** Each device renders its own scene locally. `public/js/viewer.js` is the desktop + "god-view" for authoring/testing; the phone AR client (next milestone) will render ghosts at + anchor world-positions seen through the camera. + +All coordinates are in **millimetres**, world origin at the **front-left corner** of the baseplate. +This maps 1:1 to the physical build, so tape-measure values drop straight into `data/scene.json`. + +--- + +## Markers — "Newbury Crests" + +The markers are **ArUco 4×4** (dictionary `ARUCO_4X4_1000`), chosen because a 4×4 data grid maps +cleanly onto LEGO tiles. Physically each marker is a **6×6 grid**: a 1-cell black quiet border +around the 4×4 code. + +**Design decision — colour wheel as frame, not code.** ArUco detection is brightness-based +(each cell reads black or white), which is exactly what makes it robust under bad lighting and +shallow angles. Encoding the Red/Yellow/Blue Hidden Side colours *into* the code cells would make +it pretty but fragile. So: + +- **Detector core (6×6):** black & white flat tiles — this is what the camera reads. +- **Colour-wheel ring:** a decorative Red/Yellow/Blue border *around* the core, in the zone the + detector ignores. On-theme for humans, invisible to the maths. + +See `markers/` for printable SVGs, per-anchor LEGO build maps, and `crest-01_buildplan.svg` +(a visual of core + colour ring). Print a marker now to test robustness before committing to a build. + +### Marker → anchor mapping +`markers/manifest.json` maps each physical ArUco id to a scene anchor. The `fiducialId` in +`data/scene.json` must match the `arucoId` of the marker you place there. + +| Crest | ArUco id | Ward (placeholder) | +|-------|----------|--------------------| +| crest-01 | 0 | North-West | +| crest-02 | 1 | North-East | +| crest-03 | 2 | Town Square | +| crest-04 | 3 | South-West | +| crest-05 | 4 | South-East | + +--- + +## Proof of concept: `ar-test.html` + +Camera → marker detection → a ghost sprite locked to the marker, bobbing above it. No server +needed for this page. Use it to test detection robustness on your iPhone 15 Pro. + +**Must be served over HTTPS** for the camera to work on iOS. Behind your existing nginx that's +already handled; for the exhibit it'll sit at the same kind of URL as newbury-nights. + +Drop an animated WebP at `public/assets/ghost_blue.webp` to use a real sprite; otherwise it draws +a procedural blue "Sad" wisp. + +--- + +## Run + +```bash +npm install +npm start # PORT defaults to 34034 +# open http://localhost:34034/ar-test.html (camera needs HTTPS on phones) +# open http://localhost:34034/ god-view viewer (viewer.js) +``` + +Env: `PORT` (default 34034), `TICK_HZ` (default 20). + +--- + +## Status / roadmap + +- [x] Authoritative sync server (hover + patrol motion solvers, 20 Hz broadcast) +- [x] God-view desktop viewer +- [x] Scene + anchor + spawn data model (mm, corner origin) +- [x] ArUco 4×4 marker system + LEGO build maps ("Newbury Crests") +- [x] AR PoC: camera + detection + ghost locked to marker +- [ ] Phone AR client: render anchored ghosts through the camera using marker pose +- [ ] Calibration page: scan each crest, record its measured world position +- [ ] Per-ghost colour/sprite wiring from the enriched roster +- [ ] Multi-device soak test on the real build under exhibition lighting + +--- + +## Licence +MIT (project). Vendored `js-aruco2` is MIT (Damiano Falcioni / Juan Mellado); ArUco dictionary +data is 3-clause BSD (OpenCV). See headers in `public/vendor/`. diff --git a/data/scene.json b/data/scene.json new file mode 100644 index 0000000..47e7f53 --- /dev/null +++ b/data/scene.json @@ -0,0 +1,103 @@ +{ + "_note": "Newbury exhibit scene definition. All coordinates in MILLIMETRES, world origin at one corner of the baseplate. X runs along the 1500mm length, Z along the 700mm depth, Y is height above the table surface. This maps 1:1 to the physical build so measured tape values drop straight in.", + "world": { + "units": "mm", + "buildSize": { "x": 1500, "y": 600, "z": 700 }, + "origin": "front-left corner of baseplate, table surface = Y0", + "upAxis": "Y" + }, + "anchors": [ + { + "id": "anchor-nw", + "label": "North-West Ward", + "fiducialType": "apriltag", + "fiducialId": 0, + "fiducialSizeMm": 40, + "position": { "x": 120, "y": 30, "z": 120 }, + "rotationDeg": { "x": 0, "y": 0, "z": 0 }, + "note": "Measure precisely once installed; these are placeholders." + }, + { + "id": "anchor-ne", + "label": "North-East Ward", + "fiducialType": "apriltag", + "fiducialId": 1, + "fiducialSizeMm": 40, + "position": { "x": 1380, "y": 30, "z": 120 }, + "rotationDeg": { "x": 0, "y": 0, "z": 0 }, + "note": "Placeholder." + }, + { + "id": "anchor-mid", + "label": "Town Square Ward", + "fiducialType": "apriltag", + "fiducialId": 2, + "fiducialSizeMm": 40, + "position": { "x": 750, "y": 30, "z": 350 }, + "rotationDeg": { "x": 0, "y": 0, "z": 0 }, + "note": "Placeholder, mid-span for overlap coverage." + }, + { + "id": "anchor-sw", + "label": "South-West Ward", + "fiducialType": "apriltag", + "fiducialId": 3, + "fiducialSizeMm": 40, + "position": { "x": 120, "y": 30, "z": 580 }, + "rotationDeg": { "x": 0, "y": 0, "z": 0 }, + "note": "Placeholder." + }, + { + "id": "anchor-se", + "label": "South-East Ward", + "fiducialType": "apriltag", + "fiducialId": 4, + "fiducialSizeMm": 40, + "position": { "x": 1380, "y": 30, "z": 580 }, + "rotationDeg": { "x": 0, "y": 0, "z": 0 }, + "note": "Placeholder." + } + ], + "spawns": [ + { + "id": "spawn-graveyard", + "ghostId": "lil-bo-pumpkins", + "label": "Graveyard hover", + "motion": "hover", + "position": { "x": 300, "y": 180, "z": 250 }, + "hoverRadiusMm": 60, + "hoverPeriodS": 6, + "bobAmplitudeMm": 40, + "bobPeriodS": 3, + "yawDegPerS": 12 + }, + { + "id": "spawn-square-patrol", + "ghostId": "tragico", + "label": "Town square patrol", + "motion": "patrol", + "path": [ + { "x": 600, "y": 200, "z": 350 }, + { "x": 900, "y": 240, "z": 350 }, + { "x": 900, "y": 200, "z": 450 }, + { "x": 600, "y": 240, "z": 450 } + ], + "patrolPeriodS": 16, + "bobAmplitudeMm": 25, + "bobPeriodS": 2.5, + "faceTravel": true + }, + { + "id": "spawn-highschool", + "ghostId": "samuel-mason", + "label": "High school boss anchor", + "motion": "hover", + "position": { "x": 1200, "y": 220, "z": 300 }, + "hoverRadiusMm": 40, + "hoverPeriodS": 8, + "bobAmplitudeMm": 50, + "bobPeriodS": 3.5, + "yawDegPerS": -8 + } + ] +} diff --git a/markers/BUILD_INSTRUCTIONS.md b/markers/BUILD_INSTRUCTIONS.md new file mode 100644 index 0000000..e1a588a --- /dev/null +++ b/markers/BUILD_INSTRUCTIONS.md @@ -0,0 +1,51 @@ +# Building a Newbury Crest (marker ID 0) + +A "Newbury Crest" is the physical anchor marker. It has two parts: + +1. **Detector core** — a 6×6 grid of flat black & white tiles. This is the part the camera reads. + It is an ArUco 4×4 marker (1-cell black border + 4×4 data), so it must be **exact**. +2. **Colour-wheel ring** — a decorative 1-tile-wide border of Red / Yellow / Blue around the core. + Purely cosmetic (Hidden Side theme); the detector ignores it. + +See `crest-01_buildplan.svg` for the full picture and `crest-01_buildmap.json` for the raw grid. + +## The detector core (6×6) — ID 0 + +`#` = **black** tile, `.` = **white** tile. Build it in this exact orientation; the single +yellow corner dot in the app shows which corner is the reference (top-left here). + +``` +# # # # # # +# . # . . # +# # . # . # +# # # . . # +# # # . # # +# # # # # # +``` + +- Outer ring is **all black** (the ArUco quiet border) — do not substitute colours here. +- Use **flat tiles**, not plates with exposed studs — studs cast shadows that hurt detection. +- Suggested scale: one **2×2 tile per cell** = 16 mm cells → **96 × 96 mm** core. + (For a crisper read at distance, go 2×2 *studs* per cell using four 1×1 tiles — finer edges.) + +## The colour-wheel ring + +Wrap one tile-row of Red/Yellow/Blue around the 96 mm core, cycling R→Y→B. Keep it **outside** +the black border (it can touch the black border directly — black separates it from the data). + +- Red `#F65151` · Yellow `#FFF35D` · Blue `#529EFF` +- These are the exact Hidden Side ghost-lure gradient tops, so it reads as the colour wheel. + +## Mounting + +- Mount flat on a black baseplate, flush and rigid (no warping — a curved marker breaks pose). +- Matte tiles beat glossy if you have the choice; glare is the main real-world enemy. +- Place at "street level" where a phone naturally frames it as part of the scene. + +## Test before you commit + +1. Print `crest-01_aruco0.svg` at ~100 mm and tape it down. +2. Open `ar-test.html` on your phone (over HTTPS), point at it. +3. HUD should show **id: 0** and a blue wisp should hover above it. +4. Try angles, distance, and your real exhibition lighting. If it holds lock well, build the + LEGO version with confidence; if glare is an issue, matte it down. diff --git a/markers/crest-01_aruco0.svg b/markers/crest-01_aruco0.svg new file mode 100644 index 0000000..53c49d5 --- /dev/null +++ b/markers/crest-01_aruco0.svg @@ -0,0 +1 @@ +ArUco4x4 id 0 \ No newline at end of file diff --git a/markers/crest-01_buildmap.json b/markers/crest-01_buildmap.json new file mode 100644 index 0000000..bb9599b --- /dev/null +++ b/markers/crest-01_buildmap.json @@ -0,0 +1,79 @@ +{ + "anchorName": "crest-01", + "ward": "North-West Ward", + "arucoId": 0, + "dictionary": "ARUCO_4X4_1000", + "detectorCore": { + "gridSize": 6, + "note": "1-cell black quiet border + 4x4 data. Use FLAT TILES. 0=Black, 1=White.", + "cells": [ + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 1, + 0, + 1, + 1, + 0 + ], + [ + 0, + 0, + 1, + 0, + 1, + 0 + ], + [ + 0, + 0, + 0, + 1, + 1, + 0 + ], + [ + 0, + 0, + 0, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ] + }, + "colourWheelFrame": { + "note": "Decorative Hidden Side border, 1 tile-row wide around the 6x6 core. Cycle R/Y/B. Detector ignores this zone but it must sit OUTSIDE the white quiet margin OR be separated by a black/white gap so it does not bleed into detection.", + "palette": { + "Red": "#F65151", + "Yellow": "#FFF35D", + "Blue": "#529EFF" + }, + "cycle": [ + "Red", + "Yellow", + "Blue" + ] + }, + "physical": { + "cellMm": 16, + "coreMm": 96, + "recommendedPart": "2x2 tile per cell (or 1x1 tiles 4-up for finer)", + "mountNote": "Flat on a black baseplate. Keep flush, no studs proud of tile surface." + } +} \ No newline at end of file diff --git a/markers/crest-01_buildplan.svg b/markers/crest-01_buildplan.svg new file mode 100644 index 0000000..5776575 --- /dev/null +++ b/markers/crest-01_buildplan.svg @@ -0,0 +1 @@ +Newbury Crest 01 — build mapArUco 4×4 · id 0 · black/white core reads, colour ring is decorativeCore: ■ black tile · □ white tile (16mm cells → 96mm core)Ring: Red #F65151 · Yellow #FFF35D · Blue #529EFF (decorative Hidden Side colour wheel) \ No newline at end of file diff --git a/markers/crest-02_aruco1.svg b/markers/crest-02_aruco1.svg new file mode 100644 index 0000000..fdaab1b --- /dev/null +++ b/markers/crest-02_aruco1.svg @@ -0,0 +1 @@ +ArUco4x4 id 1 \ No newline at end of file diff --git a/markers/crest-02_buildmap.json b/markers/crest-02_buildmap.json new file mode 100644 index 0000000..432932f --- /dev/null +++ b/markers/crest-02_buildmap.json @@ -0,0 +1,79 @@ +{ + "anchorName": "crest-02", + "ward": "North-East Ward", + "arucoId": 1, + "dictionary": "ARUCO_4X4_1000", + "detectorCore": { + "gridSize": 6, + "note": "1-cell black quiet border + 4x4 data. Use FLAT TILES. 0=Black, 1=White.", + "cells": [ + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 1, + 1, + 1, + 1, + 0 + ], + [ + 0, + 1, + 0, + 0, + 1, + 0 + ], + [ + 0, + 1, + 0, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ] + }, + "colourWheelFrame": { + "note": "Decorative Hidden Side border, 1 tile-row wide around the 6x6 core. Cycle R/Y/B. Detector ignores this zone but it must sit OUTSIDE the white quiet margin OR be separated by a black/white gap so it does not bleed into detection.", + "palette": { + "Red": "#F65151", + "Yellow": "#FFF35D", + "Blue": "#529EFF" + }, + "cycle": [ + "Red", + "Yellow", + "Blue" + ] + }, + "physical": { + "cellMm": 16, + "coreMm": 96, + "recommendedPart": "2x2 tile per cell (or 1x1 tiles 4-up for finer)", + "mountNote": "Flat on a black baseplate. Keep flush, no studs proud of tile surface." + } +} \ No newline at end of file diff --git a/markers/crest-03_aruco2.svg b/markers/crest-03_aruco2.svg new file mode 100644 index 0000000..a3502e3 --- /dev/null +++ b/markers/crest-03_aruco2.svg @@ -0,0 +1 @@ +ArUco4x4 id 2 \ No newline at end of file diff --git a/markers/crest-03_buildmap.json b/markers/crest-03_buildmap.json new file mode 100644 index 0000000..e7daee9 --- /dev/null +++ b/markers/crest-03_buildmap.json @@ -0,0 +1,79 @@ +{ + "anchorName": "crest-03", + "ward": "Town Square Ward", + "arucoId": 2, + "dictionary": "ARUCO_4X4_1000", + "detectorCore": { + "gridSize": 6, + "note": "1-cell black quiet border + 4x4 data. Use FLAT TILES. 0=Black, 1=White.", + "cells": [ + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1, + 1, + 0 + ], + [ + 0, + 0, + 0, + 1, + 1, + 0 + ], + [ + 0, + 0, + 0, + 1, + 0, + 0 + ], + [ + 0, + 1, + 1, + 0, + 1, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ] + }, + "colourWheelFrame": { + "note": "Decorative Hidden Side border, 1 tile-row wide around the 6x6 core. Cycle R/Y/B. Detector ignores this zone but it must sit OUTSIDE the white quiet margin OR be separated by a black/white gap so it does not bleed into detection.", + "palette": { + "Red": "#F65151", + "Yellow": "#FFF35D", + "Blue": "#529EFF" + }, + "cycle": [ + "Red", + "Yellow", + "Blue" + ] + }, + "physical": { + "cellMm": 16, + "coreMm": 96, + "recommendedPart": "2x2 tile per cell (or 1x1 tiles 4-up for finer)", + "mountNote": "Flat on a black baseplate. Keep flush, no studs proud of tile surface." + } +} \ No newline at end of file diff --git a/markers/crest-04_aruco3.svg b/markers/crest-04_aruco3.svg new file mode 100644 index 0000000..baa14b7 --- /dev/null +++ b/markers/crest-04_aruco3.svg @@ -0,0 +1 @@ +ArUco4x4 id 3 \ No newline at end of file diff --git a/markers/crest-04_buildmap.json b/markers/crest-04_buildmap.json new file mode 100644 index 0000000..5728fd6 --- /dev/null +++ b/markers/crest-04_buildmap.json @@ -0,0 +1,79 @@ +{ + "anchorName": "crest-04", + "ward": "South-West Ward", + "arucoId": 3, + "dictionary": "ARUCO_4X4_1000", + "detectorCore": { + "gridSize": 6, + "note": "1-cell black quiet border + 4x4 data. Use FLAT TILES. 0=Black, 1=White.", + "cells": [ + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 1, + 0, + 0, + 1, + 0 + ], + [ + 0, + 1, + 0, + 0, + 1, + 0 + ], + [ + 0, + 0, + 1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ] + }, + "colourWheelFrame": { + "note": "Decorative Hidden Side border, 1 tile-row wide around the 6x6 core. Cycle R/Y/B. Detector ignores this zone but it must sit OUTSIDE the white quiet margin OR be separated by a black/white gap so it does not bleed into detection.", + "palette": { + "Red": "#F65151", + "Yellow": "#FFF35D", + "Blue": "#529EFF" + }, + "cycle": [ + "Red", + "Yellow", + "Blue" + ] + }, + "physical": { + "cellMm": 16, + "coreMm": 96, + "recommendedPart": "2x2 tile per cell (or 1x1 tiles 4-up for finer)", + "mountNote": "Flat on a black baseplate. Keep flush, no studs proud of tile surface." + } +} \ No newline at end of file diff --git a/markers/crest-05_aruco4.svg b/markers/crest-05_aruco4.svg new file mode 100644 index 0000000..06964ef --- /dev/null +++ b/markers/crest-05_aruco4.svg @@ -0,0 +1 @@ +ArUco4x4 id 4 \ No newline at end of file diff --git a/markers/crest-05_buildmap.json b/markers/crest-05_buildmap.json new file mode 100644 index 0000000..d6b2c1f --- /dev/null +++ b/markers/crest-05_buildmap.json @@ -0,0 +1,79 @@ +{ + "anchorName": "crest-05", + "ward": "South-East Ward", + "arucoId": 4, + "dictionary": "ARUCO_4X4_1000", + "detectorCore": { + "gridSize": 6, + "note": "1-cell black quiet border + 4x4 data. Use FLAT TILES. 0=Black, 1=White.", + "cells": [ + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + 1, + 0 + ], + [ + 0, + 0, + 1, + 0, + 0, + 0 + ], + [ + 0, + 1, + 0, + 0, + 1, + 0 + ], + [ + 0, + 1, + 1, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ] + }, + "colourWheelFrame": { + "note": "Decorative Hidden Side border, 1 tile-row wide around the 6x6 core. Cycle R/Y/B. Detector ignores this zone but it must sit OUTSIDE the white quiet margin OR be separated by a black/white gap so it does not bleed into detection.", + "palette": { + "Red": "#F65151", + "Yellow": "#FFF35D", + "Blue": "#529EFF" + }, + "cycle": [ + "Red", + "Yellow", + "Blue" + ] + }, + "physical": { + "cellMm": 16, + "coreMm": 96, + "recommendedPart": "2x2 tile per cell (or 1x1 tiles 4-up for finer)", + "mountNote": "Flat on a black baseplate. Keep flush, no studs proud of tile surface." + } +} \ No newline at end of file diff --git a/markers/manifest.json b/markers/manifest.json new file mode 100644 index 0000000..f34567d --- /dev/null +++ b/markers/manifest.json @@ -0,0 +1,31 @@ +{ + "dictionary": "ARUCO_4X4_1000", + "note": "Maps physical ArUco marker IDs to scene.json anchors. fiducialId in scene.json must match arucoId here.", + "markers": [ + { + "id": 0, + "name": "crest-01", + "ward": "North-West Ward" + }, + { + "id": 1, + "name": "crest-02", + "ward": "North-East Ward" + }, + { + "id": 2, + "name": "crest-03", + "ward": "Town Square Ward" + }, + { + "id": 3, + "name": "crest-04", + "ward": "South-West Ward" + }, + { + "id": 4, + "name": "crest-05", + "ward": "South-East Ward" + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4817fe3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,900 @@ +{ + "name": "newbury-exhibit", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "newbury-exhibit", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "dotenv": "^16.4.5", + "express": "^5.0.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "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": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "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": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "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/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "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": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "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": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/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/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8c65852 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "newbury-exhibit", + "version": "0.1.0", + "description": "Shared multi-device AR exhibit viewer for a physical Newbury LEGO build.", + "type": "module", + "main": "server/sync-server.js", + "scripts": { + "start": "node server/sync-server.js" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "dotenv": "^16.4.5", + "express": "^5.0.0", + "ws": "^8.18.0" + }, + "license": "MIT" +} diff --git a/public/ar-test.html b/public/ar-test.html new file mode 100644 index 0000000..c959ce1 --- /dev/null +++ b/public/ar-test.html @@ -0,0 +1,75 @@ + + + + + +Newbury Exhibit — AR marker test + + + +
+ + +
+ + + +
+

Newbury Hidden Side

+

Point your camera at a Newbury Crest marker to reveal the ghost bound to it.

+ +
+
+ + + + + + + + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..bbe502d --- /dev/null +++ b/public/index.html @@ -0,0 +1,30 @@ + + + + + +Newbury Exhibit — god view + + + +
+
Newbury Exhibit · god view
+
link: connecting… · ghosts: 0
+
drag to orbit · scroll to zoom
+
+ + + diff --git a/public/js/ar-test.js b/public/js/ar-test.js new file mode 100644 index 0000000..aa59699 --- /dev/null +++ b/public/js/ar-test.js @@ -0,0 +1,214 @@ +/* Newbury Exhibit — AR marker proof of concept + * + * Goal of this module: prove the full chain works on a real phone — + * camera frame -> ArUco 4x4 detection -> a ghost sprite locked onto the marker. + * No server/sync yet; this is the localisation primitive everything else builds on. + * + * Detector: js-aruco2 (global `AR`), dictionary ARUCO_4X4_1000. + */ +(function () { + 'use strict'; + + const video = document.getElementById('video'); + const overlay = document.getElementById('overlay'); + const ctx = overlay.getContext('2d'); + const startScreen = document.getElementById('start'); + const goBtn = document.getElementById('go'); + const errEl = document.getElementById('err'); + const hud = document.getElementById('hud'); + const seenTag = document.getElementById('seen'); + const midEl = document.getElementById('mid'); + const fpsEl = document.getElementById('fps'); + + // Offscreen canvas we actually read pixels from (matches video native size). + const grab = document.createElement('canvas'); + const gctx = grab.getContext('2d', { willReadFrequently: true }); + + let detector = null; + let running = false; + let lastSeen = 0; // timestamp of last successful detection + const HOLD_MS = 250; // keep ghost visible briefly through dropped frames + + // Ghost sprite (animated WebP if present, else procedural wisp fallback). + const ghostImg = new Image(); + let ghostReady = false; + ghostImg.onload = () => { ghostReady = true; }; + ghostImg.onerror = () => { ghostReady = false; }; // fall back to procedural + // Optional: drop a sprite at this path to use it. Safe if 404 -> procedural wisp. + ghostImg.src = 'assets/ghost_blue.webp'; + + function sizeToVideo() { + const vw = video.videoWidth, vh = video.videoHeight; + if (!vw || !vh) return; + grab.width = vw; grab.height = vh; + + // Cover-fit the viewport while keeping native pixels for detection. + const scale = Math.max(window.innerWidth / vw, window.innerHeight / vh); + const dw = vw * scale, dh = vh * scale; + for (const el of [video, overlay]) { + el.style.width = dw + 'px'; + el.style.height = dh + 'px'; + } + overlay.width = dw; overlay.height = dh; + overlay._scale = scale; // px-per-native-pixel for drawing in display space + } + + async function start() { + errEl.textContent = ''; + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } }, + audio: false, + }); + video.srcObject = stream; + await video.play(); + } catch (e) { + errEl.textContent = 'Camera unavailable: ' + (e.message || e.name) + + '. On iPhone this page must be served over HTTPS and you must allow camera access.'; + return; + } + + detector = new AR.Detector({ dictionaryName: 'ARUCO_4X4_1000' }); + + await new Promise((res) => { + if (video.readyState >= 2) return res(); + video.onloadeddata = () => res(); + }); + sizeToVideo(); + window.addEventListener('resize', sizeToVideo); + + startScreen.classList.add('hidden'); + hud.classList.remove('hidden'); + running = true; + requestAnimationFrame(loop); + } + + // ---- detection + render loop ---- + let frames = 0, fpsStamp = performance.now(); + + function loop(now) { + if (!running) return; + requestAnimationFrame(loop); + if (video.readyState < 2 || !grab.width) return; + + gctx.drawImage(video, 0, 0, grab.width, grab.height); + const img = gctx.getImageData(0, 0, grab.width, grab.height); + + let markers = []; + try { markers = detector.detect(img); } catch (_) { markers = []; } + + const s = overlay._scale || 1; + ctx.clearRect(0, 0, overlay.width, overlay.height); + + if (markers.length) { + lastSeen = now; + const m = markers[0]; + drawMarkerOutline(m, s); + drawGhostOnMarker(m, s, now); + seenTag.textContent = 'locked'; + seenTag.className = 'tag on'; + midEl.textContent = m.id; + } else if (now - lastSeen < HOLD_MS) { + // brief hold-over: keep last ghost roughly; (cheap version: skip) + seenTag.textContent = 'holding…'; + seenTag.className = 'tag on'; + } else { + seenTag.textContent = 'searching…'; + seenTag.className = 'tag off'; + midEl.textContent = '—'; + } + + frames++; + if (now - fpsStamp > 500) { + fpsEl.textContent = Math.round((frames * 1000) / (now - fpsStamp)); + frames = 0; fpsStamp = now; + } + } + + function corners(m, s) { + // js-aruco corners are in native pixel space (origin top-left). + return m.corners.map((c) => ({ x: c.x * s, y: c.y * s })); + } + + function drawMarkerOutline(m, s) { + const c = corners(m, s); + ctx.lineWidth = 3; + ctx.strokeStyle = 'rgba(82,158,255,0.9)'; + ctx.beginPath(); + ctx.moveTo(c[0].x, c[0].y); + for (let i = 1; i < c.length; i++) ctx.lineTo(c[i].x, c[i].y); + ctx.closePath(); + ctx.stroke(); + // corner dot to show orientation lock (first corner) + ctx.fillStyle = 'var(--yellow)'; + ctx.fillStyle = '#fff35d'; + ctx.beginPath(); ctx.arc(c[0].x, c[0].y, 5, 0, Math.PI * 2); ctx.fill(); + } + + function centroid(c) { + let x = 0, y = 0; + for (const p of c) { x += p.x; y += p.y; } + return { x: x / c.length, y: y / c.length }; + } + + function markerSpan(c) { + // average side length in display px -> sprite scale reference + let sum = 0; + for (let i = 0; i < c.length; i++) { + const a = c[i], b = c[(i + 1) % c.length]; + sum += Math.hypot(b.x - a.x, b.y - a.y); + } + return sum / c.length; + } + + function drawGhostOnMarker(m, s, now) { + const c = corners(m, s); + const mid = centroid(c); + const span = markerSpan(c); + + // Ghost hovers above the marker, gently bobbing — a tiny taste of the + // motion solver the sync server will eventually drive. + const t = now / 1000; + const bob = Math.sin(t * 2.2) * span * 0.10; + const sway = Math.cos(t * 1.3) * span * 0.06; + const cx = mid.x + sway; + const cy = mid.y - span * 0.95 + bob; // float above the plaque + const size = span * 1.4; + + if (ghostReady) { + ctx.save(); + ctx.globalAlpha = 0.92; + ctx.drawImage(ghostImg, cx - size / 2, cy - size / 2, size, size); + ctx.restore(); + } else { + drawWisp(cx, cy, size * 0.5, t); + } + + // tether line so it reads as "bound to this marker" + ctx.strokeStyle = 'rgba(81,234,241,0.35)'; + ctx.lineWidth = 2; + ctx.setLineDash([4, 6]); + ctx.beginPath(); ctx.moveTo(mid.x, mid.y); ctx.lineTo(cx, cy + size * 0.25); ctx.stroke(); + ctx.setLineDash([]); + } + + // Procedural fallback wisp in Hidden Side "Sad/Blue" gradient. + function drawWisp(cx, cy, r, t) { + const grad = ctx.createRadialGradient(cx, cy - r * 0.3, r * 0.2, cx, cy, r); + grad.addColorStop(0, 'rgba(81,234,241,0.95)'); + grad.addColorStop(0.5, 'rgba(82,158,255,0.65)'); + grad.addColorStop(1, 'rgba(82,158,255,0)'); + ctx.fillStyle = grad; + ctx.beginPath(); + // teardrop-ish body + const wob = Math.sin(t * 3) * r * 0.08; + ctx.ellipse(cx, cy, r * 0.8 + wob, r, 0, 0, Math.PI * 2); + ctx.fill(); + // eyes + ctx.fillStyle = 'rgba(8,12,30,0.85)'; + ctx.beginPath(); ctx.ellipse(cx - r * 0.28, cy - r * 0.1, r * 0.10, r * 0.16, 0, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.ellipse(cx + r * 0.28, cy - r * 0.1, r * 0.10, r * 0.16, 0, 0, Math.PI * 2); ctx.fill(); + } + + goBtn.addEventListener('click', start); +})(); diff --git a/public/js/viewer.js b/public/js/viewer.js new file mode 100644 index 0000000..35620b5 --- /dev/null +++ b/public/js/viewer.js @@ -0,0 +1,165 @@ +import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js'; +import { OrbitControls } from 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js'; + +// ---- Config -------------------------------------------------------------- +const MM = 0.001; // millimetre -> Three.js metre scale + +const statusEl = document.getElementById('status'); +const countEl = document.getElementById('count'); + +// ---- Scene setup --------------------------------------------------------- +const scene = new THREE.Scene(); +scene.background = new THREE.Color(0x0a0a14); + +const camera = new THREE.PerspectiveCamera( + 55, + window.innerWidth / window.innerHeight, + 0.01, + 100 +); +camera.position.set(0.75, 0.9, 1.6); + +const renderer = new THREE.WebGLRenderer({ antialias: true }); +renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); +renderer.setSize(window.innerWidth, window.innerHeight); +document.body.appendChild(renderer.domElement); + +const controls = new OrbitControls(camera, renderer.domElement); +controls.enableDamping = true; +controls.target.set(0.75, 0.2, 0.35); // centre of a 1500x700 build in metres + +scene.add(new THREE.AmbientLight(0x8090b0, 0.8)); +const key = new THREE.DirectionalLight(0xffffff, 1.0); +key.position.set(2, 3, 1); +scene.add(key); + +// ---- Ghost tint gradients (from recovered GhostType data) ---------------- +const GHOST_GRADIENTS = { + Red: { top: 0xf65151, bottom: 0xff2678 }, + Yellow: { top: 0xb57f0b, bottom: 0xfff35d }, + Blue: { top: 0x529eff, bottom: 0x51eaf1 }, +}; + +// We don't have per-ghost colour on the client snapshot yet, so default to Blue; +// colour wiring comes when we feed the enriched roster in. Kept simple for M1. +function ghostMaterial(colorKey = 'Blue') { + const g = GHOST_GRADIENTS[colorKey] || GHOST_GRADIENTS.Blue; + return new THREE.MeshStandardMaterial({ + color: g.bottom, + emissive: g.top, + emissiveIntensity: 0.6, + transparent: true, + opacity: 0.85, + roughness: 0.4, + }); +} + +// ---- Build footprint + anchor markers (drawn once from /api/scene) -------- +let worldDims = { x: 1500, y: 600, z: 700 }; + +function drawBuildFootprint() { + const w = worldDims.x * MM; + const d = worldDims.z * MM; + const geo = new THREE.PlaneGeometry(w, d); + const mat = new THREE.MeshStandardMaterial({ + color: 0x141422, + roughness: 0.9, + side: THREE.DoubleSide, + }); + const plane = new THREE.Mesh(geo, mat); + plane.rotation.x = -Math.PI / 2; + // origin is a corner, so shift plane so corner sits at (0,0,0) + plane.position.set(w / 2, 0, d / 2); + scene.add(plane); + + const grid = new THREE.GridHelper(Math.max(w, d), 15, 0x334466, 0x223355); + grid.position.set(w / 2, 0.001, d / 2); + scene.add(grid); +} + +function drawAnchors(anchors) { + const mat = new THREE.MeshStandardMaterial({ + color: 0x00e5c0, + emissive: 0x00e5c0, + emissiveIntensity: 0.5, + }); + anchors.forEach((a) => { + const s = (a.fiducialSizeMm || 40) * MM; + const geo = new THREE.BoxGeometry(s, s * 0.15, s); + const m = new THREE.Mesh(geo, mat); + m.position.set(a.position.x * MM, a.position.y * MM, a.position.z * MM); + scene.add(m); + }); +} + +// ---- Ghost pool ---------------------------------------------------------- +const ghostMeshes = new Map(); // spawnId -> THREE.Mesh + +function ensureGhost(spawnId) { + if (ghostMeshes.has(spawnId)) return ghostMeshes.get(spawnId); + const geo = new THREE.IcosahedronGeometry(0.05, 1); // placeholder until OBJ wired + const mesh = new THREE.Mesh(geo, ghostMaterial('Blue')); + scene.add(mesh); + ghostMeshes.set(spawnId, mesh); + return mesh; +} + +function applyState(ghosts) { + const seen = new Set(); + for (const g of ghosts) { + seen.add(g.spawnId); + const mesh = ensureGhost(g.spawnId); + mesh.position.set(g.pos.x * MM, g.pos.y * MM, g.pos.z * MM); + mesh.rotation.y = (g.yawDeg * Math.PI) / 180; + } + // remove any ghosts no longer present + for (const [id, mesh] of ghostMeshes) { + if (!seen.has(id)) { + scene.remove(mesh); + ghostMeshes.delete(id); + } + } + countEl.textContent = String(ghosts.length); +} + +// ---- WebSocket sync ------------------------------------------------------ +function connect() { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + const ws = new WebSocket(`${proto}://${location.host}/sync`); + + ws.onopen = () => { + statusEl.textContent = 'connected'; + statusEl.className = 'ok'; + }; + ws.onclose = () => { + statusEl.textContent = 'reconnecting…'; + statusEl.className = 'warn'; + setTimeout(connect, 1500); + }; + ws.onerror = () => ws.close(); + ws.onmessage = (ev) => { + const msg = JSON.parse(ev.data); + if (msg.type === 'hello') { + if (msg.world?.buildSize) worldDims = msg.world.buildSize; + drawBuildFootprint(); + if (msg.anchors) drawAnchors(msg.anchors); + } else if (msg.type === 'state') { + applyState(msg.ghosts); + } + }; +} +connect(); + +// ---- Render loop --------------------------------------------------------- +function animate() { + requestAnimationFrame(animate); + controls.update(); + renderer.render(scene, camera); +} +animate(); + +window.addEventListener('resize', () => { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); +}); diff --git a/public/vendor/aruco.js b/public/vendor/aruco.js new file mode 100644 index 0000000..c0b3546 --- /dev/null +++ b/public/vendor/aruco.js @@ -0,0 +1,468 @@ +/* +Copyright (c) 2020 Damiano Falcioni +Copyright (c) 2011 Juan Mellado + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +/* +References: +- "ArUco: a minimal library for Augmented Reality applications based on OpenCv" + http://www.uco.es/investiga/grupos/ava/node/26 +- "js-aruco: a port to JavaScript of the ArUco library" + https://github.com/jcmellado/js-aruco +*/ + +var AR = {}; +var CV = this.CV || require('./cv').CV; +this.AR = AR; + +AR.DICTIONARIES = { + ARUCO: { + nBits: 25, + tau: 3, + codeList: [0x1084210,0x1084217,0x1084209,0x108420e,0x10842f0,0x10842f7,0x10842e9,0x10842ee,0x1084130,0x1084137,0x1084129,0x108412e,0x10841d0,0x10841d7,0x10841c9,0x10841ce,0x1085e10,0x1085e17,0x1085e09,0x1085e0e,0x1085ef0,0x1085ef7,0x1085ee9,0x1085eee,0x1085d30,0x1085d37,0x1085d29,0x1085d2e,0x1085dd0,0x1085dd7,0x1085dc9,0x1085dce,0x1082610,0x1082617,0x1082609,0x108260e,0x10826f0,0x10826f7,0x10826e9,0x10826ee,0x1082530,0x1082537,0x1082529,0x108252e,0x10825d0,0x10825d7,0x10825c9,0x10825ce,0x1083a10,0x1083a17,0x1083a09,0x1083a0e,0x1083af0,0x1083af7,0x1083ae9,0x1083aee,0x1083930,0x1083937,0x1083929,0x108392e,0x10839d0,0x10839d7,0x10839c9,0x10839ce,0x10bc210,0x10bc217,0x10bc209,0x10bc20e,0x10bc2f0,0x10bc2f7,0x10bc2e9,0x10bc2ee,0x10bc130,0x10bc137,0x10bc129,0x10bc12e,0x10bc1d0,0x10bc1d7,0x10bc1c9,0x10bc1ce,0x10bde10,0x10bde17,0x10bde09,0x10bde0e,0x10bdef0,0x10bdef7,0x10bdee9,0x10bdeee,0x10bdd30,0x10bdd37,0x10bdd29,0x10bdd2e,0x10bddd0,0x10bddd7,0x10bddc9,0x10bddce,0x10ba610,0x10ba617,0x10ba609,0x10ba60e,0x10ba6f0,0x10ba6f7,0x10ba6e9,0x10ba6ee,0x10ba530,0x10ba537,0x10ba529,0x10ba52e,0x10ba5d0,0x10ba5d7,0x10ba5c9,0x10ba5ce,0x10bba10,0x10bba17,0x10bba09,0x10bba0e,0x10bbaf0,0x10bbaf7,0x10bbae9,0x10bbaee,0x10bb930,0x10bb937,0x10bb929,0x10bb92e,0x10bb9d0,0x10bb9d7,0x10bb9c9,0x10bb9ce,0x104c210,0x104c217,0x104c209,0x104c20e,0x104c2f0,0x104c2f7,0x104c2e9,0x104c2ee,0x104c130,0x104c137,0x104c129,0x104c12e,0x104c1d0,0x104c1d7,0x104c1c9,0x104c1ce,0x104de10,0x104de17,0x104de09,0x104de0e,0x104def0,0x104def7,0x104dee9,0x104deee,0x104dd30,0x104dd37,0x104dd29,0x104dd2e,0x104ddd0,0x104ddd7,0x104ddc9,0x104ddce,0x104a610,0x104a617,0x104a609,0x104a60e,0x104a6f0,0x104a6f7,0x104a6e9,0x104a6ee,0x104a530,0x104a537,0x104a529,0x104a52e,0x104a5d0,0x104a5d7,0x104a5c9,0x104a5ce,0x104ba10,0x104ba17,0x104ba09,0x104ba0e,0x104baf0,0x104baf7,0x104bae9,0x104baee,0x104b930,0x104b937,0x104b929,0x104b92e,0x104b9d0,0x104b9d7,0x104b9c9,0x104b9ce,0x1074210,0x1074217,0x1074209,0x107420e,0x10742f0,0x10742f7,0x10742e9,0x10742ee,0x1074130,0x1074137,0x1074129,0x107412e,0x10741d0,0x10741d7,0x10741c9,0x10741ce,0x1075e10,0x1075e17,0x1075e09,0x1075e0e,0x1075ef0,0x1075ef7,0x1075ee9,0x1075eee,0x1075d30,0x1075d37,0x1075d29,0x1075d2e,0x1075dd0,0x1075dd7,0x1075dc9,0x1075dce,0x1072610,0x1072617,0x1072609,0x107260e,0x10726f0,0x10726f7,0x10726e9,0x10726ee,0x1072530,0x1072537,0x1072529,0x107252e,0x10725d0,0x10725d7,0x10725c9,0x10725ce,0x1073a10,0x1073a17,0x1073a09,0x1073a0e,0x1073af0,0x1073af7,0x1073ae9,0x1073aee,0x1073930,0x1073937,0x1073929,0x107392e,0x10739d0,0x10739d7,0x10739c9,0x10739ce,0x1784210,0x1784217,0x1784209,0x178420e,0x17842f0,0x17842f7,0x17842e9,0x17842ee,0x1784130,0x1784137,0x1784129,0x178412e,0x17841d0,0x17841d7,0x17841c9,0x17841ce,0x1785e10,0x1785e17,0x1785e09,0x1785e0e,0x1785ef0,0x1785ef7,0x1785ee9,0x1785eee,0x1785d30,0x1785d37,0x1785d29,0x1785d2e,0x1785dd0,0x1785dd7,0x1785dc9,0x1785dce,0x1782610,0x1782617,0x1782609,0x178260e,0x17826f0,0x17826f7,0x17826e9,0x17826ee,0x1782530,0x1782537,0x1782529,0x178252e,0x17825d0,0x17825d7,0x17825c9,0x17825ce,0x1783a10,0x1783a17,0x1783a09,0x1783a0e,0x1783af0,0x1783af7,0x1783ae9,0x1783aee,0x1783930,0x1783937,0x1783929,0x178392e,0x17839d0,0x17839d7,0x17839c9,0x17839ce,0x17bc210,0x17bc217,0x17bc209,0x17bc20e,0x17bc2f0,0x17bc2f7,0x17bc2e9,0x17bc2ee,0x17bc130,0x17bc137,0x17bc129,0x17bc12e,0x17bc1d0,0x17bc1d7,0x17bc1c9,0x17bc1ce,0x17bde10,0x17bde17,0x17bde09,0x17bde0e,0x17bdef0,0x17bdef7,0x17bdee9,0x17bdeee,0x17bdd30,0x17bdd37,0x17bdd29,0x17bdd2e,0x17bddd0,0x17bddd7,0x17bddc9,0x17bddce,0x17ba610,0x17ba617,0x17ba609,0x17ba60e,0x17ba6f0,0x17ba6f7,0x17ba6e9,0x17ba6ee,0x17ba530,0x17ba537,0x17ba529,0x17ba52e,0x17ba5d0,0x17ba5d7,0x17ba5c9,0x17ba5ce,0x17bba10,0x17bba17,0x17bba09,0x17bba0e,0x17bbaf0,0x17bbaf7,0x17bbae9,0x17bbaee,0x17bb930,0x17bb937,0x17bb929,0x17bb92e,0x17bb9d0,0x17bb9d7,0x17bb9c9,0x17bb9ce,0x174c210,0x174c217,0x174c209,0x174c20e,0x174c2f0,0x174c2f7,0x174c2e9,0x174c2ee,0x174c130,0x174c137,0x174c129,0x174c12e,0x174c1d0,0x174c1d7,0x174c1c9,0x174c1ce,0x174de10,0x174de17,0x174de09,0x174de0e,0x174def0,0x174def7,0x174dee9,0x174deee,0x174dd30,0x174dd37,0x174dd29,0x174dd2e,0x174ddd0,0x174ddd7,0x174ddc9,0x174ddce,0x174a610,0x174a617,0x174a609,0x174a60e,0x174a6f0,0x174a6f7,0x174a6e9,0x174a6ee,0x174a530,0x174a537,0x174a529,0x174a52e,0x174a5d0,0x174a5d7,0x174a5c9,0x174a5ce,0x174ba10,0x174ba17,0x174ba09,0x174ba0e,0x174baf0,0x174baf7,0x174bae9,0x174baee,0x174b930,0x174b937,0x174b929,0x174b92e,0x174b9d0,0x174b9d7,0x174b9c9,0x174b9ce,0x1774210,0x1774217,0x1774209,0x177420e,0x17742f0,0x17742f7,0x17742e9,0x17742ee,0x1774130,0x1774137,0x1774129,0x177412e,0x17741d0,0x17741d7,0x17741c9,0x17741ce,0x1775e10,0x1775e17,0x1775e09,0x1775e0e,0x1775ef0,0x1775ef7,0x1775ee9,0x1775eee,0x1775d30,0x1775d37,0x1775d29,0x1775d2e,0x1775dd0,0x1775dd7,0x1775dc9,0x1775dce,0x1772610,0x1772617,0x1772609,0x177260e,0x17726f0,0x17726f7,0x17726e9,0x17726ee,0x1772530,0x1772537,0x1772529,0x177252e,0x17725d0,0x17725d7,0x17725c9,0x17725ce,0x1773a10,0x1773a17,0x1773a09,0x1773a0e,0x1773af0,0x1773af7,0x1773ae9,0x1773aee,0x1773930,0x1773937,0x1773929,0x177392e,0x17739d0,0x17739d7,0x17739c9,0x17739ce,0x984210,0x984217,0x984209,0x98420e,0x9842f0,0x9842f7,0x9842e9,0x9842ee,0x984130,0x984137,0x984129,0x98412e,0x9841d0,0x9841d7,0x9841c9,0x9841ce,0x985e10,0x985e17,0x985e09,0x985e0e,0x985ef0,0x985ef7,0x985ee9,0x985eee,0x985d30,0x985d37,0x985d29,0x985d2e,0x985dd0,0x985dd7,0x985dc9,0x985dce,0x982610,0x982617,0x982609,0x98260e,0x9826f0,0x9826f7,0x9826e9,0x9826ee,0x982530,0x982537,0x982529,0x98252e,0x9825d0,0x9825d7,0x9825c9,0x9825ce,0x983a10,0x983a17,0x983a09,0x983a0e,0x983af0,0x983af7,0x983ae9,0x983aee,0x983930,0x983937,0x983929,0x98392e,0x9839d0,0x9839d7,0x9839c9,0x9839ce,0x9bc210,0x9bc217,0x9bc209,0x9bc20e,0x9bc2f0,0x9bc2f7,0x9bc2e9,0x9bc2ee,0x9bc130,0x9bc137,0x9bc129,0x9bc12e,0x9bc1d0,0x9bc1d7,0x9bc1c9,0x9bc1ce,0x9bde10,0x9bde17,0x9bde09,0x9bde0e,0x9bdef0,0x9bdef7,0x9bdee9,0x9bdeee,0x9bdd30,0x9bdd37,0x9bdd29,0x9bdd2e,0x9bddd0,0x9bddd7,0x9bddc9,0x9bddce,0x9ba610,0x9ba617,0x9ba609,0x9ba60e,0x9ba6f0,0x9ba6f7,0x9ba6e9,0x9ba6ee,0x9ba530,0x9ba537,0x9ba529,0x9ba52e,0x9ba5d0,0x9ba5d7,0x9ba5c9,0x9ba5ce,0x9bba10,0x9bba17,0x9bba09,0x9bba0e,0x9bbaf0,0x9bbaf7,0x9bbae9,0x9bbaee,0x9bb930,0x9bb937,0x9bb929,0x9bb92e,0x9bb9d0,0x9bb9d7,0x9bb9c9,0x9bb9ce,0x94c210,0x94c217,0x94c209,0x94c20e,0x94c2f0,0x94c2f7,0x94c2e9,0x94c2ee,0x94c130,0x94c137,0x94c129,0x94c12e,0x94c1d0,0x94c1d7,0x94c1c9,0x94c1ce,0x94de10,0x94de17,0x94de09,0x94de0e,0x94def0,0x94def7,0x94dee9,0x94deee,0x94dd30,0x94dd37,0x94dd29,0x94dd2e,0x94ddd0,0x94ddd7,0x94ddc9,0x94ddce,0x94a610,0x94a617,0x94a609,0x94a60e,0x94a6f0,0x94a6f7,0x94a6e9,0x94a6ee,0x94a530,0x94a537,0x94a529,0x94a52e,0x94a5d0,0x94a5d7,0x94a5c9,0x94a5ce,0x94ba10,0x94ba17,0x94ba09,0x94ba0e,0x94baf0,0x94baf7,0x94bae9,0x94baee,0x94b930,0x94b937,0x94b929,0x94b92e,0x94b9d0,0x94b9d7,0x94b9c9,0x94b9ce,0x974210,0x974217,0x974209,0x97420e,0x9742f0,0x9742f7,0x9742e9,0x9742ee,0x974130,0x974137,0x974129,0x97412e,0x9741d0,0x9741d7,0x9741c9,0x9741ce,0x975e10,0x975e17,0x975e09,0x975e0e,0x975ef0,0x975ef7,0x975ee9,0x975eee,0x975d30,0x975d37,0x975d29,0x975d2e,0x975dd0,0x975dd7,0x975dc9,0x975dce,0x972610,0x972617,0x972609,0x97260e,0x9726f0,0x9726f7,0x9726e9,0x9726ee,0x972530,0x972537,0x972529,0x97252e,0x9725d0,0x9725d7,0x9725c9,0x9725ce,0x973a10,0x973a17,0x973a09,0x973a0e,0x973af0,0x973af7,0x973ae9,0x973aee,0x973930,0x973937,0x973929,0x97392e,0x9739d0,0x9739d7,0x9739c9,0x9739ce,0xe84210,0xe84217,0xe84209,0xe8420e,0xe842f0,0xe842f7,0xe842e9,0xe842ee,0xe84130,0xe84137,0xe84129,0xe8412e,0xe841d0,0xe841d7,0xe841c9,0xe841ce,0xe85e10,0xe85e17,0xe85e09,0xe85e0e,0xe85ef0,0xe85ef7,0xe85ee9,0xe85eee,0xe85d30,0xe85d37,0xe85d29,0xe85d2e,0xe85dd0,0xe85dd7,0xe85dc9,0xe85dce,0xe82610,0xe82617,0xe82609,0xe8260e,0xe826f0,0xe826f7,0xe826e9,0xe826ee,0xe82530,0xe82537,0xe82529,0xe8252e,0xe825d0,0xe825d7,0xe825c9,0xe825ce,0xe83a10,0xe83a17,0xe83a09,0xe83a0e,0xe83af0,0xe83af7,0xe83ae9,0xe83aee,0xe83930,0xe83937,0xe83929,0xe8392e,0xe839d0,0xe839d7,0xe839c9,0xe839ce,0xebc210,0xebc217,0xebc209,0xebc20e,0xebc2f0,0xebc2f7,0xebc2e9,0xebc2ee,0xebc130,0xebc137,0xebc129,0xebc12e,0xebc1d0,0xebc1d7,0xebc1c9,0xebc1ce,0xebde10,0xebde17,0xebde09,0xebde0e,0xebdef0,0xebdef7,0xebdee9,0xebdeee,0xebdd30,0xebdd37,0xebdd29,0xebdd2e,0xebddd0,0xebddd7,0xebddc9,0xebddce,0xeba610,0xeba617,0xeba609,0xeba60e,0xeba6f0,0xeba6f7,0xeba6e9,0xeba6ee,0xeba530,0xeba537,0xeba529,0xeba52e,0xeba5d0,0xeba5d7,0xeba5c9,0xeba5ce,0xebba10,0xebba17,0xebba09,0xebba0e,0xebbaf0,0xebbaf7,0xebbae9,0xebbaee,0xebb930,0xebb937,0xebb929,0xebb92e,0xebb9d0,0xebb9d7,0xebb9c9,0xebb9ce,0xe4c210,0xe4c217,0xe4c209,0xe4c20e,0xe4c2f0,0xe4c2f7,0xe4c2e9,0xe4c2ee,0xe4c130,0xe4c137,0xe4c129,0xe4c12e,0xe4c1d0,0xe4c1d7,0xe4c1c9,0xe4c1ce,0xe4de10,0xe4de17,0xe4de09,0xe4de0e,0xe4def0,0xe4def7,0xe4dee9,0xe4deee,0xe4dd30,0xe4dd37,0xe4dd29,0xe4dd2e,0xe4ddd0,0xe4ddd7,0xe4ddc9,0xe4ddce,0xe4a610,0xe4a617,0xe4a609,0xe4a60e,0xe4a6f0,0xe4a6f7,0xe4a6e9,0xe4a6ee,0xe4a530,0xe4a537,0xe4a529,0xe4a52e,0xe4a5d0,0xe4a5d7,0xe4a5c9,0xe4a5ce,0xe4ba10,0xe4ba17,0xe4ba09,0xe4ba0e,0xe4baf0,0xe4baf7,0xe4bae9,0xe4baee,0xe4b930,0xe4b937,0xe4b929,0xe4b92e,0xe4b9d0,0xe4b9d7,0xe4b9c9,0xe4b9ce,0xe74210,0xe74217,0xe74209,0xe7420e,0xe742f0,0xe742f7,0xe742e9,0xe742ee,0xe74130,0xe74137,0xe74129,0xe7412e,0xe741d0,0xe741d7,0xe741c9,0xe741ce,0xe75e10,0xe75e17,0xe75e09,0xe75e0e,0xe75ef0,0xe75ef7,0xe75ee9,0xe75eee,0xe75d30,0xe75d37,0xe75d29,0xe75d2e,0xe75dd0,0xe75dd7,0xe75dc9,0xe75dce,0xe72610,0xe72617,0xe72609,0xe7260e,0xe726f0,0xe726f7,0xe726e9,0xe726ee,0xe72530,0xe72537,0xe72529,0xe7252e,0xe725d0,0xe725d7,0xe725c9,0xe725ce,0xe73a10,0xe73a17,0xe73a09,0xe73a0e,0xe73af0,0xe73af7,0xe73ae9,0xe73aee,0xe73930,0xe73937,0xe73929,0xe7392e,0xe739d0,0xe739d7,0xe739c9] + }, + ARUCO_MIP_36h12: { + nBits: 36, + tau: 12, + codeList: [0xd2b63a09d,0x6001134e5,0x1206fbe72,0xff8ad6cb4,0x85da9bc49,0xb461afe9c,0x6db51fe13,0x5248c541f,0x8f34503,0x8ea462ece,0xeac2be76d,0x1af615c44,0xb48a49f27,0x2e4e1283b,0x78b1f2fa8,0x27d34f57e,0x89222fff1,0x4c1669406,0xbf49b3511,0xdc191cd5d,0x11d7c3f85,0x16a130e35,0xe29f27eff,0x428d8ae0c,0x90d548477,0x2319cbc93,0xc3b0c3dfc,0x424bccc9,0x2a081d630,0x762743d96,0xd0645bf19,0xf38d7fd60,0xc6cbf9a10,0x3c1be7c65,0x276f75e63,0x4490a3f63,0xda60acd52,0x3cc68df59,0xab46f9dae,0x88d533d78,0xb6d62ec21,0xb3c02b646,0x22e56d408,0xac5f5770a,0xaaa993f66,0x4caa07c8d,0x5c9b4f7b0,0xaa9ef0e05,0x705c5750,0xac81f545e,0x735b91e74,0x8cc35cee4,0xe44694d04,0xb5e121de0,0x261017d0f,0xf1d439eb5,0xa1a33ac96,0x174c62c02,0x1ee27f716,0x8b1c5ece9,0x6a05b0c6a,0xd0568dfc,0x192d25e5f,0x1adbeccc8,0xcfec87f00,0xd0b9dde7a,0x88dcef81e,0x445681cb9,0xdbb2ffc83,0xa48d96df1,0xb72cc2e7d,0xc295b53f,0xf49832704,0x9968edc29,0x9e4e1af85,0x8683e2d1b,0x810b45c04,0x6ac44bfe2,0x645346615,0x3990bd598,0x1c9ed0f6a,0xc26729d65,0x83993f795,0x3ac05ac5d,0x357adff3b,0xd5c05565,0x2f547ef44,0x86c115041,0x640fd9e5f,0xce08bbcf7,0x109bb343e,0xc21435c92,0x35b4dfce4,0x459752cf2,0xec915b82c,0x51881eed0,0x2dda7dc97,0x2e0142144,0x42e890f99,0x9a8856527,0x8e80d9d80,0x891cbcf34,0x25dd82410,0x239551d34,0x8fe8f0c70,0x94106a970,0x82609b40c,0xfc9caf36,0x688181d11,0x718613c08,0xf1ab7629,0xa357bfc18,0x4c03b7a46,0x204dedce6,0xad6300d37,0x84cc4cd09,0x42160e5c4,0x87d2adfa8,0x7850e7749,0x4e750fc7c,0xbf2e5dfda,0xd88324da5,0x234b52f80,0x378204514,0xabdf2ad53,0x365e78ef9,0x49caa6ca2,0x3c39ddf3,0xc68c5385d,0x5bfcbbf67,0x623241e21,0xabc90d5cc,0x388c6fe85,0xda0e2d62d,0x10855dfe9,0x4d46efd6b,0x76ea12d61,0x9db377d3d,0xeed0efa71,0xe6ec3ae2f,0x441faee83,0xba19c8ff5,0x313035eab,0x6ce8f7625,0x880dab58d,0x8d3409e0d,0x2be92ee21,0xd60302c6c,0x469ffc724,0x87eebeed3,0x42587ef7a,0x7a8cc4e52,0x76a437650,0x999e41ef4,0x7d0969e42,0xc02baf46b,0x9259f3e47,0x2116a1dc0,0x9f2de4d84,0xeffac29,0x7b371ff8c,0x668339da9,0xd010aee3f,0x1cd00b4c0,0x95070fc3b,0xf84c9a770,0x38f863d76,0x3646ff045,0xce1b96412,0x7a5d45da8,0x14e00ef6c,0x5e95abfd8,0xb2e9cb729,0x36c47dd7,0xb8ee97c6b,0xe9e8f657,0xd4ad2ef1a,0x8811c7f32,0x47bde7c31,0x3adadfb64,0x6e5b28574,0x33e67cd91,0x2ab9fdd2d,0x8afa67f2b,0xe6a28fc5e,0x72049cdbd,0xae65dac12,0x1251a4526,0x1089ab841,0xe2f096ee0,0xb0caee573,0xfd6677e86,0x444b3f518,0xbe8b3a56a,0x680a75cfc,0xac02baea8,0x97d815e1c,0x1d4386e08,0x1a14f5b0e,0xe658a8d81,0xa3868efa7,0x3668a9673,0xe8fc53d85,0x2e2b7edd5,0x8b2470f13,0xf69795f32,0x4589ffc8e,0x2e2080c9c,0x64265f7d,0x3d714dd10,0x1692c6ef1,0x3e67f2f49,0x5041dad63,0x1a1503415,0x64c18c742,0xa72eec35,0x1f0f9dc60,0xa9559bc67,0xf32911d0d,0x21c0d4ffc,0xe01cef5b0,0x4e23a3520,0xaa4f04e49,0xe1c4fcc43,0x208e8f6e8,0x8486774a5,0x9e98c7558,0x2c59fb7dc,0x9446a4613,0x8292dcc2e,0x4d61631,0xd05527809,0xa0163852d,0x8f657f639,0xcca6c3e37,0xcb136bc7a,0xfc5a83e53,0x9aa44fc30,0xbdec1bd3c,0xe020b9f7c,0x4b8f35fb0,0xb8165f637,0x33dc88d69,0x10a2f7e4d,0xc8cb5ff53,0xde259ff6b,0x46d070dd4,0x32d3b9741,0x7075f1c04,0x4d58dbea0] + } +}; + +AR.Dictionary = function (dicName) { + this.codes = {}; + this.codeList = []; + this.tau = 0; + this._initialize(dicName); +}; + +AR.Dictionary.prototype._initialize = function (dicName) { + this.codes = {}; + this.codeList = []; + this.tau = 0; + this.nBits = 0; + this.markSize = 0; + this.dicName = dicName; + var dictionary = AR.DICTIONARIES[dicName]; + if (!dictionary) + throw 'The dictionary "' + dicName + '" is not recognized.'; + + this.nBits = dictionary.nBits; + this.markSize = Math.sqrt(dictionary.nBits) + 2; + for (var i = 0; i < dictionary.codeList.length; i++) { + var code = null; + if (typeof dictionary.codeList[i] === 'number') + code = this._hex2bin(dictionary.codeList[i], dictionary.nBits); + if (typeof dictionary.codeList[i] === 'string') + code = this._hex2bin(parseInt(dictionary.codeList[i], 16), dictionary.nBits); + if (Array.isArray(dictionary.codeList[i])) + code = this._bytes2bin(dictionary.codeList[i], dictionary.nBits); + if (code === null) + throw 'Invalid code ' + i + ' in dictionary ' + dicName + ': ' + JSON.stringify(dictionary.codeList[i]); + if (code.length != dictionary.nBits) + throw 'The code ' + i + ' in dictionary ' + dicName + ' is not ' + dictionary.nBits + ' bits long but ' + code.length + ': ' + code; + this.codeList.push(code); + this.codes[code] = { + id: i + }; + } + this.tau = dictionary.tau || this._calculateTau(); +}; + +AR.Dictionary.prototype.find = function (bits) { + var val = '', + i, j; + for (i = 0; i < bits.length; i++) { + var bitRow = bits[i]; + for (j = 0; j < bitRow.length; j++) { + val += bitRow[j]; + } + } + var minFound = this.codes[val]; + if (minFound) + return { + id: minFound.id, + distance: 0 + }; + + for (i = 0; i < this.codeList.length; i++) { + var code = this.codeList[i]; + var distance = this._hammingDistance(val, code); + if (this._hammingDistance(val, code) < this.tau) { + if (!minFound || minFound.distance > distance) { + minFound = { + id: this.codes[code].id, + distance: distance + }; + } + } + } + return minFound; +}; + +AR.Dictionary.prototype._hex2bin = function (hex, nBits) { + return hex.toString(2).padStart(nBits, '0'); +}; + +AR.Dictionary.prototype._bytes2bin = function (byteList, nBits) { + var bits = '', byte; + for (byte of byteList) { + bits += byte.toString(2).padStart(bits.length + 8 > nBits?nBits - bits.length:8, '0'); + } + return bits; +}; + +AR.Dictionary.prototype._hammingDistance = function (str1, str2) { + if (str1.length != str2.length) + throw 'Hamming distance calculation require inputs of the same length'; + var distance = 0, + i; + for (i = 0; i < str1.length; i++) + if (str1[i] !== str2[i]) + distance += 1; + return distance; +}; + +AR.Dictionary.prototype._calculateTau = function () { + var tau = Number.MAX_VALUE; + for(var i=0;i'; + svg += ''; + svg += ''; + for(var y=0;y'; + } + } + svg += ''; + return svg; +}; + +AR.Marker = function (id, corners, hammingDistance) { + this.id = id; + this.corners = corners; + this.hammingDistance = hammingDistance; +}; + +AR.Detector = function (config) { + config = config || {}; + this.grey = new CV.Image(); + this.thres = new CV.Image(); + this.homography = new CV.Image(); + this.binary = []; + this.contours = []; + this.polys = []; + this.candidates = []; + config.dictionaryName = config.dictionaryName || 'ARUCO_MIP_36h12'; + this.dictionary = new AR.Dictionary(config.dictionaryName); + this.dictionary.tau = config.maxHammingDistance != null ? config.maxHammingDistance : this.dictionary.tau; +}; + +AR.Detector.prototype.detectImage = function (width, height, data) { + return this.detect({ + width: width, + height: height, + data: data + }); +}; + +AR.Detector.prototype.detectStreamInit = function (width, height, callback) { + this.streamConfig = {}; + this.streamConfig.width = width; + this.streamConfig.height = height; + this.streamConfig.imageSize = width * height * 4; //provided image must be a sequence of rgba bytes (4 bytes represent a pixel) + this.streamConfig.index = 0; + this.streamConfig.imageData = new Uint8ClampedArray(this.streamConfig.imageSize); + this.streamConfig.callback = callback || function (image, markerList) {}; +}; + +//accept data chunks of different sizes +AR.Detector.prototype.detectStream = function (data) { + for (var i = 0; i < data.length; i++) { + this.streamConfig.imageData[this.streamConfig.index] = data[i]; + this.streamConfig.index = (this.streamConfig.index + 1) % this.streamConfig.imageSize; + if (this.streamConfig.index == 0) { + var image = { + width: this.streamConfig.width, + height: this.streamConfig.height, + data: this.streamConfig.imageData + }; + var markerList = this.detect(image); + this.streamConfig.callback(image, markerList); + } + } +}; + +AR.Detector.prototype.detectMJPEGStreamInit = function (width, height, callback, decoderFn) { + this.mjpeg = { + decoderFn: decoderFn, + chunks: [], + SOI: [0xff, 0xd8], + EOI: [0xff, 0xd9] + }; + this.detectStreamInit(width, height, callback); +}; + +AR.Detector.prototype.detectMJPEGStream = function (chunk) { + var eoiPos = chunk.findIndex(function (element, index, array) { + return this.mjpeg.EOI[0] == element && array.length > index + 1 && this.mjpeg.EOI[1] == array[index + 1]; + }); + var soiPos = chunk.findIndex(function (element, index, array) { + return this.mjpeg.SOI[0] == element && array.length > index + 1 && this.mjpeg.SOI[1] == array[index + 1]; + }); + + if (eoiPos === -1) { + this.mjpeg.chunks.push(chunk); + } else { + var part1 = chunk.slice(0, eoiPos + 2); + if (part1.length) { + this.mjpeg.chunks.push(part1); + } + if (this.mjpeg.chunks.length) { + var jpegImage = this.mjpeg.chunks.flat(); + var rgba = this.mjpeg.decoderFn(jpegImage); + this.detectStream(rgba); + } + this.mjpeg.chunks = []; + } + if (soiPos > -1) { + this.mjpeg.chunks = []; + this.mjpeg.chunks.push(chunk.slice(soiPos)); + } +}; + +AR.Detector.prototype.detect = function (image) { + CV.grayscale(image, this.grey); + CV.adaptiveThreshold(this.grey, this.thres, 2, 7); + + this.contours = CV.findContours(this.thres, this.binary); + //Scale Fix: https://stackoverflow.com/questions/35936397/marker-detection-on-paper-sheet-using-javascript + //this.candidates = this.findCandidates(this.contours, image.width * 0.20, 0.05, 10); + this.candidates = this.findCandidates(this.contours, image.width * 0.01, 0.05, 10); + this.candidates = this.clockwiseCorners(this.candidates); + this.candidates = this.notTooNear(this.candidates, 10); + + return this.findMarkers(this.grey, this.candidates, 49); +}; + +AR.Detector.prototype.findCandidates = function (contours, minSize, epsilon, minLength) { + var candidates = [], + len = contours.length, + contour, poly, i; + + this.polys = []; + + for (i = 0; i < len; ++i) { + contour = contours[i]; + + if (contour.length >= minSize) { + poly = CV.approxPolyDP(contour, contour.length * epsilon); + + this.polys.push(poly); + + if ((4 === poly.length) && (CV.isContourConvex(poly))) { + + if (CV.minEdgeLength(poly) >= minLength) { + candidates.push(poly); + } + } + } + } + + return candidates; +}; + +AR.Detector.prototype.clockwiseCorners = function (candidates) { + var len = candidates.length, + dx1, dx2, dy1, dy2, swap, i; + + for (i = 0; i < len; ++i) { + dx1 = candidates[i][1].x - candidates[i][0].x; + dy1 = candidates[i][1].y - candidates[i][0].y; + dx2 = candidates[i][2].x - candidates[i][0].x; + dy2 = candidates[i][2].y - candidates[i][0].y; + + if ((dx1 * dy2 - dy1 * dx2) < 0) { + swap = candidates[i][1]; + candidates[i][1] = candidates[i][3]; + candidates[i][3] = swap; + } + } + + return candidates; +}; + +AR.Detector.prototype.notTooNear = function (candidates, minDist) { + var notTooNear = [], + len = candidates.length, + dist, dx, dy, i, j, k; + + for (i = 0; i < len; ++i) { + + for (j = i + 1; j < len; ++j) { + dist = 0; + + for (k = 0; k < 4; ++k) { + dx = candidates[i][k].x - candidates[j][k].x; + dy = candidates[i][k].y - candidates[j][k].y; + + dist += dx * dx + dy * dy; + } + + if ((dist / 4) < (minDist * minDist)) { + + if (CV.perimeter(candidates[i]) < CV.perimeter(candidates[j])) { + candidates[i].tooNear = true; + } else { + candidates[j].tooNear = true; + } + } + } + } + + for (i = 0; i < len; ++i) { + if (!candidates[i].tooNear) { + notTooNear.push(candidates[i]); + } + } + + return notTooNear; +}; + +AR.Detector.prototype.findMarkers = function (imageSrc, candidates, warpSize) { + var markers = [], + len = candidates.length, + candidate, marker, i; + + for (i = 0; i < len; ++i) { + candidate = candidates[i]; + + CV.warp(imageSrc, this.homography, candidate, warpSize); + + CV.threshold(this.homography, this.homography, CV.otsu(this.homography)); + + marker = this.getMarker(this.homography, candidate); + if (marker) { + markers.push(marker); + } + } + + return markers; +}; + +AR.Detector.prototype.getMarker = function (imageSrc, candidate) { + var markSize = this.dictionary.markSize; + var width = (imageSrc.width / markSize) >>> 0, + minZero = (width * width) >> 1, + bits = [], + rotations = [], + square, inc, i, j; + + for (i = 0; i < markSize; ++i) { + inc = (0 === i || (markSize - 1) === i) ? 1 : (markSize - 1); + + for (j = 0; j < markSize; j += inc) { + square = { + x: j * width, + y: i * width, + width: width, + height: width + }; + if (CV.countNonZero(imageSrc, square) > minZero) { + return null; + } + } + } + + for (i = 0; i < markSize - 2; ++i) { + bits[i] = []; + + for (j = 0; j < markSize - 2; ++j) { + square = { + x: (j + 1) * width, + y: (i + 1) * width, + width: width, + height: width + }; + + bits[i][j] = CV.countNonZero(imageSrc, square) > minZero ? 1 : 0; + } + } + + rotations[0] = bits; + + var foundMin = null; + var rot = 0; + for (i = 0; i < 4; i++) { + var found = this.dictionary.find(rotations[i]); + if (found && (foundMin === null || found.distance < foundMin.distance)) { + foundMin = found; + rot = i; + if (foundMin.distance === 0) + break; + } + rotations[i + 1] = this.rotate(rotations[i]); + } + + if (foundMin) + return new AR.Marker(foundMin.id, this.rotate2(candidate, 4 - rot), foundMin.distance); + + return null; +}; + +AR.Detector.prototype.rotate = function (src) { + var dst = [], + len = src.length, + i, j; + + for (i = 0; i < len; ++i) { + dst[i] = []; + for (j = 0; j < src[i].length; ++j) { + dst[i][j] = src[src[i].length - j - 1][i]; + } + } + + return dst; +}; + +AR.Detector.prototype.rotate2 = function (src, rotation) { + var dst = [], + len = src.length, + i; + + for (i = 0; i < len; ++i) { + dst[i] = src[(rotation + i) % len]; + } + + return dst; +}; diff --git a/public/vendor/cv.js b/public/vendor/cv.js new file mode 100644 index 0000000..966a3cf --- /dev/null +++ b/public/vendor/cv.js @@ -0,0 +1,737 @@ +/* +Copyright (c) 2011 Juan Mellado + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +/* +References: +- "OpenCV: Open Computer Vision Library" + http://sourceforge.net/projects/opencvlibrary/ +- "Stack Blur: Fast But Goodlooking" + http://incubator.quasimondo.com/processing/fast_blur_deluxe.php +*/ + +var CV = CV || {}; +this.CV = CV; + +CV.Image = function(width, height, data){ + this.width = width || 0; + this.height = height || 0; + this.data = data || []; +}; + +CV.grayscale = function(imageSrc, imageDst){ + var src = imageSrc.data, dst = imageDst.data, len = src.length, + i = 0, j = 0; + + for (; i < len; i += 4){ + dst[j ++] = + (src[i] * 0.299 + src[i + 1] * 0.587 + src[i + 2] * 0.114 + 0.5) & 0xff; + } + + imageDst.width = imageSrc.width; + imageDst.height = imageSrc.height; + + return imageDst; +}; + +CV.threshold = function(imageSrc, imageDst, threshold){ + var src = imageSrc.data, dst = imageDst.data, + len = src.length, tab = [], i; + + for (i = 0; i < 256; ++ i){ + tab[i] = i <= threshold? 0: 255; + } + + for (i = 0; i < len; ++ i){ + dst[i] = tab[ src[i] ]; + } + + imageDst.width = imageSrc.width; + imageDst.height = imageSrc.height; + + return imageDst; +}; + +CV.adaptiveThreshold = function(imageSrc, imageDst, kernelSize, threshold){ + var src = imageSrc.data, dst = imageDst.data, len = src.length, tab = [], i; + + CV.stackBoxBlur(imageSrc, imageDst, kernelSize); + + for (i = 0; i < 768; ++ i){ + tab[i] = (i - 255 <= -threshold)? 255: 0; + } + + for (i = 0; i < len; ++ i){ + dst[i] = tab[ src[i] - dst[i] + 255 ]; + } + + imageDst.width = imageSrc.width; + imageDst.height = imageSrc.height; + + return imageDst; +}; + +CV.otsu = function(imageSrc){ + var src = imageSrc.data, len = src.length, hist = [], + threshold = 0, sum = 0, sumB = 0, wB = 0, wF = 0, max = 0, + mu, between, i; + + for (i = 0; i < 256; ++ i){ + hist[i] = 0; + } + + for (i = 0; i < len; ++ i){ + hist[ src[i] ] ++; + } + + for (i = 0; i < 256; ++ i){ + sum += hist[i] * i; + } + + for (i = 0; i < 256; ++ i){ + wB += hist[i]; + if (0 !== wB){ + + wF = len - wB; + if (0 === wF){ + break; + } + + sumB += hist[i] * i; + + mu = (sumB / wB) - ( (sum - sumB) / wF ); + + between = wB * wF * mu * mu; + + if (between > max){ + max = between; + threshold = i; + } + } + } + + return threshold; +}; + +CV.stackBoxBlurMult = + [1, 171, 205, 293, 57, 373, 79, 137, 241, 27, 391, 357, 41, 19, 283, 265]; + +CV.stackBoxBlurShift = + [0, 9, 10, 11, 9, 12, 10, 11, 12, 9, 13, 13, 10, 9, 13, 13]; + +CV.BlurStack = function(){ + this.color = 0; + this.next = null; +}; + +CV.stackBoxBlur = function(imageSrc, imageDst, kernelSize){ + var src = imageSrc.data, dst = imageDst.data, + height = imageSrc.height, width = imageSrc.width, + heightMinus1 = height - 1, widthMinus1 = width - 1, + size = kernelSize + kernelSize + 1, radius = kernelSize + 1, + mult = CV.stackBoxBlurMult[kernelSize], + shift = CV.stackBoxBlurShift[kernelSize], + stack, stackStart, color, sum, pos, start, p, x, y, i; + + stack = stackStart = new CV.BlurStack(); + for (i = 1; i < size; ++ i){ + stack = stack.next = new CV.BlurStack(); + } + stack.next = stackStart; + + pos = 0; + + for (y = 0; y < height; ++ y){ + start = pos; + + color = src[pos]; + sum = radius * color; + + stack = stackStart; + for (i = 0; i < radius; ++ i){ + stack.color = color; + stack = stack.next; + } + for (i = 1; i < radius; ++ i){ + stack.color = src[pos + i]; + sum += stack.color; + stack = stack.next; + } + + stack = stackStart; + for (x = 0; x < width; ++ x){ + dst[pos ++] = (sum * mult) >>> shift; + + p = x + radius; + p = start + (p < widthMinus1? p: widthMinus1); + sum -= stack.color - src[p]; + + stack.color = src[p]; + stack = stack.next; + } + } + + for (x = 0; x < width; ++ x){ + pos = x; + start = pos + width; + + color = dst[pos]; + sum = radius * color; + + stack = stackStart; + for (i = 0; i < radius; ++ i){ + stack.color = color; + stack = stack.next; + } + for (i = 1; i < radius; ++ i){ + stack.color = dst[start]; + sum += stack.color; + stack = stack.next; + + start += width; + } + + stack = stackStart; + for (y = 0; y < height; ++ y){ + dst[pos] = (sum * mult) >>> shift; + + p = y + radius; + p = x + ( (p < heightMinus1? p: heightMinus1) * width ); + sum -= stack.color - dst[p]; + + stack.color = dst[p]; + stack = stack.next; + + pos += width; + } + } + + return imageDst; +}; + +CV.gaussianBlur = function(imageSrc, imageDst, imageMean, kernelSize){ + var kernel = CV.gaussianKernel(kernelSize); + + imageDst.width = imageSrc.width; + imageDst.height = imageSrc.height; + + imageMean.width = imageSrc.width; + imageMean.height = imageSrc.height; + + CV.gaussianBlurFilter(imageSrc, imageMean, kernel, true); + CV.gaussianBlurFilter(imageMean, imageDst, kernel, false); + + return imageDst; +}; + +CV.gaussianBlurFilter = function(imageSrc, imageDst, kernel, horizontal){ + var src = imageSrc.data, dst = imageDst.data, + height = imageSrc.height, width = imageSrc.width, + pos = 0, limit = kernel.length >> 1, + cur, value, i, j, k; + + for (i = 0; i < height; ++ i){ + + for (j = 0; j < width; ++ j){ + value = 0.0; + + for (k = -limit; k <= limit; ++ k){ + + if (horizontal){ + cur = pos + k; + if (j + k < 0){ + cur = pos; + } + else if (j + k >= width){ + cur = pos; + } + }else{ + cur = pos + (k * width); + if (i + k < 0){ + cur = pos; + } + else if (i + k >= height){ + cur = pos; + } + } + + value += kernel[limit + k] * src[cur]; + } + + dst[pos ++] = horizontal? value: (value + 0.5) & 0xff; + } + } + + return imageDst; +}; + +CV.gaussianKernel = function(kernelSize){ + var tab = + [ [1], + [0.25, 0.5, 0.25], + [0.0625, 0.25, 0.375, 0.25, 0.0625], + [0.03125, 0.109375, 0.21875, 0.28125, 0.21875, 0.109375, 0.03125] ], + kernel = [], center, sigma, scale2X, sum, x, i; + + if ( (kernelSize <= 7) && (kernelSize % 2 === 1) ){ + kernel = tab[kernelSize >> 1]; + }else{ + center = (kernelSize - 1.0) * 0.5; + sigma = 0.8 + (0.3 * (center - 1.0) ); + scale2X = -0.5 / (sigma * sigma); + sum = 0.0; + for (i = 0; i < kernelSize; ++ i){ + x = i - center; + sum += kernel[i] = Math.exp(scale2X * x * x); + } + sum = 1 / sum; + for (i = 0; i < kernelSize; ++ i){ + kernel[i] *= sum; + } + } + + return kernel; +}; + +CV.findContours = function(imageSrc, binary){ + var width = imageSrc.width, height = imageSrc.height, contours = [], + src, deltas, pos, pix, nbd, outer, hole, i, j; + + src = CV.binaryBorder(imageSrc, binary); + + deltas = CV.neighborhoodDeltas(width + 2); + + pos = width + 3; + nbd = 1; + + for (i = 0; i < height; ++ i, pos += 2){ + + for (j = 0; j < width; ++ j, ++ pos){ + pix = src[pos]; + + if (0 !== pix){ + outer = hole = false; + + if (1 === pix && 0 === src[pos - 1]){ + outer = true; + } + else if (pix >= 1 && 0 === src[pos + 1]){ + hole = true; + } + + if (outer || hole){ + ++ nbd; + + contours.push( CV.borderFollowing(src, pos, nbd, {x: j, y: i}, hole, deltas) ); + } + } + } + } + + return contours; +}; + +CV.borderFollowing = function(src, pos, nbd, point, hole, deltas){ + var contour = [], pos1, pos3, pos4, s, s_end, s_prev; + + contour.hole = hole; + + s = s_end = hole? 0: 4; + do{ + s = (s - 1) & 7; + pos1 = pos + deltas[s]; + if (src[pos1] !== 0){ + break; + } + }while(s !== s_end); + + if (s === s_end){ + src[pos] = -nbd; + contour.push( {x: point.x, y: point.y} ); + + }else{ + pos3 = pos; + s_prev = s ^ 4; + + while(true){ + s_end = s; + + do{ + pos4 = pos3 + deltas[++ s]; + }while(src[pos4] === 0); + + s &= 7; + + if ( ( (s - 1) >>> 0) < (s_end >>> 0) ){ + src[pos3] = -nbd; + } + else if (src[pos3] === 1){ + src[pos3] = nbd; + } + + contour.push( {x: point.x, y: point.y} ); + + s_prev = s; + + point.x += CV.neighborhood[s][0]; + point.y += CV.neighborhood[s][1]; + + if ( (pos4 === pos) && (pos3 === pos1) ){ + break; + } + + pos3 = pos4; + s = (s + 4) & 7; + } + } + + return contour; +}; + +CV.neighborhood = + [ [1, 0], [1, -1], [0, -1], [-1, -1], [-1, 0], [-1, 1], [0, 1], [1, 1] ]; + +CV.neighborhoodDeltas = function(width){ + var deltas = [], len = CV.neighborhood.length, i = 0; + + for (; i < len; ++ i){ + deltas[i] = CV.neighborhood[i][0] + (CV.neighborhood[i][1] * width); + } + + return deltas.concat(deltas); +}; + +CV.approxPolyDP = function(contour, epsilon){ + var slice = {start_index: 0, end_index: 0}, + right_slice = {start_index: 0, end_index: 0}, + poly = [], stack = [], len = contour.length, + pt, start_pt, end_pt, dist, max_dist, le_eps, + dx, dy, i, j, k; + + epsilon *= epsilon; + + k = 0; + + for (i = 0; i < 3; ++ i){ + max_dist = 0; + + k = (k + right_slice.start_index) % len; + start_pt = contour[k]; + if (++ k === len) {k = 0;} + + for (j = 1; j < len; ++ j){ + pt = contour[k]; + if (++ k === len) {k = 0;} + + dx = pt.x - start_pt.x; + dy = pt.y - start_pt.y; + dist = dx * dx + dy * dy; + + if (dist > max_dist){ + max_dist = dist; + right_slice.start_index = j; + } + } + } + + if (max_dist <= epsilon){ + poly.push( {x: start_pt.x, y: start_pt.y} ); + + }else{ + slice.start_index = k; + slice.end_index = (right_slice.start_index += slice.start_index); + + right_slice.start_index -= right_slice.start_index >= len? len: 0; + right_slice.end_index = slice.start_index; + if (right_slice.end_index < right_slice.start_index){ + right_slice.end_index += len; + } + + stack.push( {start_index: right_slice.start_index, end_index: right_slice.end_index} ); + stack.push( {start_index: slice.start_index, end_index: slice.end_index} ); + } + + while(stack.length !== 0){ + slice = stack.pop(); + + end_pt = contour[slice.end_index % len]; + start_pt = contour[k = slice.start_index % len]; + if (++ k === len) {k = 0;} + + if (slice.end_index <= slice.start_index + 1){ + le_eps = true; + + }else{ + max_dist = 0; + + dx = end_pt.x - start_pt.x; + dy = end_pt.y - start_pt.y; + + for (i = slice.start_index + 1; i < slice.end_index; ++ i){ + pt = contour[k]; + if (++ k === len) {k = 0;} + + dist = Math.abs( (pt.y - start_pt.y) * dx - (pt.x - start_pt.x) * dy); + + if (dist > max_dist){ + max_dist = dist; + right_slice.start_index = i; + } + } + + le_eps = max_dist * max_dist <= epsilon * (dx * dx + dy * dy); + } + + if (le_eps){ + poly.push( {x: start_pt.x, y: start_pt.y} ); + + }else{ + right_slice.end_index = slice.end_index; + slice.end_index = right_slice.start_index; + + stack.push( {start_index: right_slice.start_index, end_index: right_slice.end_index} ); + stack.push( {start_index: slice.start_index, end_index: slice.end_index} ); + } + } + + return poly; +}; + +CV.warp = function(imageSrc, imageDst, contour, warpSize){ + var src = imageSrc.data, dst = imageDst.data, + width = imageSrc.width, height = imageSrc.height, + pos = 0, + sx1, sx2, dx1, dx2, sy1, sy2, dy1, dy2, p1, p2, p3, p4, + m, r, s, t, u, v, w, x, y, i, j; + + m = CV.getPerspectiveTransform(contour, warpSize - 1); + + r = m[8]; + s = m[2]; + t = m[5]; + + for (i = 0; i < warpSize; ++ i){ + r += m[7]; + s += m[1]; + t += m[4]; + + u = r; + v = s; + w = t; + + for (j = 0; j < warpSize; ++ j){ + u += m[6]; + v += m[0]; + w += m[3]; + + x = v / u; + y = w / u; + + sx1 = x >>> 0; + sx2 = (sx1 === width - 1)? sx1: sx1 + 1; + dx1 = x - sx1; + dx2 = 1.0 - dx1; + + sy1 = y >>> 0; + sy2 = (sy1 === height - 1)? sy1: sy1 + 1; + dy1 = y - sy1; + dy2 = 1.0 - dy1; + + p1 = p2 = sy1 * width; + p3 = p4 = sy2 * width; + + dst[pos ++] = + (dy2 * (dx2 * src[p1 + sx1] + dx1 * src[p2 + sx2]) + + dy1 * (dx2 * src[p3 + sx1] + dx1 * src[p4 + sx2]) ) & 0xff; + + } + } + + imageDst.width = warpSize; + imageDst.height = warpSize; + + return imageDst; +}; + +CV.getPerspectiveTransform = function(src, size){ + var rq = CV.square2quad(src); + + rq[0] /= size; + rq[1] /= size; + rq[3] /= size; + rq[4] /= size; + rq[6] /= size; + rq[7] /= size; + + return rq; +}; + +CV.square2quad = function(src){ + var sq = [], px, py, dx1, dx2, dy1, dy2, den; + + px = src[0].x - src[1].x + src[2].x - src[3].x; + py = src[0].y - src[1].y + src[2].y - src[3].y; + + if (0 === px && 0 === py){ + sq[0] = src[1].x - src[0].x; + sq[1] = src[2].x - src[1].x; + sq[2] = src[0].x; + sq[3] = src[1].y - src[0].y; + sq[4] = src[2].y - src[1].y; + sq[5] = src[0].y; + sq[6] = 0; + sq[7] = 0; + sq[8] = 1; + + }else{ + dx1 = src[1].x - src[2].x; + dx2 = src[3].x - src[2].x; + dy1 = src[1].y - src[2].y; + dy2 = src[3].y - src[2].y; + den = dx1 * dy2 - dx2 * dy1; + + sq[6] = (px * dy2 - dx2 * py) / den; + sq[7] = (dx1 * py - px * dy1) / den; + sq[8] = 1; + sq[0] = src[1].x - src[0].x + sq[6] * src[1].x; + sq[1] = src[3].x - src[0].x + sq[7] * src[3].x; + sq[2] = src[0].x; + sq[3] = src[1].y - src[0].y + sq[6] * src[1].y; + sq[4] = src[3].y - src[0].y + sq[7] * src[3].y; + sq[5] = src[0].y; + } + + return sq; +}; + +CV.isContourConvex = function(contour){ + var orientation = 0, convex = true, + len = contour.length, i = 0, j = 0, + cur_pt, prev_pt, dxdy0, dydx0, dx0, dy0, dx, dy; + + prev_pt = contour[len - 1]; + cur_pt = contour[0]; + + dx0 = cur_pt.x - prev_pt.x; + dy0 = cur_pt.y - prev_pt.y; + + for (; i < len; ++ i){ + if (++ j === len) {j = 0;} + + prev_pt = cur_pt; + cur_pt = contour[j]; + + dx = cur_pt.x - prev_pt.x; + dy = cur_pt.y - prev_pt.y; + dxdy0 = dx * dy0; + dydx0 = dy * dx0; + + orientation |= dydx0 > dxdy0? 1: (dydx0 < dxdy0? 2: 3); + + if (3 === orientation){ + convex = false; + break; + } + + dx0 = dx; + dy0 = dy; + } + + return convex; +}; + +CV.perimeter = function(poly){ + var len = poly.length, i = 0, j = len - 1, + p = 0.0, dx, dy; + + for (; i < len; j = i ++){ + dx = poly[i].x - poly[j].x; + dy = poly[i].y - poly[j].y; + + p += Math.sqrt(dx * dx + dy * dy) ; + } + + return p; +}; + +CV.minEdgeLength = function(poly){ + var len = poly.length, i = 0, j = len - 1, + min = Infinity, d, dx, dy; + + for (; i < len; j = i ++){ + dx = poly[i].x - poly[j].x; + dy = poly[i].y - poly[j].y; + + d = dx * dx + dy * dy; + + if (d < min){ + min = d; + } + } + + return Math.sqrt(min); +}; + +CV.countNonZero = function(imageSrc, square){ + var src = imageSrc.data, height = square.height, width = square.width, + pos = square.x + (square.y * imageSrc.width), + span = imageSrc.width - width, + nz = 0, i, j; + + for (i = 0; i < height; ++ i){ + + for (j = 0; j < width; ++ j){ + + if ( 0 !== src[pos ++] ){ + ++ nz; + } + } + + pos += span; + } + + return nz; +}; + +CV.binaryBorder = function(imageSrc, dst){ + var src = imageSrc.data, height = imageSrc.height, width = imageSrc.width, + posSrc = 0, posDst = 0, i, j; + + for (j = -2; j < width; ++ j){ + dst[posDst ++] = 0; + } + + for (i = 0; i < height; ++ i){ + dst[posDst ++] = 0; + + for (j = 0; j < width; ++ j){ + dst[posDst ++] = (0 === src[posSrc ++]? 0: 1); + } + + dst[posDst ++] = 0; + } + + for (j = -2; j < width; ++ j){ + dst[posDst ++] = 0; + } + + return dst; +}; diff --git a/public/vendor/dictionaries/aruco_4x4_1000.js b/public/vendor/dictionaries/aruco_4x4_1000.js new file mode 100644 index 0000000..2d91dd0 --- /dev/null +++ b/public/vendor/dictionaries/aruco_4x4_1000.js @@ -0,0 +1,39 @@ +/* +By downloading, copying, installing or using the software you agree to this +license. If you do not agree to this license, do not download, install, +copy or use the software. + License Agreement + For Open Source Computer Vision Library + (3-clause BSD License) +Copyright (C) 2013, OpenCV Foundation, all rights reserved. +Third party copyrights are property of their respective owners. +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the names of the copyright holders nor the names of the contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. +This software is provided by the copyright holders and contributors "as is" and +any express or implied warranties, including, but not limited to, the implied +warranties of merchantability and fitness for a particular purpose are +disclaimed. In no event shall copyright holders or contributors be liable for +any direct, indirect, incidental, special, exemplary, or consequential damages +(including, but not limited to, procurement of substitute goods or services; +loss of use, data, or profits; or business interruption) however caused +and on any theory of liability, whether in contract, strict liability, +or tort (including negligence or otherwise) arising in any way out of +the use of this software, even if advised of the possibility of such damage. +*/ + +//Dictionary extracted from https://github.com/opencv/opencv_contrib/blob/4.x/modules/aruco/src/predefined_dictionaries.hpp + +var AR = this.AR || require('../aruco').AR; +AR.DICTIONARIES['ARUCO_4X4_1000'] = { + nBits: 16, + tau: null, + codeList: [[181,50],[15,154],[51,45],[153,70],[84,158],[121,205],[158,46],[196,242],[254,218],[207,86],[249,145],[17,167],[14,183],[42,15],[36,177],[38,62],[70,101],[102,0],[108,94],[118,175],[134,139],[176,43],[204,213],[221,130],[254,71],[148,113],[172,228],[165,84],[33,35],[52,111],[68,21],[87,178],[158,207],[240,203],[8,174],[9,41],[24,117],[4,255],[13,246],[28,90],[23,24],[42,40],[50,140],[56,178],[36,232],[46,235],[45,63],[75,100],[80,46],[80,19],[81,148],[85,104],[93,65],[95,151],[104,1],[104,103],[97,36],[97,233],[107,18],[111,229],[103,223],[126,27],[128,160],[131,68],[139,162],[147,122],[132,108],[133,42],[133,156],[156,137],[159,161],[187,124],[188,4],[182,91],[191,200],[183,171],[202,31],[201,98],[217,88],[211,213],[204,152],[199,160],[197,55],[233,93],[249,37],[251,187],[238,42],[247,77],[53,117],[138,173],[118,23],[10,207],[6,75],[45,193],[73,216],[67,244],[79,54],[79,211],[105,228],[112,199],[122,110],[180,234],[237,79],[252,231],[254,166],[0,37],[0,67],[10,136],[10,134],[2,111],[0,28],[0,151],[8,55],[10,49],[9,198],[11,1],[9,251],[11,88],[16,130],[24,45],[16,120],[16,115],[18,116],[18,177],[26,249],[19,6],[12,14],[12,241],[4,51],[12,159],[14,242],[14,253],[7,76],[15,164],[7,47],[5,181],[15,145],[7,219],[30,228],[20,57],[29,128],[21,200],[31,139],[21,186],[29,177],[32,128],[40,233],[34,162],[40,83],[42,240],[34,247],[41,64],[33,70],[41,185],[43,156],[43,178],[56,202],[56,46],[48,7],[56,231],[58,73],[58,101],[50,93],[59,136],[57,29],[59,211],[38,71],[39,128],[47,170],[45,20],[37,222],[37,83],[47,119],[52,72],[60,168],[60,65],[52,13],[52,251],[54,154],[61,224],[53,106],[61,9],[61,237],[63,196],[63,108],[55,206],[61,92],[61,118],[55,176],[63,23],[63,255],[72,229],[66,104],[74,45],[65,96],[73,81],[65,221],[75,223],[88,79],[90,72],[88,22],[80,93],[90,250],[90,181],[81,35],[91,138],[89,25],[81,53],[76,105],[70,193],[78,11],[68,95],[78,89],[77,131],[77,125],[71,216],[71,115],[92,133],[94,68],[86,43],[92,187],[85,195],[95,110],[95,235],[93,18],[85,94],[98,112],[98,21],[97,194],[107,32],[99,69],[107,92],[107,91],[120,12],[122,207],[120,127],[121,128],[113,229],[113,116],[121,182],[113,211],[123,51],[100,106],[102,168],[110,167],[110,145],[101,34],[109,203],[103,141],[109,49],[126,128],[126,226],[126,141],[116,210],[124,50],[126,53],[117,171],[119,5],[127,43],[125,218],[127,146],[128,117],[128,243],[129,166],[137,237],[129,252],[152,166],[154,32],[145,67],[153,249],[145,147],[155,212],[132,9],[132,107],[134,196],[142,100],[134,26],[133,78],[141,203],[133,103],[133,175],[133,215],[135,179],[156,225],[156,242],[148,23],[149,0],[149,162],[157,35],[159,98],[157,82],[149,218],[160,197],[170,205],[162,216],[162,87],[169,61],[169,87],[171,82],[163,54],[163,89],[176,244],[184,18],[176,191],[178,157],[187,237],[185,114],[185,150],[164,195],[172,210],[174,177],[165,130],[175,101],[165,123],[175,250],[180,100],[188,98],[180,129],[182,160],[190,238],[190,13],[188,217],[190,248],[181,40],[183,9],[183,210],[192,234],[192,25],[192,253],[200,211],[202,90],[193,77],[201,180],[193,87],[195,152],[195,29],[216,128],[216,239],[218,43],[208,30],[209,5],[211,173],[219,167],[196,201],[204,120],[205,69],[197,11],[207,207],[220,172],[212,2],[220,99],[212,39],[212,245],[214,120],[222,184],[221,230],[213,93],[221,189],[223,29],[226,202],[234,107],[224,180],[226,56],[226,212],[227,34],[225,216],[240,3],[242,204],[248,246],[241,73],[243,234],[241,156],[249,245],[241,59],[236,141],[238,201],[230,15],[228,247],[231,96],[239,232],[237,178],[229,21],[239,209],[244,134],[252,1],[246,195],[244,124],[252,147],[245,66],[253,152],[245,61],[2,189],[0,225],[2,226],[2,174],[8,120],[0,116],[8,158],[8,209],[8,125],[10,50],[10,222],[2,81],[1,162],[3,128],[11,131],[11,75],[11,39],[11,239],[9,182],[9,89],[9,147],[11,248],[3,217],[3,241],[16,196],[24,171],[26,160],[26,4],[26,108],[26,174],[18,137],[16,23],[26,243],[25,64],[17,2],[17,43],[17,207],[27,34],[19,46],[17,21],[19,187],[12,32],[12,201],[12,220],[12,54],[6,20],[6,114],[13,97],[5,13],[13,143],[15,224],[15,73],[7,133],[5,144],[13,51],[15,150],[15,118],[20,96],[28,141],[20,218],[28,115],[30,148],[30,186],[22,217],[30,61],[22,251],[29,233],[29,254],[31,159],[40,139],[32,175],[34,14],[34,169],[42,141],[42,163],[42,239],[40,144],[40,59],[42,88],[34,51],[33,160],[33,2],[33,165],[33,199],[43,3],[35,103],[41,48],[41,210],[43,25],[43,155],[43,151],[56,40],[56,165],[58,134],[50,1],[56,159],[50,210],[58,153],[58,213],[57,232],[59,193],[51,67],[59,231],[49,154],[51,144],[59,158],[36,196],[44,74],[44,173],[44,207],[44,103],[38,234],[46,229],[44,112],[46,18],[46,209],[46,57],[37,100],[37,231],[47,204],[45,188],[45,113],[37,213],[37,155],[39,16],[47,124],[39,242],[39,58],[47,182],[39,211],[47,179],[39,31],[60,75],[54,192],[54,238],[62,233],[52,184],[60,20],[60,82],[52,114],[52,126],[52,191],[62,113],[62,83],[61,140],[53,162],[53,46],[53,45],[55,172],[53,112],[55,250],[63,241],[63,219],[72,196],[72,233],[74,194],[74,65],[66,235],[72,19],[74,216],[66,253],[74,23],[73,99],[67,110],[65,58],[73,177],[65,61],[75,146],[75,155],[67,63],[88,34],[80,170],[88,39],[82,200],[82,132],[82,10],[90,15],[88,152],[88,92],[80,219],[80,247],[90,244],[81,236],[81,66],[81,13],[91,3],[83,235],[81,118],[89,113],[81,147],[83,249],[91,179],[83,151],[76,76],[68,75],[76,35],[70,140],[78,39],[70,144],[78,212],[69,206],[69,229],[69,39],[79,193],[71,5],[69,52],[69,114],[92,200],[92,14],[84,235],[86,137],[86,67],[94,231],[92,112],[84,178],[94,121],[86,243],[93,163],[93,242],[85,29],[93,157],[87,252],[87,210],[95,115],[104,45],[104,195],[104,135],[106,74],[98,105],[96,185],[104,255],[106,220],[106,218],[106,62],[106,81],[106,49],[98,215],[97,204],[107,130],[107,227],[105,58],[97,158],[97,149],[97,117],[105,95],[105,55],[99,218],[112,2],[120,99],[112,79],[114,202],[122,173],[112,123],[122,20],[122,249],[122,211],[122,187],[121,226],[113,41],[123,103],[113,208],[121,57],[115,48],[115,185],[115,83],[115,255],[108,136],[100,9],[108,67],[102,6],[102,131],[100,176],[100,218],[110,159],[103,200],[111,238],[109,59],[111,210],[116,128],[124,171],[126,104],[126,2],[124,156],[116,54],[124,17],[126,222],[126,182],[118,219],[125,196],[125,138],[117,109],[119,136],[119,32],[119,65],[117,56],[117,190],[125,155],[119,87],[136,40],[128,172],[136,13],[136,103],[130,78],[138,161],[130,43],[128,24],[136,249],[128,157],[138,156],[130,49],[138,117],[130,151],[129,9],[129,235],[129,7],[139,40],[139,172],[131,46],[131,229],[129,80],[137,50],[139,122],[139,150],[131,125],[144,135],[154,252],[146,245],[145,170],[147,65],[147,37],[155,235],[153,52],[145,247],[155,218],[147,86],[132,66],[140,129],[140,79],[134,72],[134,166],[142,3],[134,227],[134,111],[142,175],[132,94],[132,119],[134,250],[142,30],[142,55],[135,10],[143,138],[143,38],[135,33],[135,13],[133,114],[135,62],[156,67],[158,97],[148,88],[148,248],[156,50],[148,118],[148,177],[148,221],[148,155],[156,219],[158,156],[158,210],[150,25],[158,177],[149,105],[159,109],[151,43],[149,182],[149,185],[157,61],[157,87],[168,236],[168,37],[162,172],[162,2],[170,102],[170,143],[170,231],[168,48],[168,122],[168,246],[168,147],[162,20],[170,52],[162,114],[170,242],[162,241],[161,64],[169,10],[161,38],[169,197],[169,207],[161,52],[169,18],[161,250],[171,152],[163,247],[176,6],[176,69],[184,141],[178,132],[184,240],[184,85],[178,118],[186,145],[178,113],[185,192],[185,66],[185,42],[179,140],[179,202],[187,102],[179,15],[177,218],[187,20],[187,246],[179,19],[164,104],[172,44],[172,161],[172,235],[172,199],[164,103],[166,192],[174,224],[166,35],[173,232],[165,204],[167,236],[173,124],[165,26],[165,145],[173,25],[165,151],[180,109],[190,203],[188,58],[188,245],[190,189],[190,243],[181,37],[181,143],[183,104],[191,228],[189,254],[189,157],[181,245],[181,243],[191,176],[183,90],[191,62],[183,57],[191,213],[183,29],[191,53],[183,127],[200,1],[192,165],[194,130],[200,189],[194,252],[202,145],[194,91],[201,68],[193,42],[195,192],[201,122],[193,185],[201,117],[193,247],[203,177],[208,108],[216,135],[208,175],[218,196],[210,12],[218,9],[208,48],[216,148],[208,58],[208,182],[208,117],[210,118],[218,93],[218,53],[210,23],[217,2],[211,232],[211,229],[209,154],[209,246],[209,81],[219,20],[211,62],[211,211],[196,96],[204,167],[198,66],[198,71],[206,231],[196,92],[204,29],[204,53],[198,188],[205,168],[197,12],[197,228],[197,194],[205,45],[205,89],[205,149],[197,147],[199,95],[212,197],[222,136],[214,36],[222,236],[214,226],[222,198],[222,35],[220,220],[220,26],[212,17],[222,84],[214,148],[222,157],[221,129],[213,165],[215,172],[215,102],[223,169],[213,220],[221,31],[223,240],[226,72],[226,232],[226,7],[224,93],[234,245],[235,38],[235,237],[225,82],[225,126],[233,219],[248,6],[240,238],[248,161],[250,0],[250,194],[240,155],[250,244],[250,60],[242,252],[242,189],[242,147],[241,96],[249,236],[241,70],[249,225],[243,72],[243,174],[243,193],[243,139],[243,167],[241,115],[241,151],[243,244],[251,50],[228,7],[230,77],[236,85],[237,192],[237,133],[239,162],[231,78],[229,213],[239,80],[244,34],[244,137],[244,41],[246,106],[254,11],[254,111],[244,149],[244,53],[244,31],[246,176],[245,232],[245,197],[253,35],[255,192],[247,204],[247,233],[245,188],[253,246],[245,217],[253,151],[253,63],[255,156],[255,90],[247,254],[255,17],[247,191]] +}; \ No newline at end of file diff --git a/public/vendor/posit1.js b/public/vendor/posit1.js new file mode 100644 index 0000000..26fc0f1 --- /dev/null +++ b/public/vendor/posit1.js @@ -0,0 +1,495 @@ +/* +Copyright (c) 2012 Juan Mellado + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +/* +References: +- "Iterative Pose Estimation using Coplanar Feature Points" + Denis Oberkampf, Daniel F. DeMenthon, Larry S. Davis + http://www.cfar.umd.edu/~daniel/daniel_papersfordownload/CoplanarPts.pdf +*/ + +var POS = POS || {}; +this.POS = POS; + +var SVD = this.SVD || require('./svd').SVD; + +POS.Posit = function(modelSize, focalLength){ + this.objectPoints = this.buildModel(modelSize); + this.focalLength = focalLength; + + this.objectVectors = []; + this.objectNormal = []; + this.objectMatrix = [[],[],[]]; + + this.init(); +}; + +POS.Posit.prototype.buildModel = function(modelSize){ + var half = modelSize / 2.0; + + return [ + [-half, half, 0.0], + [ half, half, 0.0], + [ half, -half, 0.0], + [-half, -half, 0.0] ]; +}; + +POS.Posit.prototype.init = function(){ + var np = this.objectPoints.length, + vectors = [], n = [], len = 0.0, row = 2, i; + + for (i = 0; i < np; ++ i){ + this.objectVectors[i] = [this.objectPoints[i][0] - this.objectPoints[0][0], + this.objectPoints[i][1] - this.objectPoints[0][1], + this.objectPoints[i][2] - this.objectPoints[0][2]]; + + vectors[i] = [this.objectVectors[i][0], + this.objectVectors[i][1], + this.objectVectors[i][2]]; + } + + while(0.0 === len){ + n[0] = this.objectVectors[1][1] * this.objectVectors[row][2] - + this.objectVectors[1][2] * this.objectVectors[row][1]; + n[1] = this.objectVectors[1][2] * this.objectVectors[row][0] - + this.objectVectors[1][0] * this.objectVectors[row][2]; + n[2] = this.objectVectors[1][0] * this.objectVectors[row][1] - + this.objectVectors[1][1] * this.objectVectors[row][0]; + + len = Math.sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]); + + ++ row; + } + + for (i = 0; i < 3; ++ i){ + this.objectNormal[i] = n[i] / len; + } + + POS.pseudoInverse(vectors, np, this.objectMatrix); +}; + +POS.Posit.prototype.pose = function(imagePoints){ + var posRotation1 = [[],[],[]], posRotation2 = [[],[],[]], posTranslation = [], + rotation1 = [[],[],[]], rotation2 = [[],[],[]], translation1 = [], translation2 = [], + error1, error2, valid1, valid2, i, j; + + this.pos(imagePoints, posRotation1, posRotation2, posTranslation); + + valid1 = this.isValid(posRotation1, posTranslation); + if (valid1){ + error1 = this.iterate(imagePoints, posRotation1, posTranslation, rotation1, translation1); + }else{ + error1 = {euclidean: -1.0, pixels: -1, maximum: -1.0}; + } + + valid2 = this.isValid(posRotation2, posTranslation); + if (valid2){ + error2 = this.iterate(imagePoints, posRotation2, posTranslation, rotation2, translation2); + }else{ + error2 = {euclidean: -1.0, pixels: -1, maximum: -1.0}; + } + + for (i = 0; i < 3; ++ i){ + for (j = 0; j < 3; ++ j){ + if (valid1){ + translation1[i] -= rotation1[i][j] * this.objectPoints[0][j]; + } + if (valid2){ + translation2[i] -= rotation2[i][j] * this.objectPoints[0][j]; + } + } + } + + return error1.euclidean < error2.euclidean? + new POS.Pose(error1.pixels, rotation1, translation1, error2.pixels, rotation2, translation2): + new POS.Pose(error2.pixels, rotation2, translation2, error1.pixels, rotation1, translation1); +}; + +POS.Posit.prototype.pos = function(imagePoints, rotation1, rotation2, translation){ + var np = this.objectPoints.length, imageVectors = [], + i0 = [], j0 = [], ivec = [], jvec = [], row1 = [], row2 = [], row3 = [], + i0i0, j0j0, i0j0, delta, q, lambda, mu, scale, i, j; + + for (i = 0; i < np; ++ i){ + imageVectors[i] = [imagePoints[i].x - imagePoints[0].x, + imagePoints[i].y - imagePoints[0].y]; + } + + //i0 and j0 + for (i = 0; i < 3; ++ i){ + i0[i] = 0.0; + j0[i] = 0.0; + for (j = 0; j < np; ++ j){ + i0[i] += this.objectMatrix[i][j] * imageVectors[j][0]; + j0[i] += this.objectMatrix[i][j] * imageVectors[j][1]; + } + } + + i0i0 = i0[0] * i0[0] + i0[1] * i0[1] + i0[2] * i0[2]; + j0j0 = j0[0] * j0[0] + j0[1] * j0[1] + j0[2] * j0[2]; + i0j0 = i0[0] * j0[0] + i0[1] * j0[1] + i0[2] * j0[2]; + + //Lambda and mu + delta = (j0j0 - i0i0) * (j0j0 - i0i0) + 4.0 * (i0j0 * i0j0); + + if (j0j0 - i0i0 >= 0.0){ + q = (j0j0 - i0i0 + Math.sqrt(delta) ) / 2.0; + }else{ + q = (j0j0 - i0i0 - Math.sqrt(delta) ) / 2.0; + } + + if (q >= 0.0){ + lambda = Math.sqrt(q); + if (0.0 === lambda){ + mu = 0.0; + }else{ + mu = -i0j0 / lambda; + } + }else{ + lambda = Math.sqrt( -(i0j0 * i0j0) / q); + if (0.0 === lambda){ + mu = Math.sqrt(i0i0 - j0j0); + }else{ + mu = -i0j0 / lambda; + } + } + + //First rotation + for (i = 0; i < 3; ++ i){ + ivec[i] = i0[i] + lambda * this.objectNormal[i]; + jvec[i] = j0[i] + mu * this.objectNormal[i]; + } + + scale = Math.sqrt(ivec[0] * ivec[0] + ivec[1] * ivec[1] + ivec[2] * ivec[2]); + + for (i = 0; i < 3; ++ i){ + row1[i] = ivec[i] / scale; + row2[i] = jvec[i] / scale; + } + + row3[0] = row1[1] * row2[2] - row1[2] * row2[1]; + row3[1] = row1[2] * row2[0] - row1[0] * row2[2]; + row3[2] = row1[0] * row2[1] - row1[1] * row2[0]; + + for (i = 0; i < 3; ++ i){ + rotation1[0][i] = row1[i]; + rotation1[1][i] = row2[i]; + rotation1[2][i] = row3[i]; + } + + //Second rotation + for (i = 0; i < 3; ++ i){ + ivec[i] = i0[i] - lambda * this.objectNormal[i]; + jvec[i] = j0[i] - mu * this.objectNormal[i]; + } + + for (i = 0; i < 3; ++ i){ + row1[i] = ivec[i] / scale; + row2[i] = jvec[i] / scale; + } + + row3[0] = row1[1] * row2[2] - row1[2] * row2[1]; + row3[1] = row1[2] * row2[0] - row1[0] * row2[2]; + row3[2] = row1[0] * row2[1] - row1[1] * row2[0]; + + for (i = 0; i < 3; ++ i){ + rotation2[0][i] = row1[i]; + rotation2[1][i] = row2[i]; + rotation2[2][i] = row3[i]; + } + + //Translation + translation[0] = imagePoints[0].x / scale; + translation[1] = imagePoints[0].y / scale; + translation[2] = this.focalLength / scale; +}; + +POS.Posit.prototype.isValid = function(rotation, translation){ + var np = this.objectPoints.length, zmin = Infinity, i = 0, zi; + + for (; i < np; ++ i){ + zi = translation[2] + + (rotation[2][0] * this.objectVectors[i][0] + + rotation[2][1] * this.objectVectors[i][1] + + rotation[2][2] * this.objectVectors[i][2]); + if (zi < zmin){ + zmin = zi; + } + } + + return zmin >= 0.0; +}; + +POS.Posit.prototype.iterate = function(imagePoints, posRotation, posTranslation, rotation, translation){ + var np = this.objectPoints.length, + oldSopImagePoints = [], sopImagePoints = [], + rotation1 = [[],[],[]], rotation2 = [[],[],[]], + translation1 = [], translation2 = [], + converged = false, iteration = 0, + oldImageDifference, imageDifference, factor, + error, error1, error2, delta, i, j; + + for (i = 0; i < np; ++ i){ + oldSopImagePoints[i] = {x: imagePoints[i].x, + y: imagePoints[i].y}; + } + + for (i = 0; i < 3; ++ i){ + for (j = 0; j < 3; ++ j){ + rotation[i][j] = posRotation[i][j]; + } + translation[i] = posTranslation[i]; + } + + for (i = 0; i < np; ++ i){ + factor = 0.0; + for (j = 0; j < 3; ++ j){ + factor += this.objectVectors[i][j] * rotation[2][j] / translation[2]; + } + sopImagePoints[i] = {x: (1.0 + factor) * imagePoints[i].x, + y: (1.0 + factor) * imagePoints[i].y}; + } + + imageDifference = 0.0; + + for (i = 0; i < np; ++ i){ + imageDifference += Math.abs(sopImagePoints[i].x - oldSopImagePoints[i].x); + imageDifference += Math.abs(sopImagePoints[i].y - oldSopImagePoints[i].y); + } + + for (i = 0; i < 3; ++ i){ + translation1[i] = translation[i] - + (rotation[i][0] * this.objectPoints[0][0] + + rotation[i][1] * this.objectPoints[0][1] + + rotation[i][2] * this.objectPoints[0][2]); + } + + error = error1 = this.error(imagePoints, rotation, translation1); + + //Convergence + converged = (0.0 === error1.pixels) || (imageDifference < 0.01); + + while( iteration ++ < 100 && !converged ){ + + for (i = 0; i < np; ++ i){ + oldSopImagePoints[i].x = sopImagePoints[i].x; + oldSopImagePoints[i].y = sopImagePoints[i].y; + } + + this.pos(sopImagePoints, rotation1, rotation2, translation); + + for (i = 0; i < 3; ++ i){ + translation1[i] = translation[i] - + (rotation1[i][0] * this.objectPoints[0][0] + + rotation1[i][1] * this.objectPoints[0][1] + + rotation1[i][2] * this.objectPoints[0][2]); + + translation2[i] = translation[i] - + (rotation2[i][0] * this.objectPoints[0][0] + + rotation2[i][1] * this.objectPoints[0][1] + + rotation2[i][2] * this.objectPoints[0][2]); + } + + error1 = this.error(imagePoints, rotation1, translation1); + error2 = this.error(imagePoints, rotation2, translation2); + + if ( (error1.euclidean >= 0.0) && (error2.euclidean >= 0.0) ){ + if (error2.euclidean < error1.euclidean){ + error = error2; + for (i = 0; i < 3; ++ i){ + for (j = 0; j < 3; ++ j){ + rotation[i][j] = rotation2[i][j]; + } + } + }else{ + error = error1; + for (i = 0; i < 3; ++ i){ + for (j = 0; j < 3; ++ j){ + rotation[i][j] = rotation1[i][j]; + } + } + } + } + + if ( (error1.euclidean < 0.0) && (error2.euclidean >= 0.0) ){ + error = error2; + for (i = 0; i < 3; ++ i){ + for (j = 0; j < 3; ++ j){ + rotation[i][j] = rotation2[i][j]; + } + } + } + + if ( (error2.euclidean < 0.0) && (error1.euclidean >= 0.0) ){ + error = error1; + for (i = 0; i < 3; ++ i){ + for (j = 0; j < 3; ++ j){ + rotation[i][j] = rotation1[i][j]; + } + } + } + + for (i = 0; i < np; ++ i){ + factor = 0.0; + for (j = 0; j < 3; ++ j){ + factor += this.objectVectors[i][j] * rotation[2][j] / translation[2]; + } + sopImagePoints[i].x = (1.0 + factor) * imagePoints[i].x; + sopImagePoints[i].y = (1.0 + factor) * imagePoints[i].y; + } + + oldImageDifference = imageDifference; + imageDifference = 0.0; + + for (i = 0; i < np; ++ i){ + imageDifference += Math.abs(sopImagePoints[i].x - oldSopImagePoints[i].x); + imageDifference += Math.abs(sopImagePoints[i].y - oldSopImagePoints[i].y); + } + + delta = Math.abs(imageDifference - oldImageDifference); + + converged = (0.0 === error.pixels) || (delta < 0.01); + } + + return error; +}; + +POS.Posit.prototype.error = function(imagePoints, rotation, translation){ + var np = this.objectPoints.length, + move = [], projection = [], errorvec = [], + euclidean = 0.0, pixels = 0.0, maximum = 0.0, + i, j, k; + + if ( !this.isValid(rotation, translation) ){ + return {euclidean: -1.0, pixels: -1, maximum: -1.0}; + } + + for (i = 0; i < np; ++ i){ + move[i] = []; + for (j = 0; j < 3; ++ j){ + move[i][j] = translation[j]; + } + } + + for (i = 0; i < np; ++ i){ + for (j = 0; j < 3; ++ j){ + for (k = 0; k < 3; ++ k){ + move[i][j] += rotation[j][k] * this.objectPoints[i][k]; + } + } + } + + for (i = 0; i < np; ++ i){ + projection[i] = []; + for (j = 0; j < 2; ++ j){ + projection[i][j] = this.focalLength * move[i][j] / move[i][2]; + } + } + + for (i = 0; i < np; ++ i){ + errorvec[i] = [projection[i][0] - imagePoints[i].x, + projection[i][1] - imagePoints[i].y]; + } + + for (i = 0; i < np; ++ i){ + euclidean += Math.sqrt(errorvec[i][0] * errorvec[i][0] + + errorvec[i][1] * errorvec[i][1]); + + pixels += Math.abs( Math.round(projection[i][0]) - Math.round(imagePoints[i].x) ) + + Math.abs( Math.round(projection[i][1]) - Math.round(imagePoints[i].y) ); + + if (Math.abs(errorvec[i][0]) > maximum){ + maximum = Math.abs(errorvec[i][0]); + } + if (Math.abs(errorvec[i][1]) > maximum){ + maximum = Math.abs(errorvec[i][1]); + } + } + + return {euclidean: euclidean / np, pixels: pixels, maximum: maximum}; +}; + +POS.pseudoInverse = function(a, n, b){ + var w = [], v = [[],[],[]], s = [[],[],[]], + wmax = 0.0, cn = 0, + i, j, k; + + SVD.svdcmp(a, n, 3, w, v); + + for (i = 0; i < 3; ++ i){ + if (w[i] > wmax){ + wmax = w[i]; + } + } + + wmax *= 0.01; + + for (i = 0; i < 3; ++ i){ + if (w[i] < wmax){ + w[i] = 0.0; + } + } + + for (j = 0; j < 3; ++ j){ + if (0.0 === w[j]){ + ++ cn; + for (k = j; k < 2; ++ k){ + for (i = 0; i < n; ++ i){ + a[i][k] = a[i][k + 1]; + } + for (i = 0; i < 3; ++ i){ + v[i][k] = v[i][k + 1]; + } + } + } + } + + for (j = 0; j < 2; ++ j){ + if (0.0 === w[j]){ + w[j] = w[j + 1]; + } + } + + for (i = 0; i < 3; ++ i){ + for (j = 0; j < 3 - cn; ++ j){ + s[i][j] = v[i][j] / w[j]; + } + } + + for (i = 0; i < 3; ++ i){ + for (j = 0; j < n; ++ j){ + b[i][j] = 0.0; + for (k = 0; k < 3 - cn; ++ k){ + b[i][j] += s[i][k] * a[j][k]; + } + } + } +}; + +POS.Pose = function(error1, rotation1, translation1, error2, rotation2, translation2){ + this.bestError = error1; + this.bestRotation = rotation1; + this.bestTranslation = translation1; + this.alternativeError = error2; + this.alternativeRotation = rotation2; + this.alternativeTranslation = translation2; +}; diff --git a/public/vendor/svd.js b/public/vendor/svd.js new file mode 100644 index 0000000..b4ab6b2 --- /dev/null +++ b/public/vendor/svd.js @@ -0,0 +1,284 @@ +/* +Copyright (c) 2012 Juan Mellado + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +/* +References: +- "Numerical Recipes in C - Second Edition" + http://www.nr.com/ +*/ + +var SVD = SVD || {}; +this.SVD = SVD; + +SVD.svdcmp = function(a, m, n, w, v){ + var flag, i, its, j, jj, k, l, nm, + anorm = 0.0, c, f, g = 0.0, h, s, scale = 0.0, x, y, z, rv1 = []; + + //Householder reduction to bidiagonal form + for (i = 0; i < n; ++ i){ + l = i + 1; + rv1[i] = scale * g; + g = s = scale = 0.0; + if (i < m){ + for (k = i; k < m; ++ k){ + scale += Math.abs( a[k][i] ); + } + if (0.0 !== scale){ + for (k = i; k < m; ++ k){ + a[k][i] /= scale; + s += a[k][i] * a[k][i]; + } + f = a[i][i]; + g = -SVD.sign( Math.sqrt(s), f ); + h = f * g - s; + a[i][i] = f - g; + for (j = l; j < n; ++ j){ + for (s = 0.0, k = i; k < m; ++ k){ + s += a[k][i] * a[k][j]; + } + f = s / h; + for (k = i; k < m; ++ k){ + a[k][j] += f * a[k][i]; + } + } + for (k = i; k < m; ++ k){ + a[k][i] *= scale; + } + } + } + w[i] = scale * g; + g = s = scale = 0.0; + if ( (i < m) && (i !== n - 1) ){ + for (k = l; k < n; ++ k){ + scale += Math.abs( a[i][k] ); + } + if (0.0 !== scale){ + for (k = l; k < n; ++ k){ + a[i][k] /= scale; + s += a[i][k] * a[i][k]; + } + f = a[i][l]; + g = -SVD.sign( Math.sqrt(s), f ); + h = f * g - s; + a[i][l] = f - g; + for (k = l; k < n; ++ k){ + rv1[k] = a[i][k] / h; + } + for (j = l; j < m; ++ j){ + for (s = 0.0, k = l; k < n; ++ k){ + s += a[j][k] * a[i][k]; + } + for (k = l; k < n; ++ k){ + a[j][k] += s * rv1[k]; + } + } + for (k = l; k < n; ++ k){ + a[i][k] *= scale; + } + } + } + anorm = Math.max(anorm, ( Math.abs( w[i] ) + Math.abs( rv1[i] ) ) ); + } + + //Acumulation of right-hand transformation + for (i = n - 1; i >= 0; -- i){ + if (i < n - 1){ + if (0.0 !== g){ + for (j = l; j < n; ++ j){ + v[j][i] = ( a[i][j] / a[i][l] ) / g; + } + for (j = l; j < n; ++ j){ + for (s = 0.0, k = l; k < n; ++ k){ + s += a[i][k] * v[k][j]; + } + for (k = l; k < n; ++ k){ + v[k][j] += s * v[k][i]; + } + } + } + for (j = l; j < n; ++ j){ + v[i][j] = v[j][i] = 0.0; + } + } + v[i][i] = 1.0; + g = rv1[i]; + l = i; + } + + //Acumulation of left-hand transformation + for (i = Math.min(n, m) - 1; i >= 0; -- i){ + l = i + 1; + g = w[i]; + for (j = l; j < n; ++ j){ + a[i][j] = 0.0; + } + if (0.0 !== g){ + g = 1.0 / g; + for (j = l; j < n; ++ j){ + for (s = 0.0, k = l; k < m; ++ k){ + s += a[k][i] * a[k][j]; + } + f = (s / a[i][i]) * g; + for (k = i; k < m; ++ k){ + a[k][j] += f * a[k][i]; + } + } + for (j = i; j < m; ++ j){ + a[j][i] *= g; + } + }else{ + for (j = i; j < m; ++ j){ + a[j][i] = 0.0; + } + } + ++ a[i][i]; + } + + //Diagonalization of the bidiagonal form + for (k = n - 1; k >= 0; -- k){ + for (its = 1; its <= 30; ++ its){ + flag = true; + for (l = k; l >= 0; -- l){ + nm = l - 1; + if ( Math.abs( rv1[l] ) + anorm === anorm ){ + flag = false; + break; + } + if ( Math.abs( w[nm] ) + anorm === anorm ){ + break; + } + } + if (flag){ + c = 0.0; + s = 1.0; + for (i = l; i <= k; ++ i){ + f = s * rv1[i]; + if ( Math.abs(f) + anorm === anorm ){ + break; + } + g = w[i]; + h = SVD.pythag(f, g); + w[i] = h; + h = 1.0 / h; + c = g * h; + s = -f * h; + for (j = 1; j <= m; ++ j){ + y = a[j][nm]; + z = a[j][i]; + a[j][nm] = y * c + z * s; + a[j][i] = z * c - y * s; + } + } + } + + //Convergence + z = w[k]; + if (l === k){ + if (z < 0.0){ + w[k] = -z; + for (j = 0; j < n; ++ j){ + v[j][k] = -v[j][k]; + } + } + break; + } + + if (30 === its){ + return false; + } + + //Shift from bottom 2-by-2 minor + x = w[l]; + nm = k - 1; + y = w[nm]; + g = rv1[nm]; + h = rv1[k]; + f = ( (y - z) * (y + z) + (g - h) * (g + h) ) / (2.0 * h * y); + g = SVD.pythag( f, 1.0 ); + f = ( (x - z) * (x + z) + h * ( (y / (f + SVD.sign(g, f) ) ) - h) ) / x; + + //Next QR transformation + c = s = 1.0; + for (j = l; j <= nm; ++ j){ + i = j + 1; + g = rv1[i]; + y = w[i]; + h = s * g; + g = c * g; + z = SVD.pythag(f, h); + rv1[j] = z; + c = f / z; + s = h / z; + f = x * c + g * s; + g = g * c - x * s; + h = y * s; + y *= c; + for (jj = 0; jj < n; ++ jj){ + x = v[jj][j]; + z = v[jj][i]; + v[jj][j] = x * c + z * s; + v[jj][i] = z * c - x * s; + } + z = SVD.pythag(f, h); + w[j] = z; + if (0.0 !== z){ + z = 1.0 / z; + c = f * z; + s = h * z; + } + f = c * g + s * y; + x = c * y - s * g; + for (jj = 0; jj < m; ++ jj){ + y = a[jj][j]; + z = a[jj][i]; + a[jj][j] = y * c + z * s; + a[jj][i] = z * c - y * s; + } + } + rv1[l] = 0.0; + rv1[k] = f; + w[k] = x; + } + } + + return true; +}; + +SVD.pythag = function(a, b){ + var at = Math.abs(a), bt = Math.abs(b), ct; + + if (at > bt){ + ct = bt / at; + return at * Math.sqrt(1.0 + ct * ct); + } + + if (0.0 === bt){ + return 0.0; + } + + ct = at / bt; + return bt * Math.sqrt(1.0 + ct * ct); +}; + +SVD.sign = function(a, b){ + return b >= 0.0? Math.abs(a): -Math.abs(a); +}; diff --git a/server/sync-server.js b/server/sync-server.js new file mode 100644 index 0000000..c418fcf --- /dev/null +++ b/server/sync-server.js @@ -0,0 +1,141 @@ +import 'dotenv/config'; +import express from 'express'; +import { createServer } from 'http'; +import { WebSocketServer } from 'ws'; +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); + +const PORT = process.env.PORT || 34034; +const TICK_HZ = Number(process.env.TICK_HZ || 20); // server broadcast rate + +// ---- Load scene ---------------------------------------------------------- +function loadScene() { + const raw = readFileSync(join(ROOT, 'data', 'scene.json'), 'utf-8'); + return JSON.parse(raw); +} +let scene = loadScene(); + +// ---- Motion solvers ------------------------------------------------------ +// Every solver takes (spawn, tSeconds) and returns { x,y,z, yawDeg }. +// Positions are in world millimetres, matching scene.json. + +function solveHover(spawn, t) { + const p = spawn.position; + const r = spawn.hoverRadiusMm ?? 0; + const hoverW = (2 * Math.PI) / (spawn.hoverPeriodS ?? 6); + const bobA = spawn.bobAmplitudeMm ?? 0; + const bobW = (2 * Math.PI) / (spawn.bobPeriodS ?? 3); + const yaw = ((spawn.yawDegPerS ?? 0) * t) % 360; + return { + x: p.x + Math.cos(hoverW * t) * r, + y: p.y + Math.sin(bobW * t) * bobA, + z: p.z + Math.sin(hoverW * t) * r, + yawDeg: yaw, + }; +} + +function solvePatrol(spawn, t) { + const path = spawn.path || []; + if (path.length === 0) return { x: 0, y: 0, z: 0, yawDeg: 0 }; + if (path.length === 1) return { ...path[0], yawDeg: 0 }; + const period = spawn.patrolPeriodS ?? 12; + const segCount = path.length; // looped path + const phase = ((t % period) / period) * segCount; // 0..segCount + const seg = Math.floor(phase) % segCount; + const frac = phase - Math.floor(phase); + const a = path[seg]; + const b = path[(seg + 1) % segCount]; + const x = a.x + (b.x - a.x) * frac; + const y = a.y + (b.y - a.y) * frac; + const z = a.z + (b.z - a.z) * frac; + const bobA = spawn.bobAmplitudeMm ?? 0; + const bobW = (2 * Math.PI) / (spawn.bobPeriodS ?? 3); + let yawDeg = 0; + if (spawn.faceTravel) { + yawDeg = (Math.atan2(b.x - a.x, b.z - a.z) * 180) / Math.PI; + } + return { x, y: y + Math.sin(bobW * t) * bobA, z, yawDeg }; +} + +const SOLVERS = { hover: solveHover, patrol: solvePatrol }; + +function solveSpawn(spawn, t) { + const solver = SOLVERS[spawn.motion] || solveHover; + return solver(spawn, t); +} + +// ---- Authoritative state ------------------------------------------------- +const startTime = Date.now(); +function nowSeconds() { + return (Date.now() - startTime) / 1000; +} + +function buildSnapshot() { + const t = nowSeconds(); + const ghosts = scene.spawns.map((spawn) => { + const pose = solveSpawn(spawn, t); + return { + spawnId: spawn.id, + ghostId: spawn.ghostId, + label: spawn.label, + pos: { x: round(pose.x), y: round(pose.y), z: round(pose.z) }, + yawDeg: round(pose.yawDeg), + }; + }); + return { type: 'state', serverTime: t, ghosts }; +} + +function round(n) { + return Math.round(n * 10) / 10; +} + +// ---- HTTP + static ------------------------------------------------------- +const app = express(); +app.use(express.static(join(ROOT, 'public'))); + +// Scene metadata (anchors + spawn definitions) for clients to align/render. +app.get('/api/scene', (_req, res) => { + res.json({ + world: scene.world, + anchors: scene.anchors, + spawns: scene.spawns.map((s) => ({ + id: s.id, + ghostId: s.ghostId, + label: s.label, + motion: s.motion, + })), + }); +}); + +app.get('/api/health', (_req, res) => { + res.json({ ok: true, viewers: wss ? wss.clients.size : 0, tickHz: TICK_HZ }); +}); + +const httpServer = createServer(app); + +// ---- WebSocket broadcast ------------------------------------------------- +const wss = new WebSocketServer({ server: httpServer, path: '/sync' }); + +wss.on('connection', (socket) => { + // Send scene + immediate snapshot on connect so a new phone is instantly aligned. + socket.send(JSON.stringify({ type: 'hello', world: scene.world, anchors: scene.anchors })); + socket.send(JSON.stringify(buildSnapshot())); +}); + +setInterval(() => { + if (wss.clients.size === 0) return; + const payload = JSON.stringify(buildSnapshot()); + for (const client of wss.clients) { + if (client.readyState === 1) client.send(payload); + } +}, 1000 / TICK_HZ); + +httpServer.listen(PORT, () => { + console.log(`Newbury exhibit sync server on :${PORT} (broadcast ${TICK_HZ}Hz)`); + console.log(` Viewer: http://localhost:${PORT}/`); + console.log(` Scene: ${scene.spawns.length} spawns, ${scene.anchors.length} anchors`); +}); diff --git a/upload-to-gitea.bat b/upload-to-gitea.bat new file mode 100644 index 0000000..62c6d2c --- /dev/null +++ b/upload-to-gitea.bat @@ -0,0 +1,66 @@ +@echo off +REM ============================================================ +REM Newbury Exhibit -> Gitea uploader +REM Wipe-and-re-clone _deploy checkout, copy tree, commit, push. +REM Mirrors the newbury-nights deploy pattern. +REM ============================================================ +setlocal enabledelayedexpansion + +set GITEA_HOST=gitea.hideawaygaming.com.au +set OWNER=jessikitty +set REPO=newbury-exhibit +set BRANCH=dev +set SRC=%~dp0 + +echo. +echo === Newbury Exhibit deploy === +echo Source: %SRC% +echo Target: https://%GITEA_HOST%/%OWNER%/%REPO% (branch %BRANCH%) +echo. + +REM --- fresh _deploy checkout --- +if exist "%SRC%_deploy" ( + echo Removing old _deploy checkout... + rmdir /s /q "%SRC%_deploy" +) +echo Cloning %BRANCH%... +git clone -b %BRANCH% "https://%GITEA_HOST%/%OWNER%/%REPO%.git" "%SRC%_deploy" +if errorlevel 1 ( + echo ERROR: clone failed. Check credentials / branch exists. + pause & exit /b 1 +) + +REM --- auto-detect git identity from existing global config --- +for /f "tokens=*" %%a in ('git config --global user.name') do set GITNAME=%%a +for /f "tokens=*" %%a in ('git config --global user.email') do set GITEMAIL=%%a +if "!GITNAME!"=="" set GITNAME=jessikitty +if "!GITEMAIL!"=="" set GITEMAIL=jess.rogerson.29@outlook.com + +REM --- copy project files into checkout (exclude deploy/node_modules/git) --- +echo Syncing files... +robocopy "%SRC%." "%SRC%_deploy" /E /XD "_deploy" "node_modules" ".git" /XF "*.log" >nul + +pushd "%SRC%_deploy" +git config user.name "!GITNAME!" +git config user.email "!GITEMAIL!" + +echo. +echo === Staged changes === +git add -A +git status --short +echo. + +set /p MSG="Commit message (blank = 'Update exhibit'): " +if "!MSG!"=="" set MSG=Update exhibit + +git commit -m "!MSG!" +if errorlevel 1 ( + echo Nothing to commit, or commit failed. +) else ( + git push origin %BRANCH% + if errorlevel 1 ( echo ERROR: push failed. & popd & pause & exit /b 1 ) + echo. + echo === Pushed to %BRANCH% successfully === +) +popd +pause