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:
parent
44517d4930
commit
205967d506
@ -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,
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user