fix(3d): decals snap to their preferred_wall (ported WowPlatter snapToWallAndOrient)

The store renderer placed every decal at raw pos_x/y/z with raw rotation, ignoring preferred_wall/snap/
object_type — so wall art (pos with one in-plane axis, perpendicular=0, rotation 0) floated in mid-room on
the wrong axis. Ported WowPlatter public/js/virtual/decals.js snapToWallAndOrient: a space decal (rotation
all-zero or snap=wall) keeps its in-plane axis, the perpendicular axis is pushed flat to its named wall
(north z=-D/2 yaw0 · south z=D/2 yawpi · east x=W/2 yaw-pi/2 · west x=-W/2 yaw+pi/2 · floor/ceiling lie
flat), yawed to face into the room; non-zero rotation = free manual placement, respected. Wall conventions
already matched the room's walls. Fixes booth (31 decals) + entry-hall (6) automatically. Verified the snap
math against real data (all land on-axis, in-bounds, facing in). Still TODO: crate_type(5)/rack(4) logo
decals (backend only fetches object_type=space; need the face-snap port) + hiphop room has no decals authored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-27 12:23:18 +10:00
parent d489e8807d
commit 44517d4930

View File

@ -262,6 +262,43 @@ function buildScene(data) {
});
// decals — wall art / signage as textured planes (image) or canvas text. mesh decals skipped.
// Placement is wall-RELATIVE (ported from WowPlatter public/js/virtual/decals.js snapToWallAndOrient):
// a space decal keeps its in-plane axis, the perpendicular axis is pushed flat to its preferred_wall,
// and the plane is yawed to face into the room (floor/ceiling lie flat). Data has pos with one axis 0
// + all-zero rotation = "snap me to the wall"; a non-zero rotation or snap≠wall = free manual placement.
const wallOf = p => { p = String(p || '').trim().toLowerCase();
if (p === 'north' || p === 'front') return 'north';
if (p === 'south' || p === 'back') return 'south';
if (p === 'east' || p === 'right') return 'east';
if (p === 'west' || p === 'left') return 'west';
if (p === 'floor' || p === 'ceiling') return p;
return null; };
function placeDecal(m, d) {
const rx = num(d.rotation_x), ry = num(d.rotation_y), rz = num(d.rotation_z);
const isSpace = String(d.object_type || 'space').toLowerCase() === 'space';
const snapMode = String(d.snap || '').toLowerCase();
if (!(isSpace && (snapMode === 'wall' || (rx === 0 && ry === 0 && rz === 0)))) {
m.position.set(num(d.pos_x), num(d.pos_y, 1.5), num(d.pos_z));
m.rotation.set(rad(rx), rad(ry), rad(rz)); return;
}
const eps = 0.001;
let x = num(d.pos_x), y = num(d.pos_y, H * 0.5), z = num(d.pos_z);
y = Math.max(Math.min(y, H - 0.05), 0.05);
let wall = wallOf(d.preferred_wall || d.wall);
if (!wall) { // no wall set → nearest one
const a = [['north', Math.abs(z + D / 2)], ['south', Math.abs(z - D / 2)],
['east', Math.abs(x - W / 2)], ['west', Math.abs(x + W / 2)]];
a.sort((p, q) => p[1] - q[1]); wall = a[0][0];
}
let yaw = 0, pitch = 0;
if (wall === 'north') { z = -D / 2 + eps; yaw = 0; }
else if (wall === 'south') { z = D / 2 - eps; yaw = Math.PI; }
else if (wall === 'east') { x = W / 2 - eps; yaw = -Math.PI / 2; }
else if (wall === 'west') { x = -W / 2 + eps; yaw = Math.PI / 2; }
else if (wall === 'floor') { y = 0.02; pitch = -Math.PI / 2; }
else if (wall === 'ceiling') { y = H - eps; pitch = Math.PI / 2; }
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;
@ -273,8 +310,7 @@ function buildScene(data) {
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);
m.position.set(num(d.pos_x), num(d.pos_y, 1.5), num(d.pos_z));
m.rotation.set(rad(num(d.rotation_x)), rad(num(d.rotation_y)), rad(num(d.rotation_z)));
placeDecal(m, d);
roomGroup.add(m);
});