From 0767778931895e515e4e5251425e80d4e7a8507a Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 22 Jun 2026 19:57:17 +1000 Subject: [PATCH] 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 --- RECORDGOD_NAVIGATOR_PLAN.md | 5 +++ webstore/index.html | 84 ++++++++++++++++++++++++++++++------- 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/RECORDGOD_NAVIGATOR_PLAN.md b/RECORDGOD_NAVIGATOR_PLAN.md index 131cc43..8196dea 100644 --- a/RECORDGOD_NAVIGATOR_PLAN.md +++ b/RECORDGOD_NAVIGATOR_PLAN.md @@ -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) diff --git a/webstore/index.html b/webstore/index.html index 2da9f28..3aca9b9 100644 --- a/webstore/index.html +++ b/webstore/index.html @@ -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 = `${sp.name || 'store'} · ${(data.racks||[]).length} racks · ${(data.crates||[]).length} crates · ${(data.records||[]).length} covers`; }