fix(store): WowPlatter coordinate convention in webstore renderer

- racks/portals/camera corner-relative (worldX = -W/2 + x); attach_wall snap + clamp
- crates parent to their rack_level (slot-grid or local pos offset), facing composed on rack
- free crates / decals / lights stay centered
- fixes the corner pile-up; booth-room overhead render verified

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-22 19:57:17 +10:00
parent fb533a7701
commit 0767778931
2 changed files with 74 additions and 15 deletions

View File

@ -50,6 +50,11 @@ Hierarchy: **Space → Racks(positioned) → Levels(`rack_level_index`) → Crat
Genre/style: `virtual_rack.genre_ids`/`style_ids` are comma-sep id lists; crate-level tags via a tag table
(WowPlatter `wowplatter_get_crate_tags`). Confirm the migrated tag table name in P2.
**Props vs stock (important, every phase):** `virtual_rack_type.rack_purpose``stock` (real record racks)
or `prop` (3D scenery — COLUMNs, AIRCON, STATION, DOOR, couch, trolley…). The functional navigator must
**filter `rack_purpose <> 'prop'`** everywhere (store-layout already does). `type` ∈ rack/bench/shelf/'' is the
geometry kind, NOT the stock/prop distinction — don't filter on `type`.
---
## 3. The geometry, decoded (the tricky x/y/z)

View File

