feat(virtual): finish the 3D store port — per-room scoping, decals, portals

Scene was dumping all 3 rooms' racks/crates/lights into one room, and the
served decals + portals were never rendered (trapped in booth-room).

- /virtual/scene scopes to ONE room (?space=, default = is_default space);
  filters racks/crates/lights/cameras/portals/decals/covers by space_id
- render decals (image + canvas-text planes) and portal doorways; walk into
  a portal -> loads the linked room
- portal links_to_id is the PAIRED portal's id, not a space — resolve through
  it to the destination room (to_space)
- copy the 29 referenced decal webp/svg into webstore/assets (self-contained,
  served /store/assets); rewrite WP /wp-content/uploads/ paths in the scene API
- skip the 17MB logo .glb mesh decal (flat logo-blue.webp covers branding)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-21 21:00:02 +10:00
parent a304476645
commit 8c25b65d7e
31 changed files with 581 additions and 28 deletions

View File

@ -8,9 +8,18 @@ from .mirror import enrich as mirror_enrich
# WowPlatter's payload.php: same scene shape, but from Postgres instead of a full WP bootstrap.
router = APIRouter(prefix="/virtual", tags=["virtual-store"])
# Decal/logo assets were copied out of WP's uploads into webstore/assets (served at /store/assets)
# so the store is self-contained — no live-WP dependency. Rewrite the stored WP paths on the way out.
_WP_PREFIX = "/wp-content/uploads/"
_ASSET_BASE = "/store/assets/"
async def _all(db, table, where=""):
rows = await db.execute(text(f"SELECT * FROM {table} {where}"))
def _asset(u):
return _ASSET_BASE + u[len(_WP_PREFIX):] if isinstance(u, str) and u.startswith(_WP_PREFIX) else u
async def _all(db, table, where="", params=None):
rows = await db.execute(text(f"SELECT * FROM {table} {where}"), params or {})
return [dict(r) for r in rows.mappings()]
@ -22,31 +31,50 @@ def _attach(recs, meta):
@router.get("/scene")
async def scene(db=Depends(get_db)):
"""Everything the renderer needs at boot. Records are SLIM — one front cover per crate;
the full per-crate list is lazy-loaded on dig via /virtual/crate/{id}/records."""
async def scene(space: int | None = None, db=Depends(get_db)):
"""Everything the renderer needs to draw ONE room. Pass ?space=<id> to load a linked room
(the renderer calls it when you walk through a portal); default = the marked-default space.
Records are SLIM one front cover per crate; full per-crate list lazy-loads via /crate/{id}/records."""
spaces = await _all(db, "virtual_space", "WHERE visible='y'")
active = spaces[0] if len(spaces) == 1 else next(
active = (next((s for s in spaces if s["id"] == space), None) if space else None) or next(
(s for s in spaces if s.get("is_default") == "y"), spaces[0] if spaces else None)
if not active:
return {"space": None, "spaces": spaces, "racks": [], "crates": [], "records": []}
sid = active["id"]
if active.get("logo_texture"):
active["logo_texture"] = _asset(active["logo_texture"])
p = {"s": sid}
# one front cover per crate, restricted to crates in THIS room
recs = [dict(r) for r in (await db.execute(text("""
SELECT DISTINCT ON (crate_id) crate_id, slot_number, release_id, sku, price, condition
FROM inventory
WHERE in_stock AND crate_id IS NOT NULL AND release_id IS NOT NULL
ORDER BY crate_id, slot_number NULLS LAST"""))).mappings()]
SELECT DISTINCT ON (i.crate_id) i.crate_id, i.slot_number, i.release_id, i.sku, i.price, i.condition
FROM inventory i JOIN virtual_crate c ON c.id = i.crate_id
WHERE i.in_stock AND i.release_id IS NOT NULL AND c.space_id = :s
ORDER BY i.crate_id, i.slot_number NULLS LAST"""), p)).mappings()]
recs = _attach(recs, await mirror_enrich({r["release_id"] for r in recs}))
decals = await _all(db, "virtual_decal",
"WHERE visible <> 'n' AND object_type='space' AND object_id = :s", p)
for d in decals:
d["image_url"], d["asset_url"] = _asset(d.get("image_url")), _asset(d.get("asset_url"))
# links_to_id points at the PAIRED portal, not a space — resolve to the room it lives in.
portals = [dict(r) for r in (await db.execute(text("""
SELECT p.*, t.space_id AS to_space
FROM virtual_portal p LEFT JOIN virtual_portal t ON t.id = p.links_to_id
WHERE p.space_id = :s AND p.enabled = 'y'"""), p)).mappings()]
return {
"space": active, "spaces": spaces,
"racks": await _all(db, "virtual_rack", "WHERE visible='y'"),
"racks": await _all(db, "virtual_rack", "WHERE visible='y' AND space_id = :s", p),
"rack_types": await _all(db, "virtual_rack_type"),
"rack_type_levels": await _all(db, "virtual_rack_type_level"),
"crates": await _all(db, "virtual_crate", "WHERE visible='y'"),
"crates": await _all(db, "virtual_crate", "WHERE visible='y' AND space_id = :s", p),
"crate_types": await _all(db, "virtual_crate_type"),
"cameras": await _all(db, "virtual_camera"),
"lights": await _all(db, "virtual_light"),
"decals": await _all(db, "virtual_decal", "WHERE visible <> 'n'"),
"portals": await _all(db, "virtual_portal"),
"cameras": await _all(db, "virtual_camera", "WHERE space_id = :s", p),
"lights": await _all(db, "virtual_light", "WHERE space_id = :s", p),
"decals": decals,
"portals": portals,
"records": recs,
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -0,0 +1,40 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1140 350"
width="1140" height="350"
stroke="#222" fill="none" stroke-linecap="round" stroke-linejoin="round">
<!-- OUTER BODY -->
<rect x="0" y="0" width="1140" height="350" stroke-width="6" rx="20" ry="20"/>
<!-- VENT LINES (bottom area) -->
<g stroke-width="4">
<line x1="40" y1="260" x2="1100" y2="260"/>
<line x1="40" y1="280" x2="1100" y2="280"/>
<line x1="40" y1="300" x2="1100" y2="300"/>
<line x1="40" y1="320" x2="1100" y2="320"/>
</g>
<!-- DISPLAY PANEL -->
<rect x="940" y="40" width="160" height="100" stroke-width="4" rx="10" ry="10"/>
<!-- LED 7-segment "24" -->
<g stroke-width="5" stroke="#0ff">
<!-- Digit 2 -->
<!-- segments: top, upper-right, middle, lower-left, bottom -->
<line x1="960" y1="60" x2="1000" y2="60"/> <!-- top -->
<line x1="1000" y1="60" x2="1000" y2="95"/> <!-- upper right -->
<line x1="960" y1="95" x2="1000" y2="95"/> <!-- middle -->
<line x1="960" y1="95" x2="960" y2="130"/> <!-- lower left -->
<line x1="960" y1="130" x2="1000" y2="130"/> <!-- bottom -->
<!-- Digit 4 -->
<!-- segments: upper-left, upper-right, middle, lower-right -->
<line x1="1020" y1="60" x2="1020" y2="95"/> <!-- upper left -->
<line x1="1060" y1="60" x2="1060" y2="95"/> <!-- upper right -->
<line x1="1020" y1="95" x2="1060" y2="95"/> <!-- middle -->
<line x1="1060" y1="95" x2="1060" y2="130"/> <!-- lower right -->
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,82 @@
<svg xmlns="http://www.w3.org/2000/svg"
width="2400" height="4800"
viewBox="0 0 2400 4800">
<defs>
<!-- Vertical-ridge pattern for one 1200x1200 tile -->
<pattern id="ridge-vertical" patternUnits="userSpaceOnUse" width="1200" height="1200">
<line x1="100" y1="0" x2="100" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="200" y1="0" x2="200" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="300" y1="0" x2="300" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="400" y1="0" x2="400" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="500" y1="0" x2="500" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="600" y1="0" x2="600" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="700" y1="0" x2="700" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="800" y1="0" x2="800" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="900" y1="0" x2="900" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="1000" y1="0" x2="1000" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="1100" y1="0" x2="1100" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
</pattern>
<!-- Horizontal-ridge pattern for one 1200x1200 tile -->
<pattern id="ridge-horizontal" patternUnits="userSpaceOnUse" width="1200" height="1200">
<line x1="0" y1="100" x2="1200" y2="100" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="200" x2="1200" y2="200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="300" x2="1200" y2="300" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="400" x2="1200" y2="400" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="500" x2="1200" y2="500" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="600" x2="1200" y2="600" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="700" x2="1200" y2="700" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="800" x2="1200" y2="800" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="900" x2="1200" y2="900" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="1000" x2="1200" y2="1000" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="1100" x2="1200" y2="1100" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
</pattern>
<!-- Background checkerboard pattern for alternating blue/black -->
<pattern id="checker-bg" patternUnits="userSpaceOnUse" width="2400" height="2400">
<rect x="0" y="0" width="1200" height="1200" fill="blue"/>
<rect x="1200" y="0" width="1200" height="1200" fill="black"/>
<rect x="0" y="1200" width="1200" height="1200" fill="black"/>
<rect x="1200" y="1200" width="1200" height="1200" fill="blue"/>
</pattern>
</defs>
<!-- 1. Outer border (2400 wide x 4800 high) - Dimensions updated for 2x4 -->
<rect x="0" y="0" width="2400" height="4800" stroke="#000" fill="none" stroke-width="8"/>
<!-- 2. Background Checkerboard Fill - Height updated -->
<rect x="0" y="0" width="2400" height="4800" fill="url(#checker-bg)"/>
<!-- 3. Overlay Ridge Patterns (4 Rows x 2 Columns) - R4 through R7 removed -->
<g fill-opacity="0.5">
<!-- R0: V H (y=0) -->
<rect x="0" y="0" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="1200" y="0" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<!-- R1: H V (y=1200) -->
<rect x="0" y="1200" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="1200" y="1200" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<!-- R2: V H (y=2400) -->
<rect x="0" y="2400" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="1200" y="2400" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<!-- R3: H V (y=3600) -->
<rect x="0" y="3600" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="1200" y="3600" width="1200" height="1200" fill="url(#ridge-vertical)"/>
</g>
<!-- 4. Grid lines - Adjusted to stop at 4800, only 3 horizontal lines remain -->
<g stroke="#000" fill="none" stroke-width="8">
<!-- Vertical grid line at X=1200 -->
<line x1="1200" y1="0" x2="1200" y2="4800"/>
<!-- Horizontal grid lines -->
<line x1="0" y1="1200" x2="2400" y2="1200"/>
<line x1="0" y1="2400" x2="2400" y2="2400"/>
<line x1="0" y1="3600" x2="2400" y2="3600"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1860 790"
width="930" height="395"
stroke="#000" fill="none" stroke-linejoin="miter" stroke-linecap="square">
<!-- OUTER FRAME (3cm = 30 units thick) -->
<rect x="0" y="0" width="1860" height="790" stroke-width="30"/>
<!-- FRONT OPENING (inner frame) -->
<rect x="30" y="30" width="1800" height="730" stroke-width="6"/>
<!-- DEPTH (20cm = 200 units) -->
<!-- FRONT CORNERS -->
<!-- FL(30,30) FR(1830,30) BL(30,760) BR(1830,760) -->
<!-- BACK CORNERS -->
<!-- FL'(230,230) FR'(1630,230) BL'(230,560) BR'(1630,560) -->
<!-- Connecting lines (front → back) -->
<line x1="30" y1="30" x2="230" y2="230" stroke-width="4"/>
<line x1="1830" y1="30" x2="1630" y2="230" stroke-width="4"/>
<line x1="30" y1="760" x2="230" y2="560" stroke-width="4"/>
<line x1="1830" y1="760" x2="1630" y2="560" stroke-width="4"/>
<!-- Back rectangle -->
<polygon points="230,230 1630,230 1630,560 230,560" stroke-width="4"/>
</svg>

After

Width:  |  Height:  |  Size: 1012 B

View File

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 820 2200"
width="410" height="1100"
stroke="#000" fill="none">
<!-- Outer frame (thin 1cm-ish) -->
<rect x="10" y="10" width="800" height="2180" stroke-width="8"/>
<!-- Door (fills the frame except tiny gap like real door clearance) -->
<rect x="20" y="20" width="780" height="2160" stroke-width="4"/>
<!-- Door knob (center-right, about 1100mm high) -->
<circle cx="740" cy="1100" r="20" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 493 B

View File

@ -0,0 +1,82 @@
<svg xmlns="http://www.w3.org/2000/svg"
width="2400" height="4800"
viewBox="0 0 2400 4800">
<defs>
<!-- Vertical-ridge pattern for one 1200x1200 tile -->
<pattern id="ridge-vertical" patternUnits="userSpaceOnUse" width="1200" height="1200">
<line x1="100" y1="0" x2="100" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="200" y1="0" x2="200" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="300" y1="0" x2="300" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="400" y1="0" x2="400" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="500" y1="0" x2="500" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="600" y1="0" x2="600" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="700" y1="0" x2="700" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="800" y1="0" x2="800" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="900" y1="0" x2="900" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="1000" y1="0" x2="1000" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="1100" y1="0" x2="1100" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
</pattern>
<!-- Horizontal-ridge pattern for one 1200x1200 tile -->
<pattern id="ridge-horizontal" patternUnits="userSpaceOnUse" width="1200" height="1200">
<line x1="0" y1="100" x2="1200" y2="100" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="200" x2="1200" y2="200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="300" x2="1200" y2="300" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="400" x2="1200" y2="400" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="500" x2="1200" y2="500" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="600" x2="1200" y2="600" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="700" x2="1200" y2="700" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="800" x2="1200" y2="800" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="900" x2="1200" y2="900" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="1000" x2="1200" y2="1000" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="1100" x2="1200" y2="1100" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
</pattern>
<!-- Background checkerboard pattern for alternating purple/black -->
<pattern id="checker-bg" patternUnits="userSpaceOnUse" width="2400" height="2400">
<rect x="0" y="0" width="1200" height="1200" fill="purple"/>
<rect x="1200" y="0" width="1200" height="1200" fill="black"/>
<rect x="0" y="1200" width="1200" height="1200" fill="black"/>
<rect x="1200" y="1200" width="1200" height="1200" fill="purple"/>
</pattern>
</defs>
<!-- 1. Outer border (2400 wide x 4800 high) - Dimensions updated for 2x4 -->
<rect x="0" y="0" width="2400" height="4800" stroke="#000" fill="none" stroke-width="8"/>
<!-- 2. Background Checkerboard Fill - Height updated -->
<rect x="0" y="0" width="2400" height="4800" fill="url(#checker-bg)"/>
<!-- 3. Overlay Ridge Patterns (4 Rows x 2 Columns) - R4 through R7 removed -->
<g fill-opacity="0.5">
<!-- R0: V H (y=0) -->
<rect x="0" y="0" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="1200" y="0" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<!-- R1: H V (y=1200) -->
<rect x="0" y="1200" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="1200" y="1200" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<!-- R2: V H (y=2400) -->
<rect x="0" y="2400" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="1200" y="2400" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<!-- R3: H V (y=3600) -->
<rect x="0" y="3600" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="1200" y="3600" width="1200" height="1200" fill="url(#ridge-vertical)"/>
</g>
<!-- 4. Grid lines - Adjusted to stop at 4800, only 3 horizontal lines remain -->
<g stroke="#000" fill="none" stroke-width="8">
<!-- Vertical grid line at X=1200 -->
<line x1="1200" y1="0" x2="1200" y2="4800"/>
<!-- Horizontal grid lines -->
<line x1="0" y1="1200" x2="2400" y2="1200"/>
<line x1="0" y1="2400" x2="2400" y2="2400"/>
<line x1="0" y1="3600" x2="2400" y2="3600"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@ -0,0 +1,104 @@
<svg xmlns="http://www.w3.org/2000/svg"
width="9600" height="4800"
viewBox="0 0 9600 4800">
<defs>
<!-- Vertical-ridge pattern for one 1200x1200 tile -->
<pattern id="ridge-vertical" patternUnits="userSpaceOnUse" width="1200" height="1200">
<line x1="100" y1="0" x2="100" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="200" y1="0" x2="200" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="300" y1="0" x2="300" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="400" y1="0" x2="400" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="500" y1="0" x2="500" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="600" y1="0" x2="600" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="700" y1="0" x2="700" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="800" y1="0" x2="800" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="900" y1="0" x2="900" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="1000" y1="0" x2="1000" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="1100" y1="0" x2="1100" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
</pattern>
<!-- Horizontal-ridge pattern for one 1200x1200 tile -->
<pattern id="ridge-horizontal" patternUnits="userSpaceOnUse" width="1200" height="1200">
<line x1="0" y1="100" x2="1200" y2="100" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="200" x2="1200" y2="200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="300" x2="1200" y2="300" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="400" x2="1200" y2="400" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="500" x2="1200" y2="500" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="600" x2="1200" y2="600" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="700" x2="1200" y2="700" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="800" x2="1200" y2="800" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="900" x2="1200" y2="900" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="1000" x2="1200" y2="1000" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="1100" x2="1200" y2="1100" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
</pattern>
<!-- Background checkerboard pattern with purple/black -->
<pattern id="checker-bg" patternUnits="userSpaceOnUse" width="2400" height="2400">
<rect x="0" y="0" width="1200" height="1200" fill="purple"/>
<rect x="1200" y="0" width="1200" height="1200" fill="black"/>
<rect x="0" y="1200" width="1200" height="1200" fill="black"/>
<rect x="1200" y="1200" width="1200" height="1200" fill="purple"/>
</pattern>
</defs>
<!-- outer border -->
<rect x="0" y="0" width="9600" height="4800" stroke="#000" fill="none" stroke-width="8"/>
<!-- background checkerboard -->
<rect x="0" y="0" width="9600" height="4800" fill="url(#checker-bg)"/>
<!-- overlay ridge patterns on top of checkerboard -->
<g>
<rect x="0" y="0" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="1200" y="0" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="2400" y="0" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="3600" y="0" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="4800" y="0" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="6000" y="0" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="7200" y="0" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="8400" y="0" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="0" y="1200" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="1200" y="1200" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="2400" y="1200" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="3600" y="1200" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="4800" y="1200" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="6000" y="1200" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="7200" y="1200" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="8400" y="1200" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="0" y="2400" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="1200" y="2400" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="2400" y="2400" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="3600" y="2400" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="4800" y="2400" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="6000" y="2400" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="7200" y="2400" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="8400" y="2400" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="0" y="3600" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="1200" y="3600" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="2400" y="3600" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="3600" y="3600" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="4800" y="3600" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="6000" y="3600" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="7200" y="3600" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="8400" y="3600" width="1200" height="1200" fill="url(#ridge-vertical)"/>
</g>
<!-- grid lines -->
<g stroke="#000" fill="none" stroke-width="8">
<line x1="1200" y1="0" x2="1200" y2="4800"/>
<line x1="2400" y1="0" x2="2400" y2="4800"/>
<line x1="3600" y1="0" x2="3600" y2="4800"/>
<line x1="4800" y1="0" x2="4800" y2="4800"/>
<line x1="6000" y1="0" x2="6000" y2="4800"/>
<line x1="7200" y1="0" x2="7200" y2="4800"/>
<line x1="8400" y1="0" x2="8400" y2="4800"/>
<line x1="0" y1="1200" x2="9600" y2="1200"/>
<line x1="0" y1="2400" x2="9600" y2="2400"/>
<line x1="0" y1="3600" x2="9600" y2="3600"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -0,0 +1,104 @@
<svg xmlns="http://www.w3.org/2000/svg"
width="9600" height="4800"
viewBox="0 0 9600 4800">
<defs>
<!-- Vertical-ridge pattern for one 1200x1200 tile -->
<pattern id="ridge-vertical" patternUnits="userSpaceOnUse" width="1200" height="1200">
<line x1="100" y1="0" x2="100" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="200" y1="0" x2="200" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="300" y1="0" x2="300" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="400" y1="0" x2="400" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="500" y1="0" x2="500" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="600" y1="0" x2="600" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="700" y1="0" x2="700" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="800" y1="0" x2="800" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="900" y1="0" x2="900" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="1000" y1="0" x2="1000" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="1100" y1="0" x2="1100" y2="1200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
</pattern>
<!-- Horizontal-ridge pattern for one 1200x1200 tile -->
<pattern id="ridge-horizontal" patternUnits="userSpaceOnUse" width="1200" height="1200">
<line x1="0" y1="100" x2="1200" y2="100" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="200" x2="1200" y2="200" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="300" x2="1200" y2="300" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="400" x2="1200" y2="400" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="500" x2="1200" y2="500" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="600" x2="1200" y2="600" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="700" x2="1200" y2="700" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="800" x2="1200" y2="800" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="900" x2="1200" y2="900" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="1000" x2="1200" y2="1000" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
<line x1="0" y1="1100" x2="1200" y2="1100" stroke="#000" stroke-width="8" stroke-linecap="butt"/>
</pattern>
<!-- Background checkerboard pattern for alternating blue/black -->
<pattern id="checker-bg" patternUnits="userSpaceOnUse" width="2400" height="2400">
<rect x="0" y="0" width="1200" height="1200" fill="blue"/>
<rect x="1200" y="0" width="1200" height="1200" fill="black"/>
<rect x="0" y="1200" width="1200" height="1200" fill="black"/>
<rect x="1200" y="1200" width="1200" height="1200" fill="blue"/>
</pattern>
</defs>
<!-- outer border -->
<rect x="0" y="0" width="9600" height="4800" stroke="#000" fill="none" stroke-width="8"/>
<!-- background checkerboard -->
<rect x="0" y="0" width="9600" height="4800" fill="url(#checker-bg)"/>
<!-- overlay ridge patterns on top of checkerboard -->
<g>
<rect x="0" y="0" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="1200" y="0" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="2400" y="0" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="3600" y="0" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="4800" y="0" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="6000" y="0" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="7200" y="0" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="8400" y="0" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="0" y="1200" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="1200" y="1200" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="2400" y="1200" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="3600" y="1200" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="4800" y="1200" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="6000" y="1200" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="7200" y="1200" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="8400" y="1200" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="0" y="2400" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="1200" y="2400" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="2400" y="2400" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="3600" y="2400" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="4800" y="2400" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="6000" y="2400" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="7200" y="2400" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="8400" y="2400" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="0" y="3600" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="1200" y="3600" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="2400" y="3600" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="3600" y="3600" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="4800" y="3600" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="6000" y="3600" width="1200" height="1200" fill="url(#ridge-vertical)"/>
<rect x="7200" y="3600" width="1200" height="1200" fill="url(#ridge-horizontal)"/>
<rect x="8400" y="3600" width="1200" height="1200" fill="url(#ridge-vertical)"/>
</g>
<!-- grid lines -->
<g stroke="#000" fill="none" stroke-width="8">
<line x1="1200" y1="0" x2="1200" y2="4800"/>
<line x1="2400" y1="0" x2="2400" y2="4800"/>
<line x1="3600" y1="0" x2="3600" y2="4800"/>
<line x1="4800" y1="0" x2="4800" y2="4800"/>
<line x1="6000" y1="0" x2="6000" y2="4800"/>
<line x1="7200" y1="0" x2="7200" y2="4800"/>
<line x1="8400" y1="0" x2="8400" y2="4800"/>
<line x1="0" y1="1200" x2="9600" y2="1200"/>
<line x1="0" y1="2400" x2="9600" y2="2400"/>
<line x1="0" y1="3600" x2="9600" y2="3600"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -64,7 +64,9 @@ addEventListener('keyup', e => keys[e.code] = false);
const vel = new THREE.Vector3();
// --- build the scene from /virtual/scene ---
const crateMeshes = [];
// One room at a time. roomGroup holds everything space-scoped so a portal walk-through clears
// it and rebuilds the linked room. crateMeshes/portals reset per load.
let crateMeshes = [], portals = [], roomGroup = null, switching = false;
const texLoader = new THREE.TextureLoader();
texLoader.crossOrigin = 'anonymous';
@ -72,30 +74,49 @@ function material(color, fallback) {
return new THREE.MeshStandardMaterial({ color: new THREE.Color(color || fallback), roughness: 0.85 });
}
fetch('/virtual/scene').then(r => r.json()).then(data => {
// text → CanvasTexture (shared by text decals + portal labels)
function textTexture(text, { color = '#fff', bg = null, font = 64, pad = 24 } = {}) {
const lines = String(text).split('\n');
const cv = document.createElement('canvas'); const ctx = cv.getContext('2d');
ctx.font = `600 ${font}px system-ui, sans-serif`;
const w = Math.max(...lines.map(l => ctx.measureText(l).width)) + pad * 2;
const lh = font * 1.25;
cv.width = Math.ceil(w); cv.height = Math.ceil(lines.length * lh + pad * 2);
ctx.font = `600 ${font}px system-ui, sans-serif`; ctx.textBaseline = 'top';
if (bg) { ctx.fillStyle = bg; ctx.fillRect(0, 0, cv.width, cv.height); }
ctx.fillStyle = color;
lines.forEach((l, i) => ctx.fillText(l, pad, pad + i * lh));
const tx = new THREE.CanvasTexture(cv); tx.colorSpace = THREE.SRGBColorSpace;
return { tx, aspect: cv.width / cv.height };
}
function buildScene(data) {
if (roomGroup) scene.remove(roomGroup);
roomGroup = new THREE.Group(); scene.add(roomGroup);
crateMeshes = []; portals = [];
const sp = data.space || {};
const W = num(sp.room_width, 12), D = num(sp.room_depth, 12), H = num(sp.ceiling_height, 3.2);
// room
const floor = new THREE.Mesh(new THREE.PlaneGeometry(W, D), material(sp.floor_color, '#2a2a30'));
floor.rotation.x = -Math.PI / 2; scene.add(floor);
floor.rotation.x = -Math.PI / 2; roomGroup.add(floor);
const ceil = new THREE.Mesh(new THREE.PlaneGeometry(W, D), material(sp.ceiling_color, '#1a1a1f'));
ceil.rotation.x = Math.PI / 2; ceil.position.y = H; scene.add(ceil);
ceil.rotation.x = Math.PI / 2; ceil.position.y = H; roomGroup.add(ceil);
const wallMat = material(sp.wall_color, '#3a3a42');
const walls = [[0, -D / 2, 0], [0, D / 2, Math.PI], [-W / 2, 0, Math.PI / 2], [W / 2, 0, -Math.PI / 2]];
walls.forEach(([x, z, ry], i) => {
const w = (i < 2) ? W : D;
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, H), wallMat.clone());
m.position.set(x, H / 2, z); m.rotation.y = ry; scene.add(m);
m.position.set(x, H / 2, z); m.rotation.y = ry; roomGroup.add(m);
});
// lights (data-driven + a soft ambient so it's never pitch black)
scene.add(new THREE.AmbientLight(0xffffff, num(sp.ambient_light_intensity, 0.6)));
roomGroup.add(new THREE.AmbientLight(0xffffff, num(sp.ambient_light_intensity, 0.6)));
(data.lights || []).forEach(L => {
const pl = new THREE.PointLight(new THREE.Color(L.color || '#ffffff'), num(L.intensity, 0.8), num(L.distance, 0) || 0);
pl.position.set(num(L.pos_x), num(L.pos_y, H - 0.3), num(L.pos_z)); scene.add(pl);
pl.position.set(num(L.pos_x), num(L.pos_y, H - 0.3), num(L.pos_z)); roomGroup.add(pl);
});
if (!(data.lights || []).length) { const d = new THREE.DirectionalLight(0xffffff, 0.7); d.position.set(3, 6, 2); scene.add(d); }
if (!(data.lights || []).length) { const d = new THREE.DirectionalLight(0xffffff, 0.7); d.position.set(3, 6, 2); roomGroup.add(d); }
// racks (box per rack, sized by its rack_type)
const rackTypes = Object.fromEntries((data.rack_types || []).map(t => [t.id, t]));
@ -105,7 +126,7 @@ fetch('/virtual/scene').then(r => r.json()).then(data => {
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, dp), material(t.material_color, '#5a4634'));
m.position.set(num(rk.pos_x), num(rk.pos_y, h / 2), num(rk.pos_z));
m.rotation.set(rad(num(rk.rotation_x)), rad(num(rk.rotation_y)), rad(num(rk.rotation_z)));
scene.add(m);
roomGroup.add(m);
});
// crates (box per crate, sized by crate_type) + front-cover texture
@ -126,13 +147,52 @@ fetch('/virtual/scene').then(r => r.json()).then(data => {
m.position.set(num(cr.pos_x), num(cr.pos_y, h / 2), num(cr.pos_z));
m.rotation.set(rad(num(cr.rotation_x)), rad(num(cr.rotation_y)), rad(num(cr.rotation_z)));
m.userData = { crateId: cr.id, name: cr.label_text || cr.name };
scene.add(m); crateMeshes.push(m);
roomGroup.add(m); crateMeshes.push(m);
});
// decals — wall art / signage as textured planes (image) or canvas text. mesh decals skipped.
(data.decals || []).forEach(d => {
const w = num(d.width, 1), h = num(d.height, 1), op = num(d.opacity, 1);
let mat;
if (d.content_type === 'text' && d.text_value) {
const { tx } = textTexture(d.text_value, { color: d.text_color || '#fff', bg: d.background_color || null });
mat = new THREE.MeshBasicMaterial({ map: tx, transparent: true, opacity: op, side: THREE.DoubleSide });
} else if (d.content_type === 'image' && (d.image_url || d.asset_url)) {
mat = new THREE.MeshBasicMaterial({ transparent: true, opacity: op, side: THREE.DoubleSide });
texLoader.load(d.image_url || d.asset_url, tx => { tx.colorSpace = THREE.SRGBColorSpace; mat.map = tx; mat.needsUpdate = true; }, undefined, () => {});
} else { return; } // mesh / empty — skipped (ponytail: 17MB logo .glb left out; webp art covers the look)
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat);
m.position.set(num(d.pos_x), num(d.pos_y, 1.5), num(d.pos_z));
m.rotation.set(rad(num(d.rotation_x)), rad(num(d.rotation_y)), rad(num(d.rotation_z)));
roomGroup.add(m);
});
// portals — doorways to linked rooms. A translucent labelled curtain you walk through.
(data.portals || []).forEach(p => {
if (!p.links_to_id) return;
const w = num(p.width, 1.6), h = 2.2;
const { tx } = textTexture(p.label || 'door', { color: '#fff', bg: '#ff5db1', font: 48 });
const mat = new THREE.MeshBasicMaterial({ map: tx, transparent: true, opacity: 0.82, side: THREE.DoubleSide });
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat);
m.position.set(num(p.x), h / 2, num(p.z));
m.rotation.y = rad(num(p.facing_deg));
roomGroup.add(m);
if (p.to_space) portals.push({ x: num(p.x), z: num(p.z), to: p.to_space, label: p.label });
});
// spawn at the configured camera position if present
if (sp.camera_pos_x != null) camera.position.set(num(sp.camera_pos_x), num(sp.camera_pos_y, 1.6), num(sp.camera_pos_z, 3));
stat.innerHTML = `<b>${(data.racks||[]).length}</b> racks · <b>${(data.crates||[]).length}</b> crates · <b>${(data.records||[]).length}</b> covers`;
}).catch(e => { stat.textContent = 'scene load failed: ' + e; });
stat.innerHTML = `<b>${sp.name || 'store'}</b> · ${(data.racks||[]).length} racks · ${(data.crates||[]).length} crates · ${(data.records||[]).length} covers`;
}
function loadSpace(id) {
switching = true;
const q = id ? `?space=${id}` : '';
return fetch('/virtual/scene' + q).then(r => r.json())
.then(data => { buildScene(data); switching = false; })
.catch(e => { stat.textContent = 'scene load failed: ' + e; switching = false; });
}
loadSpace();
// --- dig: click a crate (raycast from screen centre) ---
const ray = new THREE.Raycaster();
@ -148,6 +208,7 @@ renderer.domElement.addEventListener('click', () => {
// --- render loop ---
const clock = new THREE.Clock();
let portalArmed = false; // must be clear of every portal before one can fire (no bounce-loop if you spawn on a doorway)
function tick() {
requestAnimationFrame(tick);
const dt = Math.min(clock.getDelta(), 0.1);
@ -160,6 +221,15 @@ function tick() {
controls.moveRight(vel.x * speed);
controls.moveForward(-vel.z * speed);
}
// walk into a portal → load the linked room (must have stepped clear of all portals first)
if (!switching) {
let near = false;
for (const p of portals) {
const dx = camera.position.x - p.x, dz = camera.position.z - p.z;
if (dx * dx + dz * dz < 0.9 * 0.9) { near = true; if (portalArmed) { portalArmed = false; loadSpace(p.to); } break; }
}
if (!near) portalArmed = true;
}
}
renderer.render(scene, camera);
}