feat(3d): crate-type + rack logo decals on object faces; verified bin slope

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>
This commit is contained in:
type-two 2026-06-27 12:33:30 +10:00
parent 44517d4930
commit 205967d506
2 changed files with 63 additions and 13 deletions

View File

@ -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,
}

View File

@ -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);
});