← back to Animals
yolo: 3D dog park: use dog's actual photo as billboard texture
d1144d1a6a69a4ef35b7d31bc7e5a7800a4874d5 · 2026-05-08 13:43:10 -0700 · animal-yolo
Task: yolo.dog-park-dog-photo
Prompt: In ~/Projects/animals: in public/dogpark/dogpark.js, when an owner_pets photo URL is provided in the join message, build a Three.js Sprite from a CanvasTexture that loads that photo + the dog's name u
Files touched
M agents/animal-yolo/state.jsonM public/dogpark/dogpark.jsM src/server/dogpark.js
Diff
commit d1144d1a6a69a4ef35b7d31bc7e5a7800a4874d5
Author: animal-yolo <steve@designerwallcoverings.com>
Date: Fri May 8 13:43:10 2026 -0700
yolo: 3D dog park: use dog's actual photo as billboard texture
Task: yolo.dog-park-dog-photo
Prompt: In ~/Projects/animals: in public/dogpark/dogpark.js, when an owner_pets photo URL is provided in the join message, build a Three.js Sprite from a CanvasTexture that loads that photo + the dog's name u
---
agents/animal-yolo/state.json | 4 +--
public/dogpark/dogpark.js | 82 +++++++++++++++++++++++++++++++++++++++++--
src/server/dogpark.js | 15 ++++++--
3 files changed, 95 insertions(+), 6 deletions(-)
diff --git a/agents/animal-yolo/state.json b/agents/animal-yolo/state.json
index f225282..a2e732c 100644
--- a/agents/animal-yolo/state.json
+++ b/agents/animal-yolo/state.json
@@ -1,5 +1,5 @@
{
- "cursor": 4,
- "runs": 4,
+ "cursor": 5,
+ "runs": 5,
"started_at": "2026-05-08T07:01:22.386Z"
}
\ No newline at end of file
diff --git a/public/dogpark/dogpark.js b/public/dogpark/dogpark.js
index f1f8f9b..8d6c3fb 100644
--- a/public/dogpark/dogpark.js
+++ b/public/dogpark/dogpark.js
@@ -348,7 +348,12 @@ let ws = null;
const others = new Map(); // id → { ownerGroup, dogGroup, label, lastUpdate }
const ballMeshes = new Map();
-const params = { ownerName:'Owner', dogName:'Buddy', dogColor:'#c97a3e', dogAvatarUrl: null, ownerAvatarUrl: null };
+const params = { ownerName:'Owner', dogName:'Buddy', dogColor:'#c97a3e', dogAvatarUrl: null, ownerAvatarUrl: null, petPhotoUrl: null };
+
+// Captured from owner_pets.hero_image_url when the prefill picks (or the user
+// switches between) a logged-in user's pets. Sent in the WS join message so
+// other clients can render this dog's name tag with the real pet photo.
+let _selectedPetPhotoUrl = null;
// Pre-fill the join modal from /api/dog-park/me when the user is logged in.
// Anonymous callers get { pets: [] } and the manual form is left untouched.
@@ -391,6 +396,7 @@ const params = { ownerName:'Owner', dogName:'Buddy', dogColor:'#c97a3e', dogAvat
tag.textContent = `${p.name} (your pet)`;
banner.classList.add('show');
}
+ _selectedPetPhotoUrl = p.hero_image_url || null;
}
// Track that the user typed something so we don't clobber it on chip switch.
@@ -452,6 +458,7 @@ document.getElementById('joinBtn').addEventListener('click', () => {
params.dogColor = document.getElementById('dogColor').value;
params.dogAvatarUrl = localStorage.getItem('dogpark_avatar_url') || null;
params.ownerAvatarUrl = localStorage.getItem('dogpark_owner_avatar_url') || null;
+ params.petPhotoUrl = _selectedPetPhotoUrl;
document.getElementById('join').style.display = 'none';
document.getElementById('hud').style.display = 'block';
document.getElementById('counter').style.display = 'block';
@@ -463,7 +470,7 @@ function connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/dog-park/ws`);
ws.addEventListener('open', () => {
- ws.send(JSON.stringify({ t:'join', name: params.ownerName, dogName: params.dogName, dogColor: params.dogColor, dogAvatarUrl: params.dogAvatarUrl, ownerAvatarUrl: params.ownerAvatarUrl }));
+ ws.send(JSON.stringify({ t:'join', name: params.ownerName, dogName: params.dogName, dogColor: params.dogColor, dogAvatarUrl: params.dogAvatarUrl, ownerAvatarUrl: params.ownerAvatarUrl, petPhotoUrl: params.petPhotoUrl }));
addLog(`Joined park as ${params.ownerName} (dog: ${params.dogName})${params.dogAvatarUrl ? ' [custom avatar]' : ''}`);
});
ws.addEventListener('message', (e) => {
@@ -579,6 +586,63 @@ function _makeDogLegacy(name, colorHex, avatarUrl) {
return g;
}
+// Pet name tag — a portrait card (photo above, name below) drawn into a
+// CanvasTexture and rendered as a Sprite that floats above the dog group.
+// Photo loads asynchronously via Image; the canvas is re-uploaded once it
+// arrives. Cross-origin photos that don't send CORS headers will silently
+// keep the placeholder square — the name still renders. Returned sprite is
+// pre-positioned at y=2.6 (clears procedural-rig labels at ~y=1.3).
+function makePetNameTag(name, photoUrl) {
+ const W = 256, H = 320, PHOTO = 240, PAD = 8, RADIUS = 18;
+ const c = document.createElement('canvas'); c.width = W; c.height = H;
+ const ctx = c.getContext('2d');
+ // Card background
+ ctx.fillStyle = 'rgba(14,14,16,0.85)';
+ if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(0, 0, W, H, RADIUS); ctx.fill(); }
+ else ctx.fillRect(0, 0, W, H);
+ // Accent border
+ ctx.strokeStyle = 'rgba(212,160,74,0.9)'; ctx.lineWidth = 3;
+ if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(1.5, 1.5, W - 3, H - 3, RADIUS); ctx.stroke(); }
+ else ctx.strokeRect(1.5, 1.5, W - 3, H - 3);
+ // Photo placeholder (filled in once image loads)
+ ctx.fillStyle = '#3a3a3e';
+ if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(PAD, PAD, PHOTO, PHOTO, 12); ctx.fill(); }
+ else ctx.fillRect(PAD, PAD, PHOTO, PHOTO);
+ // Name beneath the photo
+ ctx.fillStyle = '#fff';
+ ctx.font = '700 36px -apple-system, system-ui, sans-serif';
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+ ctx.fillText((name || 'Pup').slice(0, 14), W / 2, PAD + PHOTO + (H - PAD - PHOTO - PAD) / 2);
+
+ const tex = new THREE.CanvasTexture(c);
+ tex.colorSpace = THREE.SRGBColorSpace;
+ const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }));
+ sprite.scale.set(1.0, 1.25, 1);
+ sprite.position.y = 2.6;
+ sprite.renderOrder = 10; // draw atop dog body even when overlap
+
+ if (photoUrl) {
+ const img = new Image();
+ img.crossOrigin = 'anonymous';
+ img.onload = () => {
+ ctx.save();
+ if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(PAD, PAD, PHOTO, PHOTO, 12); ctx.clip(); }
+ else { ctx.beginPath(); ctx.rect(PAD, PAD, PHOTO, PHOTO); ctx.clip(); }
+ // Cover-fit so the photo fills the square without stretching
+ const ar = img.width / Math.max(1, img.height);
+ let dw = PHOTO, dh = PHOTO, dx = PAD, dy = PAD;
+ if (ar > 1) { dh = PHOTO; dw = PHOTO * ar; dx = PAD - (dw - PHOTO) / 2; }
+ else if (ar < 1) { dw = PHOTO; dh = PHOTO / ar; dy = PAD - (dh - PHOTO) / 2; }
+ ctx.drawImage(img, dx, dy, dw, dh);
+ ctx.restore();
+ tex.needsUpdate = true;
+ };
+ img.onerror = () => { /* keep placeholder + name */ };
+ img.src = photoUrl;
+ }
+ return sprite;
+}
+
function makeLabel(text, color = '#fff') {
const c = document.createElement('canvas'); c.width = 256; c.height = 64;
const ctx = c.getContext('2d');
@@ -774,6 +838,20 @@ function applyState(msg) {
avatar = { ownerGroup, dogGroup, lastAction: 'idle' };
others.set(u.id, avatar);
}
+ // Pet name tag: real owner_pets photo + dog name floating above the dog.
+ // Built once on first sight, cached on the avatar record. Only fires when
+ // the join carried a same-origin or trusted-https photo URL — anonymous
+ // users never set petPhotoUrl, so they keep the procedural/billboard dog.
+ if (u.petPhotoUrl && avatar.petTag?.userData?.url !== u.petPhotoUrl) {
+ if (avatar.petTag) avatar.dogGroup.remove(avatar.petTag);
+ const tag = makePetNameTag(u.dogName || 'Pup', u.petPhotoUrl);
+ tag.userData.url = u.petPhotoUrl;
+ avatar.dogGroup.add(tag);
+ avatar.petTag = tag;
+ // Hide the procedural rig's built-in name pill so we don't double-label
+ const rigLabel = avatar.dogGroup.userData?.bones?.label || avatar.dogGroup.userData?.label;
+ if (rigLabel) rigLabel.visible = false;
+ }
// Track movement so procedural rigs animate when actually walking.
const prevX = avatar._prevX ?? u.x, prevZ = avatar._prevZ ?? u.z;
const moved = Math.hypot(u.x - prevX, u.z - prevZ);
diff --git a/src/server/dogpark.js b/src/server/dogpark.js
index 9c96458..9e52320 100644
--- a/src/server/dogpark.js
+++ b/src/server/dogpark.js
@@ -5,11 +5,11 @@
// positions at 20Hz and reflects ball/frisbee throws.
//
// Wire-format:
-// client → server : { t:'join', name, dogName, dogColor }
+// client → server : { t:'join', name, dogName, dogColor, dogAvatarUrl?, ownerAvatarUrl?, petPhotoUrl? }
// { t:'move', x, y, z, ry } (user position + facing)
// { t:'throw', x,y,z, vx,vy,vz, kind:'ball'|'frisbee' }
// { t:'call' } (call dog to user)
-// server → client : { t:'state', users:[{id,name,x,z,ry,dogName,dogColor,dx,dz,dry,action}], balls:[{id,x,y,z,vx,vy,vz,kind,owner}] }
+// server → client : { t:'state', users:[{id,name,x,z,ry,dogName,dogColor,dogAvatarUrl,petPhotoUrl,dx,dz,dry,action}], balls:[{id,x,y,z,vx,vy,vz,kind,owner}] }
// { t:'welcome', id }
import { WebSocketServer } from 'ws';
@@ -107,8 +107,17 @@ function handleMessage(ws, id, msg) {
// Validate avatar URLs: only accept same-origin /static/uploads/avatars/...
// paths to prevent broadcast of arbitrary URLs (XSS / SSRF / tracking pixel).
const isSafeAvatarUrl = u => typeof u === 'string' && /^\/static\/uploads\/avatars\/(?:people\/[\w-]+|[\w-]+\/[\w-]+)\.(png|jpg|jpeg|webp)$/i.test(u);
+ // owner_pets.hero_image_url is more permissive: it's authored by the user
+ // via /pets, may live on /static/uploads/pets/... or on a vetted external
+ // CDN. Allow same-origin /static/ paths or any https://host/ URL up to
+ // 500 chars. The URL is rendered via <Image> into a CanvasTexture client-
+ // side; CORS/taint failures are non-fatal (placeholder card still shows).
+ const isSafePetPhotoUrl = u => typeof u === 'string' && u.length < 500 && (
+ u.startsWith('/static/') || /^https:\/\/[\w.-]+\//i.test(u)
+ );
const avatarUrl = isSafeAvatarUrl(msg.dogAvatarUrl) ? msg.dogAvatarUrl : null;
const ownerAvatarUrl = isSafeAvatarUrl(msg.ownerAvatarUrl) ? msg.ownerAvatarUrl : null;
+ const petPhotoUrl = isSafePetPhotoUrl(msg.petPhotoUrl) ? msg.petPhotoUrl : null;
users.set(id, {
ws,
name: String(msg.name || 'Owner').slice(0, 20),
@@ -120,6 +129,7 @@ function handleMessage(ws, id, msg) {
name: String(msg.dogName || 'Pup').slice(0, 20),
color: String(msg.dogColor || '#c97a3e').slice(0, 30),
avatarUrl,
+ petPhotoUrl,
x: 0, z: 0, ry: 0,
action: 'idle',
target: null, // {x,z}
@@ -382,6 +392,7 @@ function tick() {
users: [...users.entries()].map(([id, u]) => ({
id, name: u.name, x: u.x, z: u.z, ry: u.ry, ownerAvatarUrl: u.ownerAvatarUrl || null,
dogName: u.dog.name, dogColor: u.dog.color, dogAvatarUrl: u.dog.avatarUrl || null,
+ petPhotoUrl: u.dog.petPhotoUrl || null,
dx: u.dog.x, dz: u.dog.z, dry: u.dog.ry, action: u.dog.action,
})),
npcs: npcArr.map(n => ({
← 2069600 yolo: Email verification on signup
·
back to Animals
·
yolo: Pet store detail: 'what they sell' section 33ff008 →