[object Object]

← 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

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 →