Initial Comming

This commit is contained in:
2026-06-26 13:58:39 +10:00
parent 8b064bbd39
commit 0d1d66a85d
28 changed files with 4328 additions and 2 deletions
+109 -2
View File
@@ -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. 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/`.
+103
View File
@@ -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
}
]
}
+51
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><rect width="100" height="100" fill="#ffffff"/><rect x="20" y="20" width="10" height="10" fill="#000000"/><rect x="30" y="20" width="10" height="10" fill="#000000"/><rect x="40" y="20" width="10" height="10" fill="#000000"/><rect x="50" y="20" width="10" height="10" fill="#000000"/><rect x="60" y="20" width="10" height="10" fill="#000000"/><rect x="70" y="20" width="10" height="10" fill="#000000"/><rect x="20" y="30" width="10" height="10" fill="#000000"/><rect x="30" y="30" width="10" height="10" fill="#ffffff"/><rect x="40" y="30" width="10" height="10" fill="#000000"/><rect x="50" y="30" width="10" height="10" fill="#ffffff"/><rect x="60" y="30" width="10" height="10" fill="#ffffff"/><rect x="70" y="30" width="10" height="10" fill="#000000"/><rect x="20" y="40" width="10" height="10" fill="#000000"/><rect x="30" y="40" width="10" height="10" fill="#000000"/><rect x="40" y="40" width="10" height="10" fill="#ffffff"/><rect x="50" y="40" width="10" height="10" fill="#000000"/><rect x="60" y="40" width="10" height="10" fill="#ffffff"/><rect x="70" y="40" width="10" height="10" fill="#000000"/><rect x="20" y="50" width="10" height="10" fill="#000000"/><rect x="30" y="50" width="10" height="10" fill="#000000"/><rect x="40" y="50" width="10" height="10" fill="#000000"/><rect x="50" y="50" width="10" height="10" fill="#ffffff"/><rect x="60" y="50" width="10" height="10" fill="#ffffff"/><rect x="70" y="50" width="10" height="10" fill="#000000"/><rect x="20" y="60" width="10" height="10" fill="#000000"/><rect x="30" y="60" width="10" height="10" fill="#000000"/><rect x="40" y="60" width="10" height="10" fill="#000000"/><rect x="50" y="60" width="10" height="10" fill="#ffffff"/><rect x="60" y="60" width="10" height="10" fill="#000000"/><rect x="70" y="60" width="10" height="10" fill="#000000"/><rect x="20" y="70" width="10" height="10" fill="#000000"/><rect x="30" y="70" width="10" height="10" fill="#000000"/><rect x="40" y="70" width="10" height="10" fill="#000000"/><rect x="50" y="70" width="10" height="10" fill="#000000"/><rect x="60" y="70" width="10" height="10" fill="#000000"/><rect x="70" y="70" width="10" height="10" fill="#000000"/><text x="50" y="96" font-family="monospace" font-size="7" text-anchor="middle" fill="#000">ArUco4x4 id 0</text></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+79
View File
@@ -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."
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><rect width="100" height="100" fill="#ffffff"/><rect x="20" y="20" width="10" height="10" fill="#000000"/><rect x="30" y="20" width="10" height="10" fill="#000000"/><rect x="40" y="20" width="10" height="10" fill="#000000"/><rect x="50" y="20" width="10" height="10" fill="#000000"/><rect x="60" y="20" width="10" height="10" fill="#000000"/><rect x="70" y="20" width="10" height="10" fill="#000000"/><rect x="20" y="30" width="10" height="10" fill="#000000"/><rect x="30" y="30" width="10" height="10" fill="#000000"/><rect x="40" y="30" width="10" height="10" fill="#000000"/><rect x="50" y="30" width="10" height="10" fill="#000000"/><rect x="60" y="30" width="10" height="10" fill="#000000"/><rect x="70" y="30" width="10" height="10" fill="#000000"/><rect x="20" y="40" width="10" height="10" fill="#000000"/><rect x="30" y="40" width="10" height="10" fill="#ffffff"/><rect x="40" y="40" width="10" height="10" fill="#ffffff"/><rect x="50" y="40" width="10" height="10" fill="#ffffff"/><rect x="60" y="40" width="10" height="10" fill="#ffffff"/><rect x="70" y="40" width="10" height="10" fill="#000000"/><rect x="20" y="50" width="10" height="10" fill="#000000"/><rect x="30" y="50" width="10" height="10" fill="#ffffff"/><rect x="40" y="50" width="10" height="10" fill="#000000"/><rect x="50" y="50" width="10" height="10" fill="#000000"/><rect x="60" y="50" width="10" height="10" fill="#ffffff"/><rect x="70" y="50" width="10" height="10" fill="#000000"/><rect x="20" y="60" width="10" height="10" fill="#000000"/><rect x="30" y="60" width="10" height="10" fill="#ffffff"/><rect x="40" y="60" width="10" height="10" fill="#000000"/><rect x="50" y="60" width="10" height="10" fill="#ffffff"/><rect x="60" y="60" width="10" height="10" fill="#000000"/><rect x="70" y="60" width="10" height="10" fill="#000000"/><rect x="20" y="70" width="10" height="10" fill="#000000"/><rect x="30" y="70" width="10" height="10" fill="#000000"/><rect x="40" y="70" width="10" height="10" fill="#000000"/><rect x="50" y="70" width="10" height="10" fill="#000000"/><rect x="60" y="70" width="10" height="10" fill="#000000"/><rect x="70" y="70" width="10" height="10" fill="#000000"/><text x="50" y="96" font-family="monospace" font-size="7" text-anchor="middle" fill="#000">ArUco4x4 id 1</text></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+79
View File
@@ -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."
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><rect width="100" height="100" fill="#ffffff"/><rect x="20" y="20" width="10" height="10" fill="#000000"/><rect x="30" y="20" width="10" height="10" fill="#000000"/><rect x="40" y="20" width="10" height="10" fill="#000000"/><rect x="50" y="20" width="10" height="10" fill="#000000"/><rect x="60" y="20" width="10" height="10" fill="#000000"/><rect x="70" y="20" width="10" height="10" fill="#000000"/><rect x="20" y="30" width="10" height="10" fill="#000000"/><rect x="30" y="30" width="10" height="10" fill="#000000"/><rect x="40" y="30" width="10" height="10" fill="#000000"/><rect x="50" y="30" width="10" height="10" fill="#ffffff"/><rect x="60" y="30" width="10" height="10" fill="#ffffff"/><rect x="70" y="30" width="10" height="10" fill="#000000"/><rect x="20" y="40" width="10" height="10" fill="#000000"/><rect x="30" y="40" width="10" height="10" fill="#000000"/><rect x="40" y="40" width="10" height="10" fill="#000000"/><rect x="50" y="40" width="10" height="10" fill="#ffffff"/><rect x="60" y="40" width="10" height="10" fill="#ffffff"/><rect x="70" y="40" width="10" height="10" fill="#000000"/><rect x="20" y="50" width="10" height="10" fill="#000000"/><rect x="30" y="50" width="10" height="10" fill="#000000"/><rect x="40" y="50" width="10" height="10" fill="#000000"/><rect x="50" y="50" width="10" height="10" fill="#ffffff"/><rect x="60" y="50" width="10" height="10" fill="#000000"/><rect x="70" y="50" width="10" height="10" fill="#000000"/><rect x="20" y="60" width="10" height="10" fill="#000000"/><rect x="30" y="60" width="10" height="10" fill="#ffffff"/><rect x="40" y="60" width="10" height="10" fill="#ffffff"/><rect x="50" y="60" width="10" height="10" fill="#000000"/><rect x="60" y="60" width="10" height="10" fill="#ffffff"/><rect x="70" y="60" width="10" height="10" fill="#000000"/><rect x="20" y="70" width="10" height="10" fill="#000000"/><rect x="30" y="70" width="10" height="10" fill="#000000"/><rect x="40" y="70" width="10" height="10" fill="#000000"/><rect x="50" y="70" width="10" height="10" fill="#000000"/><rect x="60" y="70" width="10" height="10" fill="#000000"/><rect x="70" y="70" width="10" height="10" fill="#000000"/><text x="50" y="96" font-family="monospace" font-size="7" text-anchor="middle" fill="#000">ArUco4x4 id 2</text></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+79
View File
@@ -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."
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><rect width="100" height="100" fill="#ffffff"/><rect x="20" y="20" width="10" height="10" fill="#000000"/><rect x="30" y="20" width="10" height="10" fill="#000000"/><rect x="40" y="20" width="10" height="10" fill="#000000"/><rect x="50" y="20" width="10" height="10" fill="#000000"/><rect x="60" y="20" width="10" height="10" fill="#000000"/><rect x="70" y="20" width="10" height="10" fill="#000000"/><rect x="20" y="30" width="10" height="10" fill="#000000"/><rect x="30" y="30" width="10" height="10" fill="#ffffff"/><rect x="40" y="30" width="10" height="10" fill="#000000"/><rect x="50" y="30" width="10" height="10" fill="#000000"/><rect x="60" y="30" width="10" height="10" fill="#ffffff"/><rect x="70" y="30" width="10" height="10" fill="#000000"/><rect x="20" y="40" width="10" height="10" fill="#000000"/><rect x="30" y="40" width="10" height="10" fill="#ffffff"/><rect x="40" y="40" width="10" height="10" fill="#000000"/><rect x="50" y="40" width="10" height="10" fill="#000000"/><rect x="60" y="40" width="10" height="10" fill="#ffffff"/><rect x="70" y="40" width="10" height="10" fill="#000000"/><rect x="20" y="50" width="10" height="10" fill="#000000"/><rect x="30" y="50" width="10" height="10" fill="#000000"/><rect x="40" y="50" width="10" height="10" fill="#ffffff"/><rect x="50" y="50" width="10" height="10" fill="#000000"/><rect x="60" y="50" width="10" height="10" fill="#000000"/><rect x="70" y="50" width="10" height="10" fill="#000000"/><rect x="20" y="60" width="10" height="10" fill="#000000"/><rect x="30" y="60" width="10" height="10" fill="#000000"/><rect x="40" y="60" width="10" height="10" fill="#ffffff"/><rect x="50" y="60" width="10" height="10" fill="#ffffff"/><rect x="60" y="60" width="10" height="10" fill="#000000"/><rect x="70" y="60" width="10" height="10" fill="#000000"/><rect x="20" y="70" width="10" height="10" fill="#000000"/><rect x="30" y="70" width="10" height="10" fill="#000000"/><rect x="40" y="70" width="10" height="10" fill="#000000"/><rect x="50" y="70" width="10" height="10" fill="#000000"/><rect x="60" y="70" width="10" height="10" fill="#000000"/><rect x="70" y="70" width="10" height="10" fill="#000000"/><text x="50" y="96" font-family="monospace" font-size="7" text-anchor="middle" fill="#000">ArUco4x4 id 3</text></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+79
View File
@@ -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."
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><rect width="100" height="100" fill="#ffffff"/><rect x="20" y="20" width="10" height="10" fill="#000000"/><rect x="30" y="20" width="10" height="10" fill="#000000"/><rect x="40" y="20" width="10" height="10" fill="#000000"/><rect x="50" y="20" width="10" height="10" fill="#000000"/><rect x="60" y="20" width="10" height="10" fill="#000000"/><rect x="70" y="20" width="10" height="10" fill="#000000"/><rect x="20" y="30" width="10" height="10" fill="#000000"/><rect x="30" y="30" width="10" height="10" fill="#000000"/><rect x="40" y="30" width="10" height="10" fill="#ffffff"/><rect x="50" y="30" width="10" height="10" fill="#000000"/><rect x="60" y="30" width="10" height="10" fill="#ffffff"/><rect x="70" y="30" width="10" height="10" fill="#000000"/><rect x="20" y="40" width="10" height="10" fill="#000000"/><rect x="30" y="40" width="10" height="10" fill="#000000"/><rect x="40" y="40" width="10" height="10" fill="#ffffff"/><rect x="50" y="40" width="10" height="10" fill="#000000"/><rect x="60" y="40" width="10" height="10" fill="#000000"/><rect x="70" y="40" width="10" height="10" fill="#000000"/><rect x="20" y="50" width="10" height="10" fill="#000000"/><rect x="30" y="50" width="10" height="10" fill="#ffffff"/><rect x="40" y="50" width="10" height="10" fill="#000000"/><rect x="50" y="50" width="10" height="10" fill="#000000"/><rect x="60" y="50" width="10" height="10" fill="#ffffff"/><rect x="70" y="50" width="10" height="10" fill="#000000"/><rect x="20" y="60" width="10" height="10" fill="#000000"/><rect x="30" y="60" width="10" height="10" fill="#ffffff"/><rect x="40" y="60" width="10" height="10" fill="#ffffff"/><rect x="50" y="60" width="10" height="10" fill="#ffffff"/><rect x="60" y="60" width="10" height="10" fill="#000000"/><rect x="70" y="60" width="10" height="10" fill="#000000"/><rect x="20" y="70" width="10" height="10" fill="#000000"/><rect x="30" y="70" width="10" height="10" fill="#000000"/><rect x="40" y="70" width="10" height="10" fill="#000000"/><rect x="50" y="70" width="10" height="10" fill="#000000"/><rect x="60" y="70" width="10" height="10" fill="#000000"/><rect x="70" y="70" width="10" height="10" fill="#000000"/><text x="50" y="96" font-family="monospace" font-size="7" text-anchor="middle" fill="#000">ArUco4x4 id 4</text></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+79
View File
@@ -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."
}
}
+31
View File
@@ -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"
}
]
}
+900
View File
@@ -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
}
}
}
}
}
+19
View File
@@ -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"
}
+75
View File
@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1" />
<title>Newbury Exhibit — AR marker test</title>
<style>
:root {
--red: #f65151; --yellow: #fff35d; --blue: #529eff;
--ink: #e8eaf2; --dim: #8a93ad; --panel: rgba(12,14,22,0.82);
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: #05060a; color: var(--ink);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; overflow: hidden; }
#stage { position: fixed; inset: 0; }
/* camera + canvas stack, all same size, centred */
#video, #overlay { position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%); }
#video { object-fit: cover; }
#overlay { pointer-events: none; }
/* HUD */
#hud { position: fixed; left: 12px; top: calc(12px + env(safe-area-inset-top));
background: var(--panel); border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px; padding: 10px 12px; font-size: 13px; line-height: 1.5;
backdrop-filter: blur(8px); max-width: 70vw; }
#hud b { color: var(--blue); font-weight: 600; }
.tag { display: inline-block; padding: 1px 7px; border-radius: 999px;
font-size: 11px; font-weight: 600; margin-left: 6px; }
.tag.on { background: rgba(82,158,255,0.18); color: var(--blue); }
.tag.off { background: rgba(246,81,81,0.16); color: var(--red); }
#start { position: fixed; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 20px; text-align: center;
background: radial-gradient(circle at 50% 38%, #161a2b, #05060a 70%); padding: 24px; }
#start h1 { font-size: 26px; margin: 0; letter-spacing: -0.01em; }
#start h1 span { background: linear-gradient(90deg, var(--red), var(--yellow), var(--blue));
-webkit-background-clip: text; background-clip: text; color: transparent; }
#start p { color: var(--dim); max-width: 30ch; margin: 0; line-height: 1.6; font-size: 15px; }
#go { appearance: none; border: 0; cursor: pointer; font-size: 16px; font-weight: 600;
color: #06121f; background: linear-gradient(135deg, var(--blue), #51eaf1);
padding: 14px 30px; border-radius: 999px; box-shadow: 0 8px 30px rgba(82,158,255,0.35); }
#go:active { transform: translateY(1px); }
.hidden { display: none !important; }
#err { color: var(--red); font-size: 13px; max-width: 34ch; }
</style>
</head>
<body>
<div id="stage">
<video id="video" playsinline muted autoplay></video>
<canvas id="overlay"></canvas>
</div>
<div id="hud" class="hidden">
<div>Marker <span id="seen" class="tag off">searching…</span></div>
<div>id: <b id="mid"></b> · fps: <b id="fps">0</b></div>
<div style="color:var(--dim);font-size:11px;margin-top:4px">Point at a Newbury Crest</div>
</div>
<div id="start">
<h1>Newbury <span>Hidden Side</span></h1>
<p>Point your camera at a Newbury Crest marker to reveal the ghost bound to it.</p>
<button id="go">Start camera</button>
<div id="err"></div>
</div>
<!-- js-aruco2 (vendored, MIT) -->
<script src="vendor/cv.js"></script>
<script src="vendor/svd.js"></script>
<script src="vendor/posit1.js"></script>
<script src="vendor/aruco.js"></script>
<script src="vendor/dictionaries/aruco_4x4_1000.js"></script>
<script src="js/ar-test.js"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Newbury Exhibit — god view</title>
<style>
:root { --ink:#e8eaf2; --dim:#8a93ad; }
* { box-sizing:border-box; }
html,body { margin:0; height:100%; background:#0a0a14; color:var(--ink);
font-family:'Segoe UI',system-ui,-apple-system,sans-serif; overflow:hidden; }
#panel { position:fixed; left:12px; top:12px; z-index:10;
background:rgba(12,14,22,0.82); border:1px solid rgba(255,255,255,0.08);
border-radius:12px; padding:10px 14px; font-size:13px; line-height:1.6;
backdrop-filter:blur(8px); }
#panel b { color:#529eff; }
#panel .row { color:var(--dim); font-size:12px; }
#status { font-weight:600; }
#status.ok { color:#51eaf1; } #status.warn { color:#fff35d; }
</style>
</head>
<body>
<div id="panel">
<div>Newbury Exhibit · <b>god view</b></div>
<div class="row">link: <span id="status" class="warn">connecting…</span> · ghosts: <b id="count">0</b></div>
<div class="row">drag to orbit · scroll to zoom</div>
</div>
<script type="module" src="js/viewer.js"></script>
</body>
</html>
+214
View File
@@ -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);
})();
+165
View File
@@ -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);
});
+468
View File
File diff suppressed because one or more lines are too long
+737
View File
@@ -0,0 +1,737 @@
/*
Copyright (c) 2011 Juan Mellado
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
/*
References:
- "OpenCV: Open Computer Vision Library"
http://sourceforge.net/projects/opencvlibrary/
- "Stack Blur: Fast But Goodlooking"
http://incubator.quasimondo.com/processing/fast_blur_deluxe.php
*/
var CV = CV || {};
this.CV = CV;
CV.Image = function(width, height, data){
this.width = width || 0;
this.height = height || 0;
this.data = data || [];
};
CV.grayscale = function(imageSrc, imageDst){
var src = imageSrc.data, dst = imageDst.data, len = src.length,
i = 0, j = 0;
for (; i < len; i += 4){
dst[j ++] =
(src[i] * 0.299 + src[i + 1] * 0.587 + src[i + 2] * 0.114 + 0.5) & 0xff;
}
imageDst.width = imageSrc.width;
imageDst.height = imageSrc.height;
return imageDst;
};
CV.threshold = function(imageSrc, imageDst, threshold){
var src = imageSrc.data, dst = imageDst.data,
len = src.length, tab = [], i;
for (i = 0; i < 256; ++ i){
tab[i] = i <= threshold? 0: 255;
}
for (i = 0; i < len; ++ i){
dst[i] = tab[ src[i] ];
}
imageDst.width = imageSrc.width;
imageDst.height = imageSrc.height;
return imageDst;
};
CV.adaptiveThreshold = function(imageSrc, imageDst, kernelSize, threshold){
var src = imageSrc.data, dst = imageDst.data, len = src.length, tab = [], i;
CV.stackBoxBlur(imageSrc, imageDst, kernelSize);
for (i = 0; i < 768; ++ i){
tab[i] = (i - 255 <= -threshold)? 255: 0;
}
for (i = 0; i < len; ++ i){
dst[i] = tab[ src[i] - dst[i] + 255 ];
}
imageDst.width = imageSrc.width;
imageDst.height = imageSrc.height;
return imageDst;
};
CV.otsu = function(imageSrc){
var src = imageSrc.data, len = src.length, hist = [],
threshold = 0, sum = 0, sumB = 0, wB = 0, wF = 0, max = 0,
mu, between, i;
for (i = 0; i < 256; ++ i){
hist[i] = 0;
}
for (i = 0; i < len; ++ i){
hist[ src[i] ] ++;
}
for (i = 0; i < 256; ++ i){
sum += hist[i] * i;
}
for (i = 0; i < 256; ++ i){
wB += hist[i];
if (0 !== wB){
wF = len - wB;
if (0 === wF){
break;
}
sumB += hist[i] * i;
mu = (sumB / wB) - ( (sum - sumB) / wF );
between = wB * wF * mu * mu;
if (between > max){
max = between;
threshold = i;
}
}
}
return threshold;
};
CV.stackBoxBlurMult =
[1, 171, 205, 293, 57, 373, 79, 137, 241, 27, 391, 357, 41, 19, 283, 265];
CV.stackBoxBlurShift =
[0, 9, 10, 11, 9, 12, 10, 11, 12, 9, 13, 13, 10, 9, 13, 13];
CV.BlurStack = function(){
this.color = 0;
this.next = null;
};
CV.stackBoxBlur = function(imageSrc, imageDst, kernelSize){
var src = imageSrc.data, dst = imageDst.data,
height = imageSrc.height, width = imageSrc.width,
heightMinus1 = height - 1, widthMinus1 = width - 1,
size = kernelSize + kernelSize + 1, radius = kernelSize + 1,
mult = CV.stackBoxBlurMult[kernelSize],
shift = CV.stackBoxBlurShift[kernelSize],
stack, stackStart, color, sum, pos, start, p, x, y, i;
stack = stackStart = new CV.BlurStack();
for (i = 1; i < size; ++ i){
stack = stack.next = new CV.BlurStack();
}
stack.next = stackStart;
pos = 0;
for (y = 0; y < height; ++ y){
start = pos;
color = src[pos];
sum = radius * color;
stack = stackStart;
for (i = 0; i < radius; ++ i){
stack.color = color;
stack = stack.next;
}
for (i = 1; i < radius; ++ i){
stack.color = src[pos + i];
sum += stack.color;
stack = stack.next;
}
stack = stackStart;
for (x = 0; x < width; ++ x){
dst[pos ++] = (sum * mult) >>> shift;
p = x + radius;
p = start + (p < widthMinus1? p: widthMinus1);
sum -= stack.color - src[p];
stack.color = src[p];
stack = stack.next;
}
}
for (x = 0; x < width; ++ x){
pos = x;
start = pos + width;
color = dst[pos];
sum = radius * color;
stack = stackStart;
for (i = 0; i < radius; ++ i){
stack.color = color;
stack = stack.next;
}
for (i = 1; i < radius; ++ i){
stack.color = dst[start];
sum += stack.color;
stack = stack.next;
start += width;
}
stack = stackStart;
for (y = 0; y < height; ++ y){
dst[pos] = (sum * mult) >>> shift;
p = y + radius;
p = x + ( (p < heightMinus1? p: heightMinus1) * width );
sum -= stack.color - dst[p];
stack.color = dst[p];
stack = stack.next;
pos += width;
}
}
return imageDst;
};
CV.gaussianBlur = function(imageSrc, imageDst, imageMean, kernelSize){
var kernel = CV.gaussianKernel(kernelSize);
imageDst.width = imageSrc.width;
imageDst.height = imageSrc.height;
imageMean.width = imageSrc.width;
imageMean.height = imageSrc.height;
CV.gaussianBlurFilter(imageSrc, imageMean, kernel, true);
CV.gaussianBlurFilter(imageMean, imageDst, kernel, false);
return imageDst;
};
CV.gaussianBlurFilter = function(imageSrc, imageDst, kernel, horizontal){
var src = imageSrc.data, dst = imageDst.data,
height = imageSrc.height, width = imageSrc.width,
pos = 0, limit = kernel.length >> 1,
cur, value, i, j, k;
for (i = 0; i < height; ++ i){
for (j = 0; j < width; ++ j){
value = 0.0;
for (k = -limit; k <= limit; ++ k){
if (horizontal){
cur = pos + k;
if (j + k < 0){
cur = pos;
}
else if (j + k >= width){
cur = pos;
}
}else{
cur = pos + (k * width);
if (i + k < 0){
cur = pos;
}
else if (i + k >= height){
cur = pos;
}
}
value += kernel[limit + k] * src[cur];
}
dst[pos ++] = horizontal? value: (value + 0.5) & 0xff;
}
}
return imageDst;
};
CV.gaussianKernel = function(kernelSize){
var tab =
[ [1],
[0.25, 0.5, 0.25],
[0.0625, 0.25, 0.375, 0.25, 0.0625],
[0.03125, 0.109375, 0.21875, 0.28125, 0.21875, 0.109375, 0.03125] ],
kernel = [], center, sigma, scale2X, sum, x, i;
if ( (kernelSize <= 7) && (kernelSize % 2 === 1) ){
kernel = tab[kernelSize >> 1];
}else{
center = (kernelSize - 1.0) * 0.5;
sigma = 0.8 + (0.3 * (center - 1.0) );
scale2X = -0.5 / (sigma * sigma);
sum = 0.0;
for (i = 0; i < kernelSize; ++ i){
x = i - center;
sum += kernel[i] = Math.exp(scale2X * x * x);
}
sum = 1 / sum;
for (i = 0; i < kernelSize; ++ i){
kernel[i] *= sum;
}
}
return kernel;
};
CV.findContours = function(imageSrc, binary){
var width = imageSrc.width, height = imageSrc.height, contours = [],
src, deltas, pos, pix, nbd, outer, hole, i, j;
src = CV.binaryBorder(imageSrc, binary);
deltas = CV.neighborhoodDeltas(width + 2);
pos = width + 3;
nbd = 1;
for (i = 0; i < height; ++ i, pos += 2){
for (j = 0; j < width; ++ j, ++ pos){
pix = src[pos];
if (0 !== pix){
outer = hole = false;
if (1 === pix && 0 === src[pos - 1]){
outer = true;
}
else if (pix >= 1 && 0 === src[pos + 1]){
hole = true;
}
if (outer || hole){
++ nbd;
contours.push( CV.borderFollowing(src, pos, nbd, {x: j, y: i}, hole, deltas) );
}
}
}
}
return contours;
};
CV.borderFollowing = function(src, pos, nbd, point, hole, deltas){
var contour = [], pos1, pos3, pos4, s, s_end, s_prev;
contour.hole = hole;
s = s_end = hole? 0: 4;
do{
s = (s - 1) & 7;
pos1 = pos + deltas[s];
if (src[pos1] !== 0){
break;
}
}while(s !== s_end);
if (s === s_end){
src[pos] = -nbd;
contour.push( {x: point.x, y: point.y} );
}else{
pos3 = pos;
s_prev = s ^ 4;
while(true){
s_end = s;
do{
pos4 = pos3 + deltas[++ s];
}while(src[pos4] === 0);
s &= 7;
if ( ( (s - 1) >>> 0) < (s_end >>> 0) ){
src[pos3] = -nbd;
}
else if (src[pos3] === 1){
src[pos3] = nbd;
}
contour.push( {x: point.x, y: point.y} );
s_prev = s;
point.x += CV.neighborhood[s][0];
point.y += CV.neighborhood[s][1];
if ( (pos4 === pos) && (pos3 === pos1) ){
break;
}
pos3 = pos4;
s = (s + 4) & 7;
}
}
return contour;
};
CV.neighborhood =
[ [1, 0], [1, -1], [0, -1], [-1, -1], [-1, 0], [-1, 1], [0, 1], [1, 1] ];
CV.neighborhoodDeltas = function(width){
var deltas = [], len = CV.neighborhood.length, i = 0;
for (; i < len; ++ i){
deltas[i] = CV.neighborhood[i][0] + (CV.neighborhood[i][1] * width);
}
return deltas.concat(deltas);
};
CV.approxPolyDP = function(contour, epsilon){
var slice = {start_index: 0, end_index: 0},
right_slice = {start_index: 0, end_index: 0},
poly = [], stack = [], len = contour.length,
pt, start_pt, end_pt, dist, max_dist, le_eps,
dx, dy, i, j, k;
epsilon *= epsilon;
k = 0;
for (i = 0; i < 3; ++ i){
max_dist = 0;
k = (k + right_slice.start_index) % len;
start_pt = contour[k];
if (++ k === len) {k = 0;}
for (j = 1; j < len; ++ j){
pt = contour[k];
if (++ k === len) {k = 0;}
dx = pt.x - start_pt.x;
dy = pt.y - start_pt.y;
dist = dx * dx + dy * dy;
if (dist > max_dist){
max_dist = dist;
right_slice.start_index = j;
}
}
}
if (max_dist <= epsilon){
poly.push( {x: start_pt.x, y: start_pt.y} );
}else{
slice.start_index = k;
slice.end_index = (right_slice.start_index += slice.start_index);
right_slice.start_index -= right_slice.start_index >= len? len: 0;
right_slice.end_index = slice.start_index;
if (right_slice.end_index < right_slice.start_index){
right_slice.end_index += len;
}
stack.push( {start_index: right_slice.start_index, end_index: right_slice.end_index} );
stack.push( {start_index: slice.start_index, end_index: slice.end_index} );
}
while(stack.length !== 0){
slice = stack.pop();
end_pt = contour[slice.end_index % len];
start_pt = contour[k = slice.start_index % len];
if (++ k === len) {k = 0;}
if (slice.end_index <= slice.start_index + 1){
le_eps = true;
}else{
max_dist = 0;
dx = end_pt.x - start_pt.x;
dy = end_pt.y - start_pt.y;
for (i = slice.start_index + 1; i < slice.end_index; ++ i){
pt = contour[k];
if (++ k === len) {k = 0;}
dist = Math.abs( (pt.y - start_pt.y) * dx - (pt.x - start_pt.x) * dy);
if (dist > max_dist){
max_dist = dist;
right_slice.start_index = i;
}
}
le_eps = max_dist * max_dist <= epsilon * (dx * dx + dy * dy);
}
if (le_eps){
poly.push( {x: start_pt.x, y: start_pt.y} );
}else{
right_slice.end_index = slice.end_index;
slice.end_index = right_slice.start_index;
stack.push( {start_index: right_slice.start_index, end_index: right_slice.end_index} );
stack.push( {start_index: slice.start_index, end_index: slice.end_index} );
}
}
return poly;
};
CV.warp = function(imageSrc, imageDst, contour, warpSize){
var src = imageSrc.data, dst = imageDst.data,
width = imageSrc.width, height = imageSrc.height,
pos = 0,
sx1, sx2, dx1, dx2, sy1, sy2, dy1, dy2, p1, p2, p3, p4,
m, r, s, t, u, v, w, x, y, i, j;
m = CV.getPerspectiveTransform(contour, warpSize - 1);
r = m[8];
s = m[2];
t = m[5];
for (i = 0; i < warpSize; ++ i){
r += m[7];
s += m[1];
t += m[4];
u = r;
v = s;
w = t;
for (j = 0; j < warpSize; ++ j){
u += m[6];
v += m[0];
w += m[3];
x = v / u;
y = w / u;
sx1 = x >>> 0;
sx2 = (sx1 === width - 1)? sx1: sx1 + 1;
dx1 = x - sx1;
dx2 = 1.0 - dx1;
sy1 = y >>> 0;
sy2 = (sy1 === height - 1)? sy1: sy1 + 1;
dy1 = y - sy1;
dy2 = 1.0 - dy1;
p1 = p2 = sy1 * width;
p3 = p4 = sy2 * width;
dst[pos ++] =
(dy2 * (dx2 * src[p1 + sx1] + dx1 * src[p2 + sx2]) +
dy1 * (dx2 * src[p3 + sx1] + dx1 * src[p4 + sx2]) ) & 0xff;
}
}
imageDst.width = warpSize;
imageDst.height = warpSize;
return imageDst;
};
CV.getPerspectiveTransform = function(src, size){
var rq = CV.square2quad(src);
rq[0] /= size;
rq[1] /= size;
rq[3] /= size;
rq[4] /= size;
rq[6] /= size;
rq[7] /= size;
return rq;
};
CV.square2quad = function(src){
var sq = [], px, py, dx1, dx2, dy1, dy2, den;
px = src[0].x - src[1].x + src[2].x - src[3].x;
py = src[0].y - src[1].y + src[2].y - src[3].y;
if (0 === px && 0 === py){
sq[0] = src[1].x - src[0].x;
sq[1] = src[2].x - src[1].x;
sq[2] = src[0].x;
sq[3] = src[1].y - src[0].y;
sq[4] = src[2].y - src[1].y;
sq[5] = src[0].y;
sq[6] = 0;
sq[7] = 0;
sq[8] = 1;
}else{
dx1 = src[1].x - src[2].x;
dx2 = src[3].x - src[2].x;
dy1 = src[1].y - src[2].y;
dy2 = src[3].y - src[2].y;
den = dx1 * dy2 - dx2 * dy1;
sq[6] = (px * dy2 - dx2 * py) / den;
sq[7] = (dx1 * py - px * dy1) / den;
sq[8] = 1;
sq[0] = src[1].x - src[0].x + sq[6] * src[1].x;
sq[1] = src[3].x - src[0].x + sq[7] * src[3].x;
sq[2] = src[0].x;
sq[3] = src[1].y - src[0].y + sq[6] * src[1].y;
sq[4] = src[3].y - src[0].y + sq[7] * src[3].y;
sq[5] = src[0].y;
}
return sq;
};
CV.isContourConvex = function(contour){
var orientation = 0, convex = true,
len = contour.length, i = 0, j = 0,
cur_pt, prev_pt, dxdy0, dydx0, dx0, dy0, dx, dy;
prev_pt = contour[len - 1];
cur_pt = contour[0];
dx0 = cur_pt.x - prev_pt.x;
dy0 = cur_pt.y - prev_pt.y;
for (; i < len; ++ i){
if (++ j === len) {j = 0;}
prev_pt = cur_pt;
cur_pt = contour[j];
dx = cur_pt.x - prev_pt.x;
dy = cur_pt.y - prev_pt.y;
dxdy0 = dx * dy0;
dydx0 = dy * dx0;
orientation |= dydx0 > dxdy0? 1: (dydx0 < dxdy0? 2: 3);
if (3 === orientation){
convex = false;
break;
}
dx0 = dx;
dy0 = dy;
}
return convex;
};
CV.perimeter = function(poly){
var len = poly.length, i = 0, j = len - 1,
p = 0.0, dx, dy;
for (; i < len; j = i ++){
dx = poly[i].x - poly[j].x;
dy = poly[i].y - poly[j].y;
p += Math.sqrt(dx * dx + dy * dy) ;
}
return p;
};
CV.minEdgeLength = function(poly){
var len = poly.length, i = 0, j = len - 1,
min = Infinity, d, dx, dy;
for (; i < len; j = i ++){
dx = poly[i].x - poly[j].x;
dy = poly[i].y - poly[j].y;
d = dx * dx + dy * dy;
if (d < min){
min = d;
}
}
return Math.sqrt(min);
};
CV.countNonZero = function(imageSrc, square){
var src = imageSrc.data, height = square.height, width = square.width,
pos = square.x + (square.y * imageSrc.width),
span = imageSrc.width - width,
nz = 0, i, j;
for (i = 0; i < height; ++ i){
for (j = 0; j < width; ++ j){
if ( 0 !== src[pos ++] ){
++ nz;
}
}
pos += span;
}
return nz;
};
CV.binaryBorder = function(imageSrc, dst){
var src = imageSrc.data, height = imageSrc.height, width = imageSrc.width,
posSrc = 0, posDst = 0, i, j;
for (j = -2; j < width; ++ j){
dst[posDst ++] = 0;
}
for (i = 0; i < height; ++ i){
dst[posDst ++] = 0;
for (j = 0; j < width; ++ j){
dst[posDst ++] = (0 === src[posSrc ++]? 0: 1);
}
dst[posDst ++] = 0;
}
for (j = -2; j < width; ++ j){
dst[posDst ++] = 0;
}
return dst;
};
File diff suppressed because one or more lines are too long
+495
View File
@@ -0,0 +1,495 @@
/*
Copyright (c) 2012 Juan Mellado
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
/*
References:
- "Iterative Pose Estimation using Coplanar Feature Points"
Denis Oberkampf, Daniel F. DeMenthon, Larry S. Davis
http://www.cfar.umd.edu/~daniel/daniel_papersfordownload/CoplanarPts.pdf
*/
var POS = POS || {};
this.POS = POS;
var SVD = this.SVD || require('./svd').SVD;
POS.Posit = function(modelSize, focalLength){
this.objectPoints = this.buildModel(modelSize);
this.focalLength = focalLength;
this.objectVectors = [];
this.objectNormal = [];
this.objectMatrix = [[],[],[]];
this.init();
};
POS.Posit.prototype.buildModel = function(modelSize){
var half = modelSize / 2.0;
return [
[-half, half, 0.0],
[ half, half, 0.0],
[ half, -half, 0.0],
[-half, -half, 0.0] ];
};
POS.Posit.prototype.init = function(){
var np = this.objectPoints.length,
vectors = [], n = [], len = 0.0, row = 2, i;
for (i = 0; i < np; ++ i){
this.objectVectors[i] = [this.objectPoints[i][0] - this.objectPoints[0][0],
this.objectPoints[i][1] - this.objectPoints[0][1],
this.objectPoints[i][2] - this.objectPoints[0][2]];
vectors[i] = [this.objectVectors[i][0],
this.objectVectors[i][1],
this.objectVectors[i][2]];
}
while(0.0 === len){
n[0] = this.objectVectors[1][1] * this.objectVectors[row][2] -
this.objectVectors[1][2] * this.objectVectors[row][1];
n[1] = this.objectVectors[1][2] * this.objectVectors[row][0] -
this.objectVectors[1][0] * this.objectVectors[row][2];
n[2] = this.objectVectors[1][0] * this.objectVectors[row][1] -
this.objectVectors[1][1] * this.objectVectors[row][0];
len = Math.sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]);
++ row;
}
for (i = 0; i < 3; ++ i){
this.objectNormal[i] = n[i] / len;
}
POS.pseudoInverse(vectors, np, this.objectMatrix);
};
POS.Posit.prototype.pose = function(imagePoints){
var posRotation1 = [[],[],[]], posRotation2 = [[],[],[]], posTranslation = [],
rotation1 = [[],[],[]], rotation2 = [[],[],[]], translation1 = [], translation2 = [],
error1, error2, valid1, valid2, i, j;
this.pos(imagePoints, posRotation1, posRotation2, posTranslation);
valid1 = this.isValid(posRotation1, posTranslation);
if (valid1){
error1 = this.iterate(imagePoints, posRotation1, posTranslation, rotation1, translation1);
}else{
error1 = {euclidean: -1.0, pixels: -1, maximum: -1.0};
}
valid2 = this.isValid(posRotation2, posTranslation);
if (valid2){
error2 = this.iterate(imagePoints, posRotation2, posTranslation, rotation2, translation2);
}else{
error2 = {euclidean: -1.0, pixels: -1, maximum: -1.0};
}
for (i = 0; i < 3; ++ i){
for (j = 0; j < 3; ++ j){
if (valid1){
translation1[i] -= rotation1[i][j] * this.objectPoints[0][j];
}
if (valid2){
translation2[i] -= rotation2[i][j] * this.objectPoints[0][j];
}
}
}
return error1.euclidean < error2.euclidean?
new POS.Pose(error1.pixels, rotation1, translation1, error2.pixels, rotation2, translation2):
new POS.Pose(error2.pixels, rotation2, translation2, error1.pixels, rotation1, translation1);
};
POS.Posit.prototype.pos = function(imagePoints, rotation1, rotation2, translation){
var np = this.objectPoints.length, imageVectors = [],
i0 = [], j0 = [], ivec = [], jvec = [], row1 = [], row2 = [], row3 = [],
i0i0, j0j0, i0j0, delta, q, lambda, mu, scale, i, j;
for (i = 0; i < np; ++ i){
imageVectors[i] = [imagePoints[i].x - imagePoints[0].x,
imagePoints[i].y - imagePoints[0].y];
}
//i0 and j0
for (i = 0; i < 3; ++ i){
i0[i] = 0.0;
j0[i] = 0.0;
for (j = 0; j < np; ++ j){
i0[i] += this.objectMatrix[i][j] * imageVectors[j][0];
j0[i] += this.objectMatrix[i][j] * imageVectors[j][1];
}
}
i0i0 = i0[0] * i0[0] + i0[1] * i0[1] + i0[2] * i0[2];
j0j0 = j0[0] * j0[0] + j0[1] * j0[1] + j0[2] * j0[2];
i0j0 = i0[0] * j0[0] + i0[1] * j0[1] + i0[2] * j0[2];
//Lambda and mu
delta = (j0j0 - i0i0) * (j0j0 - i0i0) + 4.0 * (i0j0 * i0j0);
if (j0j0 - i0i0 >= 0.0){
q = (j0j0 - i0i0 + Math.sqrt(delta) ) / 2.0;
}else{
q = (j0j0 - i0i0 - Math.sqrt(delta) ) / 2.0;
}
if (q >= 0.0){
lambda = Math.sqrt(q);
if (0.0 === lambda){
mu = 0.0;
}else{
mu = -i0j0 / lambda;
}
}else{
lambda = Math.sqrt( -(i0j0 * i0j0) / q);
if (0.0 === lambda){
mu = Math.sqrt(i0i0 - j0j0);
}else{
mu = -i0j0 / lambda;
}
}
//First rotation
for (i = 0; i < 3; ++ i){
ivec[i] = i0[i] + lambda * this.objectNormal[i];
jvec[i] = j0[i] + mu * this.objectNormal[i];
}
scale = Math.sqrt(ivec[0] * ivec[0] + ivec[1] * ivec[1] + ivec[2] * ivec[2]);
for (i = 0; i < 3; ++ i){
row1[i] = ivec[i] / scale;
row2[i] = jvec[i] / scale;
}
row3[0] = row1[1] * row2[2] - row1[2] * row2[1];
row3[1] = row1[2] * row2[0] - row1[0] * row2[2];
row3[2] = row1[0] * row2[1] - row1[1] * row2[0];
for (i = 0; i < 3; ++ i){
rotation1[0][i] = row1[i];
rotation1[1][i] = row2[i];
rotation1[2][i] = row3[i];
}
//Second rotation
for (i = 0; i < 3; ++ i){
ivec[i] = i0[i] - lambda * this.objectNormal[i];
jvec[i] = j0[i] - mu * this.objectNormal[i];
}
for (i = 0; i < 3; ++ i){
row1[i] = ivec[i] / scale;
row2[i] = jvec[i] / scale;
}
row3[0] = row1[1] * row2[2] - row1[2] * row2[1];
row3[1] = row1[2] * row2[0] - row1[0] * row2[2];
row3[2] = row1[0] * row2[1] - row1[1] * row2[0];
for (i = 0; i < 3; ++ i){
rotation2[0][i] = row1[i];
rotation2[1][i] = row2[i];
rotation2[2][i] = row3[i];
}
//Translation
translation[0] = imagePoints[0].x / scale;
translation[1] = imagePoints[0].y / scale;
translation[2] = this.focalLength / scale;
};
POS.Posit.prototype.isValid = function(rotation, translation){
var np = this.objectPoints.length, zmin = Infinity, i = 0, zi;
for (; i < np; ++ i){
zi = translation[2] +
(rotation[2][0] * this.objectVectors[i][0] +
rotation[2][1] * this.objectVectors[i][1] +
rotation[2][2] * this.objectVectors[i][2]);
if (zi < zmin){
zmin = zi;
}
}
return zmin >= 0.0;
};
POS.Posit.prototype.iterate = function(imagePoints, posRotation, posTranslation, rotation, translation){
var np = this.objectPoints.length,
oldSopImagePoints = [], sopImagePoints = [],
rotation1 = [[],[],[]], rotation2 = [[],[],[]],
translation1 = [], translation2 = [],
converged = false, iteration = 0,
oldImageDifference, imageDifference, factor,
error, error1, error2, delta, i, j;
for (i = 0; i < np; ++ i){
oldSopImagePoints[i] = {x: imagePoints[i].x,
y: imagePoints[i].y};
}
for (i = 0; i < 3; ++ i){
for (j = 0; j < 3; ++ j){
rotation[i][j] = posRotation[i][j];
}
translation[i] = posTranslation[i];
}
for (i = 0; i < np; ++ i){
factor = 0.0;
for (j = 0; j < 3; ++ j){
factor += this.objectVectors[i][j] * rotation[2][j] / translation[2];
}
sopImagePoints[i] = {x: (1.0 + factor) * imagePoints[i].x,
y: (1.0 + factor) * imagePoints[i].y};
}
imageDifference = 0.0;
for (i = 0; i < np; ++ i){
imageDifference += Math.abs(sopImagePoints[i].x - oldSopImagePoints[i].x);
imageDifference += Math.abs(sopImagePoints[i].y - oldSopImagePoints[i].y);
}
for (i = 0; i < 3; ++ i){
translation1[i] = translation[i] -
(rotation[i][0] * this.objectPoints[0][0] +
rotation[i][1] * this.objectPoints[0][1] +
rotation[i][2] * this.objectPoints[0][2]);
}
error = error1 = this.error(imagePoints, rotation, translation1);
//Convergence
converged = (0.0 === error1.pixels) || (imageDifference < 0.01);
while( iteration ++ < 100 && !converged ){
for (i = 0; i < np; ++ i){
oldSopImagePoints[i].x = sopImagePoints[i].x;
oldSopImagePoints[i].y = sopImagePoints[i].y;
}
this.pos(sopImagePoints, rotation1, rotation2, translation);
for (i = 0; i < 3; ++ i){
translation1[i] = translation[i] -
(rotation1[i][0] * this.objectPoints[0][0] +
rotation1[i][1] * this.objectPoints[0][1] +
rotation1[i][2] * this.objectPoints[0][2]);
translation2[i] = translation[i] -
(rotation2[i][0] * this.objectPoints[0][0] +
rotation2[i][1] * this.objectPoints[0][1] +
rotation2[i][2] * this.objectPoints[0][2]);
}
error1 = this.error(imagePoints, rotation1, translation1);
error2 = this.error(imagePoints, rotation2, translation2);
if ( (error1.euclidean >= 0.0) && (error2.euclidean >= 0.0) ){
if (error2.euclidean < error1.euclidean){
error = error2;
for (i = 0; i < 3; ++ i){
for (j = 0; j < 3; ++ j){
rotation[i][j] = rotation2[i][j];
}
}
}else{
error = error1;
for (i = 0; i < 3; ++ i){
for (j = 0; j < 3; ++ j){
rotation[i][j] = rotation1[i][j];
}
}
}
}
if ( (error1.euclidean < 0.0) && (error2.euclidean >= 0.0) ){
error = error2;
for (i = 0; i < 3; ++ i){
for (j = 0; j < 3; ++ j){
rotation[i][j] = rotation2[i][j];
}
}
}
if ( (error2.euclidean < 0.0) && (error1.euclidean >= 0.0) ){
error = error1;
for (i = 0; i < 3; ++ i){
for (j = 0; j < 3; ++ j){
rotation[i][j] = rotation1[i][j];
}
}
}
for (i = 0; i < np; ++ i){
factor = 0.0;
for (j = 0; j < 3; ++ j){
factor += this.objectVectors[i][j] * rotation[2][j] / translation[2];
}
sopImagePoints[i].x = (1.0 + factor) * imagePoints[i].x;
sopImagePoints[i].y = (1.0 + factor) * imagePoints[i].y;
}
oldImageDifference = imageDifference;
imageDifference = 0.0;
for (i = 0; i < np; ++ i){
imageDifference += Math.abs(sopImagePoints[i].x - oldSopImagePoints[i].x);
imageDifference += Math.abs(sopImagePoints[i].y - oldSopImagePoints[i].y);
}
delta = Math.abs(imageDifference - oldImageDifference);
converged = (0.0 === error.pixels) || (delta < 0.01);
}
return error;
};
POS.Posit.prototype.error = function(imagePoints, rotation, translation){
var np = this.objectPoints.length,
move = [], projection = [], errorvec = [],
euclidean = 0.0, pixels = 0.0, maximum = 0.0,
i, j, k;
if ( !this.isValid(rotation, translation) ){
return {euclidean: -1.0, pixels: -1, maximum: -1.0};
}
for (i = 0; i < np; ++ i){
move[i] = [];
for (j = 0; j < 3; ++ j){
move[i][j] = translation[j];
}
}
for (i = 0; i < np; ++ i){
for (j = 0; j < 3; ++ j){
for (k = 0; k < 3; ++ k){
move[i][j] += rotation[j][k] * this.objectPoints[i][k];
}
}
}
for (i = 0; i < np; ++ i){
projection[i] = [];
for (j = 0; j < 2; ++ j){
projection[i][j] = this.focalLength * move[i][j] / move[i][2];
}
}
for (i = 0; i < np; ++ i){
errorvec[i] = [projection[i][0] - imagePoints[i].x,
projection[i][1] - imagePoints[i].y];
}
for (i = 0; i < np; ++ i){
euclidean += Math.sqrt(errorvec[i][0] * errorvec[i][0] +
errorvec[i][1] * errorvec[i][1]);
pixels += Math.abs( Math.round(projection[i][0]) - Math.round(imagePoints[i].x) ) +
Math.abs( Math.round(projection[i][1]) - Math.round(imagePoints[i].y) );
if (Math.abs(errorvec[i][0]) > maximum){
maximum = Math.abs(errorvec[i][0]);
}
if (Math.abs(errorvec[i][1]) > maximum){
maximum = Math.abs(errorvec[i][1]);
}
}
return {euclidean: euclidean / np, pixels: pixels, maximum: maximum};
};
POS.pseudoInverse = function(a, n, b){
var w = [], v = [[],[],[]], s = [[],[],[]],
wmax = 0.0, cn = 0,
i, j, k;
SVD.svdcmp(a, n, 3, w, v);
for (i = 0; i < 3; ++ i){
if (w[i] > wmax){
wmax = w[i];
}
}
wmax *= 0.01;
for (i = 0; i < 3; ++ i){
if (w[i] < wmax){
w[i] = 0.0;
}
}
for (j = 0; j < 3; ++ j){
if (0.0 === w[j]){
++ cn;
for (k = j; k < 2; ++ k){
for (i = 0; i < n; ++ i){
a[i][k] = a[i][k + 1];
}
for (i = 0; i < 3; ++ i){
v[i][k] = v[i][k + 1];
}
}
}
}
for (j = 0; j < 2; ++ j){
if (0.0 === w[j]){
w[j] = w[j + 1];
}
}
for (i = 0; i < 3; ++ i){
for (j = 0; j < 3 - cn; ++ j){
s[i][j] = v[i][j] / w[j];
}
}
for (i = 0; i < 3; ++ i){
for (j = 0; j < n; ++ j){
b[i][j] = 0.0;
for (k = 0; k < 3 - cn; ++ k){
b[i][j] += s[i][k] * a[j][k];
}
}
}
};
POS.Pose = function(error1, rotation1, translation1, error2, rotation2, translation2){
this.bestError = error1;
this.bestRotation = rotation1;
this.bestTranslation = translation1;
this.alternativeError = error2;
this.alternativeRotation = rotation2;
this.alternativeTranslation = translation2;
};
+284
View File
@@ -0,0 +1,284 @@
/*
Copyright (c) 2012 Juan Mellado
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
/*
References:
- "Numerical Recipes in C - Second Edition"
http://www.nr.com/
*/
var SVD = SVD || {};
this.SVD = SVD;
SVD.svdcmp = function(a, m, n, w, v){
var flag, i, its, j, jj, k, l, nm,
anorm = 0.0, c, f, g = 0.0, h, s, scale = 0.0, x, y, z, rv1 = [];
//Householder reduction to bidiagonal form
for (i = 0; i < n; ++ i){
l = i + 1;
rv1[i] = scale * g;
g = s = scale = 0.0;
if (i < m){
for (k = i; k < m; ++ k){
scale += Math.abs( a[k][i] );
}
if (0.0 !== scale){
for (k = i; k < m; ++ k){
a[k][i] /= scale;
s += a[k][i] * a[k][i];
}
f = a[i][i];
g = -SVD.sign( Math.sqrt(s), f );
h = f * g - s;
a[i][i] = f - g;
for (j = l; j < n; ++ j){
for (s = 0.0, k = i; k < m; ++ k){
s += a[k][i] * a[k][j];
}
f = s / h;
for (k = i; k < m; ++ k){
a[k][j] += f * a[k][i];
}
}
for (k = i; k < m; ++ k){
a[k][i] *= scale;
}
}
}
w[i] = scale * g;
g = s = scale = 0.0;
if ( (i < m) && (i !== n - 1) ){
for (k = l; k < n; ++ k){
scale += Math.abs( a[i][k] );
}
if (0.0 !== scale){
for (k = l; k < n; ++ k){
a[i][k] /= scale;
s += a[i][k] * a[i][k];
}
f = a[i][l];
g = -SVD.sign( Math.sqrt(s), f );
h = f * g - s;
a[i][l] = f - g;
for (k = l; k < n; ++ k){
rv1[k] = a[i][k] / h;
}
for (j = l; j < m; ++ j){
for (s = 0.0, k = l; k < n; ++ k){
s += a[j][k] * a[i][k];
}
for (k = l; k < n; ++ k){
a[j][k] += s * rv1[k];
}
}
for (k = l; k < n; ++ k){
a[i][k] *= scale;
}
}
}
anorm = Math.max(anorm, ( Math.abs( w[i] ) + Math.abs( rv1[i] ) ) );
}
//Acumulation of right-hand transformation
for (i = n - 1; i >= 0; -- i){
if (i < n - 1){
if (0.0 !== g){
for (j = l; j < n; ++ j){
v[j][i] = ( a[i][j] / a[i][l] ) / g;
}
for (j = l; j < n; ++ j){
for (s = 0.0, k = l; k < n; ++ k){
s += a[i][k] * v[k][j];
}
for (k = l; k < n; ++ k){
v[k][j] += s * v[k][i];
}
}
}
for (j = l; j < n; ++ j){
v[i][j] = v[j][i] = 0.0;
}
}
v[i][i] = 1.0;
g = rv1[i];
l = i;
}
//Acumulation of left-hand transformation
for (i = Math.min(n, m) - 1; i >= 0; -- i){
l = i + 1;
g = w[i];
for (j = l; j < n; ++ j){
a[i][j] = 0.0;
}
if (0.0 !== g){
g = 1.0 / g;
for (j = l; j < n; ++ j){
for (s = 0.0, k = l; k < m; ++ k){
s += a[k][i] * a[k][j];
}
f = (s / a[i][i]) * g;
for (k = i; k < m; ++ k){
a[k][j] += f * a[k][i];
}
}
for (j = i; j < m; ++ j){
a[j][i] *= g;
}
}else{
for (j = i; j < m; ++ j){
a[j][i] = 0.0;
}
}
++ a[i][i];
}
//Diagonalization of the bidiagonal form
for (k = n - 1; k >= 0; -- k){
for (its = 1; its <= 30; ++ its){
flag = true;
for (l = k; l >= 0; -- l){
nm = l - 1;
if ( Math.abs( rv1[l] ) + anorm === anorm ){
flag = false;
break;
}
if ( Math.abs( w[nm] ) + anorm === anorm ){
break;
}
}
if (flag){
c = 0.0;
s = 1.0;
for (i = l; i <= k; ++ i){
f = s * rv1[i];
if ( Math.abs(f) + anorm === anorm ){
break;
}
g = w[i];
h = SVD.pythag(f, g);
w[i] = h;
h = 1.0 / h;
c = g * h;
s = -f * h;
for (j = 1; j <= m; ++ j){
y = a[j][nm];
z = a[j][i];
a[j][nm] = y * c + z * s;
a[j][i] = z * c - y * s;
}
}
}
//Convergence
z = w[k];
if (l === k){
if (z < 0.0){
w[k] = -z;
for (j = 0; j < n; ++ j){
v[j][k] = -v[j][k];
}
}
break;
}
if (30 === its){
return false;
}
//Shift from bottom 2-by-2 minor
x = w[l];
nm = k - 1;
y = w[nm];
g = rv1[nm];
h = rv1[k];
f = ( (y - z) * (y + z) + (g - h) * (g + h) ) / (2.0 * h * y);
g = SVD.pythag( f, 1.0 );
f = ( (x - z) * (x + z) + h * ( (y / (f + SVD.sign(g, f) ) ) - h) ) / x;
//Next QR transformation
c = s = 1.0;
for (j = l; j <= nm; ++ j){
i = j + 1;
g = rv1[i];
y = w[i];
h = s * g;
g = c * g;
z = SVD.pythag(f, h);
rv1[j] = z;
c = f / z;
s = h / z;
f = x * c + g * s;
g = g * c - x * s;
h = y * s;
y *= c;
for (jj = 0; jj < n; ++ jj){
x = v[jj][j];
z = v[jj][i];
v[jj][j] = x * c + z * s;
v[jj][i] = z * c - x * s;
}
z = SVD.pythag(f, h);
w[j] = z;
if (0.0 !== z){
z = 1.0 / z;
c = f * z;
s = h * z;
}
f = c * g + s * y;
x = c * y - s * g;
for (jj = 0; jj < m; ++ jj){
y = a[jj][j];
z = a[jj][i];
a[jj][j] = y * c + z * s;
a[jj][i] = z * c - y * s;
}
}
rv1[l] = 0.0;
rv1[k] = f;
w[k] = x;
}
}
return true;
};
SVD.pythag = function(a, b){
var at = Math.abs(a), bt = Math.abs(b), ct;
if (at > bt){
ct = bt / at;
return at * Math.sqrt(1.0 + ct * ct);
}
if (0.0 === bt){
return 0.0;
}
ct = at / bt;
return bt * Math.sqrt(1.0 + ct * ct);
};
SVD.sign = function(a, b){
return b >= 0.0? Math.abs(a): -Math.abs(a);
};
+141
View File
@@ -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`);
});
+66
View File
@@ -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