Backend (virtual.py) now also returns object_decals: crate_type decals (apply to every crate of that type) + rack/crate decals for this space (was space-only). Frontend: makeDecalMesh() shared by wall/ crate/rack decals; snapToFace() ports WowPlatter snapToCrate/RackFaceAndOrient (preferred_face + face_offset → front/back/left/right/top/bottom, eps proud of the face, yaw outward, right-face mirror, rack east/west readability flip). buildCrate(t,front,typeDecals) stamps crate_type logos on the front; rack loop stamps rack decals using the rack yaw. Booth now shows 5 crate-type + 4 rack logos. SLOPE CONFIRMED CORRECT: the custom column is front_height (back=height, front=front_height); slopedSide already builds the trapezoid right (top edge slopes back→front). Space-decal loop refactored onto makeDecalMesh (DRY). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
124 lines
6.1 KiB
Python
124 lines
6.1 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"))
|
|
|
|
# logos on crates/racks — crate_type decals apply to EVERY crate of that type; rack/crate decals
|
|
# to the named object. Placed on the parent's preferred_face (front/back/left/right/top/bottom).
|
|
obj_decals = await _all(db, "virtual_decal", """
|
|
WHERE visible <> 'n' AND (
|
|
object_type='crate_type'
|
|
OR (object_type='rack' AND object_id IN (SELECT id FROM virtual_rack WHERE space_id=:s AND visible='y'))
|
|
OR (object_type='crate' AND object_id IN (SELECT id FROM virtual_crate WHERE space_id=:s AND visible='y'))
|
|
)""", p)
|
|
for d in obj_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,
|
|
"object_decals": obj_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)]},
|
|
}
|