From 205967d5065e3bd340d1f881769824fc30cb3e1c Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 27 Jun 2026 12:33:30 +1000 Subject: [PATCH] feat(3d): crate-type + rack logo decals on object faces; verified bin slope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/virtual.py | 12 +++++++++ webstore/index.html | 64 ++++++++++++++++++++++++++++++++++++--------- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/app/virtual.py b/app/virtual.py index ec221b7..78f98c3 100644 --- a/app/virtual.py +++ b/app/virtual.py @@ -58,6 +58,17 @@ async def scene(space: int | None = None, db=Depends(get_db)): 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 @@ -74,6 +85,7 @@ async def scene(space: int | None = None, db=Depends(get_db)): "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, } diff --git a/webstore/index.html b/webstore/index.html index 8b50003..064831e 100644 --- a/webstore/index.html +++ b/webstore/index.html @@ -123,7 +123,44 @@ function slopedSide(x, ch, cd, hf, th, m) { geom.setIndex([0, 1, 2, 2, 1, 3]); geom.computeVertexNormals(); return new THREE.Mesh(geom, m); } -function buildCrate(t, front) { +// a decal's mesh (image plane or canvas-text plane) — shared by wall, crate-logo + rack decals. +function makeDecalMesh(dec) { + const w = num(dec.width, 0.2), h = num(dec.height, 0.2), op = num(dec.opacity, 1); + let mat; + if (dec.content_type === 'text' && dec.text_value) { + const { tx } = textTexture(dec.text_value, { color: dec.text_color || '#fff', bg: dec.background_color || null }); + mat = new THREE.MeshBasicMaterial({ map: tx, transparent: true, opacity: op, side: THREE.DoubleSide }); + } else if (dec.content_type === 'image' && (dec.image_url || dec.asset_url)) { + mat = new THREE.MeshBasicMaterial({ transparent: true, opacity: op, side: THREE.DoubleSide }); + texLoader.load(dec.image_url || dec.asset_url, tx => { tx.colorSpace = THREE.SRGBColorSpace; mat.map = tx; mat.needsUpdate = true; }, undefined, () => {}); + } else return null; // mesh / empty + return new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat); +} + +// place a decal on an OBJECT face (crate/rack) — WowPlatter snapToCrate/RackFaceAndOrient. +// preferred_face picks the face; face_offset_x/y (fallback pos_x/y) shift it along that face; +// the plane sits just proud of the face (eps) and yaws to face outward. parentYaw flips the +// texture for racks rotated against east/west walls so the logo stays readable. +function snapToFace(m, dec, cw, ch, cd, parentYaw) { + const face = String(dec.preferred_face || '').trim().toLowerCase(); + if (!face) return false; + const ox = num(dec.face_offset_x, num(dec.pos_x)), oy = num(dec.face_offset_y, num(dec.pos_y)); + const eps = 0.002; let x = 0, y = 0, z = 0, yaw = 0, pitch = 0; + if (face === 'front') { z = cd / 2 + eps; yaw = 0; x += ox; y += oy; } + else if (face === 'back') { z = -cd / 2 - eps; yaw = Math.PI; x += ox; y += oy; } + else if (face === 'left') { x = -cw / 2 - eps; yaw = Math.PI / 2; z += ox; y += oy; } + else if (face === 'right') { x = cw / 2 + eps; yaw = -Math.PI / 2; z += ox; y += oy; m.scale.x = -Math.abs(m.scale.x || 1); } + else if (face === 'top') { y = ch / 2 + eps; pitch = Math.PI / 2; x += ox; z += oy; } + else if (face === 'bottom') { y = -ch / 2 - eps; pitch = -Math.PI / 2; x += ox; z += oy; } + else return false; + if (parentYaw != null) { + const ny = ((parentYaw % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); + if ((Math.abs(ny - 3 * Math.PI / 2) < 0.01 && face === 'right') || (Math.abs(ny - Math.PI / 2) < 0.01 && face === 'left')) yaw += Math.PI; + } + m.position.set(x, y, z); m.rotation.set(pitch, yaw, 0); return true; +} + +function buildCrate(t, front, typeDecals) { const g = new THREE.Group(); const w = num(t.width, 0.34), h = num(t.height, 0.2), d = num(t.depth, 0.53), th = num(t.wall_thickness, 0.01); const hf = (t.front_height != null && +t.front_height > 0) ? +t.front_height : null; @@ -141,6 +178,8 @@ function buildCrate(t, front) { const cov = new THREE.Mesh(new THREE.PlaneGeometry(s, s), pm); cov.position.set(0, hf ? (-h / 2 + hf * 0.55) : 0, 0); cov.rotation.x = -Math.PI / 2.3; g.add(cov); } + // crate_type logos — applied to every crate of this type, on the named face (front logo, etc.) + (typeDecals || []).forEach(dec => { const dm = makeDecalMesh(dec); if (dm && snapToFace(dm, dec, w, h, d)) g.add(dm); }); return g; } @@ -194,6 +233,12 @@ function buildScene(data) { // • 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 decalsByCrateType = {}, decalsByRack = {}; // logos: crate_type → every crate of that type; rack → that rack + (data.object_decals || []).forEach(dec => { + const ot = String(dec.object_type || '').toLowerCase(); + if (ot === 'crate_type') (decalsByCrateType[dec.object_id] ||= []).push(dec); + else if (ot === 'rack') (decalsByRack[dec.object_id] ||= []).push(dec); + }); 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); }); @@ -217,7 +262,7 @@ function buildScene(data) { } 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]); + const g = buildCrate(t, frontByCrate[cr.id], decalsByCrateType[cr.crate_type_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 }; @@ -248,13 +293,14 @@ function buildScene(data) { 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)); + (decalsByRack[rk.id] || []).forEach(dec => { const dm = makeDecalMesh(dec); if (dm && snapToFace(dm, dec, w, h, dp, yaw)) rackGroup.add(dm); }); }); // 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]); + const g = buildCrate(t, frontByCrate[cr.id], decalsByCrateType[cr.crate_type_id]); 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 }; @@ -300,16 +346,8 @@ function buildScene(data) { m.position.set(x, y, z); m.rotation.set(pitch, yaw, 0); } (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); + const m = makeDecalMesh(d); // mesh / empty decals → null, skipped + if (!m) return; placeDecal(m, d); roomGroup.add(m); });