@ -36,6 +36,9 @@ import { createDig } from './dig.js';
const num = (v, d = 0) => (v == null || isNaN(+v) ? d : +v);
const rad = THREE.MathUtils.degToRad;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
// WowPlatter facing convention (utils.js directionYaw): front/forward=0, back=π, left=−π/2, right=+π/2
const dirYaw = d => { d = String(d || '').toLowerCase(); return d === 'back' ? Math.PI : d === 'left' ? -Math.PI / 2 : d === 'right' ? Math.PI / 2 : 0; };
const stat = document.getElementById('stat');
const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('c'), antialias: true });
@ -185,25 +188,75 @@ function buildScene(data) {
});
if (!(data.lights || []).length) { const d = new THREE.DirectionalLight(0xffffff, 0.7); d.position.set(3, 6, 2); roomGroup.add(d); }
// racks — proper shelving (bench / rack / cupboard) from rack_type + its levels
// racks + crates — WowPlatter coordinate convention (racks.js / crates.js):
// • racks: pos_x/pos_z are CORNER-relative (0..room) → centered scene space; facing = rack.direction + rotation_y; attach_wall snaps to a wall
// • rack crates: CHILD of the rack, dropped into their rack_level's slot grid (cell 0.34m, row-major); facing = crate.direction + rotation_y (composes on the rack's)
// • free crates (no rack): pos_x/pos_z are CENTERED, like decals/lights
const rackTypes = Object.fromEntries((data.rack_types || []).map(t => [t.id, t]));
const crateTypes = Object.fromEntries((data.crate_types || []).map(t => [t.id, t]));
const levelsByType = {}; (data.rack_type_levels || []).forEach(l => { (levelsByType[l.rack_type_id] ||= []).push(l); });
const frontByCrate = Object.fromEntries((data.records || []).filter(r => r.thumb).map(r => [r.crate_id, r]));
const cratesByRack = {}; (data.crates || []).forEach(c => { if (c.rack_id != null) (cratesByRack[c.rack_id] ||= []).push(c); });
function placeCrateOnRack(rackGroup, levels, rackW, rackD, rackTopY, cr) {
const t = crateTypes[cr.crate_type_id] || {};
const cw = num(t.width, 0.34), ch = num(t.height, 0.2), cd = num(t.depth, 0.53);
const lvl = levels.find(l => cr.rack_type_level_id != null && +l.id === +cr.rack_type_level_id)
|| levels.find(l => cr.rack_level_index != null && +l.level_index === +cr.rack_level_index)
|| levels[0] || null;
const levelW = lvl ? (num(lvl.width, 0) || rackW) : rackW;
const levelD = lvl ? (num(lvl.depth, 0) || rackD) : rackD;
const levelY = lvl ? num(lvl.y_pos, 0) : rackTopY; // shelf-surface height above floor
let lx, lz;
if (cr.slot_number != null && +cr.slot_number > 0) { // slot → grid cell
const cell = cw > 0.1 ? cw : 0.34;
const cols = Math.max(1, Math.floor(levelW / cell)), rows = Math.max(1, Math.round(levelD / cell));
const csx = levelW / cols, csz = levelD / rows, si = +cr.slot_number - 1;
lx = -levelW / 2 + csx / 2 + (si % cols) * csx;
lz = -levelD / 2 + csz / 2 + Math.floor(si / cols) * csz;
} else { lx = num(cr.pos_x); lz = num(cr.pos_z); }
lx = clamp(lx, -(levelW / 2 - cw / 2), levelW / 2 - cw / 2);
lz = clamp(lz, -(levelD / 2 - cd / 2), levelD / 2 - cd / 2);
const g = buildCrate(t, frontByCrate[cr.id]);
g.position.set(lx, levelY + ch / 2, lz);
g.rotation.set(rad(num(cr.rotation_x)), dirYaw(cr.direction) + rad(num(cr.rotation_y)), rad(num(cr.rotation_z)));
g.userData = { crateId: cr.id, name: cr.label_text || cr.name };
rackGroup.add(g); crateMeshes.push(g);
}
(data.racks || []).forEach(rk => {
const t = rackTypes[rk.rack_type_id] || {};
const g = buildRack(t, levelsByType[rk.rack_type_id] || []);
g.position.set(num(rk.pos_x), num(rk.pos_y, num(t.height, 0.9) / 2), num(rk.pos_z));
g.rotation.set(rad(num(rk.rotation_x)), rad(num(rk.rotation_y)), rad(num(rk.rotation_z)));
roomGroup.add(g);
const w = num(rk.width, num(t.width, 1)), h = num(rk.height, num(t.height, 0.9)), dp = num(rk.depth, num(t.depth, 0.5));
const halfX = w / 2, halfZ = dp / 2, rpx = num(rk.pos_x), rpz = num(rk.pos_z);
let yaw = dirYaw(rk.direction) + rad(num(rk.rotation_y));
const attach = String(rk.attach_wall || '').toLowerCase();
let cx, cz;
if (attach === 'north') { yaw = rad(num(rk.rotation_y)); cz = -D / 2 + halfZ + Math.max(0, rpz); cx = -W / 2 + halfX + rpx; }
else if (attach === 'south') { yaw = Math.PI + rad(num(rk.rotation_y)); cz = D / 2 - halfZ - Math.max(0, rpz); cx = -W / 2 + halfX + rpx; }
else if (attach === 'east') { yaw = -Math.PI / 2 + rad(num(rk.rotation_y)); cx = W / 2 - halfZ - Math.max(0, rpz); cz = -D / 2 + halfX + rpx; }
else if (attach === 'west') { yaw = Math.PI / 2 + rad(num(rk.rotation_y)); cx = -W / 2 + halfZ + Math.max(0, rpz); cz = -D / 2 + halfX + rpx; }
else { cx = -W / 2 + rpx + halfX * Math.cos(yaw) - halfZ * Math.sin(yaw); cz = -D / 2 + rpz + halfX * Math.sin(yaw) + halfZ * Math.cos(yaw); }
const wallish = attach === 'east' || attach === 'west';
cx = clamp(cx, -W / 2 + (wallish ? halfZ : halfX), W / 2 - (wallish ? halfZ : halfX));
cz = clamp(cz, -D / 2 + (wallish ? halfX : halfZ), D / 2 - (wallish ? halfX : halfZ));
const rackGroup = new THREE.Group();
rackGroup.position.set(cx, num(rk.pos_y, 0), cz);
rackGroup.rotation.set(rad(num(rk.rotation_x)), yaw, rad(num(rk.rotation_z)));
const levels = levelsByType[rk.rack_type_id] || [];
const geo = buildRack({ ...t, width: w, height: h, depth: dp }, levels);
geo.position.y = h / 2; rackGroup.add(geo); // bottom on the floor
roomGroup.add(rackGroup);
(cratesByRack[rk.id] || []).forEach(cr => placeCrateOnRack(rackGroup, levels, w, dp, h, cr));
});
// crates — open record bins (floor + short front + sloped sides) with the front cover laid in
const crateTypes = Object.fromEntries((data.crate_types || []).map(t => [t.id, t]));
const frontByCrate = Object.fromEntries((data.records || []).filter(r => r.thumb).map(r => [r.crate_id, r]));
(data.crates || []).forEach(cr => {
// free-standing crates (no rack) — centered coords like decals/lights
(data.crates || []).filter(c => c.rack_id == null).forEach(cr => {
const t = crateTypes[cr.crate_type_id] || {};
const cw = num(t.width, 0.34), cd = num(t.depth, 0.53);
const g = buildCrate(t, frontByCrate[cr.id]);
g.position.set(num(cr.pos_x), num(cr.pos_y, num(t.height, 0.2) / 2), num(cr.pos_z));
g.rotation.set(rad(num(cr.rotation_x)), rad(num(cr.rotation_y)), rad(num(cr.rotation_z)));
g.position.set(clamp(num(cr.pos_x), -W / 2 + cw / 2, W / 2 - cw / 2), num(cr.pos_y, num(t.height, 0.2) / 2), clamp(num(cr.pos_z), -D / 2 + cd / 2, D / 2 - cd / 2));
g.rotation.set(rad(num(cr.rotation_x)), dirYaw(cr.direction) + rad(num(cr.rotation_y)), rad(num(cr.rotation_z)));
g.userData = { crateId: cr.id, name: cr.label_text || cr.name };
roomGroup.add(g); crateMeshes.push(g);
});
@ -232,14 +285,15 @@ function buildScene(data) {
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));
const wx = -W / 2 + num(p.x), wz = -D / 2 + num(p.z); // portal x/z are corner-relative
m.position.set(wx, h / 2, wz);
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 });
if (p.to_space) portals.push({ x: wx, z: wz, 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));
// spawn at the configured camera position (corner-relative) if present
if (sp.camera_pos_x != null) camera.position.set(-W / 2 + num(sp.camera_pos_x), num(sp.camera_pos_y, 1.6), -D / 2 + num(sp.camera_pos_z));
stat.innerHTML = `<b>${sp.name || 'store'}</b> · ${(data.racks||[]).length} racks · ${(data.crates||[]).length} crates · ${(data.records||[]).length} covers`;
}