RECORDGOD/app/virtual.py
type-two 8c25b65d7e 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>
2026-06-21 21:00:02 +10:00

112 lines
5.4 KiB
Python

from fastapi import APIRouter, Depends
from sqlalchemy import text
from .db import get_db
from .mirror import enrich as mirror_enrich
# The virtual store API — PUBLIC read (it's the customer-facing storefront). Replaces
# 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/"
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()]
def _attach(recs, meta):
for r in recs:
m = meta.get(r["release_id"], {})
r["title"], r["artist"], r["thumb"] = m.get("title"), m.get("artist"), m.get("thumb")
return recs
@router.get("/scene")
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 = (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 (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' 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' AND space_id = :s", p),
"crate_types": await _all(db, "virtual_crate_type"),
"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,
}
@router.get("/crate/{crate_id}/records")
async def crate_records(crate_id: int, db=Depends(get_db)):
"""Full contents of one crate — loaded when the shopper starts digging it."""
recs = [dict(r) for r in (await db.execute(text("""
SELECT release_id, slot_number, sku, price, condition
FROM inventory WHERE crate_id = :c AND in_stock AND release_id IS NOT NULL
ORDER BY slot_number NULLS LAST"""), {"c": crate_id})).mappings()]
recs = _attach(recs, await mirror_enrich({r["release_id"] for r in recs}))
return {"ok": True, "crate_id": crate_id, "records": recs}
@router.get("/locate")
async def locate(release_id: int, db=Depends(get_db)):
"""Locate-stock: where in the physical store is this record? Returns the crate + position
so the UI can fly the camera there and flash the bin."""
row = (await db.execute(text("""
SELECT i.crate_id, i.slot_number, i.sku,
c.name, c.label_text, c.pos_x, c.pos_y, c.pos_z
FROM inventory i LEFT JOIN virtual_crate c ON c.id = i.crate_id
WHERE i.release_id = :r AND i.in_stock AND i.crate_id IS NOT NULL
ORDER BY i.slot_number NULLS LAST LIMIT 1"""), {"r": release_id})).mappings().first()
if not row:
return {"ok": True, "located": False, "release_id": release_id}
d = dict(row)
return {
"ok": True, "located": True, "release_id": release_id,
"crate_id": d["crate_id"], "slot_number": d["slot_number"], "sku": d["sku"],
"crate": {"name": d["name"], "label_text": d["label_text"],
"pos": [float(d["pos_x"] or 0), float(d["pos_y"] or 0), float(d["pos_z"] or 0)]},
}