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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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
+
+
+
+
+
+
+
+
+
+
Marker searching…
+
id: — · fps: 0
+
Point at a Newbury Crest
+
+
+
+
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