diff --git a/app/virtual.py b/app/virtual.py index 70d78bd..ec221b7 100644 --- a/app/virtual.py +++ b/app/virtual.py @@ -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= 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, } diff --git a/webstore/assets/2025/12/robot-window.webp b/webstore/assets/2025/12/robot-window.webp new file mode 100644 index 0000000..0511bf5 Binary files /dev/null and b/webstore/assets/2025/12/robot-window.webp differ diff --git a/webstore/assets/wowplatter/aircon.svg b/webstore/assets/wowplatter/aircon.svg new file mode 100755 index 0000000..163fdad --- /dev/null +++ b/webstore/assets/wowplatter/aircon.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/webstore/assets/wowplatter/bluequart.svg b/webstore/assets/wowplatter/bluequart.svg new file mode 100755 index 0000000..80cc23d --- /dev/null +++ b/webstore/assets/wowplatter/bluequart.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/webstore/assets/wowplatter/booth.svg b/webstore/assets/wowplatter/booth.svg new file mode 100755 index 0000000..b65782c --- /dev/null +++ b/webstore/assets/wowplatter/booth.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/webstore/assets/wowplatter/door2.svg b/webstore/assets/wowplatter/door2.svg new file mode 100755 index 0000000..f588d77 --- /dev/null +++ b/webstore/assets/wowplatter/door2.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/webstore/assets/wowplatter/purpquart.svg b/webstore/assets/wowplatter/purpquart.svg new file mode 100755 index 0000000..275af3b --- /dev/null +++ b/webstore/assets/wowplatter/purpquart.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/webstore/assets/wowplatter/rooflong-purp.svg b/webstore/assets/wowplatter/rooflong-purp.svg new file mode 100755 index 0000000..f5fef16 --- /dev/null +++ b/webstore/assets/wowplatter/rooflong-purp.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/webstore/assets/wowplatter/rooflong.svg b/webstore/assets/wowplatter/rooflong.svg new file mode 100755 index 0000000..c0ac841 --- /dev/null +++ b/webstore/assets/wowplatter/rooflong.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/webstore/assets/wowplatter/webp/banksia.webp b/webstore/assets/wowplatter/webp/banksia.webp new file mode 100755 index 0000000..0952029 Binary files /dev/null and b/webstore/assets/wowplatter/webp/banksia.webp differ diff --git a/webstore/assets/wowplatter/webp/boothsticker.webp b/webstore/assets/wowplatter/webp/boothsticker.webp new file mode 100755 index 0000000..268ca10 Binary files /dev/null and b/webstore/assets/wowplatter/webp/boothsticker.webp differ diff --git a/webstore/assets/wowplatter/webp/curtains.webp b/webstore/assets/wowplatter/webp/curtains.webp new file mode 100755 index 0000000..c51494f Binary files /dev/null and b/webstore/assets/wowplatter/webp/curtains.webp differ diff --git a/webstore/assets/wowplatter/webp/euc.webp b/webstore/assets/wowplatter/webp/euc.webp new file mode 100755 index 0000000..5f27a83 Binary files /dev/null and b/webstore/assets/wowplatter/webp/euc.webp differ diff --git a/webstore/assets/wowplatter/webp/foam.webp b/webstore/assets/wowplatter/webp/foam.webp new file mode 100755 index 0000000..d2a67dc Binary files /dev/null and b/webstore/assets/wowplatter/webp/foam.webp differ diff --git a/webstore/assets/wowplatter/webp/gallaxx.webp b/webstore/assets/wowplatter/webp/gallaxx.webp new file mode 100755 index 0000000..628cb17 Binary files /dev/null and b/webstore/assets/wowplatter/webp/gallaxx.webp differ diff --git a/webstore/assets/wowplatter/webp/godzillaa.webp b/webstore/assets/wowplatter/webp/godzillaa.webp new file mode 100755 index 0000000..5fc3aa7 Binary files /dev/null and b/webstore/assets/wowplatter/webp/godzillaa.webp differ diff --git a/webstore/assets/wowplatter/webp/kangpaww.webp b/webstore/assets/wowplatter/webp/kangpaww.webp new file mode 100755 index 0000000..1e14875 Binary files /dev/null and b/webstore/assets/wowplatter/webp/kangpaww.webp differ diff --git a/webstore/assets/wowplatter/webp/logo-blue.webp b/webstore/assets/wowplatter/webp/logo-blue.webp new file mode 100755 index 0000000..a7a4f1f Binary files /dev/null and b/webstore/assets/wowplatter/webp/logo-blue.webp differ diff --git a/webstore/assets/wowplatter/webp/logo-mrp.webp b/webstore/assets/wowplatter/webp/logo-mrp.webp new file mode 100755 index 0000000..267729b Binary files /dev/null and b/webstore/assets/wowplatter/webp/logo-mrp.webp differ diff --git a/webstore/assets/wowplatter/webp/mecha.webp b/webstore/assets/wowplatter/webp/mecha.webp new file mode 100755 index 0000000..78db8a6 Binary files /dev/null and b/webstore/assets/wowplatter/webp/mecha.webp differ diff --git a/webstore/assets/wowplatter/webp/mons-stat.webp b/webstore/assets/wowplatter/webp/mons-stat.webp new file mode 100755 index 0000000..87191b3 Binary files /dev/null and b/webstore/assets/wowplatter/webp/mons-stat.webp differ diff --git a/webstore/assets/wowplatter/webp/purple.webp b/webstore/assets/wowplatter/webp/purple.webp new file mode 100755 index 0000000..ca19693 Binary files /dev/null and b/webstore/assets/wowplatter/webp/purple.webp differ diff --git a/webstore/assets/wowplatter/webp/purplestrip.webp b/webstore/assets/wowplatter/webp/purplestrip.webp new file mode 100755 index 0000000..b8ddaea Binary files /dev/null and b/webstore/assets/wowplatter/webp/purplestrip.webp differ diff --git a/webstore/assets/wowplatter/webp/redd.webp b/webstore/assets/wowplatter/webp/redd.webp new file mode 100755 index 0000000..b25460d Binary files /dev/null and b/webstore/assets/wowplatter/webp/redd.webp differ diff --git a/webstore/assets/wowplatter/webp/robo-stat.webp b/webstore/assets/wowplatter/webp/robo-stat.webp new file mode 100755 index 0000000..ff64b80 Binary files /dev/null and b/webstore/assets/wowplatter/webp/robo-stat.webp differ diff --git a/webstore/assets/wowplatter/webp/rugg.webp b/webstore/assets/wowplatter/webp/rugg.webp new file mode 100755 index 0000000..8bcfd46 Binary files /dev/null and b/webstore/assets/wowplatter/webp/rugg.webp differ diff --git a/webstore/assets/wowplatter/webp/sparky-3.webp b/webstore/assets/wowplatter/webp/sparky-3.webp new file mode 100755 index 0000000..b161604 Binary files /dev/null and b/webstore/assets/wowplatter/webp/sparky-3.webp differ diff --git a/webstore/assets/wowplatter/webp/tents.webp b/webstore/assets/wowplatter/webp/tents.webp new file mode 100755 index 0000000..9e213ba Binary files /dev/null and b/webstore/assets/wowplatter/webp/tents.webp differ diff --git a/webstore/assets/wowplatter/webp/tyle.webp b/webstore/assets/wowplatter/webp/tyle.webp new file mode 100755 index 0000000..cbb6b55 Binary files /dev/null and b/webstore/assets/wowplatter/webp/tyle.webp differ diff --git a/webstore/assets/wowplatter/webp/walkbot.webp b/webstore/assets/wowplatter/webp/walkbot.webp new file mode 100755 index 0000000..ed09528 Binary files /dev/null and b/webstore/assets/wowplatter/webp/walkbot.webp differ diff --git a/webstore/index.html b/webstore/index.html index bfbe2d0..58a7ee9 100644 --- a/webstore/index.html +++ b/webstore/index.html @@ -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 = `${(data.racks||[]).length} racks · ${(data.crates||[]).length} crates · ${(data.records||[]).length} covers`; -}).catch(e => { stat.textContent = 'scene load failed: ' + e; }); + stat.innerHTML = `${sp.name || 'store'} · ${(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); }