Back to Body Journal
SOURCE / PINNED RELEASE

Made of little things.

Body Journal

Release
e8049f8da4e2…
Author-recorded commit
61c04bf0e8aa…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-6c4554844c7

Archive hash verified: 4369d778da046a8b…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/body-capture.ts
import { describeJaw, mouthLimit } from './jaw-pose.js';
import { describeFingers } from './finger-pose.js';
import { layerNames } from './layers.js';
import { Box3, Color, Matrix3, PerspectiveCamera, Raycaster, Vector3, WebGLRenderTarget, SRGBColorSpace } from 'three';
import type { Scene, WebGLRenderer } from 'three';
import type { Concern } from './case.js';
import type { SkinModel } from './skin-model.js';
import { MAX_IMAGE_CHARS, MAX_IMAGE_TOTAL, type VisualEvidence } from './vision-contract.js';
import type { BodyInspection } from './body-inspection.js';
import { focusLabel } from './inspection-record.js';

type Sample={point:Vector3;normal:Vector3;size:number};
type View={camera:PerspectiveCamera;covered:number[];label:string};
function directionLabel(direction:Vector3,model:SkinModel):string {
  const d=direction.clone().transformDirection(model.group.matrixWorld.clone().invert());
  const labels:string[]=[];
  if(Math.abs(d.z)>.3)labels.push(d.z>0?'front':'back');
  if(Math.abs(d.x)>.3)labels.push(d.x>0?'patient left':'patient right');
  if(Math.abs(d.y)>.5)labels.push(d.y>0?'from above':'from below');
  return labels.join(' / ') || 'oblique';
}
function cameraAt(target:Vector3,direction:Vector3,radius:number,minDistance=.14):PerspectiveCamera {
  const camera=new PerspectiveCamera(32,384/420,.003,30);
  camera.position.copy(target).addScaledVector(direction,Math.max(minDistance,radius/Math.sin(16*Math.PI/180)*1.3));
  if(Math.abs(direction.y)>.95)camera.up.set(0,0,-1);
  camera.lookAt(target);camera.updateMatrixWorld(true);return camera;
}
function chooseViews(samples:Sample[],model:SkinModel,inspection:BodyInspection):View[] {
  const pending=new Set(samples.map((_,i)=>i)),views:View[]=[];
  const oral=model.layer==='mouth', clearance=oral?.0005:.003;
  const ray=new Raycaster(); let alternate:View|undefined;
  while(pending.size && views.length<6){
    const seed=samples[[...pending][0]], near=samples.filter(s=>s.point.distanceTo(seed.point)<(oral?.055:.22) && s.normal.dot(seed.normal)>-.2);
    const target=near.reduce((v,s)=>v.add(s.point),new Vector3()).multiplyScalar(1/near.length);
    const radius=Math.max(oral?.018:seed.size<.01?.045:.09,...near.map(s=>s.point.distanceTo(target)+s.size));
    const tangent=new Vector3(0,1,0).cross(seed.normal).normalize();if(tangent.lengthSq()<.1)tangent.set(1,0,0);
    const other=new Vector3().crossVectors(seed.normal,tangent).normalize();
    const dirs=[seed.normal.clone(),...[-.65,.65].flatMap(v=>[seed.normal.clone().addScaledVector(tangent,v).normalize(),seed.normal.clone().addScaledVector(other,v).normalize()])];
    let best:View|undefined,bestScore=-1;const candidates:View[]=[];
    for(const d of dirs){
      const camera=cameraAt(target,d,radius,oral?.06:.14),covered:number[]=[];
      for(const i of pending){
        const s=samples[i],delta=camera.position.clone().sub(s.point),projection=s.point.clone().project(camera);
        if(delta.dot(s.normal)<=0 || Math.abs(projection.x)>.88 || Math.abs(projection.y)>.88 || Math.abs(projection.z)>1)continue;
        // Ray outward from the surface: a thigh/hand/torso in the way rejects the view.
        ray.set(s.point.clone().addScaledVector(s.normal,clearance),delta.normalize());ray.near=clearance;ray.far=s.point.distanceTo(camera.position);
        if(!ray.intersectObjects(inspection.surfaces,false).some(hit=>inspection.accepts(hit)))covered.push(i);
      }
      candidates.push({camera,covered,label:directionLabel(d,model)});
      const score=covered.length+(covered.includes([...pending][0])?.2:0);
      if(score>bestScore){bestScore=score;best={camera,covered,label:directionLabel(d,model)};}
    }
    if(!best?.covered.length){pending.delete([...pending][0]);continue;}
    if(!views.length)alternate=candidates.filter(v=>v.label!==best!.label && v.covered.length>=Math.max(1,best!.covered.length*.6)).sort((a,b)=>b.covered.length-a.covered.length)[0];
    views.push(best);for(const i of best.covered)pending.delete(i);
  }
  if(views.length===1 && alternate)views.push(alternate);
  return views;
}
/** Local offscreen render only. The caller restores its scene state in finally. */
export function captureBodyImages(model:SkinModel,scene:Scene,renderer:WebGLRenderer,concerns:Concern[],pose:(c:Concern)=>PerspectiveCamera|undefined,inspection:BodyInspection):VisualEvidence {
  const evidence:VisualEvidence={images:[],notes:[]};
  const target=new WebGLRenderTarget(384,420),pixels=new Uint8Array(384*420*4);
  target.texture.colorSpace=SRGBColorSpace;
  const tile=document.createElement('canvas');tile.width=384;tile.height=420;
  const tileCtx=tile.getContext('2d');if(!tileCtx)throw new Error('Could not prepare body-map images.');
  const oldTarget=renderer.getRenderTarget(),background=scene.background;
  scene.background=new Color('#ffffff');
  try {
    for(const c of concerns){
      if(!c.localized){evidence.notes.push(`Concern ${c.id}: general symptom; no body location to illustrate.`);continue;}
      if(!c.marks.length){evidence.notes.push(`Concern ${c.id}: named location only; no painted surface to capture.`);continue;}
      const savedCamera=pose(c);inspection.clear();model.clearDots();for(const m of c.marks)model.addDot(m,c.color);model.update();model.updateDots();scene.updateMatrixWorld(true);
      if(c.inspection?.target && !savedCamera)evidence.notes.push(`Concern ${c.id}: saved focus is unavailable for the current illustrated anatomy; using whole-body and automatic views.`);
      const samples:Sample[]=[];let missing=0,valid=0;
      const normalMatrix=new Matrix3().getNormalMatrix(model.group.matrixWorld);
      for(const m of c.marks){
        const a=model.anchor(m);if(!a){missing++;continue;}valid++;
        const p=model.group.localToWorld(a.point.clone()),n=a.normal.clone().applyNormalMatrix(normalMatrix);
        if(samples.every(s=>s.point.distanceTo(p)>.012 || s.normal.dot(n)<.9))samples.push({point:p,normal:n,size:m.size});
      }
      if(missing)evidence.notes.push(`Concern ${c.id}: ${missing} marks cannot be rendered with the current anatomy or old mesh. Their text remains included.`);
      if(!samples.length)continue;
      // Bound the expensive skinned occlusion checks while sampling the entire stroke.
      const checked=samples.length>48?Array.from({length:48},(_,i)=>samples[Math.floor(i*(samples.length-1)/47)]):samples;
      // Reveal buried marked structures in automatic close-ups too, but never
      // hide other marks belonging to the same observation to make coverage pass.
      if(savedCamera && c.inspection)inspection.setHidden(c.inspection.hidden);
      const focusedCloseups=inspection.hidden.length>0 && c.marks.filter(m=>model.anchor(m)).every(m=>inspection.includesMark(m));
      if(!focusedCloseups)inspection.clear();
      const views=chooseViews(checked,model,inspection),coverage=new Set(views.flatMap(v=>v.covered));
      if(focusedCloseups)for(const v of views)v.label+=` · visibility selection`;
      const uncovered=checked.length-coverage.size;
      if(uncovered)evidence.notes.push(`Concern ${c.id}: ${uncovered} of ${checked.length} sampled surface points are occluded or outside the selected close-ups. Adjust the recorded pose or explain these areas in words.`);
      if(samples.length>48)evidence.notes.push(`Concern ${c.id}: visibility checked at 48 representative points across ${valid} painted samples.`);
      const box=new Box3();
      // Bone and skinned vertex positions, rather than rest-pose geometry bounds.
      for(let i=0;i<model.data.meta.vertices;i+=12)box.expandByPoint(model.mesh.localToWorld(model.mesh.getVertexPosition(i,new Vector3())));
      const center=box.getCenter(new Vector3()),radius=box.getSize(new Vector3()).length()/2;
      const front=checked[0].normal.clone().transformDirection(model.group.matrixWorld.clone().invert()).z>=0;
      const direction=new Vector3(0,.12,front?1:-1).normalize().transformDirection(model.group.matrixWorld);
      const overview:View={camera:cameraAt(center,direction,radius),covered:[],label:`Whole body · ${directionLabel(direction,model)}`};
      const savedView:View|undefined=savedCamera && c.inspection ? {camera:savedCamera,covered:[],label:`Saved view · ${focusLabel(c.inspection)} · ${c.inspection.hidden.length ? 'visibility selection' : 'all shown'}`} : undefined;
      const all=[overview,...(savedView?[savedView]:[]),...views],columns=Math.min(3,all.length),rows=Math.ceil(all.length/columns);
      const sheet=document.createElement('canvas');sheet.width=384*columns;sheet.height=rows*456+60;
      const ctx=sheet.getContext('2d');if(!ctx)throw new Error('Could not prepare body-map images.');
      ctx.fillStyle='#fff';ctx.fillRect(0,0,sheet.width,sheet.height);ctx.fillStyle='#202124';ctx.font='bold 18px sans-serif';
      ctx.fillText(`Observation ${c.id} · ${c.location}`.slice(0,100),14,24,sheet.width-28);
      ctx.font='13px sans-serif';ctx.fillText(`${layerNames[c.layer].toUpperCase()} BODY MAP · ${c.pose} pose · colored marks are user annotations, not lesions`,14,46,sheet.width-28);
      all.forEach((v,i)=>{
        if((v===savedView || (focusedCloseups && views.includes(v))) && c.inspection)inspection.setHidden(c.inspection.hidden);else inspection.clear();
        model.updateDots();scene.updateMatrixWorld(true);
        renderer.setRenderTarget(target);renderer.render(scene,v.camera);renderer.readRenderTargetPixels(target,0,0,384,420,pixels);
        const data=tileCtx.createImageData(384,420);for(let y=0;y<420;y++)data.data.set(pixels.subarray((419-y)*384*4,(420-y)*384*4),y*384*4);tileCtx.putImageData(data,0,0);
        const x=(i%columns)*384,y=60+Math.floor(i/columns)*456;
        ctx.drawImage(tile,x,y);ctx.fillStyle='#202124';ctx.font='14px sans-serif';ctx.fillText(v.label,x+12,y+440,360);
      });
      let dataUrl=sheet.toDataURL('image/jpeg',.82);
      if(dataUrl.length>MAX_IMAGE_CHARS)dataUrl=sheet.toDataURL('image/jpeg',.60);
      if(dataUrl.length>MAX_IMAGE_CHARS)throw new Error(`Observation ${c.id} has too many image details. Split its painted areas into separate observations.`);
      evidence.images.push({concernId:c.id,dataUrl,caption:`${c.location}. ${layerNames[c.layer]} illustration layer. Saved ${c.pose} pose${describeFingers(c.fingers).length ? ` with ${describeFingers(c.fingers).join('; ')}` : ''}. ${c.layer === 'mouth' ? `${describeJaw(c.jawOpening)}. ${mouthLimit} ` : ''}Finger bends illustrate the chosen posture, not measured joint angles. Views: ${all.map(v=>v.label).join('; ')}. ${coverage.size}/${checked.length} representative painted points visible in automatic close-ups. Whole-body view shows the complete available layer. Hidden surroundings in labeled detail views are display choices, not absent anatomy. Open or uneven edges on filtered skin are mesh cuts, not injuries. Patient left/right, not viewer left/right. Illustration only; not a photograph or examination.`});
    }
    if(evidence.images.reduce((n,i)=>n+i.dataUrl.length,0)>MAX_IMAGE_TOTAL)throw new Error('The body-map images exceed this bridge’s capacity. Split this case into fewer observations.');
    return evidence;
  } finally {inspection.clear();renderer.setRenderTarget(oldTarget);scene.background=background;target.dispose();}
}