Files
chrmc40 9fd6498ba6 Publish snapshot: v1->v2 character port + accumulated fixes
- Tag-builder: port ~1,477 v1 characters into the v2 chip format so the
  character picker shows 1,486 characters (was 9); 1,390 carry rich per-chip
  descriptions restructured from each character's own bio. Adds
  scripts/port_v1_characters_to_v2.py.
- Ideogram: rewrite caption to the real Ideogram-4 schema + broaden refusal
  detection; sampler updates.
- Pose-studio, regional-detailer, attention-couple: assorted fixes.
- Add validate_templates / test_regions / test_crop_windows scripts.
2026-06-28 20:49:04 -07:00

11197 lines
638 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Pose Studio — a Three.js posable-figure viewport mounted into the
// PromptChain_PoseStudio node body. The figure is posed here; on every
// interaction-end the viewport renders a control map, uploads it to the input
// dir, and writes the filename into the (hidden) control_map widget so the
// backend node just reloads a finished image. Mirrors the CodeMirror mounting
// harness in main.js (addDOMWidget + bespoke event isolation + clean teardown).
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { poseRegistry } from "./lib/pose-registry.js";
import { setEditorContent, attachDocChangeListener } from "./lib/editor.js";
import { getLink } from "./lib/slot-utils.js";
import { traceRegionConsumer, ensureConsumerPoseWire, seedRegionBlocks, readRegionBlockBodies } from "./lib/regional-binding.js";
const NODE_TYPE = "PromptChain_PoseStudio";
const MIN_NODE_SIZE = [320, 400];
// Build stamp — open the browser console (F12). If you do NOT see this exact line after
// restarting ComfyUI, your server is still serving the OLD extension JS (it caches it at
// startup) and no code change can reach you until it actually reloads this file.
console.log("%c[PromptChain PoseStudio] BUILD region-rename-reverse-1 (text $name{} rename -> figure rename) loaded", "color:#7fffb0;font-weight:bold");
// ── figure/prop clipboard ───────────────────────────────────────────────────
// Copy a selection (person, prop, or band-selected group) and paste it into the
// same viewport or any other 3D Poser — including one in another workflow. The
// payload rides THREE channels, tried in order on paste: the SYSTEM clipboard
// (crosses browsers/profiles, but its async read is permission- and focus-gated
// and silently times out cross-tab), this module-level copy (same page only),
// and localStorage (shared across every tab/window of this origin and surviving
// reloads, with no permission gate — the reliable bridge between two workflows
// opened in separate tabs, which the clipboard read can't be counted on for).
const CLIP_FORMAT = "promptchain-pose-clip";
const POSE_CLIP_STORE_KEY = "promptchain:poseClip";
let poseClipMemory = null;
function writePoseClipStore(json) { try { localStorage.setItem(POSE_CLIP_STORE_KEY, json); } catch {} }
function readPoseClipStore() { try { return localStorage.getItem(POSE_CLIP_STORE_KEY); } catch { return null; } }
// ── lazy Three.js loader ────────────────────────────────────────────────────
// three.bundle.js is an IIFE that assigns window.PromptChainThree, but only
// when run as a classic <script> (a module import scopes the var locally). So
// inject a script tag on first use — same trick as loadCodeMirror, and it keeps
// the 700KB bundle off pages that never open a Pose Studio node.
let threePromise = null;
function loadThree() {
if (threePromise) return threePromise;
threePromise = new Promise((resolve, reject) => {
if (window.PromptChainThree) return resolve(window.PromptChainThree);
const script = document.createElement("script");
script.src = new URL("./three.bundle.js?v=2", import.meta.url).href; // v=2: + OBJLoader
script.onload = () =>
window.PromptChainThree
? resolve(window.PromptChainThree)
: reject(new Error("three.bundle.js loaded but window.PromptChainThree is missing"));
script.onerror = () => reject(new Error("Failed to load three.bundle.js"));
document.head.appendChild(script);
});
return threePromise;
}
// ── viewport wheel dolly ────────────────────────────────────────────────────
// A single window-capture wheel handler. Capture-at-window fires before the
// 2.0 frontend's TransformPane @wheel.capture, so the canvas never zooms; we
// dolly the camera ourselves (OrbitControls' own zoom is disabled because its
// listener would never get the event past the capture-phase block).
const viewportContainers = new Set();
let wheelInstalled = false;
function installViewportWheel() {
if (wheelInstalled) return;
wheelInstalled = true;
window.addEventListener(
"wheel",
(event) => {
let host = null;
for (const c of viewportContainers) {
if (c.contains(event.target)) { host = c; break; }
}
if (!host) return;
// Scrollable sub-panels (e.g. the 66-slider custom body panel) own the
// wheel while hovered: still swallow propagation (or the frontend's
// capture handler zooms the GRAPH) but skip preventDefault + dolly so
// the browser scrolls the panel natively. Panel-level stopPropagation
// can't do this — this capture-at-window handler fires first.
const scrollPane = event.target?.closest?.("[data-pcr-wheel-scroll]");
if (scrollPane && host.contains(scrollPane) && scrollPane.scrollHeight > scrollPane.clientHeight) {
event.stopPropagation();
event.stopImmediatePropagation();
return;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
host._pcrDolly?.(event.deltaY);
},
{ capture: true, passive: false }
);
}
// ── viewport chrome styles ──────────────────────────────────────────────────
// The tool strip is quiet chrome: dimmed when the cursor is away from the
// viewport, full when hovering it. The sub-panels live inside the container, so
// hovering them keeps :hover true and the strip stays up while you're posing.
let poseStylesInstalled = false;
function installPoseStyles() {
if (poseStylesInstalled) return;
poseStylesInstalled = true;
const style = document.createElement("style");
style.textContent =
".pcr-pose-toolbar{opacity:0;transition:opacity .18s ease;}" + // fully hidden until the cursor enters the viewport
".pcr-pose-studio:hover .pcr-pose-toolbar{opacity:1;}";
document.head.appendChild(style);
}
// ── event isolation ─────────────────────────────────────────────────────────
// Stop pointer/key events from bubbling to LiteGraph (which would drag the node
// or pop its context menu), but only in the BUBBLE phase — OrbitControls listens
// on the canvas in the target phase, which runs first, so it still gets to orbit.
// `mousedown` is isolated too: when the viewport is docked inside the PromptChain
// fullscreen overlay, that overlay preventDefaults stray mousedowns to keep focus
// in the editor — which silently suppresses the popup of every native <select>
// here (foot/hand pose, body pose, shoe pickers), making them open then snap shut.
function isolateViewportEvents(container) {
const stop = (event) => event.stopPropagation();
for (const type of ["pointerdown", "mousedown", "pointerup", "pointermove", "keydown", "keyup", "contextmenu"]) {
container.addEventListener(type, stop);
}
// LiteGraph captures the pointer on its canvas; release it so a drag here
// orbits the camera instead of moving the node.
container.addEventListener("pointerdown", (event) => {
const canvas = document.querySelector("canvas.lgraphcanvas");
if (canvas && typeof event.pointerId === "number") {
try {
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
} catch {} // capture may already be released
}
});
viewportContainers.add(container);
installViewportWheel();
}
// ── rigged figure ───────────────────────────────────────────────────────────
// A volumetric capsule mannequin built as a parented bone hierarchy: each joint
// is a THREE.Group pivot, and the limb leaving it is attached to that group, so
// rotating a joint swings everything downstream (forward kinematics). Each mesh
// carries userData.joint = the group that rotates it, so clicking a body part
// selects the right joint. Returns the root, a flat mesh list (for raycasting),
// and the named joints (for serialization). No asset needed; the real rigged/
// morphable mesh lands in a later phase.
function buildFigure(THREE) {
const skin = new THREE.MeshStandardMaterial({ color: 0xb9b6c2, roughness: 0.85, metalness: 0.0 });
const up = new THREE.Vector3(0, 1, 0);
const meshes = [];
const joints = {};
const joint = (name, parent, off) => {
const g = new THREE.Group();
g.position.set(off[0], off[1], off[2]);
g.userData.jointName = name;
parent.add(g);
joints[name] = g;
return g;
};
// Capsule from the group origin to a local end point, owned by that joint.
const limbTo = (group, end, r) => {
const b = new THREE.Vector3(end[0], end[1], end[2]);
const span = b.length();
const geo = new THREE.CapsuleGeometry(r, Math.max(0.0001, span - 2 * r), 6, 14);
const m = new THREE.Mesh(geo, skin);
m.position.copy(b).multiplyScalar(0.5);
m.quaternion.setFromUnitVectors(up, b.clone().normalize());
m.userData.joint = group;
group.add(m);
meshes.push(m);
};
const ballAt = (group, p, r) => {
const m = new THREE.Mesh(new THREE.SphereGeometry(r, 18, 14), skin);
m.position.set(p[0], p[1], p[2]);
m.userData.joint = group;
group.add(m);
meshes.push(m);
};
const boxAt = (group, p, dim) => {
const m = new THREE.Mesh(new THREE.BoxGeometry(dim[0], dim[1], dim[2]), skin);
m.position.set(p[0], p[1], p[2]);
m.userData.joint = group;
group.add(m);
meshes.push(m);
};
const root = new THREE.Group(); // pelvis — selecting it rotates the whole body
root.position.set(0, 0.98, 0);
root.userData.jointName = "root";
joints.root = root;
// Trunk — root → spine (lumbar) → chest (thoracic); the two 0.11 offsets sum
// to the original 0.22 so chest/shoulders/neck/head heights are unchanged.
limbTo(root, [0, 0.14, 0], 0.135); // pelvis mass
const spine = joint("spine", root, [0, 0.11, 0]);
limbTo(spine, [0, 0.11, 0], 0.115); // lumbar mass
const chest = joint("chest", spine, [0, 0.11, 0]);
limbTo(chest, [0, 0.26, 0], 0.150); // chest mass
limbTo(chest, [0.17, 0.26, 0], 0.060); // clavicle L (selects chest)
limbTo(chest, [-0.17, 0.26, 0], 0.060); // clavicle R
const neck = joint("neck", chest, [0, 0.28, 0]);
limbTo(neck, [0, 0.10, 0], 0.055);
const head = joint("head", neck, [0, 0.14, 0]);
ballAt(head, [0, 0.06, 0], 0.115);
// Arms
for (const [side, s] of [["L", 1], ["R", -1]]) {
const sh = joint("shoulder" + side, chest, [s * 0.17, 0.26, 0]);
limbTo(sh, [s * 0.25, 0, 0], 0.052); // upper arm
const el = joint("elbow" + side, sh, [s * 0.25, 0, 0]);
limbTo(el, [s * 0.22, 0, 0], 0.044); // forearm
const wr = joint("wrist" + side, el, [s * 0.22, 0, 0]);
ballAt(wr, [s * 0.05, 0, 0], 0.050); // hand
}
// Legs
for (const [side, s] of [["L", 1], ["R", -1]]) {
const hip = joint("hip" + side, root, [s * 0.10, 0, 0]);
limbTo(hip, [s * 0.02, -0.43, 0], 0.088); // thigh
const kn = joint("knee" + side, hip, [s * 0.02, -0.43, 0]);
limbTo(kn, [0, -0.46, 0], 0.062); // shin
const an = joint("ankle" + side, kn, [0, -0.46, 0]);
boxAt(an, [0, -0.06, 0.05], [0.10, 0.06, 0.22]); // forward-pointing foot
}
return { root, meshes, joints, jointForHit: (hit) => hit.object.userData.joint || null };
}
// ── realistic mesh loading (GLB) ────────────────────────────────────────────
// Load a rigged humanoid GLB and retarget our canonical joints onto its
// skeleton so the gizmo/FK posing works on a realistic skinned mesh — which is
// what DepthAnything needs to read the figure as human. Falls back to the
// capsule mannequin if the file is absent or unrigged.
const CANON_JOINTS = [
"root", "spine", "back", "chest", "neck", "head",
"shoulderL", "elbowL", "wristL", "shoulderR", "elbowR", "wristR",
"hipL", "kneeL", "ankleL", "hipR", "kneeR", "ankleR",
];
// Heuristic bone-name → canonical-joint classifier covering Mixamo, Unity-
// humanoid, and Blender/Rigify/MakeHuman naming. Logs are emitted on load so a
// rig that doesn't map cleanly can be diagnosed and the rules tightened.
function classifyBone(rawName) {
const n = rawName.toLowerCase().replace(/mixamorig[:_]?/g, "");
const side = /(^|[^a-z])l([^a-z]|$)|left|lft/.test(n) ? "L"
: /(^|[^a-z])r([^a-z]|$)|right|rgt/.test(n) ? "R" : "";
const has = (...k) => k.some((x) => n.includes(x));
const sided = (base) => (side ? base + side : null);
if (has("head") && !has("end", "top")) return "head";
if (has("neck")) return "neck";
if (has("hand") || has("wrist")) return sided("wrist");
if (has("forearm", "lowerarm")) return sided("elbow");
if (has("upperarm")) return sided("shoulder");
if (has("clavicle", "shoulder")) return null; // skip clavicle; the upper-arm bone is our "shoulder"
if (has("arm")) return sided("shoulder"); // Mixamo "Arm" = upper arm
if (has("toe")) return null;
if (has("foot", "ankle")) return sided("ankle");
if (has("upleg", "upperleg", "thigh")) return sided("hip");
if (has("shin", "calf", "lowerleg")) return sided("knee");
if (has("leg")) return sided("knee"); // Mixamo "Leg" = lower leg
if (has("hips", "pelvis", "root")) return "root";
if (has("upperchest", "chest", "spine2", "spine1")) return "chest";
if (has("spine")) return "chest";
return null;
}
// Blender Rigify exports keep hundreds of control/mechanism bones; only the
// hierarchically-parented DEF- bones actually deform the skinned mesh (the rig's
// constraints don't survive glTF). Map our joints onto those. Limb twist
// segments (".001") are skipped — we drive the primary bone.
function classifyRigifyDef(rawName) {
// three's GLTFLoader strips dots from node names, so "DEF-thigh.L.001_228"
// arrives as "DEF-thighL001_228". Normalize by dropping the DEF- prefix, the
// trailing "_<id>", and any remaining dots — then parse undotted names.
let n = rawName.replace(/^DEF-/i, "").replace(/_\d+$/, "").replace(/\./g, "").toLowerCase();
// Spine chain is intentionally NOT posable on constraint-driven Rigify
// exports: the skull (DEF-spine006) and the face bones (DEF-jaw/temple/lip…)
// sit in separate parent branches kept in sync by constraints that glTF
// drops, so rotating any spine bone tears the head off the face. Limbs are
// unaffected (their DEF chains are self-contained). Torso/head posing waits
// for a clean simple-rig mesh.
if (/^spine\d*$/.test(n)) return null;
// Limbs: trailing digits = twist segment (skip); trailing l/r = side.
const twist = /\d$/.test(n);
n = n.replace(/\d+$/, "");
let side = "";
if (/l$/.test(n)) { side = "L"; n = n.replace(/l$/, ""); }
else if (/r$/.test(n)) { side = "R"; n = n.replace(/r$/, ""); }
const sided = (b) => (side ? b + side : null);
if (n === "upper_arm") return twist ? null : sided("shoulder");
if (n === "forearm") return twist ? null : sided("elbow");
if (n === "hand") return sided("wrist");
if (n === "thigh") return twist ? null : sided("hip");
if (n === "shin") return twist ? null : sided("knee");
if (n === "foot") return sided("ankle");
return null; // shoulder (clavicle), pelvis, toe, forehead, etc.
}
function buildBoneMap(bones, profile) {
// Non-biped rigs carry an author-baked explicit map — exact bone names, no
// guessing. The heuristic path below is biped-only by design (hard rule:
// never classify a quadruped's bones heuristically; they'd silently drop).
if (profile?.boneMap) {
const joints = {};
for (const bone of bones) {
const slot = profile.boneMap[bone.name];
if (slot && !joints[slot]) joints[slot] = bone;
}
return joints;
}
const canon = profile?.joints || CANON_JOINTS;
const isRigify = bones.some((b) => /^DEF-/i.test(b.name));
const spineRank = (nm) => { const m = nm.match(/(\d+)\s*$/); return m ? +m[1] : 0; };
const joints = {};
const spineBones = [];
for (const bone of bones) {
if (isRigify && !/^DEF-/i.test(bone.name)) continue;
const slot = isRigify ? classifyRigifyDef(bone.name) : classifyBone(bone.name);
if (!slot || !canon.includes(slot)) continue;
// Generic-rig spine: collect every spine bone, then split the lowest
// (lumbar) into "spine" and the highest (thoracic) into "chest" so the
// torso bends at the waist AND the upper back, not as one rigid block.
if (slot === "chest" && !isRigify) { spineBones.push(bone); continue; }
// Root slot: prefer the actual PELVIS bone over an armature "Root"/master node
// above it (this rig has both). The base handle then sits at the hips and
// body rotation pivots around the body's center, not the floor.
if (slot === "root") {
const rank = (nm) => /pelvis|hip/i.test(nm) ? 2 : /root|master|armature/i.test(nm) ? 0 : 1;
if (!joints.root || rank(bone.name) > rank(joints.root.name)) joints.root = bone;
continue;
}
if (!joints[slot]) joints[slot] = bone;
}
if (spineBones.length) {
spineBones.sort((a, b) => spineRank(a.name) - spineRank(b.name));
joints.chest = spineBones[spineBones.length - 1]; // highest = upper torso
if (spineBones.length > 1) joints.spine = spineBones[0]; // lowest = lumbar
if (spineBones.length > 3) {
// Our subdivided rig (tools/spine_subdiv.py): the game rig packs
// spine_01..03 into the lower torso and spans the whole ribcage with
// spine_03 alone, so the builds split it. The new top bone (spine_04,
// shoulder-blade level) = "back"; "chest" stays spine_03 so old saves
// restore onto the bone they were posed with.
joints.back = spineBones[spineBones.length - 1];
joints.chest = spineBones[spineBones.length - 2];
} else if (spineBones.length > 2) {
joints.back = spineBones[1]; // legacy 3-bone GLB: middle vertebra
}
}
return joints;
}
function loadGlbFigure(THREE, GLTFLoader, url, profile) {
return new Promise((resolve) => {
new GLTFLoader().load(
url,
(gltf) => {
const root = gltf.scene;
const skinned = [];
root.traverse((o) => { if (o.isSkinnedMesh) { o.frustumCulled = false; skinned.push(o); } });
if (!skinned.length) {
console.warn("[PoseStudio] GLB has no skinned mesh — using capsule fallback");
resolve(null);
return;
}
// This asset (and similar) can ship multiple bodies + eye meshes. Show
// only the highest-poly mesh (the body) and hide the rest; pose its rig.
// Profiles with keepAllMeshes (creature GLBs whose extra meshes are real
// parts — mane/tail hair — not alternate bodies) keep everything visible.
const keepAll = !!profile?.keepAllMeshes;
const body = skinned.reduce((a, b) =>
(b.geometry.attributes.position?.count || 0) > (a.geometry.attributes.position?.count || 0) ? b : a);
for (const m of skinned) m.visible = keepAll || (m === body);
const meshes = keepAll ? skinned : [body];
const skeleton = body.skeleton;
if (!skeleton) { console.warn("[PoseStudio] body mesh has no skeleton"); resolve(null); return; }
const bones = skeleton.bones;
console.log("%c[PoseStudio] FIGURE GLB URL =", "color:#ffd24a;font-weight:bold", url, "| meshVerts =", body.geometry.attributes.position?.count);
console.log("[PoseStudio] GLB bones:", bones.map((b) => b.name));
const joints = buildBoneMap(bones, profile);
console.log("[PoseStudio] mapped joints:", Object.fromEntries(Object.entries(joints).map(([k, v]) => [k, v.name])));
const missing = (profile?.joints || CANON_JOINTS).filter((j) => !joints[j]);
if (missing.length) console.warn("[PoseStudio] unmapped joints (not selectable yet):", missing);
// Finger/toe bones for hand & foot posing. MakeHuman's Game rig names fingers
// `<finger>_<NN>_<side>` (e.g. index_03_l) and ONE `ball_<side>` per foot; our
// build (tools/toe_split.py) splits that ball into `toe<1-5>_<side>`. Underscores
// survive GLTFLoader's dot-stripping, so match raw.
const fingers = { wristL: {}, wristR: {} };
const toes = {}; // ankleL/R → ball bone (whole-foot presets, parent of the toes)
const toeChains = {}; // ankleL/R → five 2-bone toe digits: [ [toeN_1, toeN_2] × 5 ]
const fingerRe = /^(thumb|index|middle|ring|pinky)_(\d\d)_(l|r)$/i;
const toeRe = /^toe(\d)_(\d)_(l|r)$/i; // toe<num>_<segment>_<side>
const ankleOf = (side) => "ankle" + (side.toLowerCase() === "l" ? "L" : "R");
const toeByNum = {}; // ankle → { toeNum → { segment → bone } }
for (const b of bones) {
const fm = b.name.match(fingerRe);
if (fm) {
const wrist = "wrist" + (fm[3].toLowerCase() === "l" ? "L" : "R");
(fingers[wrist][fm[1].toLowerCase()] ||= []).push(b);
continue;
}
const bm = b.name.match(/^ball_(l|r)$/i);
if (bm) { toes[ankleOf(bm[1])] = b; continue; }
const tm = b.name.match(toeRe);
if (tm) (((toeByNum[ankleOf(tm[3])] ||= {})[+tm[1]] ||= {}))[+tm[2]] = b;
}
// Order each finger proximal→distal.
for (const wrist of ["wristL", "wristR"]) {
for (const f of Object.keys(fingers[wrist])) {
fingers[wrist][f].sort((a, b) => a.name.localeCompare(b.name));
}
}
// Assemble toe chains: toes 1→5, each ordered [proximal (seg 1), distal (seg 2)].
for (const a of Object.keys(toeByNum)) {
toeChains[a] = Object.keys(toeByNum[a]).sort((x, y) => x - y).map((num) =>
Object.keys(toeByNum[a][num]).sort((x, y) => x - y).map((s) => toeByNum[a][num][s]));
}
// Clean opaque material: imported skin can carry alpha / missing-texture
// data that renders as holes, and a matte surface reads cleanest through
// DepthAnything. (Skinning still works — it's independent of material.)
// Neutral Blender-clay gray with a soft sheen (was a flat warm tan) —
// paired with the key+fill studio lights where the scene is built.
for (const m of meshes) {
disposeMaterial(m.material); // free the original PBR material + textures we're replacing
m.material = new THREE.MeshStandardMaterial({
color: 0xc7a892, roughness: 0.78, metalness: 0.0, side: THREE.DoubleSide,
});
}
// Stand upright: FBX→glTF conversions often land the figure lying down,
// so rotate the longest extent to vertical. Quadruped profiles disable
// this (a standing horse is LONGER than tall — the heuristic would tip
// it onto its tail).
root.updateMatrixWorld(true);
let box = new THREE.Box3().setFromObject(root);
let size = box.getSize(new THREE.Vector3());
if (profile?.uprightHeuristic !== false) {
if (size.y < size.x && size.x >= size.z) root.rotateZ(Math.PI / 2);
else if (size.y < size.z && size.z > size.x) root.rotateX(-Math.PI / 2);
root.updateMatrixWorld(true);
}
// Scale to the profile's standing height (human 1.8), ground feet at
// y=0, center x/z.
box = new THREE.Box3().setFromObject(root);
size = box.getSize(new THREE.Vector3());
if (size.y > 0) root.scale.multiplyScalar((profile?.targetHeight || 1.8) / size.y);
root.updateMatrixWorld(true);
box = new THREE.Box3().setFromObject(root);
root.position.x -= (box.min.x + box.max.x) / 2;
root.position.z -= (box.min.z + box.max.z) / 2;
root.position.y -= box.min.y;
// Inflate the body's raycast bounding sphere. Mesh.raycast early-rejects rays that
// miss geometry.boundingSphere, which three computes in the BIND (T) pose — so an
// extended limb (leg kicked up, arm reaching) lands OUTSIDE it and hovering that
// limb stops registering, making its handles unselectable. Skinning never moves a
// joint more than ~a body-length from center, so a 3× radius covers any human pose.
for (const m of meshes) {
m.geometry.computeBoundingSphere();
if (m.geometry.boundingSphere) m.geometry.boundingSphere.radius *= 3;
}
const tmp = new THREE.Vector3();
const jointForHit = (hit) => {
let best = null, bestD = Infinity;
for (const b of Object.values(joints)) {
b.getWorldPosition(tmp);
const d = tmp.distanceToSquared(hit.point);
if (d < bestD) { bestD = d; best = b; }
}
return best;
};
resolve({ root, meshes, joints, jointForHit, fingers, toes, toeChains, isGlb: true });
},
undefined,
(err) => { console.warn("[PoseStudio] GLB load failed:", err); resolve(null); }
);
});
}
// Dispose a material AND its textures — Material.dispose() does NOT free the GPU
// textures it references, so a removed figure would otherwise leak every skin/normal/eye
// map. Safe on shared materials only if the caller owns them (we only call it on
// per-figure GLB materials, never the shared prop/handle materials).
function disposeMaterial(m) {
if (!m) return;
for (const mat of Array.isArray(m) ? m : [m]) {
if (!mat) continue;
for (const k in mat) { const v = mat[k]; if (v && v.isTexture) v.dispose(); }
mat.dispose?.();
}
}
function setWidgetValue(node, name, value) {
const w = node.widgets?.find((x) => x.name === name);
if (w) w.value = value;
}
// Hide a serializable widget: keep its value (it still serializes into the
// prompt) but give it zero height so the node body is just the viewport.
function hideWidget(node, name) {
const w = node.widgets?.find((x) => x.name === name);
if (!w) return;
w.type = "hidden";
w.computeSize = () => [0, -4];
}
// A freehand sculpt edit = a sparse per-vertex rest-space displacement (Map<vertexIndex,
// [dx,dy,dz]>). Serialized as parallel idx/d arrays, rounded to 0.01mm to keep the JSON
// small (a breast tweak is a few hundred verts). Indices are GLB/geometry vertex indices,
// so they stay valid as long as the body/prop asset version is unchanged.
function sculptMapToData(m) {
const idx = [], d = [];
for (const [vi, dv] of m) { idx.push(vi); d.push(Math.round(dv[0] * 1e5) / 1e5, Math.round(dv[1] * 1e5) / 1e5, Math.round(dv[2] * 1e5) / 1e5); }
return { idx, d };
}
function sculptDataToMap(data) {
const m = new Map();
if (!data || !Array.isArray(data.idx) || !Array.isArray(data.d)) return m;
for (let j = 0; j < data.idx.length; j++) m.set(data.idx[j], [data.d[j * 3] || 0, data.d[j * 3 + 1] || 0, data.d[j * 3 + 2] || 0]);
return m;
}
// One figure's full pose: joint rotations, every bone position (fold/Ctrl-move +
// skeletal morphs), finger/toe + spine bones by name, surface morphs, skeletal
// morph amounts, and the figure's world root. Keyed by name so it's forward-
// compatible and rig-shape-agnostic (skeleton vs capsule fallback).
function serializeFigure(rig) {
const joints = {};
for (const [name, g] of Object.entries(rig.joints)) joints[name] = g.quaternion.toArray();
// Toes save as raw quaternions but under a SEPARATE key (`toeQuats`), kept OUT of `digits`:
// a pre-pivot-fix save stored toe rotations in `digits`, and replaying those on the rebuilt
// bones crumpled the foot — keeping them out of `digits` lets the loader ignore them while
// still restoring fingers. Only non-rest toes are written (toe-tip IK pose).
const toeNameSet = new Set();
if (rig.toeChains) for (const k of Object.keys(rig.toeChains)) for (const ch of rig.toeChains[k]) for (const b of ch) toeNameSet.add(b.name);
const digits = {};
for (const b of digitBones(rig)) if (!toeNameSet.has(b.name)) digits[b.name] = b.quaternion.toArray();
const toeQuats = {};
if (rig.toeChains) for (const k of Object.keys(rig.toeChains)) for (const ch of rig.toeChains[k]) for (const b of ch) toeQuats[b.name] = b.quaternion.toArray();
const spine = {};
if (rig.spineChain) for (const b of rig.spineChain) spine[b.name] = b.quaternion.toArray();
const jointPos = {};
if (rig.skel) for (const b of rig.skel.bones) jointPos[b.name] = b.position.toArray();
else for (const [name, g] of Object.entries(rig.joints)) jointPos[name] = g.position.toArray();
const morphs = {};
const mm = rig.morphMesh;
if (mm?.morphTargetInfluences) mm.morphTargetInfluences.forEach((v, i) => { if (v) morphs[rig.morphNames[i] ?? i] = v; });
const skeletalAmount = rig.skeletalAmount ? { ...rig.skeletalAmount } : {};
const footPins = {}; // planted-foot world anchors (so pinned-feet mode survives reload/undo)
if (rig.footPins) for (const k of Object.keys(rig.footPins)) if (rig.footPins[k]) footPins[k] = rig.footPins[k].toArray();
// uid = stable per-figure identity so undo/restore can match poses to the right body
// even after a middle figure was removed (array-index matching scrambles them).
// rootQuat = the figure-select gizmo rotates fig.root (the scene wrapper); for GLB
// rigs joints.root is a separate skeleton bone, so without this the whole-figure
// rotation is serialized nowhere and lost on reload.
// name = the user's custom label (absent → consumers default to mannequinN by
// position). It feeds the server-side region→figure NAME binding, so renamed
// $blocks keep pointing at the right mask.
// skinBind = an imported mesh bound to this figure's skeleton (the "make my
// monster posable" feature): mesh file ref + its placement + the skeleton's
// BIND pose. Weights recompute deterministically on load, so none serialize.
// rootScale is LOAD-BEARING for skinBind: the fit scales the skeleton to the
// model and meshLocal/bindPose are relative to that scale — restoring at the
// default scale displaced and tore the bound mesh on every reload.
return { uid: rig.uid, ...(rig.body && rig.body !== "human" ? { body: rig.body } : {}), ...(rig.customName ? { name: rig.customName } : {}), root: rig.root.position.toArray(), rootQuat: rig.root.quaternion.toArray(), rootScale: rig.root.scale.toArray(), joints, jointPos, digits, ...(Object.keys(toeQuats).length ? { toeQuats } : {}), spine, morphs, skeletalAmount, footPins, shoes: rig.shoes ? { style: rig.shoeStyle || "procedural", scope: rig.shoeScope || "pair" } : false, ...(rig.skinBind ? { skinBind: rig.skinBind } : {}), ...(rig.customEngine ? { sliders: { ...rig.customEngine.values } } : {}), ...(rig.sculptMap && rig.sculptMap.size ? { sculpt: sculptMapToData(rig.sculptMap) } : {}) };
}
// Whole-scene state: every figure's pose, which one is active, the camera, and the
// props (props are scene-level, not per figure). version 2 = multi-figure; a
// version-1 (flat single-figure) save still loads — restoreSavedState detects it.
// Project each figure's head ball through the GIVEN camera so the Regional
// Detailer can detail a head the face detector can't see (profile / tilted
// back) and knows which way it's looking. Boxes are normalized 0..1 in the
// output frame; facing = dot(head forward, direction to camera): 1 = straight
// at the camera, 0 = profile, -1 = back of head.
// MUST be called with the exact camera state that renders the control map
// (inside captureBlob, aspect already set) — a separately-cloned camera can
// disagree with the map (orbit damping, mid-settle captures) and emit head
// boxes that don't correspond to the rendered frame at all.
function computeHeadExports(THREE, figures, camera) {
if (!THREE || !camera) return [];
const HAIR_INFLATE = 1.6; // rendered hair/headgear overshoots the mannequin skull
// Both rig types are normalized to ~1.8 world units tall, so the skull is
// ~0.115 around a point ~0.06 above the head joint — in WORLD units. Never
// use the bone's own scale/axes: GLB bones carry unit-conversion scales and
// arbitrary local frames (that bug read every GLB head as "looking away").
const HEAD_RADIUS = 0.115, HEAD_LIFT = 0.06;
const clamp01 = (v) => Math.min(1, Math.max(0, v));
return (figures || []).map((fig) => {
const head = fig?.joints?.head;
if (!head) return { box: null, facing: -1 };
fig.root.updateMatrixWorld(true);
// Rest-relative world rotation: restWorld is captured in bind pose (facing
// +Z) per joint NAME, regardless of the rig's local-axis convention — the
// same trick the pose mirror uses. delta = now ⊗ rest⁻¹ rotates rest-world
// directions to their current world directions (root turns included).
const nowQ = head.getWorldQuaternion(new THREE.Quaternion());
const restQ = fig.restWorld?.get("head");
const delta = restQ ? nowQ.clone().multiply(restQ.clone().invert()) : nowQ;
const center = head.getWorldPosition(new THREE.Vector3())
.add(new THREE.Vector3(0, HEAD_LIFT, 0).applyQuaternion(delta));
const ndc = center.clone().project(camera);
if (ndc.z > 1 || ndc.z < -1) return { box: null, facing: -1 }; // behind/outside the camera
const r = HEAD_RADIUS * HAIR_INFLATE;
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion);
const up = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion);
const rx = Math.abs(center.clone().addScaledVector(right, r).project(camera).x - ndc.x) / 2;
const ry = Math.abs(center.clone().addScaledVector(up, r).project(camera).y - ndc.y) / 2;
const cx = (ndc.x + 1) / 2, cy = (1 - ndc.y) / 2;
const forward = new THREE.Vector3(0, 0, 1).applyQuaternion(delta);
const toCam = camera.position.clone().sub(center).normalize();
return {
box: [clamp01(cx - rx), clamp01(cy - ry), clamp01(cx + rx), clamp01(cy + ry)].map((v) => +v.toFixed(4)),
facing: +forward.dot(toCam).toFixed(3),
};
});
}
/* VOXELW-BEGIN — pure (no THREE); the offline harness extracts and runs THIS code against real GLBs */
// Geodesic-voxel skin weights (the Maya "geodesic voxel bind" idea): voxelize
// the mesh into a solid, then per bone run a BFS THROUGH the solid — so the
// distance from a belly vertex to the arm bone is measured up through the
// shoulder, not across the air gap. Straight-line distance (the naive method)
// mutilates bulky bodies exactly because of that gap.
// meshes: [{ pos: Float32Array (world xyz), index: ArrayLike|null }]
// segs: [{ bone: int, a: [x,y,z], b: [x,y,z] }] world-space bone capsules
// returns [{ sIdx: Uint16Array(n*4), sWgt: Float32Array(n*4) }] per mesh
function voxelSkinWeights(meshes, segs, res = 64) {
let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (const m of meshes) {
const p = m.pos;
for (let i = 0; i < p.length; i += 3) {
if (p[i] < minX) minX = p[i]; if (p[i] > maxX) maxX = p[i];
if (p[i + 1] < minY) minY = p[i + 1]; if (p[i + 1] > maxY) maxY = p[i + 1];
if (p[i + 2] < minZ) minZ = p[i + 2]; if (p[i + 2] > maxZ) maxZ = p[i + 2];
}
}
const span = Math.max(maxX - minX, maxY - minY, maxZ - minZ, 1e-6);
const h = span / res, pad = 2;
const nx = Math.ceil((maxX - minX) / h) + pad * 2;
const ny = Math.ceil((maxY - minY) / h) + pad * 2;
const nz = Math.ceil((maxZ - minZ) / h) + pad * 2;
const nvox = nx * ny * nz;
const vx = (x) => Math.max(0, Math.min(nx - 1, Math.floor((x - minX) / h) + pad));
const vy = (y) => Math.max(0, Math.min(ny - 1, Math.floor((y - minY) / h) + pad));
const vz = (z) => Math.max(0, Math.min(nz - 1, Math.floor((z - minZ) / h) + pad));
const vid = (x, y, z) => (z * ny + y) * nx + x;
// 1. Occupancy: every vertex + points sampled across each triangle face (a
// vertex-only fill leaves holes in big low-poly triangles).
const occ = new Uint8Array(nvox);
const mark = (x, y, z) => { occ[vid(vx(x), vy(y), vz(z))] = 1; };
for (const m of meshes) {
const p = m.pos, idx = m.index;
const triCount = idx ? idx.length / 3 : p.length / 9;
for (let t = 0; t < triCount; t++) {
const i0 = (idx ? idx[t * 3] : t * 3) * 3;
const i1 = (idx ? idx[t * 3 + 1] : t * 3 + 1) * 3;
const i2 = (idx ? idx[t * 3 + 2] : t * 3 + 2) * 3;
const e1 = Math.hypot(p[i1] - p[i0], p[i1 + 1] - p[i0 + 1], p[i1 + 2] - p[i0 + 2]);
const e2 = Math.hypot(p[i2] - p[i0], p[i2 + 1] - p[i0 + 1], p[i2 + 2] - p[i0 + 2]);
const steps = Math.min(8, Math.max(1, Math.ceil(Math.max(e1, e2) / h)));
for (let u = 0; u <= steps; u++) {
for (let v = 0; v + u <= steps; v++) {
const a = u / steps, b = v / steps, c = 1 - a - b;
mark(p[i0] * c + p[i1] * a + p[i2] * b,
p[i0 + 1] * c + p[i1 + 1] * a + p[i2 + 1] * b,
p[i0 + 2] * c + p[i1 + 2] * a + p[i2 + 2] * b);
}
}
}
}
// 2. Solid = everything NOT reachable from outside through empty voxels
// (flood from the padded border) — surface shells AND enclosed interiors.
const outside = new Uint8Array(nvox);
const queue = new Int32Array(nvox);
let qh = 0, qt = 0;
const pushOut = (x, y, z) => {
const id = vid(x, y, z);
if (!outside[id] && !occ[id]) { outside[id] = 1; queue[qt++] = id; }
};
for (let x = 0; x < nx; x++) for (let y = 0; y < ny; y++) { pushOut(x, y, 0); pushOut(x, y, nz - 1); }
for (let x = 0; x < nx; x++) for (let z = 0; z < nz; z++) { pushOut(x, 0, z); pushOut(x, ny - 1, z); }
for (let y = 0; y < ny; y++) for (let z = 0; z < nz; z++) { pushOut(0, y, z); pushOut(nx - 1, y, z); }
while (qh < qt) {
const id = queue[qh++];
const z = Math.floor(id / (nx * ny)), y = Math.floor((id - z * nx * ny) / nx), x = id - (z * ny + y) * nx;
if (x > 0) pushOut(x - 1, y, z); if (x < nx - 1) pushOut(x + 1, y, z);
if (y > 0) pushOut(x, y - 1, z); if (y < ny - 1) pushOut(x, y + 1, z);
if (z > 0) pushOut(x, y, z - 1); if (z < nz - 1) pushOut(x, y, z + 1);
}
const solid = (id) => !outside[id];
// 3. Per-bone BFS through the solid. Keep the best FOUR bone distances per
// voxel (all four skin slots blend — K=2 with a sharp falloff tore at the
// chest/shoulder seam under wide poses: adjacent verts flipped owners).
const D4 = new Uint16Array(nvox * 4).fill(65535);
const B4 = new Int16Array(nvox * 4).fill(-1);
const dist = new Uint16Array(nvox);
const snapToSolid = (x, y, z) => {
for (let r = 0; r <= 4; r++) {
for (let dz = -r; dz <= r; dz++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
if (Math.max(Math.abs(dx), Math.abs(dy), Math.abs(dz)) !== r) continue;
const X = x + dx, Y = y + dy, Z = z + dz;
if (X < 0 || Y < 0 || Z < 0 || X >= nx || Y >= ny || Z >= nz) continue;
const id = vid(X, Y, Z);
if (solid(id)) return id;
}
}
return -1;
};
for (const s of segs) {
dist.fill(65535);
qh = 0; qt = 0;
const segLen = Math.hypot(s.b[0] - s.a[0], s.b[1] - s.a[1], s.b[2] - s.a[2]);
const steps = Math.max(1, Math.ceil(segLen / (h * 0.5)));
for (let k = 0; k <= steps; k++) {
const t = k / steps;
const id = snapToSolid(vx(s.a[0] + (s.b[0] - s.a[0]) * t), vy(s.a[1] + (s.b[1] - s.a[1]) * t), vz(s.a[2] + (s.b[2] - s.a[2]) * t));
if (id >= 0 && dist[id] === 65535) { dist[id] = 0; queue[qt++] = id; }
}
while (qh < qt) {
const id = queue[qh++];
const d = dist[id] + 1;
const z = Math.floor(id / (nx * ny)), y = Math.floor((id - z * nx * ny) / nx), x = id - (z * ny + y) * nx;
const tryN = (X, Y, Z) => {
const n = vid(X, Y, Z);
if (dist[n] === 65535 && solid(n)) { dist[n] = d; queue[qt++] = n; }
};
if (x > 0) tryN(x - 1, y, z); if (x < nx - 1) tryN(x + 1, y, z);
if (y > 0) tryN(x, y - 1, z); if (y < ny - 1) tryN(x, y + 1, z);
if (z > 0) tryN(x, y, z - 1); if (z < nz - 1) tryN(x, y, z + 1);
}
// Optional segment RADIUS (world units): torso bones are thin lines in the
// middle of a thick barrel, so a bulky pec sits geodesically closer to the
// SHOULDER than to the spine and rides the arm (tearing at the sternum).
// Subtracting the bone's volume radius lets the chest claim its barrel.
// FLOOR AT 1, never 0: a zero plateau makes the bone TOTALLY dominate
// everything inside its bubble (1/(0+1)³ = 1 vs neighbors' crumbs) — the
// chest bubble swallowed the monster's skull and head rotation tore the
// face at the bubble edge. d≥1 keeps an ordering gradient everywhere.
const rVox = s.r ? Math.round(s.r / h) : 0;
for (let id = 0; id < nvox; id++) {
let d = dist[id];
if (d === 65535) continue;
if (rVox) d = Math.max(1, d - rVox);
const o = id * 4;
if (d >= D4[o + 3]) continue;
let k = 3;
while (k > 0 && d < D4[o + k - 1]) { D4[o + k] = D4[o + k - 1]; B4[o + k] = B4[o + k - 1]; k--; }
D4[o + k] = d; B4[o + k] = s.bone;
}
}
// 4. Per-vertex weights from the voxel fields (up to 4 bones, gentle 1/(d+1)²
// falloff — broad blends, no hard ownership seams); stray vertices
// (outside the solid, e.g. floating debris) fall back to straight-line.
const lineFallback = (x, y, z) => {
let b0 = segs[0]?.bone ?? 0, d0 = Infinity, b1 = b0, d1 = Infinity;
for (const s of segs) {
const abx = s.b[0] - s.a[0], aby = s.b[1] - s.a[1], abz = s.b[2] - s.a[2];
const apx = x - s.a[0], apy = y - s.a[1], apz = z - s.a[2];
const ll = abx * abx + aby * aby + abz * abz;
const t = ll > 1e-12 ? Math.max(0, Math.min(1, (apx * abx + apy * aby + apz * abz) / ll)) : 0;
const dx = apx - abx * t, dy = apy - aby * t, dz = apz - abz * t;
const d = dx * dx + dy * dy + dz * dz;
if (d < d0) { b1 = b0; d1 = d0; b0 = s.bone; d0 = d; }
else if (d < d1) { b1 = s.bone; d1 = d; }
}
return [b0, Math.sqrt(d0) / h, b1, Math.sqrt(d1) / h]; // in voxel units, like the BFS
};
// TRILINEAR sampling of the distance fields: nearest-voxel weights step at
// voxel boundaries (adjacent verts jump owners = crumple), and the model's
// detail-shell mesh hugging the body sampled DIFFERENT voxels than the body
// beneath it (shell separated into debris when posed). Interpolating the
// per-bone distances across the 8 surrounding voxels makes the weight field
// continuous in SPACE — same answer for any mesh at the same spot.
const sampleBones = (x, y, z, accOut) => {
const fx = (x - minX) / h + pad - 0.5, fy = (y - minY) / h + pad - 0.5, fz = (z - minZ) / h + pad - 0.5;
const x0 = Math.floor(fx), y0 = Math.floor(fy), z0 = Math.floor(fz);
const tx = fx - x0, ty = fy - y0, tz = fz - z0;
let total = 0;
for (let c = 0; c < 8; c++) {
const X = Math.max(0, Math.min(nx - 1, x0 + (c & 1)));
const Y = Math.max(0, Math.min(ny - 1, y0 + ((c >> 1) & 1)));
const Z = Math.max(0, Math.min(nz - 1, z0 + ((c >> 2) & 1)));
const tw = ((c & 1) ? tx : 1 - tx) * (((c >> 1) & 1) ? ty : 1 - ty) * (((c >> 2) & 1) ? tz : 1 - tz);
if (tw < 1e-6) continue;
let id = vid(X, Y, Z);
if (B4[id * 4] < 0) { // empty corner (outside the solid) — borrow the nearest solid voxel
const snapped = snapToSolid(X, Y, Z);
if (snapped < 0 || B4[snapped * 4] < 0) continue;
id = snapped;
}
const o = id * 4;
for (let k = 0; k < 4; k++) {
if (B4[o + k] < 0) break;
const w = tw / Math.pow(D4[o + k] + 1, 3); // ^3: joint blending without arm influence flooding the chest
accOut.set(B4[o + k], (accOut.get(B4[o + k]) || 0) + w);
}
total += tw;
}
return total > 0;
};
const acc0 = new Map();
const out = meshes.map((m) => {
const p = m.pos;
const n = p.length / 3;
const sIdx = new Uint16Array(n * 4);
const sWgt = new Float32Array(n * 4);
for (let i = 0; i < n; i++) {
const x = p[i * 3], y = p[i * 3 + 1], z = p[i * 3 + 2];
acc0.clear();
let sum = 0;
if (sampleBones(x, y, z, acc0)) {
const kept = [...acc0.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4);
kept.forEach(([bone, wv], k) => { sIdx[i * 4 + k] = bone; sWgt[i * 4 + k] = wv; sum += wv; });
} else {
const [b0, d0, b1, d1] = lineFallback(x, y, z);
sIdx[i * 4] = b0; sWgt[i * 4] = 1 / Math.pow(d0 + 1, 3);
sIdx[i * 4 + 1] = b1; sWgt[i * 4 + 1] = 1 / Math.pow(d1 + 1, 3);
sum = sWgt[i * 4] + sWgt[i * 4 + 1];
}
if (sum > 0) for (let k = 0; k < 4; k++) sWgt[i * 4 + k] /= sum;
else sWgt[i * 4] = 1; // pathological — pin to first bone rather than NaN
}
return { sIdx, sWgt };
});
// 5. WELD + Laplacian weight smoothing (indexed meshes only). Weld first:
// glTF duplicates vertices along UV/normal seams — same position, two
// records, NOT connected in the index — so they can take different
// weights and visibly RIP apart when posed (the clean-edged chest tear).
// Coincident verts are unioned into the smoothing adjacency AND forced to
// identical weights at the end. Smoothing then mixes 40% of neighbors'
// weights per pass so no edge crosses a hard ownership seam.
out.forEach((w, mi) => {
const idx = meshes[mi].index;
if (!idx) return;
const p = meshes[mi].pos;
const n = w.sWgt.length / 4;
// Weld groups by quantized position.
const groupOf = new Int32Array(n).fill(-1);
const groups = [];
{
const seen = new Map();
const q = h * 1e-3; // far below voxel scale — only true duplicates weld
for (let i = 0; i < n; i++) {
const key = `${Math.round(p[i * 3] / q)},${Math.round(p[i * 3 + 1] / q)},${Math.round(p[i * 3 + 2] / q)}`;
let g = seen.get(key);
if (g == null) { g = groups.length; groups.push([]); seen.set(key, g); }
groupOf[i] = g;
groups[g].push(i);
}
}
const deg = new Uint16Array(n);
for (let t = 0; t < idx.length; t += 3) {
deg[idx[t]] += 2; deg[idx[t + 1]] += 2; deg[idx[t + 2]] += 2;
}
const off = new Uint32Array(n + 1);
for (let i = 0; i < n; i++) off[i + 1] = off[i] + deg[i];
const nbr = new Uint32Array(off[n]);
const cur = new Uint32Array(n);
const addEdge = (a, b) => { nbr[off[a] + cur[a]++] = b; nbr[off[b] + cur[b]++] = a; };
for (let t = 0; t < idx.length; t += 3) {
addEdge(idx[t], idx[t + 1]); addEdge(idx[t + 1], idx[t + 2]); addEdge(idx[t + 2], idx[t]);
}
let sI = w.sIdx, sW = w.sWgt;
const weld = () => { // every weld group shares its averaged weights
for (const g of groups) {
if (g.length < 2) continue;
const acc = new Map();
for (const i of g) for (let k = 0; k < 4; k++) if (sW[i * 4 + k] > 0) acc.set(sI[i * 4 + k], (acc.get(sI[i * 4 + k]) || 0) + sW[i * 4 + k]);
const kept = [...acc.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4);
let sum = 0;
for (const [, wv] of kept) sum += wv;
for (const i of g) kept.forEach(([bone, wv], k) => { sI[i * 4 + k] = bone; sW[i * 4 + k] = wv / sum; });
}
};
weld();
for (let pass = 0; pass < 2; pass++) {
const nI = new Uint16Array(n * 4), nW = new Float32Array(n * 4);
const acc = new Map();
for (let i = 0; i < n; i++) {
acc.clear();
for (let k = 0; k < 4; k++) if (sW[i * 4 + k] > 0) acc.set(sI[i * 4 + k], (acc.get(sI[i * 4 + k]) || 0) + sW[i * 4 + k] * 0.6);
const nb = deg[i];
if (nb) {
const share = 0.4 / nb;
for (let e = off[i]; e < off[i] + nb; e++) {
const j = nbr[e];
for (let k = 0; k < 4; k++) if (sW[j * 4 + k] > 0) acc.set(sI[j * 4 + k], (acc.get(sI[j * 4 + k]) || 0) + sW[j * 4 + k] * share);
}
}
// keep the 4 heaviest, renormalize
let kept = [...acc.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4);
let sum = 0;
for (const [, wv] of kept) sum += wv;
kept.forEach(([bone, wv], k) => { nI[i * 4 + k] = bone; nW[i * 4 + k] = wv / sum; });
}
sI = nI; sW = nW;
weld(); // re-weld after each pass — smoothing pulls seam twins apart again
}
w.sIdx = sI; w.sWgt = sW;
});
return out;
}
/* VOXELW-END */
function serializePose(node) {
const ps = node._pcrPose;
if (!ps) return {};
const figs = ps.figures || [];
return {
version: 3,
camera: {
position: ps.camera.position.toArray(),
target: ps.controls.target.toArray(),
},
activeIndex: Math.max(0, figs.indexOf(ps.rig)),
figures: figs.map(serializeFigure),
props: ps.propsApi ? ps.propsApi.serialize() : [],
attachments: ps.attachApi ? ps.attachApi.serialize() : [],
// Computed during the control-map render (captureBlob) with that exact
// camera — never recomputed here, so heads always match the rendered map.
heads: node._pcrHeads || [],
// Region entities in EXACT mask-row order (figures, then named props),
// stamped by captureRegionMasks — the server binds $blocks to mask rows
// through this list. Empty = no regional masks (server derives figures-only,
// the v2 behavior). Never derived here: capture skips load-failed prop
// placeholders that a name-walk would count, which would shift every row.
regionEntities: node._pcrRegionEntities || [],
};
}
// Render the current frame, upload it as this node's control map, and stash the
// resulting filename + pose state on the hidden widgets. Coalesced so rapid
// interaction-ends don't stack uploads.
// Regional conditioning: when this Poser's MASKS feed a PromptChain_AttentionCouple
// node, keep the bound PromptChain prompt stocked with one $mannequinN{} block per
// figure. Add-only — deleting a figure leaves its block (so typed tags survive; the
// couple node harmlessly clamps an unbound index). Idempotent, so ordinary posing
// never rewrites the editor; only spawning a new figure injects. Gated by the
// MASKS->couple trace below, so non-regional Posers are a no-op regardless of count.
// Trace this Poser's MASKS -> AttentionCouple -> its regions input -> PromptChain.
// Returns {} when the Poser isn't wired regionally.
function traceRegionalNodes(node) {
const graph = app.graph;
if (!graph) return {};
const maskOut = node.outputs?.find((o) => o.name === "MASKS");
if (!maskOut?.links?.length) return {};
for (const linkId of maskOut.links) {
// graph.links is a Map in some ComfyUI builds and a plain object in others —
// getLink handles both (a bare graph.links[id] silently fails on the Map).
const link = getLink(graph, linkId);
const couple = link && graph.getNodeById(link.target_id);
if (couple?.comfyClass !== "PromptChain_AttentionCouple") continue;
const regIn = couple.inputs?.find((i) => i.name === "regions");
const rlink = regIn?.link != null ? getLink(graph, regIn.link) : null;
const pc = rlink && graph.getNodeById(rlink.origin_id);
if (pc?.comfyClass === "PromptChain_PromptChain") return { couple, pc };
}
return {};
}
function reconcileRegionBlocks(node, names) {
if (!names?.length) return;
const { pc } = traceRegionalNodes(node);
if (!pc?._pcrEditor) return;
const text = pc._pcrEditor.state.doc.toString();
// Ghost guard: this sync is keyed by each figure's CURRENT name and has no
// memory, so whenever a name and its block drift (manual block rename, undo
// reverting a rename, old saves without names) a naive "name missing → add"
// re-seeds default-named ghost blocks next to the user's real ones. Only seed
// while the doc has fewer region blocks than figures — equal counts mean the
// user is managing the blocks — and spend the budget on the NEWEST figures
// (a fresh spawn is what actually needs seeding).
let budget = names.length - (text.match(/\$\w+\s*\{/g) || []).length;
if (budget <= 0) return;
const missing = [];
for (let i = names.length - 1; i >= 0 && budget > 0; i--) {
// Case-insensitive to match the server-side name binding (it lower-cases both).
if (new RegExp("\\$" + names[i] + "\\s*\\{", "i").test(text)) continue;
missing.unshift(names[i]);
budget--;
}
if (!missing.length) return;
const blocks = missing.map((name) => "$" + name + " {\n\n}").join("\n\n");
// Insert ABOVE the negative section — blind appending dropped the new block
// inside the negative prompt whenever the doc had a "Negative Prompt:" marker.
// Same marker regex the compiler splits on.
const neg = text.match(/negative\s+prompt\s*:/i);
if (neg) {
const head = text.slice(0, neg.index);
setEditorContent(pc._pcrEditor,
(head.trim() ? head.replace(/\s*$/, "\n\n") : "") + blocks + "\n\n" + text.slice(neg.index));
} else {
setEditorContent(pc._pcrEditor, text + (text.trim() ? "\n\n" : "") + blocks);
}
}
// Rewrite every `$oldName {` block in the bound prompt to the figure's new name,
// so the user's typed tags travel with the rename. If no block exists yet,
// reconcileRegionBlocks adds the new-name block on the next capture.
function renameRegionBlock(node, oldName, newName) {
const { pc } = traceRegionalNodes(node);
if (!pc?._pcrEditor) return;
const text = pc._pcrEditor.state.doc.toString();
const re = new RegExp("\\$" + oldName + "(\\s*\\{)", "gi");
if (!re.test(text)) return;
re.lastIndex = 0;
setEditorContent(pc._pcrEditor, text.replace(re, (m, brace) => "$" + newName + brace));
}
// ── Text → Poser region-name sync (the reverse of renameRegionBlock) ─────────
// renameRegionBlock pushes a Poser-side rename INTO the prompt; this brings a
// prompt-side rename BACK to the figure. Renaming `$mannequin1 {` → `$alice {`
// in the bound PromptChain editor renames mannequin 1 in the viewport. The
// editor has no figure identity in it (only names, in order), so we act ONLY on
// an unambiguous single in-place rename and debounce (the editor fires per
// keystroke) so half-typed names never touch figure state. Add/delete edits are
// left to the forward reconcile; invalid/duplicate names are rejected by the
// same gate the inline rename uses (renameEntityByName on _pcrPose).
const REGION_BLOCK_NAME_RE = /\$([A-Za-z_]\w*)\s*\{/g;
function regionBlockNames(text) {
const out = [];
REGION_BLOCK_NAME_RE.lastIndex = 0;
let m;
while ((m = REGION_BLOCK_NAME_RE.exec(text)) !== null) out.push(m[1]);
return out;
}
// One block renamed in place → {from,to}; otherwise null. Differing counts mean
// an add/delete (the reconcile's job); more than one change means an ambiguous
// paste we can't map without identity. Case-sensitive so a case-only edit still
// propagates (binding itself stays case-insensitive).
function singleBlockRename(prev, cur) {
if (prev.length !== cur.length) return null;
let hit = null;
for (let i = 0; i < cur.length; i++) {
if (cur[i] === prev[i]) continue;
if (hit) return null;
hit = { from: prev[i], to: cur[i] };
}
return hit;
}
function syncRegionRenamesFromText(pc) {
if (!pc?._pcrEditor) return;
const cur = regionBlockNames(pc._pcrEditor.state.doc.toString());
const prev = pc._pcrLastBlockNames;
pc._pcrLastBlockNames = cur; // advance the baseline every run, applied or not
if (!prev) return; // first observation — nothing to diff against
const ren = singleBlockRename(prev, cur);
if (!ren || !ren.to) return;
// The Poser bound to THIS prompt (MASKS→couple→regions→pc). Found live so it
// stays correct across rewiring/teardown; a torn-down Poser left the registry.
const poser = poseRegistry.all().find((p) => p._pcrAlive && traceRegionalNodes(p).pc === pc);
poser?._pcrPose?.renameEntityByName?.(ren.from, ren.to);
}
// Attach the debounced observer to the bound prompt's editor, once per editor
// instance (re-attaches if the node rebuilds its editor on reload). Called from
// the same spots as the forward sync, so it's live the moment a Poser is wired
// regionally. No-op when this Poser doesn't feed an AttentionCouple.
function ensureReverseRegionSync(node) {
const { pc } = traceRegionalNodes(node);
if (!pc?._pcrEditor) return;
if (pc._pcrRegionSyncView === pc._pcrEditor) return; // already observing this view
pc._pcrRegionSyncView = pc._pcrEditor;
pc._pcrLastBlockNames = regionBlockNames(pc._pcrEditor.state.doc.toString());
attachDocChangeListener(pc._pcrEditor, () => {
clearTimeout(pc._pcrRegionSyncTimer);
pc._pcrRegionSyncTimer = setTimeout(() => syncRegionRenamesFromText(pc), 400);
});
}
// Custom names bind region→figure by NAME on the server, which needs the figure
// list — wire POSE_JSON into the couple's pose input if it isn't already. Old
// couple defs (page loaded before the backend gained 'pose') just skip; default
// mannequinN names still bind positionally without the wire.
function ensureCouplePoseWire(node) {
const { couple } = traceRegionalNodes(node);
if (!couple) return;
const poseIn = couple.inputs?.findIndex((i) => i.name === "pose");
if (poseIn == null || poseIn < 0 || couple.inputs[poseIn].link != null) return;
const poseOut = node.outputs?.findIndex((o) => o.name === "POSE_JSON");
if (poseOut >= 0) node.connect(poseOut, couple, poseIn);
}
// Inject region prompts carried from a cross-scene paste (a character built in
// another workflow brings its $name{} body with it). Held on the node until the
// bound PromptChain editor exists, so a paste made before that editor builds
// isn't lost — re-tried from captureAndUpload on the next capture, never polled.
// Uses the broad consumer trace (AttentionCouple / RegionalConditioning /
// Ideogram), so "region mode" here is purely the destination's wiring.
function flushPendingRegions(node) {
const pend = node._pcrPendingRegions;
if (!pend?.length) return;
// Only seed entities that still exist — a figure deleted before the bound
// editor became available must not leave a ghost $name{} bound to no mask row.
const live = node._pcrPose?.entityNames?.();
const fresh = live ? pend.filter((e) => live.some((n) => n.toLowerCase() === e.name.toLowerCase())) : pend;
// seedRegionBlocks returns true when done OR when the dest isn't region-wired
// at all (nothing to inject into) — either way stop retrying; only a wired
// consumer whose editor isn't built yet keeps the paste pending.
if (seedRegionBlocks(node, fresh)) node._pcrPendingRegions = null;
}
// Content id for a capture set (control map + masks), salted with the node id.
// The salt keeps two posers in one graph from sharing filenames — sharing would
// let one poser's superseded-set delete orphan the other's live reference.
// crypto.subtle needs a secure context (https/localhost); plain-http LAN access
// gets a dual-FNV fallback — it only has to separate scenes, not resist attack.
async function hashCaptureSet(node, mapBlob, maskCaptures) {
const buffers = [await mapBlob.arrayBuffer()];
for (const m of maskCaptures) buffers.push(await m.blob.arrayBuffer());
buffers.push(new TextEncoder().encode(String(node.id)).buffer);
const bytes = new Uint8Array(buffers.reduce((n, b) => n + b.byteLength, 0));
let offset = 0;
for (const b of buffers) { bytes.set(new Uint8Array(b), offset); offset += b.byteLength; }
if (crypto?.subtle) {
const digest = await crypto.subtle.digest("SHA-256", bytes);
return [...new Uint8Array(digest, 0, 6)].map((x) => x.toString(16).padStart(2, "0")).join("");
}
let h1 = 0x811c9dc5, h2 = 0x6c62272e;
for (const b of bytes) {
h1 = Math.imul(h1 ^ b, 0x01000193) >>> 0;
h2 = Math.imul(h2 ^ b, 0x01000193) >>> 0;
}
return (h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0")).slice(0, 12);
}
async function uploadPoseFile(blob, filename) {
const form = new FormData();
form.append("image", new File([blob], filename, { type: "image/png" }));
form.append("subfolder", "promptchain_pose");
form.append("type", "input");
form.append("overwrite", "true");
const resp = await api.fetchApi("/upload/image", { method: "POST", body: form });
return resp.json();
}
// Remove the capture set a new upload superseded. Only content-addressed bases
// are deletable — legacy node-id names are shared across workflows, and the
// server enforces the same pattern. Fire-and-forget: a missed delete is just a
// file the age sweep collects later.
function deleteSupersededSet(previousRef, newBase) {
const prevName = previousRef?.split("/").pop()?.replace(/ \[\w+\]$/, "") || "";
const prevBase = prevName.endsWith(".png") ? prevName.slice(0, -4) : null;
if (!prevBase || prevBase === newBase || !/^promptchain_pose_[0-9a-f]{12}$/.test(prevBase)) return;
api.fetchApi("/promptchain/pose-files/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ base: prevBase }),
}).catch((e) => console.warn("[PoseStudio] superseded-set delete failed", e));
}
// Pin a content-addressed capture set so a later re-pose's superseded-delete
// can't remove it (the delete route honours <base>.keep). Fire-and-forget.
function pinCaptureSet(ref) {
const name = ref?.split("/").pop()?.replace(/ \[\w+\]$/, "") || "";
const base = name.endsWith(".png") ? name.slice(0, -4) : null;
if (!base || !/^promptchain_pose_[0-9a-f]{12}$/.test(base)) return;
api.fetchApi("/promptchain/pose-files/pin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ base }),
}).catch((e) => console.warn("[PoseStudio] pin failed", e));
}
// Before a prompt is enqueued, pin every live Poser's current control map so a
// re-pose while that render waits in a busy queue can't GC the set it
// references (execute() also pins, but only once the render actually starts).
function pinLivePoserSets() {
for (const n of poseRegistry.all()) {
if (!n._pcrAlive) continue;
const ref = n.widgets?.find((w) => w.name === "control_map")?.value;
if (ref) pinCaptureSet(ref);
}
}
async function captureAndUpload(node) {
const ps = node._pcrPose;
if (!ps || !node._pcrAlive) return; // bail if torn down (renderer may be disposed)
// Persist scene state SYNCHRONOUSLY, every call, decoupled from the async image
// upload below. pose_state is pure scene data and must always reflect the latest
// scene — gating it behind the (coalesced, awaited) upload meant a save during an
// in-flight upload wrote stale state, silently dropping figures/props the user had
// just added. The control-map image can lag; the saved scene must not.
setWidgetValue(node, "pose_state", JSON.stringify(serializePose(node)));
ps.recordHistory?.(); // snapshot the pose for undo/redo (dedupes; skipped during undo)
if (ps.uploading) { ps.uploadPending = true; return; }
ps.uploading = true;
try {
const blob = ps.captureBlob
? await ps.captureBlob()
: await new Promise((res) => ps.renderer.domElement.toBlob(res, "image/png"));
if (blob) {
// Masks are captured BEFORE any upload so the whole set shares one
// content hash. Content-addressed names make a filename pin exactly its
// own scene's pixels: two workflows whose posers share a node id used to
// overwrite each other's promptchain_pose_<id>.png and silently render
// with the other scene's map.
const maskCaptures = ps.captureRegionMasks ? await ps.captureRegionMasks() : [];
const base = `promptchain_pose_${await hashCaptureSet(node, blob, maskCaptures)}`;
const previousRef = node.widgets?.find((w) => w.name === "control_map")?.value;
const data = await uploadPoseFile(blob, `${base}.png`);
let ref = data.subfolder ? `${data.subfolder}/${data.name}` : data.name;
if (data.type && data.type !== "input") ref += ` [${data.type}]`;
// Export one region mask per figure (for regional conditioning). Files
// only — no new output sockets, so saved graphs are unaffected. The
// server finds them beside the map as <map-base>_mask<i>.png.
for (const m of maskCaptures) {
await uploadPoseFile(m.blob, `${base}_mask${m.index}.png`);
}
setWidgetValue(node, "control_map", ref); // pose_state already persisted at the top
// Re-persist pose_state now that captureBlob refreshed the head boxes
// (the synchronous write at the top carried the previous capture's).
setWidgetValue(node, "pose_state", JSON.stringify(serializePose(node)));
deleteSupersededSet(previousRef, base);
// Background upscale's queue gate listens for this: a complete capture
// set (map + masks) for this node's current scene is now on the server.
ps.uploadedOnce = true;
window.dispatchEvent(new CustomEvent("promptchain:pose-uploaded", { detail: { nodeId: node.id } }));
}
// Body-aware first: drain any region prompts carried in from a cross-scene
// paste (writes $name{ tags }), so the add-only reconcile below sees those
// blocks already present and won't re-seed them empty.
flushPendingRegions(node);
// Regional: keep the bound PromptChain prompt's $name{} blocks in sync
// with the region entities — figures AND named props (add-only). No-op
// unless this Poser feeds an AttentionCouple.
reconcileRegionBlocks(node, ps.entityNames ? ps.entityNames() : effectiveFigureNames(ps.figures));
ensureReverseRegionSync(node); // and watch the prompt for $name{} renames typed back the other way
} catch (e) {
console.error("[PoseStudio] control-map upload failed", e);
} finally {
ps.uploading = false;
if (ps.uploadPending) { ps.uploadPending = false; captureAndUpload(node); }
}
}
// ── auto-sync to the generation latent ──────────────────────────────────────
// Trace the graph downstream to the sampler, back up its latent input to the
// node that defines the generation resolution (EmptyLatentImage et al.), and
// adopt its width/height so the control map always matches the output frame.
const SAMPLER_TYPES = new Set([
"KSampler", "KSamplerAdvanced", "SamplerCustom", "SamplerCustomAdvanced",
"PromptChain_RegionalDetailer", "UltimateSDUpscale",
]);
// Nodes that actually DEFINE the generation resolution — preferred when the upstream
// sweep would otherwise latch onto some unrelated node that happens to carry width/height.
const LATENT_RES_TYPES = new Set([
"EmptyLatentImage", "EmptySD3LatentImage", "EmptyHunyuanLatentVideo",
"SDXLEmptyLatentSizePicker", "EmptyImage", "EmptyMochiLatentVideo", "EmptyLTXVLatentVideo",
]);
function linkOf(graph, id) {
return graph?.links?.get?.(id) || graph?.links?.[id] || null;
}
function findDownstreamSampler(start, graph) {
const seen = new Set();
const queue = [start];
while (queue.length) {
const n = queue.shift();
if (seen.has(n.id)) continue;
seen.add(n.id);
for (const out of n.outputs || []) {
for (const lid of out.links || []) {
const l = linkOf(graph, lid);
if (!l) continue;
const t = graph.getNodeById(l.target_id);
if (!t) continue;
if (SAMPLER_TYPES.has(t.comfyClass || t.type)) return t;
queue.push(t);
}
}
}
return null;
}
// BFS upstream for the node defining the generation resolution. A real latent-size node
// (EmptyLatentImage et al.) wins immediately; otherwise fall back to the first node that
// merely carries numeric width/height (which may be an unrelated loader/resize node).
function findResUpstream(start, graph) {
const seen = new Set();
const queue = [start];
let fallback = null;
while (queue.length) {
const n = queue.shift();
if (seen.has(n.id)) continue;
seen.add(n.id);
if ((n.comfyClass || n.type) !== NODE_TYPE) {
const w = n.widgets?.find((x) => x.name === "width");
const h = n.widgets?.find((x) => x.name === "height");
if (w && h && typeof w.value === "number" && typeof h.value === "number") {
const res = { width: Math.round(w.value), height: Math.round(h.value), node: n };
if (LATENT_RES_TYPES.has(n.comfyClass || n.type)) return res; // a true latent-size node wins
if (!fallback) fallback = res; // remember the first generic w/h node
}
}
for (const inp of n.inputs || []) {
if (inp.link == null) continue;
const l = linkOf(graph, inp.link);
const s = l && graph.getNodeById(l.origin_id);
if (s) queue.push(s);
}
}
return fallback;
}
function findLatentResolution(node) {
const graph = app.graph;
if (!graph) return null;
const sampler = findDownstreamSampler(node, graph);
if (!sampler) return null;
// Prefer the sampler's latent input; fall back to a full upstream sweep.
const latentInput = (sampler.inputs || []).find((i) => /latent|samples/i.test(i.name) && i.link != null);
let start = sampler;
if (latentInput) {
const l = linkOf(graph, latentInput.link);
const src = l && graph.getNodeById(l.origin_id);
if (src) start = src;
}
return findResUpstream(start, graph);
}
// Live follow: once a poser has resolved its latent-size node, hook that
// node's width/height widget callbacks so EDITING THE LATENT pushes the new
// resolution back into every connected poser — event-driven, no polling.
// Each poser re-traces its own chain on notify, so a poser fed by a different
// latent ignores the change; the hook outliving its posers is a harmless
// no-op (the registry is empty). Rewiring to a NEW latent node is covered by
// the existing triggers (mount / the poser's own connection changes / ⟳),
// which hook the new node on their next sync.
function hookLatentResWidgets(resNode) {
for (const name of ["width", "height"]) {
const w = resNode.widgets?.find((x) => x.name === name);
if (!w || w._pcrPoseResHooked) continue;
w._pcrPoseResHooked = true;
const orig = w.callback;
w.callback = function (...args) {
const r = orig?.apply(this, args);
requestAnimationFrame(() => {
for (const p of poseRegistry.all()) if (p._pcrAlive) syncToLatent(p);
});
return r;
};
}
}
// Returns "matched" | "unchanged" | "not-found".
function syncToLatent(node) {
const res = findLatentResolution(node);
if (!res) return "not-found";
if (res.node) hookLatentResWidgets(res.node); // future latent edits flow back automatically
const wW = node.widgets?.find((w) => w.name === "width");
const hW = node.widgets?.find((w) => w.name === "height");
if (!wW || !hW) return "not-found";
if (wW.value === res.width && hW.value === res.height) return "unchanged";
wW.value = res.width;
hW.value = res.height;
// Fire ONE wrapped widget callback (setup wraps both with fitCanvas +
// render + capture): setting .value alone left the viewport letterboxed at
// the OLD aspect until the next container resize. Both values are already
// set, so a single callback re-fits with the full new resolution.
if (hW.callback) hW.callback(res.height);
else { node._pcrPose?.requestRender?.(); captureAndUpload(node); }
node.setDirtyCanvas?.(true, true);
return "matched";
}
// Apply one figure's serialized pose (joint rotations + positions, digits, spine,
// whole-figure root, morphs) to its rig. Camera and props are scene-level and
// handled by the caller (restoreSavedState/applyHistory), not here. Every field is
// optional so older saves load cleanly; unknown joints are skipped.
function applyFigureData(rig, data) {
if (!rig || !data) return;
if (data.joints) for (const [name, q] of Object.entries(data.joints)) {
const b = rig.joints[name];
if (b && Array.isArray(q) && q.length === 4) b.quaternion.fromArray(q);
}
if (data.jointPos) {
// Pre-spine_04 saves (no spine_04 key) stored neck/clavicle LOCAL positions
// relative to the old undivided spine_03; restoring them onto the subdivided
// rig would shove the neck up by the split distance. Skip those three — the
// new rest is correct (only a rare Ctrl-moved neck/clavicle loses anything).
const preSubdivSave = rig.boneByName?.has("spine_04") && !data.jointPos.spine_04;
for (const [name, p] of Object.entries(data.jointPos)) {
if (preSubdivSave && (name === "neck_01" || name === "clavicle_l" || name === "clavicle_r")) continue;
// Saved by bone name (all skeleton bones) for morph rigs; by canonical joint
// name for the capsule fallback. Try both maps.
const b = rig.boneByName?.get(name) || rig.joints[name];
if (b && Array.isArray(p) && p.length === 3) b.position.fromArray(p);
}
}
// Toe bones restore from `toeQuats` (a separate key), NOT raw `digits`: skip any toe entry
// in digits so a pre-2-bone save's stale toe quaternion can't crumple the rebuilt foot.
const toeNameSet = new Set();
if (rig.toeChains) for (const k of Object.keys(rig.toeChains)) for (const ch of rig.toeChains[k]) for (const b of ch) toeNameSet.add(b.name);
if (data.digits) {
const byName = {};
for (const b of digitBones(rig)) byName[b.name] = b;
for (const [name, q] of Object.entries(data.digits))
if (byName[name] && !toeNameSet.has(name) && Array.isArray(q) && q.length === 4) byName[name].quaternion.fromArray(q);
}
// Toes (2-bone digits): reset every toe to its captured rest, then apply `toeQuats` if present.
// Old/incompatible saves carry no toeQuats → toes land at a clean rest instead of crumpled.
if (rig.toeChains && rig.toeRest) {
const byName = {};
for (const k of Object.keys(rig.toeChains)) for (const ch of rig.toeChains[k]) for (const b of ch) {
const r = rig.toeRest.get(b); if (r) b.quaternion.copy(r);
byName[b.name] = b;
}
if (data.toeQuats) for (const [name, q] of Object.entries(data.toeQuats))
if (byName[name] && Array.isArray(q) && q.length === 4) byName[name].quaternion.fromArray(q);
}
if (data.spine && rig.spineChain) {
const byName = {};
for (const b of rig.spineChain) byName[b.name] = b;
for (const [name, q] of Object.entries(data.spine))
if (byName[name] && Array.isArray(q) && q.length === 4) byName[name].quaternion.fromArray(q);
}
if (Array.isArray(data.root) && rig.root) rig.root.position.fromArray(data.root);
// Whole-figure rotation (fig.root wrapper). Older saves lack it → leave identity.
if (Array.isArray(data.rootQuat) && rig.root) rig.root.quaternion.fromArray(data.rootQuat);
// Whole-figure scale — set by the skeleton-fit (sized to the bound model).
// Older saves lack it → keep the spawn's own auto-fit scale untouched.
if (Array.isArray(data.rootScale) && rig.root) rig.root.scale.fromArray(data.rootScale);
if (data.morphs && rig.morphMesh?.morphTargetInfluences) {
rig.morphMesh.morphTargetInfluences.fill(0); // unsaved morphs → neutral, not stale
for (const [name, v] of Object.entries(data.morphs)) {
const i = rig.morphIndex[name];
if (i != null && Number.isFinite(v)) rig.morphMesh.morphTargetInfluences[i] = v;
}
}
if (rig.skeletalAmount) rig.skeletalAmount = { ...(data.skeletalAmount || {}) };
rig.customName = (typeof data.name === "string" && data.name.trim()) ? data.name.trim() : null;
rig.skinBind = data.skinBind || null; // materialized by syncSkinBinds (async mesh load)
}
// Display/binding name per figure: the custom name, else positional mannequinN.
function effectiveFigureNames(figs) {
return (figs || []).map((f, i) => f.customName || "mannequin" + (i + 1));
}
// ── inverse kinematics ──────────────────────────────────────────────────────
// Each limb is a 2-bone chain (root→mid→tip) the user can pose by dragging the
// tip. The pole hint is a WORLD direction (the figure faces +Z) used only to pick
// a bend side when the limb starts dead-straight; otherwise the current bend
// plane is preserved so dragging never flips the elbow/knee.
const IK_LIMBS = [
{ tip: "wristL", chain: ["shoulderL", "elbowL", "wristL"], pole: [0, -0.5, -1] },
{ tip: "wristR", chain: ["shoulderR", "elbowR", "wristR"], pole: [0, -0.5, -1] },
{ tip: "ankleL", chain: ["hipL", "kneeL", "ankleL"], pole: [0, -0.2, 1] },
{ tip: "ankleR", chain: ["hipR", "kneeR", "ankleR"], pole: [0, -0.2, 1] },
];
// Anatomical elbow/knee bend range. Enforced by clamping the IK reach (below) so
// the interior angle never leaves this band: HINGE_MAX < 180° means the limb can't
// straighten fully (so it can't hyperextend / cross to a backward bend, and the
// bend plane never degenerates → no flips); HINGE_MIN stops it folding through itself.
const HINGE_MIN = 0.18; // ~10°
const HINGE_MAX = Math.PI * 0.96; // ~173°
// Max bend between consecutive spine segments (head-IK FABRIK) — keeps the spine a
// smooth curve instead of a crumple/kink. The base segment is left unconstrained so
// the whole spine can still bow/lean freely.
// 50°/vertebra × 4 bending joints (spine_01..03 + neck) = a full 200° inward
// roll — 30° capped the whole spine at 120°, which couldn't tuck the head
// ("body is incapable of a full inward curl"). Skin verified smooth at 50°
// (_headless/curl.mjs screenshots); IK below the cap is unchanged.
const MAX_SPINE_JOINT_BEND = (50 * Math.PI) / 180;
// ── rig profiles ────────────────────────────────────────────────────────────
// One entry per rig KIND. The posing toolkit (joint handles, two-bone IK,
// mirror, planted-limb pins, skeleton-bind capsules) reads the ACTIVE figure's
// profile instead of human-only module constants, so a non-human rig (horse,
// cat…) declares its own joint vocabulary and chains. `human` is exactly the
// historical constants; figures resolve their profile at spawn from
// FIGURE_LIBRARY[body].rig (absent = human, so old saves are untouched).
// Non-biped profiles must supply an explicit `boneMap` (bone name → canonical
// joint, authored per rig) — the heuristic classifiers below are biped-only
// and silently drop quadruped bones.
// Mirror pairs: L↔R joint names swapped across the sagittal plane.
const MIRROR_PAIRS = [
["shoulderL", "shoulderR"], ["elbowL", "elbowR"], ["wristL", "wristR"],
["hipL", "hipR"], ["kneeL", "kneeR"], ["ankleL", "ankleR"],
];
// Limb chains for "mirror just the selected limb" (base names; L/R appended).
const LIMB_MIRROR_SETS = [["shoulder", "elbow", "wrist"], ["hip", "knee", "ankle"]];
// Skeleton-bind capsules: canonical joint pairs spanning each body segment, and
// tip stubs that extend INTO the head/hands/feet so those verts don't grab the
// parent limb.
const SKIN_SEGMENTS = [
["root", "spine"], ["spine", "chest"], ["chest", "neck"], ["neck", "head"],
["shoulderL", "elbowL"], ["elbowL", "wristL"],
["shoulderR", "elbowR"], ["elbowR", "wristR"],
["hipL", "kneeL"], ["kneeL", "ankleL"],
["hipR", "kneeR"], ["kneeR", "ankleR"],
];
const SKIN_STUBS = [
["head", "neck", 0.9], ["wristL", "elbowL", 0.45], ["wristR", "elbowR", 0.45],
["ankleL", "kneeL", 0.35], ["ankleR", "kneeR", 0.35],
];
const RIG_PROFILES = {
human: {
joints: CANON_JOINTS,
ikLimbs: IK_LIMBS,
mirrorPairs: MIRROR_PAIRS,
limbMirrorSets: LIMB_MIRROR_SETS,
plantedTips: ["ankleL", "ankleR"], // feet pinned to the floor while the body moves
skinSegments: SKIN_SEGMENTS,
skinStubs: SKIN_STUBS,
boneMap: null, // null = heuristic biped classifiers
},
// Quadruped: authored for the bundled CC-BY horse (MAXDESIGN-3D, Sketchfab —
// see js/assets/props/CREDITS.txt). Vocabulary mirrors the human pattern with
// front/hind legs; chain tips are the fetlocks (Hand/Foot bones) so planted
// hooves pin where the leg meets the ground. Scapula/shoulder, hooves, and
// tail are FK posture bones. Head-drag spine FABRIK falls out of the generic
// head→pelvis chain walk (spine + neck bones).
horse: {
joints: [
"root", "spine", "back", "chest", "neck", "head", "tail", "tailMid",
"frontShoulderL", "frontHipL", "frontKneeL", "frontAnkleL", "frontHoofL",
"frontShoulderR", "frontHipR", "frontKneeR", "frontAnkleR", "frontHoofR",
"hindHipL", "hindKneeL", "hindAnkleL", "hindHoofL",
"hindHipR", "hindKneeR", "hindAnkleR", "hindHoofR",
],
ikLimbs: [
{ tip: "frontAnkleL", chain: ["frontHipL", "frontKneeL", "frontAnkleL"], pole: [0, -0.3, 1] },
{ tip: "frontAnkleR", chain: ["frontHipR", "frontKneeR", "frontAnkleR"], pole: [0, -0.3, 1] },
{ tip: "hindAnkleL", chain: ["hindHipL", "hindKneeL", "hindAnkleL"], pole: [0, -0.3, 1] },
{ tip: "hindAnkleR", chain: ["hindHipR", "hindKneeR", "hindAnkleR"], pole: [0, -0.3, 1] },
// A horse hind leg has THREE long segments (femur/tibia/cannon) — one
// two-bone chain can't span it, so the chain above tips at the HOCK and
// the lower leg hung FK-only. Second chain on the lower leg: the hoof
// gets its own IK diamond solving tibia+cannon about the hock; the hock
// diamond above keeps driving femur+tibia from the hip.
{ tip: "hindHoofL", chain: ["hindKneeL", "hindAnkleL", "hindHoofL"], pole: [0, -0.2, -1] },
{ tip: "hindHoofR", chain: ["hindKneeR", "hindAnkleR", "hindHoofR"], pole: [0, -0.2, -1] },
],
mirrorPairs: [
["frontShoulderL", "frontShoulderR"], ["frontHipL", "frontHipR"], ["frontKneeL", "frontKneeR"],
["frontAnkleL", "frontAnkleR"], ["frontHoofL", "frontHoofR"],
["hindHipL", "hindHipR"], ["hindKneeL", "hindKneeR"], ["hindAnkleL", "hindAnkleR"], ["hindHoofL", "hindHoofR"],
],
limbMirrorSets: [["frontHip", "frontKnee", "frontAnkle", "frontHoof"], ["hindHip", "hindKnee", "hindAnkle", "hindHoof"]],
plantedTips: ["frontAnkleL", "frontAnkleR", "hindAnkleL", "hindAnkleR"],
skinSegments: [
["root", "spine"], ["spine", "chest"], ["chest", "neck"], ["neck", "head"],
["frontHipL", "frontKneeL"], ["frontKneeL", "frontAnkleL"],
["frontHipR", "frontKneeR"], ["frontKneeR", "frontAnkleR"],
["hindHipL", "hindKneeL"], ["hindKneeL", "hindAnkleL"],
["hindHipR", "hindKneeR"], ["hindKneeR", "hindAnkleR"],
],
skinStubs: [
["head", "neck", 0.9],
["frontAnkleL", "frontKneeL", 0.35], ["frontAnkleR", "frontKneeR", 0.35],
["hindAnkleL", "hindKneeL", 0.35], ["hindAnkleR", "hindKneeR", 0.35],
],
boneMap: {
Hips_01: "root",
Spine_02: "spine", Spine2_04: "back", Spine3_05: "chest",
Neck_06: "neck", Head_010: "head",
Tail_043: "tail", Tail2_045: "tailMid",
LeftShoulder_024: "frontShoulderL", RightShoulder_031: "frontShoulderR",
LeftArm_025: "frontHipL", LeftForeArm_026: "frontKneeL", LeftHand_027: "frontAnkleL", LeftFingerBase_028: "frontHoofL",
RightArm_032: "frontHipR", RightForeArm_033: "frontKneeR", RightHand_034: "frontAnkleR", RightFingerBase_035: "frontHoofR",
LeftUpLeg_037: "hindHipL", LeftLeg_038: "hindKneeL", LeftFoot_039: "hindAnkleL", LeftToeBase_040: "hindHoofL",
RightUpLeg_049: "hindHipR", RightLeg_050: "hindKneeR", RightFoot_051: "hindAnkleR", RightToeBase_052: "hindHoofR",
},
keepAllMeshes: true, // body + mane/tail hair meshes — both are the horse
uprightHeuristic: false, // standing horse is longer than tall; don't tip it
targetHeight: 1.65, // withers-to-ground-ish overall height in meters
},
// Quadruped: authored for the bundled CC-BY cat (Evil_Katz, Sketchfab — see
// js/assets/props/CREDITS.txt). Same front/hind vocabulary as the horse, but
// the rig is fully semantic (j_l_femur, j_spine_1…) so the boneMap is exact.
// A cat is digitigrade — it stands on its paws, not its wrists/hocks — so the
// PLANTED tips and the ground-contact IK targets are the paws (palm/ball),
// and every leg carries BOTH a wrist/hock chain and a paw chain so the toe end
// is draggable too (the lesson the horse hind leg taught us, applied up front).
cat: {
joints: [
"root", "spine", "back", "chest", "neck", "head", "tail", "tailMid",
"frontShoulderL", "frontHipL", "frontKneeL", "frontAnkleL", "frontHoofL",
"frontShoulderR", "frontHipR", "frontKneeR", "frontAnkleR", "frontHoofR",
"hindHipL", "hindKneeL", "hindAnkleL", "hindHoofL",
"hindHipR", "hindKneeR", "hindAnkleR", "hindHoofR",
],
ikLimbs: [
// Front legs: humerus→elbow→wrist (2-bone to the wrist) + the lower chain
// elbow→wrist→paw so the paw has its own diamond. Hind legs likewise.
{ tip: "frontAnkleL", chain: ["frontHipL", "frontKneeL", "frontAnkleL"], pole: [0, -0.3, 1] },
{ tip: "frontAnkleR", chain: ["frontHipR", "frontKneeR", "frontAnkleR"], pole: [0, -0.3, 1] },
{ tip: "frontHoofL", chain: ["frontKneeL", "frontAnkleL", "frontHoofL"], pole: [0, -0.2, 1] },
{ tip: "frontHoofR", chain: ["frontKneeR", "frontAnkleR", "frontHoofR"], pole: [0, -0.2, 1] },
{ tip: "hindAnkleL", chain: ["hindHipL", "hindKneeL", "hindAnkleL"], pole: [0, -0.3, -1] },
{ tip: "hindAnkleR", chain: ["hindHipR", "hindKneeR", "hindAnkleR"], pole: [0, -0.3, -1] },
{ tip: "hindHoofL", chain: ["hindKneeL", "hindAnkleL", "hindHoofL"], pole: [0, -0.2, -1] },
{ tip: "hindHoofR", chain: ["hindKneeR", "hindAnkleR", "hindHoofR"], pole: [0, -0.2, -1] },
],
mirrorPairs: [
["frontShoulderL", "frontShoulderR"], ["frontHipL", "frontHipR"], ["frontKneeL", "frontKneeR"],
["frontAnkleL", "frontAnkleR"], ["frontHoofL", "frontHoofR"],
["hindHipL", "hindHipR"], ["hindKneeL", "hindKneeR"], ["hindAnkleL", "hindAnkleR"], ["hindHoofL", "hindHoofR"],
],
limbMirrorSets: [["frontHip", "frontKnee", "frontAnkle", "frontHoof"], ["hindHip", "hindKnee", "hindAnkle", "hindHoof"]],
plantedTips: ["frontHoofL", "frontHoofR", "hindHoofL", "hindHoofR"],
skinSegments: [
["root", "spine"], ["spine", "chest"], ["chest", "neck"], ["neck", "head"],
["frontHipL", "frontKneeL"], ["frontKneeL", "frontAnkleL"], ["frontAnkleL", "frontHoofL"],
["frontHipR", "frontKneeR"], ["frontKneeR", "frontAnkleR"], ["frontAnkleR", "frontHoofR"],
["hindHipL", "hindKneeL"], ["hindKneeL", "hindAnkleL"], ["hindAnkleL", "hindHoofL"],
["hindHipR", "hindKneeR"], ["hindKneeR", "hindAnkleR"], ["hindAnkleR", "hindHoofR"],
],
skinStubs: [
["head", "neck", 0.9],
["frontHoofL", "frontAnkleL", 0.3], ["frontHoofR", "frontAnkleR", 0.3],
["hindHoofL", "hindAnkleL", 0.3], ["hindHoofR", "hindAnkleR", 0.3],
],
boneMap: {
j_body_00_12: "root",
j_spine_1_02_13: "spine", j_spine_2_03_14: "back", j_spine_4_05_16: "chest",
j_neck_base_06_17: "neck", j_head_08_19: "head",
j_tail_1_043_54: "tail", j_tail_3_045_56: "tailMid",
j_l_scap_029_40: "frontShoulderL", j_r_scap_030_41: "frontShoulderR",
j_l_humerous_031_42: "frontHipL", j_l_elbow_032_43: "frontKneeL", j_l_wrist_033_44: "frontAnkleL", j_l_palm_034_45: "frontHoofL",
j_r_humerous_037_48: "frontHipR", j_r_elbow_038_49: "frontKneeR", j_r_wrist_039_50: "frontAnkleR", j_r_palm_040_51: "frontHoofR",
j_l_femur_050_61: "hindHipL", j_l_knee_051_62: "hindKneeL", j_l_ankle_052_63: "hindAnkleL", j_l_ball_053_64: "hindHoofL",
j_r_femur_055_66: "hindHipR", j_r_knee_056_67: "hindKneeR", j_r_ankle_057_68: "hindAnkleR", j_r_ball_058_69: "hindHoofR",
},
keepAllMeshes: true, // body + eye/inner meshes — all part of the cat
uprightHeuristic: false, // standing cat is longer than tall; don't tip it
targetHeight: 0.45, // ear-to-ground-ish overall height in meters
// This GLB's bind pose is a sitting cat with its head curled down to the floor —
// unworkable as a default. Pitch the neck + head UP about world Z (the cat's
// left-right axis; it stands facing +X) into an alert, head-up sit. Applied
// before rest capture, so this IS the pose it resets / mirrors / head-drags from.
restPose: [
{ bone: "j_neck_base_06_17", axis: [0, 0, 1], deg: 50 },
{ bone: "j_neck_1_07_18", axis: [0, 0, 1], deg: 35 },
{ bone: "j_head_08_19", axis: [0, 0, 1], deg: 25 },
],
},
};
// Active profile for a figure (everything pre-profile resolves to human).
const figProfile = (r) => r?.profile || RIG_PROFILES.human;
// ── hand / foot pose presets ────────────────────────────────────────────────
// Fingers are driven by a single 0–1 CURL AMOUNT per finger (the slider value).
// The amount scales a per-phalanx profile (proximal→distal degrees at amount=1),
// applied about each phalanx's bend axis. Presets are just sets of amounts, so
// picking one moves the sliders — and the user can fine-tune from there.
const FINGER_PROFILE = [82, 92, 68]; // deg at amount=1 (proximal, middle, distal)
const THUMB_CURL_DEG = [0, 60, 48]; // thumb_02/03 flexion (fold) at amount=1 (metacarpal aims, not folds)
const FINGER_ORDER = ["index", "middle", "ring", "pinky"];
// Hand-control sliders: [amount-key, label]. Fingers only — the thumb is posed by
// dragging its IK tip handle (a slider can't capture its 2-DOF motion).
const HAND_SLIDERS = [
["index", "index"], ["middle", "middle"], ["ring", "ring"], ["pinky", "pinky"],
];
const HAND_PRESETS = {
"Relaxed": { thumb: 0.15, thumbCurl: 0.10, index: 0.13, middle: 0.14, ring: 0.15, pinky: 0.15 },
"Fist": { thumb: 1.0, thumbCurl: 0.65, index: 1.0, middle: 1.0, ring: 1.0, pinky: 1.0 },
"Open": { thumb: 0.0, thumbCurl: 0.0, index: 0.0, middle: 0.0, ring: 0.0, pinky: 0.0 },
"Point": { thumb: 0.85, thumbCurl: 0.35, index: 0.0, middle: 1.0, ring: 1.0, pinky: 1.0 },
"Peace ✌": { thumb: 0.85, thumbCurl: 0.40, index: 0.0, middle: 0.0, ring: 1.0, pinky: 1.0 },
"Middle finger": { thumb: 0.85, thumbCurl: 0.35, index: 1.0, middle: 0.0, ring: 1.0, pinky: 1.0 },
"Chop": { thumb: 0.30, thumbCurl: 0.10, index: 0.0, middle: 0.0, ring: 0.0, pinky: 0.0 },
"Hold (large)": { thumb: 0.60, thumbCurl: 0.30, index: 0.50, middle: 0.52, ring: 0.52, pinky: 0.50 },
"Hold (small)": { thumb: 0.90, thumbCurl: 0.55, index: 0.80, middle: 0.82, ring: 0.80, pinky: 0.78 },
"Thumbs up": { thumb: 0.0, thumbCurl: 0.0, index: 1.0, middle: 1.0, ring: 1.0, pinky: 1.0 },
};
// Single `ball` bone per foot → flex angle in degrees (negative = point/extend). The ball
// is the PARENT of the five toe bones, so a preset still flexes every toe together.
const FOOT_PRESETS = { "Neutral": 0, "Pointed": -38, "Curled": 42 };
// Per-toe curl: each toe bone (big→little) curls about its bend axis by amount(0-1) × this.
const TOE_LABELS = ["big", "long", "middle", "ring", "little"];
const TOE_CURL_DEG = 45;
// Hand-authored CAPTURED poses (raw bone quaternions, keyed by base bone name, in
// RIGHT-hand convention — mirrored for the left). These reproduce a pose exactly,
// including the IK-posed thumb, which the amount sliders can't. Captured by posing
// the figure and reading pose_state.digits from a saved workflow.
const POSE_PRESETS = {
"Peace ✌": {
thumb_01: [0.0157, -0.131, -0.0587, 0.9895], thumb_02: [0.2102, 0.0141, 0.5779, 0.7884], thumb_03: [0.0236, -0.4833, 0.4229, 0.7662],
index_01: [0.1141, -0.473, 0.0048, 0.8736], index_02: [0.1078, -0.0028, -0.0061, 0.9942], index_03: [0.0642, -0.0001, -0.0187, 0.9978],
middle_01: [0.1035, -0.4057, 0.1552, 0.8948], middle_02: [0.0456, -0.0014, -0.0373, 0.9983], middle_03: [0.071, 0, 0.0058, 0.9975],
ring_01: [0.6105, -0.1594, 0.3522, 0.6913], ring_02: [0.7026, 0.0246, -0.1131, 0.7021], ring_03: [0.5648, 0.0213, -0.044, 0.8238],
pinky_01: [0.5248, -0.077, 0.4512, 0.7177], pinky_02: [0.6685, 0.0178, 0.0268, 0.743], pinky_03: [0.452, 0.023, 0.0421, 0.8907],
},
};
// Captured FULL-BODY poses (raw local quaternions for the canonical joints, the
// spine chain, and the digits). All GLB bodies share the 54-bone rig, so a pose
// captured on one transfers to any of them; body shape lives in bone POSITIONS
// (jointPos/sliders), which a pose never touches. Authoring workflow: pose a
// figure in the poser, save the ComfyUI workflow, run
// _posebuild/extract_body_pose.py on it, paste the entry here.
const BODY_POSES = {
"arms behind head": {
joints: {
root: [0.5306, 0, 0, 0.8476], shoulderL: [-0.3572, 0.704, -0.2933, -0.5392], elbowL: [-0.8592, -0.0093, -0.003, -0.5115],
wristL: [-0.1102, -0.4111, 0.0161, -0.9048], shoulderR: [0.3572, 0.704, -0.2933, 0.5392], elbowR: [0.8592, -0.0093, -0.003, 0.5115],
wristR: [0.1102, -0.4111, 0.0161, 0.9048], neck: [0.1624, 0, 0, 0.9867], head: [-0.2058, 0, 0, 0.9786],
hipL: [-0.9581, -0.0917, 0.2139, 0.1671], kneeL: [0.0588, 0.2322, -0.0085, 0.9709], ankleL: [-0.5353, -0.0011, 0.0008, 0.8446],
hipR: [-0.9581, 0.0917, -0.2139, 0.1671], kneeR: [0.0588, -0.2322, 0.0085, 0.9709], ankleR: [-0.5353, 0.0011, -0.0008, 0.8446],
chest: [0.0085, 0, 0, 1], spine: [0.3345, 0, 0, 0.9424], back: [0, 0, 0, 1],
},
spine: {
spine_01: [0.3345, 0, 0, 0.9424], spine_02: [-0.1026, 0, 0, 0.9947], spine_03: [0.0085, 0, 0, 1],
spine_04: [0, 0, 0, 1], neck_01: [0.1624, 0, 0, 0.9867], head: [-0.2058, 0, 0, 0.9786],
},
digits: {
index_01_l: [0.1101, 0.4792, -0.0033, 0.8708], index_02_l: [0.1087, 0.0029, 0.0061, 0.9941], index_03_l: [0.0627, 0.0006, 0.0187, 0.9979],
middle_01_l: [0.0982, 0.4131, -0.1524, 0.8925], middle_02_l: [0.0468, 0.0005, 0.0367, 0.9982], middle_03_l: [0.0677, 0.0009, -0.0059, 0.9977],
pinky_01_l: [0.0467, 0.2921, -0.3033, 0.9058], pinky_02_l: [0.0809, 0.0011, 0.0246, 0.9964], pinky_03_l: [-0.0087, -0.0002, -0.0113, 0.9999],
ring_01_l: [0.0521, 0.3896, -0.2305, 0.8902], ring_02_l: [0.0595, 0.0003, 0.0431, 0.9973], ring_03_l: [0.0596, 0.0007, -0.0245, 0.9979],
thumb_01_l: [-0.2744, -0.7312, -0.4376, -0.4456], thumb_02_l: [-0.069, 0.0171, 0.1829, -0.9806], thumb_03_l: [-0.1421, 0.0069, 0.0328, -0.9893],
index_01_r: [0.1101, -0.4792, 0.0033, 0.8708], index_02_r: [0.1087, -0.0029, -0.0061, 0.9941], index_03_r: [0.0627, -0.0006, -0.0187, 0.9979],
middle_01_r: [0.0982, -0.4131, 0.1524, 0.8925], middle_02_r: [0.0468, -0.0005, -0.0367, 0.9982], middle_03_r: [0.0677, -0.0009, 0.0059, 0.9977],
pinky_01_r: [0.0467, -0.2921, 0.3033, 0.9058], pinky_02_r: [0.0809, -0.0011, -0.0246, 0.9964], pinky_03_r: [-0.0087, 0.0002, 0.0113, 0.9999],
ring_01_r: [0.0521, -0.3896, 0.2305, 0.8902], ring_02_r: [0.0595, -0.0003, -0.0431, 0.9973], ring_03_r: [0.0596, -0.0007, 0.0245, 0.9979],
thumb_01_r: [0.2744, -0.7312, -0.4376, 0.4456], thumb_02_r: [0.069, 0.0171, 0.1829, 0.9806], thumb_03_r: [0.1421, 0.0069, 0.0328, 0.9893],
ball_l: [-0.2622, 0.0073, 0.0292, 0.9645], ball_r: [-0.2622, -0.0073, -0.0292, 0.9645],
},
},
};
// Flat list of all finger + toe bones on a rig (for serialize/restore).
function digitBones(rig) {
const out = [];
if (rig?.fingers) {
for (const wrist of ["wristL", "wristR"]) {
const hand = rig.fingers[wrist];
if (hand) for (const f of Object.keys(hand)) out.push(...hand[f]);
}
}
if (rig?.toes) for (const k of Object.keys(rig.toes)) out.push(rig.toes[k]);
if (rig?.toeChains) for (const k of Object.keys(rig.toeChains)) for (const ch of rig.toeChains[k]) out.push(...ch);
return out;
}
// Two-bone analytic IK (Daniel Holden, "Simple Two Joint IK"). Closed-form → one
// drag is one deterministic, jitter-free solve. a/b/c are the root/mid/tip joints
// (any Object3D — works for GLB bones or capsule groups); targetW is the desired
// tip world position. Rig-agnostic: it rotates each joint by the DELTA from its
// current angle, converting the world-space bend axis into each joint's own frame
// via that joint's original world quaternion, so it never assumes a bone's local
// axis convention (which differs across MakeHuman/Mixamo/Rigify exports).
function solveTwoBoneIK(THREE, a, b, c, targetW, poleW) {
if (!a || !b || !c) return; // an unmapped joint (GLB miss) must not throw in the IK/foot-pin path
const eps = 1e-5;
const aPos = a.getWorldPosition(new THREE.Vector3());
const bPos = b.getWorldPosition(new THREE.Vector3());
const cPos = c.getWorldPosition(new THREE.Vector3());
const lab = aPos.distanceTo(bPos);
const lcb = bPos.distanceTo(cPos);
if (lab < eps || lcb < eps) return;
// Clamp the reach to the span that keeps the interior elbow/knee angle inside
// [HINGE_MIN, HINGE_MAX] (law of cosines). This both enforces the joint limits
// and keeps the solved triangle valid (the tip stays on the target unless the
// target is out of the allowed range, where the limb sits at its limit).
const latMin = Math.sqrt(Math.max(0, lab * lab + lcb * lcb - 2 * lab * lcb * Math.cos(HINGE_MIN)));
const latMax = Math.sqrt(Math.max(0, lab * lab + lcb * lcb - 2 * lab * lcb * Math.cos(HINGE_MAX)));
const lat = THREE.MathUtils.clamp(aPos.distanceTo(targetW), latMin, latMax);
const dCA = cPos.clone().sub(aPos).normalize();
const dBA = bPos.clone().sub(aPos).normalize();
const dAB = aPos.clone().sub(bPos).normalize();
const dCB = cPos.clone().sub(bPos).normalize();
const dTA = targetW.clone().sub(aPos).normalize();
const cl = (x) => THREE.MathUtils.clamp(x, -1, 1); // every acos arg — NaN guard
const acAb0 = Math.acos(cl(dCA.dot(dBA)));
const baBc0 = Math.acos(cl(dAB.dot(dCB)));
const acAt0 = Math.acos(cl(dCA.dot(dTA)));
const acAb1 = Math.acos(cl((lcb * lcb - lab * lab - lat * lat) / (-2 * lab * lat)));
const baBc1 = Math.acos(cl((lat * lat - lab * lab - lcb * lcb) / (-2 * lab * lcb)));
// Bend-plane normal: keep the limb's current plane (flip-free while dragging);
// if it's straight (degenerate cross), pick the side from the heuristic pole.
const axis0 = new THREE.Vector3().crossVectors(dCA, dBA);
if (axis0.lengthSq() < eps && poleW) {
axis0.crossVectors(dTA, poleW.clone().sub(aPos).normalize());
}
if (axis0.lengthSq() < eps) {
axis0.crossVectors(dCA, new THREE.Vector3(0, 0, 1));
if (axis0.lengthSq() < eps) axis0.crossVectors(dCA, new THREE.Vector3(1, 0, 0));
}
axis0.normalize();
const axis1 = new THREE.Vector3().crossVectors(dCA, dTA);
const swing = axis1.lengthSq() > eps;
axis1.normalize();
// Original world quaternions (captured before any rotation, per Holden).
const aQi = a.getWorldQuaternion(new THREE.Quaternion()).invert();
const bQi = b.getWorldQuaternion(new THREE.Quaternion()).invert();
const localAxis = new THREE.Vector3();
const dq = new THREE.Quaternion();
localAxis.copy(axis0).applyQuaternion(aQi).normalize();
a.quaternion.multiply(dq.setFromAxisAngle(localAxis, acAb1 - acAb0));
localAxis.copy(axis0).applyQuaternion(bQi).normalize();
b.quaternion.multiply(dq.setFromAxisAngle(localAxis, baBc1 - baBc0));
if (swing) {
localAxis.copy(axis1).applyQuaternion(aQi).normalize();
a.quaternion.multiply(dq.setFromAxisAngle(localAxis, acAt0));
}
a.updateMatrixWorld(true); // refresh the chain so the tip handle + capture are current
}
// Set q to the minimal rotation from `from` to `to` (both unit) — but when they're
// near-ANTIPARALLEL, setFromUnitVectors picks an arbitrary perpendicular axis that
// flips with tiny input changes (the IK "spazz" when a chain is dragged to reverse
// on itself, e.g. the head pulled straight down in front view). Guard that case by
// rotating π about a STABLE axis derived from `fallback` (use world-forward so a
// reversing spine folds forward instead of flickering).
function setRotFromTo(THREE, q, from, to, fallback) {
if (from.dot(to) < -0.999999) {
const axis = new THREE.Vector3().crossVectors(from, fallback);
if (axis.lengthSq() < 1e-8) axis.crossVectors(from, new THREE.Vector3(1, 0, 0));
if (axis.lengthSq() < 1e-8) axis.crossVectors(from, new THREE.Vector3(0, 1, 0));
q.setFromAxisAngle(axis.normalize(), Math.PI);
} else {
q.setFromUnitVectors(from, to);
}
}
// Bend the spine chain toward a target DIRECTION, distributing the bend evenly
// across the vertebrae. This replaces FABRIK for the head handle. FABRIK reaches
// an exact POSITION, but near the spine's own axis (dragging the head up/down in
// front view) the bend AZIMUTH is undefined, so it spun the torso around the
// vertical axis. Here the bend axis is derived ONCE from the REST spine direction
// → target — a smooth, well-defined function of the drag with no azimuthal
// freedom to spin. Absolute from rest each call (so it's idempotent, never
// accumulates) and capped per vertebra so it stays a smooth, bounded curve.
// bones[0] is the base (rides the pelvis); bones[last] (head) isn't rotated — it
// follows the neck. `rest` maps each bone → its rest local quaternion.
function bendSpineToward(THREE, bones, rest, targetW) {
const n = bones.length;
if (n < 2) return;
for (const b of bones) { const r = rest.get(b); if (r) b.quaternion.copy(r); } // start from rest each frame
bones[0].updateMatrixWorld(true);
const basePos = bones[0].getWorldPosition(new THREE.Vector3());
const restDir = bones[n - 1].getWorldPosition(new THREE.Vector3()).sub(basePos);
const tgtDir = targetW.clone().sub(basePos);
if (restDir.lengthSq() < 1e-9 || tgtDir.lengthSq() < 1e-9) return;
restDir.normalize(); tgtDir.normalize();
const dot = THREE.MathUtils.clamp(restDir.dot(tgtDir), -1, 1);
if (dot > 0.99999) return; // already aligned with rest — no bend
const axis = new THREE.Vector3().crossVectors(restDir, tgtDir);
if (axis.lengthSq() < 1e-8) {
// Antiparallel (head dragged straight opposite the spine) → fold forward (+Z).
axis.crossVectors(restDir, new THREE.Vector3(0, 0, 1));
if (axis.lengthSq() < 1e-8) axis.crossVectors(restDir, new THREE.Vector3(1, 0, 0));
}
axis.normalize();
// Distribute the swing evenly: rotating each of the k bending joints by `per`
// about the world axis compounds to a k·per swing of the head — exactly the
// angle to rest→target when uncapped, and a smooth arc when capped.
const k = n - 1;
const per = Math.min(Math.acos(dot) / k, MAX_SPINE_JOINT_BEND);
const dq = new THREE.Quaternion().setFromAxisAngle(axis, per);
for (let i = 0; i < n - 1; i++) {
const b = bones[i];
const newWorld = dq.clone().multiply(b.getWorldQuaternion(new THREE.Quaternion()));
const parentInv = b.parent ? b.parent.getWorldQuaternion(new THREE.Quaternion()).invert() : new THREE.Quaternion();
b.quaternion.copy(parentInv.multiply(newWorld));
b.updateMatrixWorld(true);
}
}
// Aim a single bone so its child joint points at targetW, rotating only that bone
// (the rest of the limb rides along as children). Used to drag the elbow/knee:
// grab it and the upper arm/thigh swings to put it there. Rig-agnostic — applies a
// world-space delta in the bone's own frame, like the FABRIK retarget step.
function aimBoneAt(THREE, bone, childJoint, targetW) {
bone.updateMatrixWorld(true);
const pPos = bone.getWorldPosition(new THREE.Vector3());
const curDir = childJoint.getWorldPosition(new THREE.Vector3()).sub(pPos);
const tgtDir = targetW.clone().sub(pPos);
if (curDir.lengthSq() < 1e-10 || tgtDir.lengthSq() < 1e-10) return;
curDir.normalize(); tgtDir.normalize();
const q = new THREE.Quaternion();
setRotFromTo(THREE, q, curDir, tgtDir, new THREE.Vector3(0, 0, 1));
const newWorld = q.multiply(bone.getWorldQuaternion(new THREE.Quaternion()));
const parentInv = bone.parent ? bone.parent.getWorldQuaternion(new THREE.Quaternion()).invert() : new THREE.Quaternion();
bone.quaternion.copy(parentInv.multiply(newWorld));
bone.updateMatrixWorld(true);
}
async function mountViewport(node, container) {
let lib;
try {
lib = await loadThree();
} catch (e) {
container.textContent = "Failed to load 3D engine — see console.";
console.error(e);
return;
}
if (!node._pcrAlive) return; // node was removed during the async load
// Re-mount safety: drop a prior mount's global capture listeners before
// building a new scene — node._pcrPose is overwritten at the end of this
// function, so without this a re-mount would leak the previous keydown/
// pointerdown/contextmenu handlers (a duplicate global Ctrl+Z over a dead scene).
const _priorPose = node._pcrPose;
if (_priorPose) {
if (_priorPose.onKey) window.removeEventListener("keydown", _priorPose.onKey, true);
if (_priorPose.onWinPointerDown) window.removeEventListener("pointerdown", _priorPose.onWinPointerDown, true);
if (_priorPose.onContextMenu) window.removeEventListener("contextmenu", _priorPose.onContextMenu, true);
}
const { THREE, OrbitControls, TransformControls, GLTFLoader, OBJLoader } = lib;
const scene = new THREE.Scene();
scene.background = null; // cleared manually so the letterbox bars can differ from the frame
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 1.3, 3.4);
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
const canvas = renderer.domElement;
// Absolute + explicitly sized/positioned by fitCanvas (never width/height:100%),
// so the drawing-buffer size never feeds back into the container's flow height
// (that path is an endless setSize → taller container → ResizeObserver loop).
canvas.style.cssText = "position:absolute;display:block;";
container.appendChild(canvas);
// Blender-style studio clay lighting: bright ambient dome + a soft key and an
// opposite fill, so forms shade gently instead of splitting into a lit side
// and a black side (the old single 1.25 sun). Affects the capture too — the
// depth net doesn't care, it reads geometry cues, and matte gray is the same
// family as the props/floor.
scene.add(new THREE.HemisphereLight(0xffffff, 0x3c3e46, 1.25));
const sun = new THREE.DirectionalLight(0xffffff, 0.85);
sun.position.set(2, 4, 3);
scene.add(sun);
const fill = new THREE.DirectionalLight(0xffffff, 0.4);
fill.position.set(-3, 2.5, -2.5);
scene.add(fill);
const grid = new THREE.GridHelper(4, 8, 0x4a4a55, 0x2a2a30);
scene.add(grid);
// Opt-in solid ground plane at foot level (y=0). Unlike the grid (lines, a
// viewport aid hidden from the capture), this is a matte surface that STAYS in
// the captured render so DepthAnything reads a receding floor — grounding the
// figure with a continuous depth gradient and a foot-contact edge. Off by
// default; toggled from the toolbar.
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(40, 40),
new THREE.MeshStandardMaterial({ color: 0x8a8a92, roughness: 0.96, metalness: 0.0, side: THREE.DoubleSide })
);
floor.rotation.x = -Math.PI / 2;
floor.visible = false;
scene.add(floor);
// ── figure body library ─────────────────────────────────────────────────────
// Official posable bodies the 👤 button offers. EVERY entry must ride the same
// MakeHuman game rig (classifyBone names: upperarm_l, spine_01..03, single
// head bone, fingers/toes) — that contract is what lets IK, hand presets, the
// thumb drag, foot pins, mirror, morphs and regional head boxes work on every
// body with zero retuning. `bones` = the per-morph bone-delta JSON (null =
// the body ships without skeletal morph sliders). ?v bumps on asset change so
// browsers refetch instead of serving a stale cached GLB.
// Authoring pipeline: MakeHuman (game rig) → FBX → fbx2glb.py / MPFB2 bake
// (tools/build_pose_figure.py) → drop in js/assets/ → add an entry here.
const FIGURE_LIBRARY = {
human: { label: "🧍 human", file: "pose-figure.glb?v=16", bones: "pose-figure-bones.json?v=10" },
// User MakeHuman recipes rebuilt through MPFB2 with the game rig attached
// in script — tools/build_body_figure.py (recipe per body, own slider set
// baked relative to its base; identity dimensions deliberately absent:
// heavy's weight/muscle, stacked's bust/weight).
heavy: { label: "🏋 heavy man", file: "pose-figure-heavy.glb?v=13", bones: "pose-figure-heavy-bones.json?v=6" },
stacked: { label: "💃 stacked", file: "pose-figure-stacked.glb?v=13", bones: "pose-figure-stacked-bones.json?v=6" },
// The slider-engine body: a morph-free default-macro GLB + a packed MakeHuman
// target library applied at RUNTIME (CPU vertex deltas + analytic bone-rest
// moves). GLB and pack are a MATCHED PAIR (the pack's vertex remap is bound
// to that GLB's exact vertex order) — rebuilding one means rebuilding both
// (tools/build_body_figure.py custom + tools/build_slider_pack.py) and
// bumping BOTH ?v=.
custom: { label: "🧬 custom", file: "pose-figure-custom.glb?v=16", bones: null, pack: "body-pack.bin.gz?v=7" },
// Creatures: non-human rigs (rig key selects the RIG_PROFILES entry; the
// profile's explicit boneMap is authored against this exact GLB's skeleton).
horse: { label: "🐴 horse", file: "pose-figure-horse.glb?v=1", bones: null, rig: "horse" },
cat: { label: "🐈 cat", file: "pose-figure-cat.glb?v=1", bones: null, rig: "cat" },
};
// 👤 menu entries: every template spawns the CUSTOM slider-engine body with a
// preset dict, so each one stays fully tweakable afterwards. The slider
// values are exact transcriptions of the baked bodies' build recipes (the
// user's .mhm files — see tools/build_body_figure.py RECIPES), and the
// engine computes the same MakeHuman blend the bakes did, so `woman` and
// `giant` reproduce the old stacked/heavy meshes. The baked GLB entries
// above stay registered (not in the menu) so saves that reference them
// restore byte-identically. Age presets are in UI scale (0..1 spans MH age
// 0.5..1.0 — see the slider's uiToValue).
const BODY_TEMPLATES = {
basic: { label: "🧍 basic", sliders: {} },
woman: {
label: "💃 woman",
sliders: {
gender: 0, age: 0.008, muscle: 0.422, weight: 0.865, height: 0.624,
proportions: 0.681, cupsize: 0.894, firmness: 0.84,
"breast-trans": 0.602, "breast-dist": -0.014, "breast-point": -0.164,
"breast-volume-vert": -0.242, "nipple-size": -0.056, "nipple-point": -0.142,
},
},
giant: {
label: "🏋 giant",
sliders: {
gender: 1, age: 0.12, muscle: 0.872, weight: 0.95, height: 0.465,
proportions: 0.755, cupsize: 0.17, firmness: 0.486,
african: 0.426, asian: 0.287, caucasian: 0.287,
"torso-scale-depth": 0.482, "torso-scale-horiz": 0.56, "torso-scale-vert": 0.056,
"torso-trans-horiz": -0.086, "torso-trans-vert": -0.092, "torso-trans-depth": 0.086,
"torso-vshape": 0.304, "torso-dorsi": 0.334, "torso-pectoral": 1,
"breast-trans": 0.568,
},
},
};
const DEFAULT_BODY = "human";
const bodyKeyOf = (key) => (FIGURE_LIBRARY[key] ? key : DEFAULT_BODY);
const figureUrl = (body) => new URL(`./assets/${FIGURE_LIBRARY[bodyKeyOf(body)].file}`, import.meta.url).href;
// Rig profile for a body key — FIGURE_LIBRARY entries opt into a non-human
// rig via `rig: "<kind>"`; everything else (all current bodies) is human.
const rigProfileOf = (body) => RIG_PROFILES[FIGURE_LIBRARY[bodyKeyOf(body)].rig] || RIG_PROFILES.human;
// ── multiple figures ────────────────────────────────────────────────────────
// The viewport hosts N posable people. `figures` holds them all; `rig` is the
// ACTIVE one — a mutable reference the gizmo, handles, IK, body sliders and
// hand/foot panels all read, so clicking a figure retargets the whole posing
// layer onto it. ALL per-figure state (rest pose, morph state, spine chain,
// finger axes, joint handles, foot pins) lives ON each rig; only the scene,
// camera, gizmo and DOM panels are shared. initFigure() builds that per-figure
// state, so a spawned person is set up identically to the first.
const figures = [];
let figureUidSeq = 1; // monotonic stable per-figure id (see serializeFigure / history reconcile)
let rig = null; // the ACTIVE figure
let bodyHovered = false; // pointer is over the active figure's body mesh
const handlesRoot = new THREE.Group(); // parents every figure's handle group
scene.add(handlesRoot);
// Link status line — shows when the selected prop is linked to something, with how to
// undo. Driven from updatePropUI (event-driven, no timers).
const linkStatus = document.createElement("div");
linkStatus.style.cssText =
"position:absolute;bottom:6px;left:50%;transform:translateX(-50%);z-index:5;display:none;" +
"padding:3px 10px;background:rgba(20,20,26,0.9);color:#cfe;border:1px solid #3a7d52;border-radius:5px;" +
"font:11px/1.4 system-ui,sans-serif;pointer-events:none;white-space:nowrap;";
container.appendChild(linkStatus);
const jointFriendly = (n) => /^wrist/i.test(n) ? "hand" : /^ankle/i.test(n) ? "foot" : /^head/i.test(n) ? "head" : n;
// Floor ring under the active figure — the "this person is selected" marker, only
// meaningful with more than one in the scene. Turns amber when the figure is ARMED
// (a joint of it is selected on the gizmo), the state in which Del removes it. Lives
// under handlesRoot so it's stripped from the captured control map like the handles.
const selectionRing = new THREE.Mesh(
new THREE.RingGeometry(0.26, 0.32, 48),
new THREE.MeshBasicMaterial({ color: 0x46b1ff, transparent: true, opacity: 0.45, depthTest: false, side: THREE.DoubleSide })
);
selectionRing.rotation.x = -Math.PI / 2;
selectionRing.renderOrder = 998;
selectionRing.visible = false;
handlesRoot.add(selectionRing);
// ── attach constraints (pinning) ────────────────────────────────────────────
// A follower tracks a leader each frame with a FROZEN offset, so a prop pinned to a
// hand rides the wrist as you pose ("hold the pole"). Direction is fixed —
// follower → leader, never mutual — so there are no cycles (the convention every DCC
// uses). Runtime refs hold live objects; attachApi maps them to indices/names for
// pose_state. follower: {kind:'prop', mesh}; leader: {kind:'prop', mesh} | {kind:'joint', fig, joint}.
const attachments = [];
const _atM = new THREE.Matrix4(), _atP = new THREE.Vector3(), _atQ = new THREE.Quaternion(), _atS = new THREE.Vector3(), _atJ = new THREE.Vector3();
const _atRigid = new THREE.Matrix4(), _atOne = new THREE.Vector3(1, 1, 1);
const leaderWorldMatrix = (ref) => {
if (ref.kind === "prop") { ref.mesh.updateWorldMatrix(true, false); return ref.mesh.matrixWorld; }
const bone = ref.fig?.joints?.[ref.joint];
if (!bone) return null;
bone.updateWorldMatrix(true, false);
return bone.matrixWorld;
};
// RIGID (position + rotation, scale forced to 1) leader frame. The figure root carries
// a ~1.8× scale that bakes into every bone's world matrix; using the full matrix as the
// link frame makes the decompose in enforceAttachments scale-skewed. A rigid frame keeps
// the offset an exact pose transform so the prop rides position+orientation cleanly and
// keeps its own size. Returns the shared _atRigid buffer (clone before mutating).
const leaderRigidMatrix = (ref) => {
const w = leaderWorldMatrix(ref);
if (!w) return null;
w.decompose(_atP, _atQ, _atS);
return _atRigid.compose(_atP, _atQ, _atOne);
};
// Re-place every follower at leaderWorld · offset. Props sit directly under propsGroup
// (identity transform), so the decomposed world TRS is the prop's local TRS; scale is
// left alone (the prop keeps its own size, it doesn't inherit the hand's).
const enforceAttachments = () => {
for (const a of attachments) {
if (a.follower.kind !== "prop") continue;
const lW = leaderRigidMatrix(a.leader);
if (!lW) continue;
_atM.multiplyMatrices(lW, a.offset);
_atM.decompose(_atP, _atQ, _atS); // _atP = target world point (translation of leader·offset)
if (a.follower.jointIndex != null) {
// CHAIN-JOINT pin: keep ONE joint glued to the frozen point in the leader
// frame by translating the whole prop — re-enforced every frame, so posing
// the other joints can't drag the pinned joint off (it snaps back here).
// Bones/orientation untouched; only the prop's position shifts.
const m = a.follower.mesh;
m.updateWorldMatrix(true, true); // refresh the bones' world matrices first
chainJointWorld(m, a.follower.jointIndex, _atJ);
m.position.add(_atP.sub(_atJ));
} else {
a.follower.mesh.position.copy(_atP);
a.follower.mesh.quaternion.copy(_atQ);
}
}
};
// A thin connector line per attachment (follower → leader) so a pin is visible. Under
// handlesRoot, so it's stripped from the capture like the handles.
const ATTACH_LINE_MAX = 64; // segments; far beyond any realistic pin count
const attachLinePos = new Float32Array(ATTACH_LINE_MAX * 6);
const attachConnectors = new THREE.LineSegments(
new THREE.BufferGeometry().setAttribute("position", new THREE.BufferAttribute(attachLinePos, 3)),
new THREE.LineBasicMaterial({ color: 0xff8c1a, transparent: true, opacity: 0.7, depthTest: false })
);
attachConnectors.renderOrder = 997;
attachConnectors.frustumCulled = false;
handlesRoot.add(attachConnectors);
// Update the shared buffer in place (no per-frame allocation) and draw only the
// active segments.
const updateAttachConnectors = () => {
let i = 0;
for (const a of attachments) {
if (i + 6 > attachLinePos.length) break;
const lW = leaderWorldMatrix(a.leader);
if (!lW) continue;
const f = a.follower.jointIndex != null ? chainJointWorld(a.follower.mesh, a.follower.jointIndex, _atP) : a.follower.mesh.getWorldPosition(_atP);
attachLinePos[i++] = f.x; attachLinePos[i++] = f.y; attachLinePos[i++] = f.z;
attachLinePos[i++] = lW.elements[12]; attachLinePos[i++] = lW.elements[13]; attachLinePos[i++] = lW.elements[14];
}
attachConnectors.geometry.attributes.position.needsUpdate = true;
attachConnectors.geometry.setDrawRange(0, i / 3);
};
const removeAttachmentsFor = (mesh) => {
if (mesh?.userData) mesh.userData._pinIndex = null; // drop the chain-pin marker
for (let i = attachments.length - 1; i >= 0; i--) {
const a = attachments[i];
if ((a.follower.kind === "prop" && a.follower.mesh === mesh) ||
(a.leader.kind === "prop" && a.leader.mesh === mesh)) attachments.splice(i, 1);
}
};
const removeAttachmentsForFigure = (fig) => {
for (let i = attachments.length - 1; i >= 0; i--)
if (attachments[i].leader.kind === "joint" && attachments[i].leader.fig === fig) attachments.splice(i, 1);
};
const propAttachment = (mesh) => attachments.find((a) => a.follower.kind === "prop" && a.follower.mesh === mesh);
// Walk a prop's attachment chain to the figure it ultimately rides (a sword pinned
// to a hand, or a charm pinned to that sword…). Returns null for an unpinned prop,
// so it belongs to no figure's region mask. Seen-set guards against any stray cycle.
const figureOwningProp = (mesh) => {
const seen = new Set();
let cur = mesh;
while (cur && !seen.has(cur)) {
seen.add(cur);
const a = propAttachment(cur);
if (!a) return null;
if (a.leader.kind === "joint") return a.leader.fig;
cur = a.leader.mesh;
}
return null;
};
// Pin `propMesh` to `leaderRef`, freezing the current relative offset so the prop
// stays put and then rides the leader. One leader per follower.
const createPropAttach = (propMesh, leaderRef) => {
const lW = leaderRigidMatrix(leaderRef);
if (!lW) return;
propMesh.updateWorldMatrix(true, false);
removeAttachmentsFor(propMesh);
attachments.push({ follower: { kind: "prop", mesh: propMesh }, leader: leaderRef, offset: lW.clone().invert().multiply(propMesh.matrixWorld) });
};
// Pin ONE joint of a chain prop to a leader (mannequin joint / prop): the joint
// glues to its current spot in the leader frame and rides it, while every OTHER
// joint stays free to pose around it (enforceAttachments re-locks it each frame).
// offset = the joint's position in the leader's rigid frame, as a translation
// matrix, so it round-trips through the same attachment serialization.
const createChainPin = (chainMesh, jointIndex, leaderRef) => {
const lW = leaderRigidMatrix(leaderRef);
if (!lW) return;
removeAttachmentsFor(chainMesh);
// Zero offset → the joint SNAPS onto the leader joint ("stuck on there"); the
// user then nudges with the move gizmo, which re-freezes the offset.
attachments.push({ follower: { kind: "prop", mesh: chainMesh, jointIndex }, leader: leaderRef, offset: new THREE.Matrix4() });
chainMesh.userData._pinIndex = jointIndex; // quick lookup for handle/IK code
};
// The active figure is "armed" when a joint of it is selected on the rotate gizmo —
// clicking its body arms it, clicking empty space / Esc disarms it. Del removes an
// armed figure (so a stray Del with nothing selected is inert).
function gizmoOnActiveJoint() {
return !!transformControls?.object && !!rig?.handles?.some((h) => h.userData.joint === transformControls.object);
}
// Handles are hidden until the pointer is over the figure (or a handle), so the body
// reads clean; they also stay up through any active posing so a drag that leaves the
// mesh, or a held selection/foot-pin, doesn't make them vanish mid-edit. While the
// figure is selected as an object they stay up too, so the path from "picked the
// person" to "now pose a limb" is always visible (the discoverability bridge).
function handlesShouldShow() {
return !!(bodyHovered || hoveredHandle || selectedFigure === rig || ik.active
|| transformControls.dragging || gizmoOnActiveJoint() || anyFootPinned() || pinPickProp || pinPickGroup
|| skinFit); // skeleton-fitting: the body is hidden, so hover can't reveal them — force them on
}
const D2R = Math.PI / 180;
const HANDLE = { fk: 0x46b1ff, ik: 0xffb24a, hover: 0xffffff, pin: 0x46d18a };
const ikGeo = new THREE.OctahedronGeometry(1, 0); // shared by every figure's handles
const fkGeo = new THREE.SphereGeometry(1, 16, 12);
const HANDLE_SIZE = 0.015; // was 0.02 — the torso's 5 joints left no clickable body
// Above this rendered-frame height (px) handles stop growing with the viewport, so they
// don't balloon and cover their own joints in fullscreen. At/below it (the node) the
// original constant-angular sizing is untouched.
const HANDLE_REF_H = 800;
const HANDLE_DIM = 0.22, HANDLE_BRIGHT = 0.95; // idle vs hovered/active/dragging
// Per-morph bone deltas are identical for every figure OF THE SAME BODY —
// fetched once per body key. Bodies without a bones JSON get {} (their
// morphs, if any, are all treated as surface).
const morphDeltasCache = new Map();
const loadMorphDeltas = async (body = DEFAULT_BODY) => {
const key = bodyKeyOf(body);
if (morphDeltasCache.has(key)) return morphDeltasCache.get(key);
let morphs = {};
const src = FIGURE_LIBRARY[key].bones;
if (src) {
try {
const bj = await fetch(new URL(`./assets/${src}`, import.meta.url).href);
if (bj.ok) morphs = (await bj.json()).morphs || {};
} catch (e) { console.warn("[PoseStudio] morph bone deltas missing:", e); }
}
morphDeltasCache.set(key, morphs);
return morphs;
};
// ── runtime slider pack (the `custom` body) ─────────────────────────────────
// One binary holds every MakeHuman target the slider engine needs: sparse
// per-vertex deltas remapped onto the GLB's vertex order, per-target bone-rest
// deltas, and the dense default-blend the GLB ships baked in (subtracted back
// at runtime so default sliders reproduce the GLB exactly). Built by
// tools/build_slider_pack.py; format documented there. Fetched lazily on the
// first custom-figure spawn, shared read-only by every custom figure.
const sliderPackCache = new Map(); // body key -> Promise<pack | {failed:true}>
const loadSliderPack = (body = DEFAULT_BODY) => {
const key = bodyKeyOf(body);
if (sliderPackCache.has(key)) return sliderPackCache.get(key);
const src = FIGURE_LIBRARY[key].pack;
const promise = (async () => {
if (!src) return { failed: true };
const res = await fetch(new URL(`./assets/${src}`, import.meta.url).href);
if (!res.ok) throw new Error(`pack fetch ${res.status}`);
const buf = await new Response(res.body.pipeThrough(new DecompressionStream("gzip"))).arrayBuffer();
const dv = new DataView(buf);
if (String.fromCharCode(dv.getUint8(0), dv.getUint8(1), dv.getUint8(2), dv.getUint8(3)) !== "PCPK")
throw new Error("bad pack magic");
const manifestLen = dv.getUint32(20, true), blobStart = dv.getUint32(24, true);
const manifest = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, 32, manifestLen)));
const f32 = (off, n) => new Float32Array(buf, blobStart + off, n);
const u16 = (off, n) => new Uint16Array(buf, blobStart + off, n);
const i16 = (off, n) => new Int16Array(buf, blobStart + off, n);
const targets = manifest.targets.map((t) => ({
tokens: t.tokens,
vIdx: u16(t.vOff, t.vCount),
vDelta: i16(t.vOff + 2 * t.vCount, t.vCount * 3),
// bone-id run is padded to a 4-byte boundary before the f32 deltas
bones: t.bCount ? {
idx: u16(t.bOff, t.bCount),
delta: f32((t.bOff + 2 * t.bCount + 3) & ~3, t.bCount * 3),
} : null,
}));
return {
manifest, targets,
defaultVerts: f32(manifest.defaultBlend.vOff, manifest.vertCount * 3),
defaultBones: f32(manifest.defaultBlend.bOff, manifest.boneCount * 3),
};
})().catch((e) => {
console.warn("[PoseStudio] slider pack failed:", e);
sliderPackCache.delete(key); // a later spawn may retry (transient server hiccup)
return { failed: true };
});
sliderPackCache.set(key, promise);
return promise;
};
// 1:1 port of MakeHuman's macro factor math (apps/human.py). Target weight =
// sliderValue x PRODUCT(factor per macro token in the target's filename); the
// packer pre-parsed the tokens, so this is the entire blending engine.
const computeMacroFactors = (m) => {
const tri = (x, lo, mid, hi, dominant) => {
const mx = Math.max(0, x * 2 - 1), mn = Math.max(0, 1 - x * 2);
return { [lo]: mn, [mid]: 1 - (dominant ? Math.max(mx, mn) : mx + mn), [hi]: mx };
};
const f = { male: m.gender, female: 1 - m.gender };
if (m.age < 0.5) {
f.old = 0;
f.baby = Math.max(0, 1 - m.age * 5.333);
f.young = Math.max(0, (m.age - 0.1875) * 3.2);
f.child = Math.max(0, Math.min(1, 5.333 * m.age) - f.young);
} else {
f.baby = f.child = 0;
f.old = Math.max(0, m.age * 2 - 1);
f.young = 1 - f.old;
}
Object.assign(f,
tri(m.muscle, "minmuscle", "averagemuscle", "maxmuscle", false),
tri(m.weight, "minweight", "averageweight", "maxweight", false),
tri(m.height, "minheight", "averageheight", "maxheight", true),
tri(m.proportions, "uncommonproportions", "regularproportions", "idealproportions", true),
tri(m.cupsize, "mincup", "averagecup", "maxcup", true),
tri(m.firmness, "minfirmness", "averagefirmness", "maxfirmness", true));
const raceSum = m.african + m.asian + m.caucasian;
for (const r of ["african", "asian", "caucasian"]) f[r] = raceSum ? m[r] / raceSum : 1 / 3;
return f;
};
// limbFor/aimFor/legsAreIkable take the figure explicitly so they can be used
// while BUILDING a figure that isn't active yet. Chains come from the figure's
// rig profile (human's = IK_LIMBS verbatim).
const limbFor = (r, tip) => {
const limb = figProfile(r).ikLimbs.find((l) => l.tip === tip);
return limb && limb.chain.every((n) => r.joints[n]) ? limb : null;
};
// Mid-joint (elbow/knee) drag = aim: rotate the parent bone (shoulder/hip) so the
// joint points at the cursor; the rest of the limb follows.
const aimFor = (r, name) => {
const limb = figProfile(r).ikLimbs.find((l) => l.chain[1] === name);
return limb && r.joints[limb.chain[0]] && r.joints[name]
? { aim: true, parentName: limb.chain[0], jointName: name }
: null;
};
// Foot-pinning (planted feet while you move/rotate the whole figure) needs every
// planted limb IK-solvable; gates whether the 📌 feet button appears.
const legsAreIkable = (r) => figProfile(r).plantedTips.every((t) => limbFor(r, t));
// ── pose helpers (drive the ACTIVE figure) ───────────────────────────────────
// These read the active figure's finger axes / rest pose / curl amounts via the
// mutable `rig`, so they always pose whichever figure is selected.
const curlBone = (bone, deg) => {
const info = rig.digitInfo.get(bone);
if (!info) return;
bone.quaternion.copy(info.rest).multiply(new THREE.Quaternion().setFromAxisAngle(info.axis, deg * D2R));
};
// Aim a bone so the direction to `dirChild` points at a world target, by a 0–1
// amount (slerp rest → fully aimed). Recomputed live so it tracks the current hand
// orientation, and can't overshoot — at amount=1 it points exactly at the target.
const aimBone = (bone, dirChild, targetWorld, amount) => {
const restLocal = rig.rest.get(bone);
if (!restLocal) return;
bone.quaternion.copy(restLocal);
bone.updateMatrixWorld(true);
const base = bone.getWorldPosition(new THREE.Vector3());
const dir = dirChild.getWorldPosition(new THREE.Vector3()).sub(base);
const tgt = targetWorld.clone().sub(base);
if (dir.lengthSq() < 1e-9 || tgt.lengthSq() < 1e-9) return;
const aim = new THREE.Quaternion().setFromUnitVectors(dir.normalize(), tgt.normalize());
const restWorld = bone.getWorldQuaternion(new THREE.Quaternion());
const newWorld = new THREE.Quaternion().slerp(aim, amount).multiply(restWorld);
const parentQ = (bone.parent || rig.root).getWorldQuaternion(new THREE.Quaternion());
bone.quaternion.copy(parentQ.invert().multiply(newWorld));
bone.updateMatrixWorld(true);
};
// Curl one finger to a 0–1 amount: scale FINGER_PROFILE across the phalanges.
const curlFinger = (wristName, finger, amount) => {
const chain = rig.fingers?.[wristName]?.[finger];
if (!chain) return;
for (let i = 0; i < chain.length; i++) curlBone(chain[i], amount * (FINGER_PROFILE[i] ?? FINGER_PROFILE[FINGER_PROFILE.length - 1]));
};
// Thumb DOF: "thumb" (across) aims the metacarpal toward the finger bases so the
// whole thumb sweeps across the palm to lie over the fingers (CMC flexion); "curl"
// folds the phalanges about the thumb's bend normal (MCP/IP flexion).
const curlThumb = (wristName, across, curl) => {
const td = rig.thumbData.get(wristName);
if (!td) return;
const tgt = rig.fingers?.[wristName]?.middle?.[0] || rig.fingers?.[wristName]?.index?.[0];
if (tgt && td.bones[1]) aimBone(td.bones[0], td.bones[1], tgt.getWorldPosition(new THREE.Vector3()), across);
else if (rig.rest.get(td.bones[0])) td.bones[0].quaternion.copy(rig.rest.get(td.bones[0]));
const rot = (axis, deg) => new THREE.Quaternion().setFromAxisAngle(axis, deg * D2R);
for (let i = 1; i < td.bones.length; i++) {
td.bones[i].quaternion.copy(td.rest[i]).multiply(rot(td.curl[i], curl * (THUMB_CURL_DEG[i] ?? 0)));
}
};
const applyHandAmounts = (wristName, amts) => {
rig.handAmounts.set(wristName, amts);
// Fingers only — the thumb is posed by its IK drag handle, so presets/sliders
// must not reset it.
for (const f of FINGER_ORDER) curlFinger(wristName, f, amts[f] ?? 0);
rig.joints[wristName]?.updateMatrixWorld(true);
};
// Apply a captured raw-quaternion pose (POSE_PRESETS) to a hand: set each digit
// bone from the stored RIGHT-hand quat, mirrored (x,-y,-z,w) for the left.
const applyHandPose = (wristName, poseByBase) => {
const flip = wristName.endsWith("L");
const sideRe = flip ? /_l$/i : /_r$/i;
for (const b of digitBones(rig)) {
if (!sideRe.test(b.name)) continue;
const q = poseByBase[b.name.replace(/_(l|r)$/i, "")];
if (q) b.quaternion.set(q[0], flip ? -q[1] : q[1], flip ? -q[2] : q[2], q[3]);
}
rig.joints[wristName]?.updateMatrixWorld(true);
};
const applyFootPreset = (ankleName, key) => {
const ball = rig.toes?.[ankleName];
if (!ball || !(key in FOOT_PRESETS)) return;
curlBone(ball, FOOT_PRESETS[key]);
rig.joints[ankleName]?.updateMatrixWorld(true);
};
// Curl one toe to a 0–1 amount (independent of the whole-foot preset on the parent ball).
const curlToe = (ankleName, idx, amount) => {
const tb = rig.toeBones?.[ankleName]?.[idx];
if (tb) curlBone(tb, amount * TOE_CURL_DEG);
rig.joints[ankleName]?.updateMatrixWorld(true);
};
// ── per-figure setup ──────────────────────────────────────────────────────
// Build all posing state onto `fig` (rest snapshots, morph setup, spine chain,
// finger/toe bend axes, joint handles). Mirrors what the first figure used to get
// inline, so a spawned figure is fully poseable. Everything addresses `fig`, not
// the active `rig`, so it's safe to set up a figure before it's selected.
// Some authored creature GLBs ship a curled bind pose (the cat's head folds to
// the floor). profile.restPose pitches named bones in WORLD space into a natural
// neutral, applied BEFORE rest capture so the corrected pose IS the rest the
// figure resets / mirrors / head-drags from. Parent-worlds are read from the bind
// pose (corrections don't compound) so the result matches what was authored.
const applyProfileRestPose = (fig) => {
const rp = figProfile(fig).restPose;
if (!rp) return;
const byName = new Map();
fig.root.traverse((o) => { if (o.isBone) byName.set(o.name, o); });
fig.root.updateMatrixWorld(true);
const corrections = [];
for (const { bone, axis, deg } of rp) {
const b = byName.get(bone);
if (!b || !deg) continue;
const Qw = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(axis[0], axis[1], axis[2]).normalize(), deg * Math.PI / 180);
const pW = b.parent.getWorldQuaternion(new THREE.Quaternion());
corrections.push([b, pW.clone().invert().multiply(Qw).multiply(pW)]);
}
for (const [b, c] of corrections) b.quaternion.premultiply(c);
fig.root.updateMatrixWorld(true);
};
const initFigure = async (fig) => {
fig.uid = figureUidSeq++; // stable identity (restore overrides with the saved uid)
scene.add(fig.root);
applyProfileRestPose(fig); // straighten an authored curl before rest is captured
// Rest pose for "reset pose" (bind pose for a GLB, identity for capsules).
fig.rest = new Map();
fig.restPos = new Map(); // rest POSITIONS (fold + Ctrl-move translate bones)
for (const j of Object.values(fig.joints)) { fig.rest.set(j, j.quaternion.clone()); fig.restPos.set(j, j.position.clone()); }
fig.rootRest = fig.root.position.clone();
// Rest WORLD orientation/position per joint name — the mirror routes motion-
// from-rest through these so it's correct regardless of the rig's local-axis
// convention. Captured in bind pose, before any restore.
fig.root.updateMatrixWorld(true);
fig.restWorld = new Map();
fig.restWorldPos = new Map();
for (const [name, j] of Object.entries(fig.joints)) {
fig.restWorld.set(name, j.getWorldQuaternion(new THREE.Quaternion()));
fig.restWorldPos.set(name, j.getWorldPosition(new THREE.Vector3()));
}
for (const b of digitBones(fig)) {
fig.rest.set(b, b.quaternion.clone());
fig.restPos.set(b, b.position.clone());
fig.restWorld.set(b.name, b.getWorldQuaternion(new THREE.Quaternion()));
}
// Body-shape morph targets (height/weight/muscle/breast/hips/glutes). Surface
// morphs (weight/muscle/breast/glutes) drive morphTargetInfluences (shape keys);
// skeletal morphs (gender/height/hips — any bone delta) move the BONES directly
// so handles + pivots track. A figure with no morphs leaves morphMesh null.
fig.morphMesh = (fig.meshes || []).find((m) => m.morphTargetInfluences?.length) || null;
fig.morphNames = [];
fig.morphIndex = {};
// skel/boneByName are RIG properties, not morph properties — bodies without
// morph targets (e.g. the heavy man) still need them for full-skeleton pose
// serialization/restore and skeleton-fit binding.
fig.skel = (fig.meshes || []).find((m) => m.isSkinnedMesh && m.skeleton)?.skeleton || null;
fig.boneByName = new Map();
if (fig.skel) for (const b of fig.skel.bones) fig.boneByName.set(b.name, b);
if (fig.morphMesh) {
const dict = fig.morphMesh.morphTargetDictionary;
if (dict) for (const [name, i] of Object.entries(dict)) { fig.morphNames[i] = name; fig.morphIndex[name] = i; }
fig.morphMesh.morphTargetInfluences.forEach((_, i) => {
if (fig.morphNames[i] == null) { fig.morphNames[i] = `morph ${i}`; fig.morphIndex[`morph ${i}`] = i; }
});
fig.boneRestPos = new Map(); // neutral bind local position per bone
fig.curSkelRest = new Map(); // current skeletal-morph rest (fold/move ride on top)
for (const b of fig.skel.bones) {
fig.boneRestPos.set(b, b.position.clone());
fig.curSkelRest.set(b, b.position.clone());
}
fig.morphBoneDeltas = await loadMorphDeltas(fig.body);
fig.skeletalAmount = {}; // name -> -1..1 for bone-driven morphs
fig.isSkeletalMorph = (name) => Object.keys(fig.morphBoneDeltas[name] || {}).length > 0;
} else if (FIGURE_LIBRARY[bodyKeyOf(fig.body)].pack && fig.skel) {
// Slider-engine body: no GPU morph targets — vertices are recomputed on the
// CPU from the slider pack, bone rests ride the existing skeletal-morph
// machinery via one synthetic morph (__custom). boneRestPos/curSkelRest only
// exist in the morphMesh branch above, and applySkeletalMorph dies without
// them — they MUST be created here.
fig.boneRestPos = new Map();
fig.curSkelRest = new Map();
for (const b of fig.skel.bones) {
fig.boneRestPos.set(b, b.position.clone());
fig.curSkelRest.set(b, b.position.clone());
}
fig.skeletalAmount = {};
fig.morphBoneDeltas = {};
fig.isSkeletalMorph = () => false; // baked-morph predicate; the engine owns bones
const pack = await loadSliderPack(fig.body);
if (pack && !pack.failed) {
fig.customEngine = makeCustomEngine(fig, pack);
// Defaults reproduce the GLB exactly (blend - defaultBlend = 0); restore
// mode because the figure may not be the active rig yet.
fig.customEngine?.recompute({ restore: true });
} else {
fig._packFailed = true;
showClipToast("body slider pack failed to load — figure is posable, sliders disabled", true);
}
}
// Spine IK chain for the head handle: pelvis-up to head, excluding the leg-
// parent pelvis so a head drag bends the spine only (not the legs).
fig.spineChain = null;
const pelvisBone = fig.joints.hipL?.parent || fig.joints.hipR?.parent || fig.joints.root;
if (fig.joints.head && pelvisBone) {
const chain = [];
for (let b = fig.joints.head; b && b !== pelvisBone; b = b.parent) chain.unshift(b);
if (chain.length >= 2 && chain[0].parent === pelvisBone) fig.spineChain = chain;
}
if (fig.spineChain) for (const b of fig.spineChain) if (!fig.rest.has(b)) fig.rest.set(b, b.quaternion.clone());
// Per-phalanx bend data: each digit bone's rest local rotation + flex axis in
// its own local frame. Fingers share one world axis (parallel knuckle line);
// sign fixed by a geometric toward-wrist test (rig-agnostic); thumb in its own
// bend plane; the foot's single ball bone signed so +angle drops the toe.
fig.digitInfo = new Map();
fig.thumbData = new Map();
fig.handAmounts = new Map();
const wpos = (b) => b.getWorldPosition(new THREE.Vector3());
const signToward = (axis, pivotB, tipB, target) => {
if (!target) return axis;
const pivot = wpos(pivotB), tip = wpos(tipB);
const moved = tip.clone().sub(pivot).applyAxisAngle(axis, 0.2).add(pivot);
return moved.distanceTo(target) > tip.distanceTo(target) ? axis.negate() : axis;
};
const storeAxis = (b, axisW) => {
const inv = b.getWorldQuaternion(new THREE.Quaternion()).invert();
fig.digitInfo.set(b, { rest: b.quaternion.clone(), axis: axisW.clone().applyQuaternion(inv).normalize() });
};
for (const wrist of ["wristL", "wristR"]) {
const hand = fig.fingers?.[wrist];
if (!hand) continue;
const wristPos = fig.joints[wrist] ? wpos(fig.joints[wrist]) : null;
const i0 = hand.index?.[0], p0 = hand.pinky?.[0], m = hand.middle;
const knuckle = (i0 && p0) ? wpos(p0).sub(wpos(i0)).normalize() : new THREE.Vector3(1, 0, 0);
const fwd = (m?.length >= 2) ? wpos(m[m.length - 1]).sub(wpos(m[0])).normalize() : new THREE.Vector3(0, 1, 0);
let palmN = new THREE.Vector3().crossVectors(fwd, knuckle);
if (palmN.lengthSq() < 1e-8) palmN.set(0, 0, 1);
palmN.normalize();
for (const finger of Object.keys(hand)) {
const chain = hand[finger];
const tip = chain[chain.length - 1];
if (finger === "thumb") {
const s1 = wpos(chain[1]).sub(wpos(chain[0]));
const s2 = chain[2] ? wpos(chain[2]).sub(wpos(chain[1])) : s1.clone();
let nB = new THREE.Vector3().crossVectors(s1, s2);
if (nB.lengthSq() < 1e-8) nB.copy(palmN);
nB.normalize();
const toLocal = (b, aw) => aw.clone().applyQuaternion(b.getWorldQuaternion(new THREE.Quaternion()).invert()).normalize();
fig.thumbData.set(wrist, {
bones: chain,
curl: chain.map((b) => toLocal(b, nB)),
rest: chain.map((b) => b.quaternion.clone()),
});
continue;
}
const fdir = chain.length >= 2 ? wpos(chain[1]).sub(wpos(chain[0])) : knuckle.clone();
let ax = new THREE.Vector3().crossVectors(fdir, palmN);
if (ax.lengthSq() < 1e-8) ax.copy(knuckle);
ax = signToward(ax.normalize(), chain[0], tip, wristPos);
for (const b of chain) storeAxis(b, ax);
}
}
for (const ankleName of Object.keys(fig.toes || {})) {
const ball = fig.toes[ankleName];
const ankle = fig.joints[ankleName], knee = fig.joints[ankleName.replace("ankle", "knee")];
let axisW = new THREE.Vector3(1, 0, 0);
if (ankle && knee) {
axisW.crossVectors(wpos(ball).sub(wpos(ankle)), wpos(ankle).sub(wpos(knee)));
if (axisW.lengthSq() < 1e-8) axisW.set(1, 0, 0);
const bp = wpos(ball);
const moved = bp.clone().sub(wpos(ankle)).applyAxisAngle(axisW.normalize(), 0.2).add(wpos(ankle));
if (moved.y > bp.y) axisW.negate();
}
storeAxis(ball, axisW.normalize()); // whole-foot flex preset still curls the ball (parent of the toes)
}
fig.footAmounts = new Map(); // legacy (per-toe slider, now hidden) — kept so dead refs don't throw
// Each toe is now a 2-bone digit posed by its tip IK handle (built below) — NOT a curl axis.
// Capture each toe bone's REST quaternion so toeQuats can restore poses and reset cleanly.
fig.toeRest = new Map();
for (const k of Object.keys(fig.toeChains || {})) for (const ch of fig.toeChains[k]) for (const b of ch) fig.toeRest.set(b, b.quaternion.clone());
// Joint handles for this figure, in its OWN group under handlesRoot (so only
// the active figure's handles are shown/raycast). Diamonds = IK limb tips,
// spheres = FK joints. The pelvis (root) is NOT a handle: the whole figure is
// moved/rotated by selecting it as an object (gizmo on its scene root), like a
// prop — so there's no stray pivot dot to confuse it with joint posing. Drawn
// over the body, hidden from capture.
fig.footPins = Object.fromEntries(figProfile(fig).plantedTips.map((t) => [t, null]));
fig.handlesGroup = new THREE.Group();
fig.handlesGroup.visible = false; // only the active figure's group is shown (setActiveFigure + hover gate)
handlesRoot.add(fig.handlesGroup);
fig.handles = [];
for (const name of figProfile(fig).joints) {
if (name === "root") continue; // the pelvis is posed via whole-figure selection, not a joint handle
const joint = fig.joints[name];
if (!joint) continue;
const limb = limbFor(fig, name)
|| (name === "head" && fig.spineChain ? { spine: true, bones: fig.spineChain } : null)
|| aimFor(fig, name);
const kind = limb ? "ik" : "fk";
const mesh = new THREE.Mesh(
limb ? ikGeo : fkGeo,
new THREE.MeshBasicMaterial({ color: HANDLE[kind], transparent: true, opacity: 0.22, depthTest: false })
);
mesh.renderOrder = 999;
mesh.userData = { joint, kind, base: HANDLE[kind], limb, name };
fig.handlesGroup.add(mesh);
fig.handles.push(mesh);
}
// A fingertip IK diamond on every digit (thumb + 4 fingers, both hands): grab the tip and
// the two proximal phalanges bend via the same two-bone IK as the limbs, so the finger
// curls to follow. The pole is the digit's own current bend direction — it's only consulted
// when a finger is perfectly straight (otherwise the solve keeps the finger's existing bend
// plane), so a dead-straight finger folds toward the palm instead of hyperextending.
for (const wrist of ["wristL", "wristR"]) {
const hand = fig.fingers?.[wrist];
if (!hand) continue;
for (const finger of Object.keys(hand)) {
const chain = hand[finger];
if (!chain || chain.length < 3) continue;
const aP = wpos(chain[0]), midP = wpos(chain[1]), cP = wpos(chain[2]);
const ac = cP.clone().sub(aP), acLen2 = ac.lengthSq() || 1, am = midP.clone().sub(aP);
const poleV = am.clone().sub(ac.clone().multiplyScalar(am.dot(ac) / acLen2)); // mid's offset from the a→tip line = the bend bulge side
if (poleV.lengthSq() < 1e-9) poleV.set(0, -0.4, -1); // dead-straight finger → palm-ward default
poleV.normalize();
const limb = { bones: [chain[0], chain[1], chain[2]], pole: [poleV.x, poleV.y, poleV.z] };
const mesh = new THREE.Mesh(ikGeo, new THREE.MeshBasicMaterial({ color: HANDLE.ik, transparent: true, opacity: 0.22, depthTest: false }));
mesh.renderOrder = 999;
mesh.userData = { joint: chain[2], kind: "ik", base: HANDLE.ik, limb, finger: true, thumb: finger === "thumb" };
fig.handlesGroup.add(mesh);
fig.handles.push(mesh);
}
}
// Each TOE is a 2-bone digit (proximal + distal) driven by the SAME two-bone IK as the
// fingers: a tip grab handle, solveTwoBoneIK bends both segments so the tip follows the
// cursor and the toe curls at its knuckle. The distal bone has no tail node in the GLB, so
// the tip is reconstructed: project the distal bone's own verts onto the toe's forward axis
// (MCP→PIP) and place the marker at the 90th-percentile forward extent. Projection discards
// lateral position, so an inward-curving or stray vert can no longer pull a tip sideways
// onto the neighbour toe (the old "farthest weighted vert" did exactly that — adjacent tips
// piled onto one toe and left another bare). The marker is the IK effector AND the handle.
const toeSkin = fig.toeChains && fig.meshes.find((m) => m.isSkinnedMesh && m.skeleton?.bones?.some((b) => /^toe\d_2_(l|r)$/.test(b.name)));
if (toeSkin) {
fig.root.updateMatrixWorld(true);
toeSkin.skeleton.update();
const bones = toeSkin.skeleton.bones;
const si = toeSkin.geometry.attributes.skinIndex, sw = toeSkin.geometry.attributes.skinWeight;
const idxToDist = new Map(); // distal bone's skeleton index → bone
const distAim = new Map(); // distal bone → { axis, head, verts:[{p,u}] }
for (const k of Object.keys(fig.toeChains)) for (const ch of fig.toeChains[k]) {
const prox = ch[0], d = ch[ch.length - 1];
const head = prox.getWorldPosition(new THREE.Vector3()); // MCP (toe base) so `u` spans the WHOLE toe
const axis = d.getWorldPosition(new THREE.Vector3()).sub(head).normalize();
idxToDist.set(bones.indexOf(d), d);
idxToDist.set(bones.indexOf(prox), d); // proximal verts contribute too → lateral centre is the toe's middle, not its inward-angled tip
distAim.set(d, { axis, head, verts: [] });
}
const vtmp = new THREE.Vector3();
for (let v = 0; v < si.count; v++) {
let bi = -1, bw = 0;
for (let k = 0; k < 4; k++) { const w = sw.getComponent(v, k); if (w > bw) { bw = w; bi = si.getComponent(v, k); } }
const d = idxToDist.get(bi);
if (!d) continue;
const aim = distAim.get(d);
toeSkin.getVertexPosition(v, vtmp); const wp = vtmp.clone().applyMatrix4(toeSkin.matrixWorld);
const u = wp.clone().sub(aim.head).dot(aim.axis);
if (u > 0) aim.verts.push({ p: wp, u });
}
for (const k of Object.keys(fig.toeChains)) for (const ch of fig.toeChains[k]) {
const prox = ch[0], dist = ch[ch.length - 1];
const aim = distAim.get(dist);
// Tip = CENTROID of the toe's forward tip band (its own most-forward 30% of verts), so the
// marker sits on the toe's LATERAL CENTRE. The old axis projection drifted inward (toes
// angle toward the midline), pushing the wide big-toe marker off onto its neighbour —
// one toe ended up with two diamonds and one (the big toe) with none.
let tipW;
if (aim.verts.length) {
aim.verts.sort((a, b) => a.u - b.u);
const band = aim.verts.slice(Math.floor(aim.verts.length * 0.7)); // forward tip band → dorsal height + forward reach
let by = 0, bz = 0; for (const e of band) { by += e.p.y; bz += e.p.z; } by /= band.length; bz /= band.length;
// LATERAL position = the toe's full vertex centre (its lobe centre), NOT the tip verts:
// the toes angle toward the midline, so the tip is inboard of the toe and using it bunched
// the markers (the wide big toe lost its diamond to the neighbour). Centre laterally, keep
// the tip's depth/height so the diamond still sits at the front of the toe.
let cx = 0; for (const e of aim.verts) cx += e.p.x; cx /= aim.verts.length;
tipW = new THREE.Vector3(cx, by, bz);
} else {
tipW = aim.head.clone().addScaledVector(aim.axis, 0.02);
}
const tip = new THREE.Object3D();
dist.add(tip); dist.updateMatrixWorld(true);
tip.position.copy(dist.worldToLocal(tipW.clone()));
// Bend plane (pole): the PIP's offset from the MCP→tip line; default DORSAL (up) so
// dragging the tip down curls the toe under (knuckle bulges up), like fingers fold.
const aP = prox.getWorldPosition(new THREE.Vector3());
const midP = dist.getWorldPosition(new THREE.Vector3());
const cP = tip.getWorldPosition(new THREE.Vector3());
const ac = cP.clone().sub(aP); const acL2 = ac.lengthSq() || 1;
const pole = midP.clone().sub(aP); pole.addScaledVector(ac, -pole.dot(ac) / acL2);
if (pole.lengthSq() < 1e-9) pole.set(0, 1, 0);
pole.normalize();
const mesh = new THREE.Mesh(ikGeo, new THREE.MeshBasicMaterial({ color: HANDLE.ik, transparent: true, opacity: 0.22, depthTest: false }));
mesh.renderOrder = 999;
mesh.userData = { joint: tip, kind: "ik", base: HANDLE.ik, limb: { bones: [prox, dist, tip], pole: [pole.x, pole.y, pole.z] }, finger: true, toe: true };
fig.handlesGroup.add(mesh);
fig.handles.push(mesh);
}
}
return fig;
};
// Render-time: keep the ACTIVE figure's handles glued to its joints, scaled to
// stay grabbable; inactive figures' handle groups are hidden.
const updateHandles = () => {
// Imported-prop 🦴 handles sync FIRST: they are figure-independent, and the
// rig-null early-return below otherwise orphans them in a figure-less scene
// (all handles stacked at the origin at unit scale = one giant blue ball).
updatePropBoneHandles();
// Selection ring tracks the active figure's base; only shown with a cast to pick
// between. Amber while armed = "Del removes this one".
const multi = figures.length > 1;
selectionRing.visible = multi;
if (multi && rig) {
const p = rig.root.getWorldPosition(new THREE.Vector3());
selectionRing.position.set(p.x, 0.012, p.z);
const armed = selectedFigure === rig; // selected as an object → Del removes it
selectionRing.material.color.setHex(armed ? 0xffb24a : 0x46b1ff);
selectionRing.material.opacity = armed ? 0.8 : 0.45;
}
if (!rig?.handles) return;
rig.handlesGroup.visible = handlesShouldShow();
for (const h of rig.handles) {
// A bound import follows whole-hand only (its fingers don't match the
// mannequin's), so a fingertip IK diamond would move an invisible bone and
// nothing else — hide them rather than offer dead controls.
const dead = !!rig.skinBind && h.userData.finger;
h.visible = !dead;
if (dead) continue;
h.userData.joint.getWorldPosition(h.position);
const d = h.position.distanceTo(camera.position);
// Fingertip diamonds are small so they read as digits, not the larger wrist
// (hand-reach) diamond sitting right next to them.
const sizeMul = h.userData.finger ? 0.7 : (h.userData.kind === "ik" ? 1.3 : 1);
// Cap pixel growth in a large (fullscreen) frame; node sizes (<= ref) are unchanged.
const vpScale = Math.min(1, HANDLE_REF_H / Math.max(1, frameH));
h.scale.setScalar(Math.max(0.005, d * HANDLE_SIZE * vpScale) * sizeMul);
// Faint until reached for: bright when hovered, when its joint is selected
// for the rotate gizmo, or while its IK tip is being dragged.
const lit = h === hoveredHandle
|| transformControls.object === h.userData.joint
|| h.userData.name === selectedJointName // selected in move mode (gizmo is on the IK proxy)
|| (ik.active && ik.handle === h)
|| !!skinFit; // fitting a skeleton: full brightness, these ARE the UI right now
const pinned = !!rig.footPins[h.userData.name];
h.material.opacity = (lit || pinned) ? HANDLE_BRIGHT : HANDLE_DIM;
h.material.color.setHex(h === hoveredHandle ? HANDLE.hover : pinned ? HANDLE.pin : h.userData.base);
}
// Keep the move gizmo glued to its wrist/ankle except while it's being dragged (the
// drag IS moving the proxy). So after a free diamond-drag or any other pose change the
// gizmo follows the joint instead of floating where it last sat.
if (transformControls.object === ikMoveProxy && !transformControls.dragging && selectedJointName)
rig?.joints[selectedJointName]?.getWorldPosition(ikMoveProxy.position);
};
// Make `fig` the active figure: show only its handles, retarget the gizmo/panels.
// null = NO active figure (the cast is empty) — every rig consumer is guarded.
const setActiveFigure = (fig) => {
rig = fig || null;
for (const f of figures) if (f.handlesGroup) f.handlesGroup.visible = (f === rig);
if (node._pcrPose) node._pcrPose.rig = rig;
detachGizmo(); // drop any selection on the previous figure's joint
buildBodySliders(rig); // a different BODY ships a different slider set
syncBodySliders(); // body-shape sliders reflect this figure
requestRender();
};
// Spawn another person: load a fresh GLB instance, set it up, stand it to the
// side, and select it. Used by the toolbar button (body picked from the
// FIGURE_LIBRARY flyout when there's more than one) and by restore-on-reload.
const spawnFigure = async ({ select = true, capture = true, body = DEFAULT_BODY } = {}) => {
if (!node._pcrAlive) return null;
const fig = (await loadGlbFigure(THREE, GLTFLoader, figureUrl(body), rigProfileOf(body))) || buildFigure(THREE);
if (!node._pcrAlive) return null;
fig.body = bodyKeyOf(body); // before initFigure — it loads this body's morph deltas
fig.profile = rigProfileOf(body); // rig vocabulary (joints/IK/mirror/pins) — before initFigure builds handles
await initFigure(fig); // adds fig.root to scene + fig.handlesGroup to handlesRoot
// Teardown can land during initFigure's await; if so, free this figure instead of
// leaving its GLB attached to an already-disposed scene (a leaked skinned mesh).
if (!node._pcrAlive) {
scene.remove(fig.root);
if (fig.handlesGroup) handlesRoot.remove(fig.handlesGroup);
disposeFigure(fig);
return null;
}
fig.root.position.x += figures.length * 0.7; // stand each new person to the side
fig.root.updateMatrixWorld(true);
fig.rootRest = fig.root.position.clone();
figures.push(fig);
updateFigureLabels();
if (select) setActiveFigure(fig);
requestRender();
if (capture) captureAndUpload(node);
return fig;
};
// Free a removed figure's GPU resources. Everything under fig.root is owned by that
// GLB/capsule instance (nothing shared), so traversing it is safe; the handle MESHES
// carry their own materials but SHARE ikGeo/fkGeo with every figure, so only their
// materials are disposed — never the geometry.
const disposeFigure = (fig) => {
fig.root.traverse((o) => {
o.geometry?.dispose?.();
disposeMaterial(o.material); // material + its textures (incl. hidden eye/extra sub-meshes)
});
for (const h of fig.handles || []) h.material?.dispose?.(); // handle materials have no textures; geometry is shared
};
// Remove a person from the scene. The last figure is never removed (there's always
// an active rig). Undoable: applyHistory reconciles the cast count, so the figure
// and its pose come back on undo. capture=false during history replay to avoid
// re-recording.
const removeFigure = (fig, { capture = true } = {}) => {
// The LAST figure is deletable too: imported meshes / named props make
// figure-less scenes legitimate (a statue-only composition). 👤 re-adds.
if (!fig) return;
const idx = figures.indexOf(fig);
if (idx === -1) return;
// Drop any selection that points at this figure before it's gone (clear selectedFigure
// first so clearFigureSelection doesn't touch the about-to-be-disposed material).
if (selectedFigure === fig) selectedFigure = null;
removeAttachmentsForFigure(fig); // drop any prop pinned to this figure's joints
if (transformControls.object === fig.root ||
(transformControls.object && Object.values(fig.joints).includes(transformControls.object))) detachGizmo();
scene.remove(fig.root);
handlesRoot.remove(fig.handlesGroup);
disposeFigure(fig);
figures.splice(idx, 1);
updateFigureLabels(); // renumber remaining figures' labels
if (rig === fig) setActiveFigure(figures.length ? figures[Math.min(idx, figures.length - 1)] : null);
cancelPinPick(); // re-enable the gizmo if a link was mid-pick
updatePinBtn();
updateFigureUI();
requestRender();
if (capture) captureAndUpload(node);
};
// Floating "mannequinN" name labels above each figure so it's clear which figure
// each $mannequinN region block targets. Viewport-only — hidden during the
// control-map and mask captures so they never leak into depth maps / silhouettes.
const makeLabelSprite = (text) => {
// Drawn as the region syntax the name IS ("$mannequin2"), in a fitted
// rounded pill with breathing room — not a bare full-width bar.
const label = "$" + text;
const cnv = document.createElement("canvas");
cnv.width = 256; cnv.height = 64;
const ctx = cnv.getContext("2d");
ctx.font = "600 23px sans-serif"; // smaller type inside a roomier pill (reference look)
// Long custom names shrink to keep the pill inside the canvas.
let w = ctx.measureText(label).width;
if (w > 200) {
ctx.font = `600 ${Math.max(13, Math.floor(23 * 200 / w))}px sans-serif`;
w = ctx.measureText(label).width;
}
const padX = 24, pillH = 46, pillW = Math.min(252, w + padX * 2);
ctx.fillStyle = "rgba(12,12,16,0.88)";
ctx.beginPath();
ctx.roundRect(128 - pillW / 2, (64 - pillH) / 2, pillW, pillH, 9);
ctx.fill();
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillStyle = "#ff9d5c";
ctx.fillText(label, 128, 33);
const tex = new THREE.CanvasTexture(cnv);
tex.colorSpace = THREE.SRGBColorSpace;
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true, opacity: 0.9 }));
sp.scale.set(0.7, 0.175, 1);
sp.position.set(0, 1.8, 0); // just above the head, in figure-root local space
sp.renderOrder = 999;
return sp;
};
const updateFigureLabels = () => {
figures.forEach((f, i) => {
const name = f.customName || "mannequin" + (i + 1);
if (f._label && f._labelText === name) return; // unchanged
if (f._label) { f.root.remove(f._label); f._label.material.map?.dispose(); f._label.material.dispose(); }
const sp = makeLabelSprite(name);
f.root.add(sp);
f._label = sp; f._labelText = name;
});
// No requestRender here — callers (spawn/remove/first-figure setup) render after,
// and requestRender is defined later in setup (calling it here = TDZ crash).
};
const setLabelsVisible = (v) => {
for (const f of figures) if (f._label) f._label.visible = v;
// Prop labels too — same rule, labels are viewport-only and must never
// reach the control map or a silhouette mask (the named prop's own pass
// would otherwise bake its label into its region mask).
for (const m of propsGroup.children) if (m._pcrLabel) m._pcrLabel.visible = v;
};
// Keep labels a roughly constant on-screen size regardless of zoom (world-space
// sprites otherwise balloon as you dolly in) AND clamp them inside the viewport
// so they never slide off when you zoom in or a figure nears a border — the
// label hugs the nearest edge, staying above (or as close as possible to) the
// head. Must run after camera.updateProjectionMatrix() so project/unproject use
// the frame's actual aspect.
const _labelAnchor = new THREE.Vector3();
const _labelNdc = new THREE.Vector3();
const _labelScale = new THREE.Vector3();
const scaleLabelsForCamera = () => {
let cameraReady = false;
for (const f of figures) {
const sp = f._label;
if (!sp || !sp.visible) continue;
if (!cameraReady) { camera.updateMatrixWorld(); cameraReady = true; }
f.root.updateWorldMatrix(true, false); // localToWorld/worldToLocal need it fresh
// Natural anchor: just above the LIVE head bone — a fixed root-space
// height sat inside tall bodies (height slider, big templates) and
// ignored posing entirely. Clearance is WORLD-space: root scale is a unit
// conversion (human ~0.96, the cm-unit horse 0.008), not figure size —
// scaling by it collapsed the gap to millimeters on creature GLBs.
const headJ = f.joints?.head;
if (headJ) {
headJ.getWorldPosition(_labelAnchor);
_labelAnchor.y += 0.32;
} else {
_labelAnchor.set(0, 1.8, 0); // capsule/odd rigs: the old fixed anchor
f.root.localToWorld(_labelAnchor);
}
const dist = camera.position.distanceTo(_labelAnchor);
const h = dist * 0.035; // screen-size factor; sprite canvas is 4:1
// Counter-scale by the root's baked unit scale, exactly like prop labels
// (updatePropLabels): the sprite is a CHILD of fig.root, so a cm-unit GLB
// otherwise renders its label at 0.8% size — an invisible speck the user
// can't see or dblclick-rename.
f.root.getWorldScale(_labelScale);
sp.scale.set(h * 4 / (_labelScale.x || 1), h / (_labelScale.y || 1), 1);
// Half-extents of the view at the label's depth → NDC margins that keep the
// whole sprite on screen. The sprite inherits the figure root's baked scale
// (~1.8/native-height), so fold that into its effective on-screen size.
f.root.getWorldScale(_labelScale);
const viewHalfH = dist * Math.tan((camera.fov * Math.PI / 180) / 2);
const viewHalfW = viewHalfH * camera.aspect;
const marginY = Math.min(0.9, (sp.scale.y * _labelScale.y / 2) / viewHalfH);
const marginX = Math.min(0.9, (sp.scale.x * _labelScale.x / 2) / viewHalfW);
// Clamp the anchor's projected position into that box, then unproject back so
// the sprite slides along the screen plane (same depth → constant size).
_labelNdc.copy(_labelAnchor).project(camera);
_labelNdc.x = Math.max(-1 + marginX, Math.min(1 - marginX, _labelNdc.x));
_labelNdc.y = Math.max(-1 + marginY, Math.min(1 - marginY, _labelNdc.y));
_labelNdc.unproject(camera);
f.root.worldToLocal(_labelNdc);
sp.position.copy(_labelNdc);
}
};
// The first figure (always present). Spawned as the baked DEFAULT_BODY here
// because the custom slider-engine machinery (requestRender, makeCustomEngine,
// …) is defined further down this function and would TDZ if a custom body
// initialised at this point. A FRESH node is upgraded to the custom slider body
// right after restoreSavedState (once that machinery exists); old saves restore
// their own body via reconcileCast.
if (!node._pcrAlive) return;
{
const fig = (await loadGlbFigure(THREE, GLTFLoader, figureUrl(DEFAULT_BODY), rigProfileOf(DEFAULT_BODY))) || buildFigure(THREE);
if (!node._pcrAlive) return;
fig.body = DEFAULT_BODY;
fig.profile = rigProfileOf(DEFAULT_BODY);
await initFigure(fig);
if (!node._pcrAlive) { scene.remove(fig.root); if (fig.handlesGroup) handlesRoot.remove(fig.handlesGroup); disposeFigure(fig); return; }
figures.push(fig);
rig = fig;
fig.handlesGroup.visible = true;
}
updateFigureLabels();
// Regional: if this Poser is wired to an Attention Couple (e.g. the [add]
// regional branch just connected MASKS), seed the bound prompt with
// $mannequin1 now that the first figure exists. No-op otherwise; adding more
// people tops it up on each capture.
reconcileRegionBlocks(node, effectiveFigureNames(figures)); // no props exist yet at mount
ensureReverseRegionSync(node); // observe the bound prompt for typed $name{} renames
// Let external triggers (the regional [add]) re-run the block sync after they
// wire MASKS->couple, since pure rewiring fires no capture. Reads live names —
// figures and named props (liveEntityNames hoists; props exist by call time).
node._pcrReconcileRegions = () => { reconcileRegionBlocks(node, liveEntityNames()); ensureReverseRegionSync(node); };
// Orbit listens on the canvas (same element as the posing handlers + transform gizmo,
// so pointer capture is shared and selection/gizmo dragging keep working). The canvas
// fills the node body's width, so this rotates the whole viewport — the dead left strip
// was the toolbar's invisible box, now made click-through (pointer-events:none) below.
const controls = new OrbitControls(camera, canvas);
controls.enableZoom = false; // dolly handled by the window-capture wheel handler
controls.enableDamping = false; // damping needs a render loop; we stay event-driven
controls.target.set(0, 1.0, 0);
// Left orbits, right pans. Middle is left to LiteGraph (it pans the ComfyUI
// graph and can't be reliably reclaimed from inside a DOM widget).
controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: null, RIGHT: THREE.MOUSE.PAN };
controls.update();
const HOME = { pos: camera.position.clone(), target: controls.target.clone() };
// Target control-map resolution drives the framing — the viewport letterboxes
// to this aspect so what you pose is exactly the output frame.
const wWidget = node.widgets?.find((w) => w.name === "width");
const hWidget = node.widgets?.find((w) => w.name === "height");
const getW = () => Math.max(64, Math.round(wWidget?.value || 1024));
const getH = () => Math.max(64, Math.round(hWidget?.value || 1216));
const FRAME_BG = 0x24242b; // inside the output frame (the GL canvas)
// Fit the GL canvas to the target-aspect frame, centered in the node body; the
// node background shows around it as the letterbox bars. Sizing the canvas to
// the frame (rather than letterboxing INSIDE a full-bleed canvas) keeps pointer
// math correct: canvas pixels === frame pixels, so raycasts AND the
// TransformControls gizmo line up no matter how the node is stretched.
let frameW = 1, frameH = 1;
const fitCanvas = () => {
const cw = Math.max(1, container.clientWidth);
const ch = Math.max(1, container.clientHeight);
const aspect = getW() / getH();
let vw = cw, vh = cw / aspect;
if (vh > ch) { vh = ch; vw = ch * aspect; }
frameW = Math.max(1, Math.round(vw));
frameH = Math.max(1, Math.round(vh));
canvas.style.left = Math.round((cw - frameW) / 2) + "px";
canvas.style.top = Math.round((ch - frameH) / 2) + "px";
renderer.setSize(frameW, frameH, true); // updateStyle: also sets canvas CSS w/h
};
// ── Blender-style selection outlines (screen-space) ────────────────────────
// Port of Blender's overlay_outline pass: outlined objects re-render FLAT into
// an offscreen ID buffer that shares the scene's depth (a depth prepass of all
// content), so only their VISIBLE pixels land there — outlines hug visible
// silhouettes, occluders clip them, and a seam appears where two outlined
// objects touch on screen. A fullscreen edge-detect then draws constant-width
// lines over the canvas: bright orange for the ACTIVE object, darker orange
// for other selected ones (Blender's theme colors). The pass only runs in
// renderFrame — the offscreen capture/mask renders never see any of it.
const OUTLINE_ACTIVE = 0xffaa40; // Blender "active object"
const OUTLINE_SELECTED = 0xed5700; // Blender "object selected"
const OUTLINE_PX = 2.0; // line width in device pixels
const OUTLINE_LAYER = 30; // scratch layer for the per-set ID renders
const outlineDepthMat = new THREE.MeshBasicMaterial({ colorWrite: false });
const outlineSelMat = new THREE.MeshBasicMaterial({ color: OUTLINE_SELECTED });
const outlineActMat = new THREE.MeshBasicMaterial({ color: OUTLINE_ACTIVE });
let outlineTarget = null;
const outlineQuadGeo = new THREE.BufferGeometry(); // fullscreen triangle
outlineQuadGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3));
outlineQuadGeo.setAttribute("uv", new THREE.BufferAttribute(new Float32Array([0, 0, 2, 0, 0, 2]), 2));
const outlineQuadMat = new THREE.ShaderMaterial({
uniforms: { tId: { value: null }, texelSize: { value: new THREE.Vector2() }, radius: { value: OUTLINE_PX } },
vertexShader: "varying vec2 vUv;\nvoid main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }",
fragmentShader: [
"uniform sampler2D tId;",
"uniform vec2 texelSize;",
"uniform float radius;",
"varying vec2 vUv;",
"void main() {",
" vec4 c = texture2D(tId, vUv);",
" vec4 best = vec4(0.0);",
" bool seam = false;",
" vec2 o[8];",
" o[0] = vec2(1.0, 0.0); o[1] = vec2(-1.0, 0.0); o[2] = vec2(0.0, 1.0); o[3] = vec2(0.0, -1.0);",
" o[4] = vec2(0.7071, 0.7071); o[5] = vec2(-0.7071, 0.7071); o[6] = vec2(0.7071, -0.7071); o[7] = vec2(-0.7071, -0.7071);",
" for (int i = 0; i < 8; i++) {",
" vec4 n = texture2D(tId, vUv + o[i] * texelSize * radius);",
" if (n.a < 0.5) continue;",
" if (c.a > 0.5 && distance(n.rgb, c.rgb) > 0.08) seam = true;",
" if (best.a < 0.5 || dot(n.rgb, vec3(1.0)) > dot(best.rgb, vec3(1.0))) best = n;",
" }",
" vec3 col = vec3(0.0);",
" if (c.a < 0.5 && best.a > 0.5) col = best.rgb; // exterior rim",
" else if (c.a > 0.5 && seam) col = max(c.rgb, best.rgb); // boundary between two outlined objects",
" else discard;",
" gl_FragColor = vec4(pow(col, vec3(0.4545)), 1.0); // RT is linear; canvas wants sRGB",
"}",
].join("\n"),
depthTest: false,
depthWrite: false,
transparent: true,
});
const outlineQuad = new THREE.Mesh(outlineQuadGeo, outlineQuadMat);
outlineQuad.frustumCulled = false;
const outlineScene = new THREE.Scene();
outlineScene.add(outlineQuad);
const outlineCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); // shader ignores it; render() requires one
const _obSize = new THREE.Vector2();
// active = what the gizmo is on (bright); selected = everything else that reads
// as selected — band-select members and props the active figure carries (the
// same "figure plus what it carries" semantics its region mask uses).
const collectOutlineSets = () => {
const active = [], selected = [];
if (selectedFigure) {
active.push(selectedFigure.root);
for (const m of propsGroup.children)
if (m.visible && figureOwningProp(m) === selectedFigure) selected.push(m);
}
if (selectedProp?.visible) active.push(selectedProp);
for (const s of bandSel) selected.push(s.kind === "figure" ? s.o.root : s.o);
return { active, selected };
};
const drawOutlineSet = (roots, mat) => {
if (!roots.length) return;
const tagged = [];
for (const r of roots) r.traverse((obj) => { if (obj.isMesh) { obj.layers.enable(OUTLINE_LAYER); tagged.push(obj); } });
const prevMask = camera.layers.mask;
camera.layers.set(OUTLINE_LAYER);
scene.overrideMaterial = mat;
renderer.render(scene, camera);
scene.overrideMaterial = null;
camera.layers.mask = prevMask;
for (const obj of tagged) obj.layers.disable(OUTLINE_LAYER);
};
const renderOutlinePass = () => {
const sets = collectOutlineSets();
if (!sets.active.length && !sets.selected.length) return;
renderer.getDrawingBufferSize(_obSize);
if (!outlineTarget || outlineTarget.width !== _obSize.x || outlineTarget.height !== _obSize.y) {
outlineTarget?.dispose();
outlineTarget = new THREE.WebGLRenderTarget(_obSize.x, _obSize.y, {
minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter,
});
}
// Viewport aids must neither occlude the outlines nor get outlined.
const aidVis = [grid.visible, tcHelper.visible, handlesRoot.visible];
grid.visible = tcHelper.visible = handlesRoot.visible = false;
setLabelsVisible(false);
const prevAutoClear = renderer.autoClear;
try {
renderer.setRenderTarget(outlineTarget);
renderer.setClearColor(0x000000, 0);
renderer.clear();
renderer.autoClear = false; // the set renders must not wipe the prepass depth
scene.overrideMaterial = outlineDepthMat; // depth prepass: all content occludes
renderer.render(scene, camera);
scene.overrideMaterial = null;
drawOutlineSet(sets.selected, outlineSelMat);
drawOutlineSet(sets.active, outlineActMat);
renderer.setRenderTarget(null);
outlineQuadMat.uniforms.tId.value = outlineTarget.texture;
outlineQuadMat.uniforms.texelSize.value.set(1 / _obSize.x, 1 / _obSize.y);
renderer.render(outlineScene, outlineCam); // composite over the live canvas
} finally {
renderer.autoClear = prevAutoClear;
scene.overrideMaterial = null;
renderer.setRenderTarget(null);
grid.visible = aidVis[0]; tcHelper.visible = aidVis[1]; handlesRoot.visible = aidVis[2];
setLabelsVisible(true);
}
};
const renderFrame = () => {
// A queued rAF can fire after the node was torn down/rebuilt (e.g. a graph
// reload races our undo): rendering then hits a disposed renderer/context and
// throws deep in THREE's shader code. Skip stale frames, and never let a render
// error escape as an uncaught exception.
if (!node._pcrAlive) return;
// A repaint interleaving the mask-capture loop (its per-figure toBlob await
// yields to rAF) resets the clear color to FRAME_BG and the viewport to the
// screen size — later figures' masks then capture on a gray background that
// downstream binarization reads as "figure" (full-canvas mask = total
// prompt bleed). The capture's finally re-queues a repaint.
if (captureBusy) return;
enforceAttachments(); // pinned props ride their leader (hand/prop) before we draw
updateAttachConnectors();
updateHandles();
renderer.setViewport(0, 0, frameW, frameH);
renderer.setClearColor(FRAME_BG, 1);
camera.aspect = frameW / frameH;
camera.updateProjectionMatrix();
scaleLabelsForCamera(); // constant on-screen size + clamp inside viewport (needs current projection)
try {
renderer.render(scene, camera);
renderOutlinePass(); // Blender-style selection outlines, composited on top
}
catch (err) { console.warn("[PoseStudio] render skipped:", err?.message || err); }
};
// Event-driven render: render once per change, never a perpetual rAF loop
// (CLAUDE.md: event-driven only).
let renderQueued = false;
const requestRender = () => {
if (renderQueued) return;
renderQueued = true;
requestAnimationFrame(() => { renderQueued = false; renderFrame(); });
};
// Capture the control map at the exact target resolution, independent of the
// node-body size. Render into an offscreen target (so the visible canvas never
// flickers) with sRGB output to match the viewport, read it back, flip Y, and
// pack to PNG.
// Rubber-band multi-select (defined far below; forward-declared so captureBlob and
// the single-select helpers above can reference them — assigned at runtime).
let clearBand = () => {};
let bandActive = () => false;
const bandSel = []; // [{ kind:'figure'|'prop', o }] — feeds the outline pass too
let captureTarget = null;
let brushCursor = null; // clothing-paint brush footprint ring (viewport aid; assigned later)
let sculptRing = null; // sculpt brush footprint ring (viewport aid; assigned in the sculpt block below)
let sculptWire = null; // sculpt "show mesh" wireframe overlay (viewport aid; assigned in the sculpt block below)
// ── True geometric depth output (toggle: node.properties.pcrTrueDepth) ─────────
// Instead of letting a downstream monocular estimator (DepthAnythingV2) guess depth
// from the clay render — which flattens thin clothing — output the REAL z-depth of
// the scene. We render the scene NORMALLY into a target that has a DepthTexture (so
// every vertex-shader offset, incl. the garment's normal-offset raise, lands in the
// depth buffer exactly as drawn), then a fullscreen pass linearizes that depth and
// remaps it over the content's view-space range: near = white, background = black.
// (A material-swap / MeshDepthMaterial pass crashes this three build's uniform
// refresh, and would also miss the garment's shader offset — the depth-texture route
// avoids both.)
let depthOutTarget = null;
const depthLinearGeo = new THREE.BufferGeometry(); // fullscreen triangle
depthLinearGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3));
depthLinearGeo.setAttribute("uv", new THREE.BufferAttribute(new Float32Array([0, 0, 2, 0, 0, 2]), 2));
const depthLinearMat = new THREE.ShaderMaterial({
uniforms: { tDepth: { value: null }, cameraNear: { value: 0.1 }, cameraFar: { value: 100 }, dMin: { value: 0 }, dMax: { value: 1 }, curve: { value: 1 } },
vertexShader: "varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position, 1.0); }",
fragmentShader: [
"#include <packing>",
"uniform sampler2D tDepth; uniform float cameraNear, cameraFar, dMin, dMax, curve; varying vec2 vUv;",
"void main(){",
" float depth = texture2D(tDepth, vUv).x;",
" if (depth >= 1.0) { gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); return; }", // empty pixel → background
" float dist = -perspectiveDepthToViewZ(depth, cameraNear, cameraFar);", // positive metres in front of camera
" float lin = 1.0 - clamp((dist - dMin) / max(0.0001, dMax - dMin), 0.0, 1.0);", // linear distance, near=white
" float idMin = 1.0 / max(dMin, 0.0001), idMax = 1.0 / max(dMax, 0.0001);", // inverse-depth (disparity) range
" float disp = clamp((1.0 / dist - idMax) / max(0.0001, idMin - idMax), 0.0, 1.0);", // near=white; matches DepthAnything's 1/dist distribution
" gl_FragColor = vec4(vec3(mix(lin, disp, curve)), 1.0);", // curve=1 disparity (default), 0 linear
"}",
].join("\n"),
});
const depthLinearQuad = new THREE.Mesh(depthLinearGeo, depthLinearMat); depthLinearQuad.frustumCulled = false;
const depthLinearScene = new THREE.Scene(); depthLinearScene.add(depthLinearQuad);
const depthLinearCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const _depthBox = new THREE.Box3(), _depthV = new THREE.Vector3();
const computeDepthRange = () => {
camera.updateMatrixWorld();
// View-depth range from the POSED bone world-positions, not the mesh box:
// expandByObject() on a SkinnedMesh uses the static BIND pose (and was collapsing
// to the camera near/far fallback), so the range never matched the posed figures
// -> the shader remapped them into a tiny bright sliver and they came out ~20% grey
// on black instead of near-white. Bones are reliably posed in world space.
let zmin = Infinity, zmax = -Infinity;
const accDepth = () => { _depthV.applyMatrix4(camera.matrixWorldInverse); const d = -_depthV.z; if (d < zmin) zmin = d; if (d > zmax) zmax = d; };
for (const f of figures) {
const bones = f.skel?.bones;
if (!bones) continue;
for (const b of bones) { b.getWorldPosition(_depthV); accDepth(); }
}
if (propsGroup.children.length) {
_depthBox.makeEmpty(); _depthBox.expandByObject(propsGroup);
if (!_depthBox.isEmpty()) {
const mn = _depthBox.min, mx = _depthBox.max;
for (let i = 0; i < 8; i++) { _depthV.set(i & 1 ? mx.x : mn.x, i & 2 ? mx.y : mn.y, i & 4 ? mx.z : mn.z); accDepth(); }
}
}
if (zmin === Infinity) { // no bones (non-skinned) -> bind-box, then camera
_depthBox.makeEmpty();
for (const f of figures) for (const m of f.meshes || []) if (m.visible) _depthBox.expandByObject(m);
if (_depthBox.isEmpty()) return { near: camera.near, far: camera.far };
const mn = _depthBox.min, mx = _depthBox.max;
for (let i = 0; i < 8; i++) { _depthV.set(i & 1 ? mx.x : mn.x, i & 2 ? mx.y : mn.y, i & 4 ? mx.z : mn.z); accDepth(); }
}
// bones sit INSIDE the body; pad outward so the surface spans full white→black.
const surf = 0.2;
return { near: Math.max(0.01, zmin - surf), far: zmax + surf };
};
// When true-depth is on, the Poser's IMAGE already IS the depth map, so the
// downstream DepthAnythingV2 estimator must not re-process it — bypass it (mode 4
// passes its input straight through to the ControlNet). Direct wiring only (v1).
const setDepthBypass = (on) => {
const g = node.graph; if (!g) return;
const out = (node.outputs || []).find((o) => o.name === "IMAGE");
if (!out || !out.links) return;
for (const linkId of out.links) {
const link = g.links[linkId]; if (!link) continue;
const tgt = g.getNodeById(link.target_id);
const cls = tgt && (tgt.comfyClass || tgt.type);
if (cls === "DepthAnythingV2Preprocessor" || cls === "PromptChain_DepthAnything") {
tgt.mode = on ? 4 : 0;
}
}
g.setDirtyCanvas?.(true, true);
};
const captureBlob = async () => {
enforceAttachments(); // make sure pinned props sit on their leader in the captured map
const W = getW(), H = getH();
if (!captureTarget || captureTarget.width !== W || captureTarget.height !== H) {
captureTarget?.dispose();
captureTarget = new THREE.WebGLRenderTarget(W, H);
captureTarget.texture.colorSpace = THREE.SRGBColorSpace; // match on-screen output
captureTarget.depthTexture = new THREE.DepthTexture(W, H); // readable depth for true-depth output
captureTarget.depthTexture.type = THREE.UnsignedIntType;
}
const prevAspect = camera.aspect;
camera.aspect = W / H;
camera.updateProjectionMatrix();
// setRenderTarget sets the GL viewport/scissor to the target's exact pixel
// size. Do NOT call renderer.setViewport here — it re-applies _viewport
// scaled by pixelRatio, which on a HiDPI display renders the scene at 2× into
// the corner (zoomed-in, cropped capture).
// The grid and rotate gizmo are viewport aids — never let them into the
// control map handed to ControlNet.
const showGrid = grid.visible, showGizmo = tcHelper.visible, showHandles = handlesRoot.visible;
const showBrush = brushCursor ? brushCursor.visible : false;
const showSculptRing = sculptRing ? sculptRing.visible : false;
const showSculptWire = sculptWire ? sculptWire.visible : false;
// Output mode: "default" clay render | "white" isolated figure on white (pose-anchor
// exports — mannequin-pose LoRAs are trained on white-background anchors) | "depth"
// true z-depth. pcrTrueDepth stays mirrored for pre-mode saves.
const outMode = node.properties?.pcrOutputMode || (node.properties?.pcrTrueDepth ? "depth" : "default");
const depthMode = outMode === "depth";
const whiteMode = outMode === "white";
const showFloor = floor.visible;
// Missing-mesh markers are viewport-only — never let them into the control map.
const missingMarkers = propsGroup.children.filter((m) => m.userData._failedRecord);
const buf = new Uint8Array(W * H * 4);
// try/finally so a render/readback throw (e.g. a disposed-context teardown race) can
// never leave the grid/gizmo/handles hidden on screen. (Selection outlines are a
// screen-space pass that only runs in renderFrame — nothing to hide here.)
try {
grid.visible = false;
tcHelper.visible = false;
handlesRoot.visible = false; // hide every figure's handles in the capture
if (brushCursor) brushCursor.visible = false; // the brush ring is a viewport aid
if (sculptRing) sculptRing.visible = false; // the sculpt ring is a viewport aid
if (sculptWire) sculptWire.visible = false; // the sculpt wireframe is a viewport aid
setLabelsVisible(false); // name labels are viewport-only, never in the control map
for (const mk of missingMarkers) mk.visible = false; // missing-mesh markers never enter the control map
if (depthMode || whiteMode) floor.visible = false; // clean isolated figure (depth-on-black / anchor-on-white)
renderer.setRenderTarget(captureTarget);
renderer.setClearColor(depthMode ? 0x000000 : whiteMode ? 0xffffff : FRAME_BG, 1);
renderer.clear();
renderer.render(scene, camera); // normal render — fills captureTarget.depthTexture
// Head boxes for the Regional Detailer — measured with the EXACT camera
// state that just rendered the control map, so they always correspond to
// the frame downstream nodes will see.
node._pcrHeads = computeHeadExports(THREE, figures, camera);
if (depthMode) {
// Linearize the captured depth buffer and remap over the content's view-space
// distance range → near=white on black. The garment's vertex-shader offset is
// already baked into depthTexture, so its raise shows here automatically.
if (!depthOutTarget || depthOutTarget.width !== W || depthOutTarget.height !== H) {
depthOutTarget?.dispose();
depthOutTarget = new THREE.WebGLRenderTarget(W, H);
}
const range = computeDepthRange();
depthLinearMat.uniforms.tDepth.value = captureTarget.depthTexture;
depthLinearMat.uniforms.cameraNear.value = camera.near;
depthLinearMat.uniforms.cameraFar.value = camera.far;
depthLinearMat.uniforms.dMin.value = range.near;
depthLinearMat.uniforms.dMax.value = range.far;
// Default to the disparity (inverse-depth) curve — it matches the 1/dist distribution
// DepthAnything-trained depth ControlNets expect; pcrDepthCurve:"linear" opts back to raw
// linear distance (the one consumer that wants it is the Flux2 Klein pose-refcontrol template).
depthLinearMat.uniforms.curve.value = node.properties?.pcrDepthCurve === "linear" ? 0.0 : 1.0;
renderer.setRenderTarget(depthOutTarget);
renderer.render(depthLinearScene, depthLinearCam);
renderer.setRenderTarget(null);
renderer.readRenderTargetPixels(depthOutTarget, 0, 0, W, H, buf);
} else {
renderer.setRenderTarget(null);
renderer.readRenderTargetPixels(captureTarget, 0, 0, W, H, buf);
}
} finally {
renderer.setRenderTarget(null);
grid.visible = showGrid;
tcHelper.visible = showGizmo;
handlesRoot.visible = showHandles;
floor.visible = showFloor;
if (brushCursor) brushCursor.visible = showBrush;
if (sculptRing) sculptRing.visible = showSculptRing;
if (sculptWire) sculptWire.visible = showSculptWire;
setLabelsVisible(true); // restore viewport name labels
for (const mk of missingMarkers) mk.visible = true; // restore the viewport markers
camera.aspect = prevAspect;
camera.updateProjectionMatrix();
requestRender(); // repaint the on-screen letterboxed view
}
const cnv = document.createElement("canvas");
cnv.width = W; cnv.height = H;
const ctx = cnv.getContext("2d");
const imgData = ctx.createImageData(W, H);
const row = W * 4;
for (let y = 0; y < H; y++) {
imgData.data.set(buf.subarray((H - 1 - y) * row, (H - y) * row), y * row); // GL origin is bottom-left
}
ctx.putImageData(imgData, 0, 0);
return await new Promise((res) => cnv.toBlob(res, "image/png"));
};
// Per-entity region masks: render each REGION ENTITY alone on black (its own
// posed silhouette) so regional conditioning (Attention Couple) can apply one
// entity's tags to one body. Entities = every figure (always a region) followed
// by every NAMED prop (naming a prop promotes it to a region; unnamed props
// stay depth dressing). Uses the live materials so skinning/pose is preserved;
// isolates by visibility, so it survives overlaps a screen-space split can't.
let maskTarget = null;
let maskOverrideMat = null; // shared silhouette override — built once, disposed in disposeCapture (not per capture)
let captureBusy = false; // blocks renderFrame from clobbering renderer state mid-capture
const captureRegionMasks = async () => {
// Mask-row order = entity order: figures first (so heads[] / detailer face
// boxes keep entity_index == figure_index), then named props. ONE contiguous
// counter across both — a restarted counter would collide _mask0.png and let
// prop masks overwrite figure masks on the server.
const namedProps = namedPropMeshes();
const entities = [
...figures.map((f, i) => ({ kind: "figure", o: f, name: f.customName || "mannequin" + (i + 1), uid: f.uid })),
...namedProps.map((m) => ({ kind: "prop", o: m, name: m.userData.customName, uid: m.userData.uid })),
];
// A single figure is still a region: its silhouette mask is what binds a
// $block's tags to the body instead of the whole frame. Gating this at
// two entities silently degraded one-figure regional graphs to a
// full-canvas region (the server's no-masks fallback).
node._pcrRegionEntities = entities.map((e) => ({ kind: e.kind, name: e.name }));
if (!entities.length) return [];
const W = getW(), H = getH();
if (!maskTarget || maskTarget.width !== W || maskTarget.height !== H) {
maskTarget?.dispose();
maskTarget = new THREE.WebGLRenderTarget(W, H);
maskTarget.texture.colorSpace = THREE.SRGBColorSpace;
}
const prevAspect = camera.aspect;
camera.aspect = W / H; camera.updateProjectionMatrix();
const sv = {
grid: grid.visible, gizmo: tcHelper.visible, handles: handlesRoot.visible,
floor: floor.visible, props: propsGroup.visible,
figVis: figures.map((f) => f.root.visible),
propVis: propsGroup.children.map((m) => m.visible),
};
// Each prop's owning figure (null = unpinned), resolved once: a figure's region
// mask must include the props it carries (held/pinned), so they aren't clipped
// out of its conditioning. Shoes already ride the ankle bone, so they're in the
// figure render automatically. A NAMED prop is its OWN region — exclusive
// membership, it leaves its holder's mask even while pinned to a hand.
enforceAttachments(); // props sit on their leader before we read membership/render
const namedSet = new Set(namedProps);
// Ownership generalizes across entity kinds: an unnamed prop belongs to the
// FIRST region entity up its 🔗 chain — a figure (sword in a hand) or a
// NAMED prop (charm linked to the named statue) — so every region masks
// "the entity plus what it carries", whoever the carrier is.
const entityOwningProp = (mesh) => {
const seen = new Set();
let cur = mesh;
while (cur && !seen.has(cur)) {
seen.add(cur);
const a = propAttachment(cur);
if (!a) return null;
if (a.leader.kind === "joint") return a.leader.fig;
if (namedSet.has(a.leader.mesh)) return a.leader.mesh;
cur = a.leader.mesh;
}
return null;
};
const propOwner = propsGroup.children.map((m) => (namedSet.has(m) ? null : entityOwningProp(m)));
const out = [];
try {
captureBusy = true;
grid.visible = false; tcHelper.visible = false; handlesRoot.visible = false;
floor.visible = false; propsGroup.visible = true;
setLabelsVisible(false); // keep name labels out of the silhouette masks
// Flat unlit white: a mask must be binary figure-vs-background. Rendering
// with the normal lit materials bakes shading into the mask (~0.6 gray),
// which downstream consumers multiply by — the regional detailer's
// re-render strength was silently capped at that gray level.
if (!maskOverrideMat) maskOverrideMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
scene.overrideMaterial = maskOverrideMat;
for (let i = 0; i < entities.length; i++) {
const ent = entities[i];
figures.forEach((f) => { f.root.visible = ent.kind === "figure" && f === ent.o; });
propsGroup.children.forEach((m, k) => {
// The entity itself, plus every unnamed prop riding it (🔗 chain).
// A missing-mesh marker never enters a region mask.
m.visible = (m === ent.o || propOwner[k] === ent.o) && !m.userData._failedRecord;
});
// Re-asserted per pass (not hoisted): the toBlob await below yields to
// rAF, and anything that slips through must not leave a stale target,
// viewport, or clear color for the next entity's pass.
renderer.setRenderTarget(maskTarget);
renderer.setClearColor(0x000000, 1);
renderer.clear();
renderer.render(scene, camera);
const buf = new Uint8Array(W * H * 4);
renderer.readRenderTargetPixels(maskTarget, 0, 0, W, H, buf);
const cnv = document.createElement("canvas");
cnv.width = W; cnv.height = H;
const ctx = cnv.getContext("2d");
const imgData = ctx.createImageData(W, H);
const rowLen = W * 4;
for (let y = 0; y < H; y++) imgData.data.set(buf.subarray((H - 1 - y) * rowLen, (H - y) * rowLen), y * rowLen);
ctx.putImageData(imgData, 0, 0);
out.push({ index: i, kind: ent.kind, name: ent.name, uid: ent.uid, blob: await new Promise((res) => cnv.toBlob(res, "image/png")) });
}
} finally {
captureBusy = false;
scene.overrideMaterial = null;
renderer.setRenderTarget(null);
grid.visible = sv.grid; tcHelper.visible = sv.gizmo; handlesRoot.visible = sv.handles;
floor.visible = sv.floor; propsGroup.visible = sv.props;
propsGroup.children.forEach((m, k) => { if (k < sv.propVis.length) m.visible = sv.propVis[k]; });
setLabelsVisible(true);
figures.forEach((f, j) => { f.root.visible = sv.figVis[j]; });
camera.aspect = prevAspect; camera.updateProjectionMatrix();
requestRender();
}
return out;
};
const disposeCapture = () => {
captureTarget?.dispose(); maskTarget?.dispose(); maskOverrideMat?.dispose();
depthOutTarget?.dispose(); depthLinearGeo.dispose(); depthLinearMat.dispose();
outlineTarget?.dispose(); outlineQuadGeo.dispose();
outlineQuadMat.dispose(); outlineDepthMat.dispose(); outlineSelMat.dispose(); outlineActMat.dispose();
};
controls.addEventListener("change", requestRender);
controls.addEventListener("end", () => captureAndUpload(node));
// Rotate gizmo: click a body part to select its joint, drag the rings to pose
// it (FK). OrbitControls is suspended while a ring is being dragged, and the
// control map re-uploads when the drag ends.
const transformControls = new TransformControls(camera, canvas);
transformControls.setMode("rotate");
transformControls.setSpace("local");
transformControls.setSize(0.7);
// r169+: the gizmo is a separate helper object you add to the scene.
const tcHelper = transformControls.getHelper ? transformControls.getHelper() : transformControls;
scene.add(tcHelper);
transformControls.addEventListener("change", requestRender);
transformControls.addEventListener("dragging-changed", (e) => {
controls.enabled = !e.value; // don't orbit the camera while posing a joint
if (bandActive()) { // gizmo is on the multi-select proxy
if (e.value) bandDragStart();
else { placeBandProxy(); captureAndUpload(node); } // re-center proxy on the moved group
return;
}
if (transformControls.object === ikMoveProxy) { // gizmo-IK move of a wrist/ankle
if (!e.value) {
const nm = selectedJointName;
if (nm && rig.footPins?.[nm]) rig.footPins[nm] = rig.joints[nm].getWorldPosition(new THREE.Vector3()); // re-plant a pinned foot
if (nm) rig.joints[nm].getWorldPosition(ikMoveProxy.position); // snap the gizmo back to the (clamped) joint
captureAndUpload(node);
}
return;
}
if (selectedFigure && transformControls.object === selectedFigure.root && transformControls.mode === "scale") {
if (e.value) figScaleAnchor = figScaleAnchorOf(selectedFigure); // remember size + ground spot
else { groundFigureAfterScale(selectedFigure, figScaleAnchor); figScaleAnchor = null; captureAndUpload(node); }
return;
}
if (!e.value) captureAndUpload(node);
});
// ── hand/foot pose controls ──────────────────────────────────────────────
// A wrist selection shows a preset dropdown + 5 per-finger curl sliders (direct
// control); an ankle shows the foot-pose dropdown. The sliders are the source of
// truth for hands — presets just set them.
const jointName = (j) => j && rig && Object.keys(rig.joints).find((k) => rig.joints[k] === j);
const presetSelect = document.createElement("select");
// Contextual panels sit at the RIGHT edge now that the tool strip owns the left.
presetSelect.style.cssText =
"position:absolute;top:6px;right:6px;z-index:3;display:none;font:11px system-ui,sans-serif;" +
"background:#2a2a35;color:#ccd;border:1px solid #4a4a55;border-radius:4px;padding:2px 4px;cursor:pointer;";
presetSelect.addEventListener("pointerdown", (e) => e.stopPropagation());
// Per-finger curl slider panel (hands), under the preset dropdown on the right.
const handPanel = document.createElement("div");
handPanel.style.cssText =
"position:absolute;top:38px;right:6px;z-index:3;display:none;background:rgba(20,20,26,0.82);" +
"border:1px solid #4a4a55;border-radius:5px;padding:5px 7px;font:10px system-ui,sans-serif;color:#ccd;";
handPanel.addEventListener("pointerdown", (e) => e.stopPropagation());
const fingerSliders = {};
for (const [key, label] of HAND_SLIDERS) {
const row = document.createElement("label");
row.style.cssText = "display:flex;align-items:center;gap:6px;margin:2px 0;cursor:pointer;";
const tag = document.createElement("span");
tag.textContent = label;
tag.style.cssText = "width:56px;opacity:0.85;";
const s = document.createElement("input");
s.type = "range"; s.min = "0"; s.max = "100"; s.value = "0";
s.style.cssText = "width:96px;";
s.addEventListener("input", () => {
const name = selectedJointName; // NOT transformControls.object — a wrist selects in MOVE mode, so the gizmo is on the IK proxy, not the bone (same fix the presets use)
if (!name?.startsWith("wrist")) return;
const amts = rig.handAmounts.get(name) || {};
amts[key] = +s.value / 100;
rig.handAmounts.set(name, amts);
curlFinger(name, key, amts[key]); // ONLY this finger — never re-pose the rest
rig.joints[name]?.updateMatrixWorld(true);
requestRender();
});
s.addEventListener("change", () => captureAndUpload(node)); // commit on release
row.append(tag, s);
handPanel.appendChild(row);
fingerSliders[key] = s;
}
// Per-toe curl slider panel (feet), same placement as the hand panel under the foot-pose
// dropdown. Each slider curls one toe bone; the dropdown's presets still flex the whole foot.
const footPanel = document.createElement("div");
footPanel.style.cssText =
"position:absolute;top:38px;right:6px;z-index:3;display:none;background:rgba(20,20,26,0.82);" +
"border:1px solid #4a4a55;border-radius:5px;padding:5px 7px;font:10px system-ui,sans-serif;color:#ccd;";
footPanel.addEventListener("pointerdown", (e) => e.stopPropagation());
const toeSliders = [];
for (let i = 0; i < TOE_LABELS.length; i++) {
const row = document.createElement("label");
row.style.cssText = "display:flex;align-items:center;gap:6px;margin:2px 0;cursor:pointer;";
const tag = document.createElement("span");
tag.textContent = TOE_LABELS[i];
tag.style.cssText = "width:56px;opacity:0.85;";
const s = document.createElement("input");
s.type = "range"; s.min = "0"; s.max = "100"; s.value = "0";
s.style.cssText = "width:96px;";
s.addEventListener("input", () => {
const name = selectedJointName; // ankle selects in MOVE mode → gizmo is on the IK proxy, not the bone
if (!name?.startsWith("ankle")) return;
const amts = rig.footAmounts.get(name) || [];
amts[i] = +s.value / 100;
rig.footAmounts.set(name, amts);
curlToe(name, i, amts[i]); // ONLY this toe
requestRender();
});
s.addEventListener("change", () => captureAndUpload(node));
row.append(tag, s);
footPanel.appendChild(row);
toeSliders.push(s);
}
const setToeSliders = (amts) => { for (let i = 0; i < toeSliders.length; i++) toeSliders[i].value = String(Math.round((amts?.[i] ?? 0) * 100)); };
container.append(presetSelect, handPanel, footPanel);
// ── body-shape morph sliders ────────────────────────────────────────────
// One slider per shape-key morph the GLB carries, centred at 0 = the neutral
// base. Dragging positive grows toward the baked extreme, negative shrinks
// past it — glTF morphs are relative deltas, so the shader extrapolates
// out-of-[0,1] cleanly and bidirectional sliders need only one shape key.
// Toggled by the 💪 body button; absent entirely if the GLB has no morphs.
const bodyPanel = document.createElement("div");
// Bottom-right so the body sliders don't collide with the top-right selection panel
// (you often shape a figure while it's selected).
bodyPanel.style.cssText =
"position:absolute;bottom:6px;right:6px;z-index:3;display:none;background:rgba(20,20,26,0.82);" +
"border:1px solid #4a4a55;border-radius:5px;padding:5px 7px;font:10px system-ui,sans-serif;color:#ccd;";
bodyPanel.addEventListener("pointerdown", (e) => e.stopPropagation());
bodyPanel.dataset.pcrWheelScroll = "1"; // wheel scrolls the panel, not the camera (see installViewportWheel)
const bodySliders = [];
// (Re)built from a figure's morph list. With one body in the library this
// runs once; with mixed bodies in a scene, setActiveFigure rebuilds whenever
// the active figure's morph set differs from the panel's (different GLBs
// ship different sliders). The handlers read/write the ACTIVE rig.
let bodySlidersSig = null;
// Custom-body panel: grouped sliders from the pack manifest. Group-open state
// is sticky across rebuilds/selections; only "main" starts expanded (66
// sliders would otherwise swallow the viewport).
const customGroupOpen = new Set(["main"]);
const buildCustomSliderPanel = (fromFig) => {
const { manifest } = fromFig.customEngine;
bodyPanel.style.maxHeight = "62%";
bodyPanel.style.overflowY = "auto";
for (const group of manifest.groups) {
const rows = [];
const head = document.createElement("div");
head.textContent = (customGroupOpen.has(group.id) ? "▾ " : "▸ ") + group.label;
head.style.cssText = "margin:3px 0 1px;cursor:pointer;font-weight:600;color:#aab;user-select:none;";
head.addEventListener("click", () => {
const open = !customGroupOpen.has(group.id);
customGroupOpen[open ? "add" : "delete"](group.id);
head.textContent = (open ? "▾ " : "▸ ") + group.label;
for (const r of rows) r.style.display = open ? "flex" : "none";
});
bodyPanel.appendChild(head);
for (const sid of group.sliders) {
const def = manifest.sliders.find((s) => s.id === sid);
if (!def) continue;
const row = document.createElement("label");
row.style.cssText = "display:flex;align-items:center;gap:6px;margin:2px 0;cursor:pointer;" +
(customGroupOpen.has(group.id) ? "" : "display:none;");
const tag = document.createElement("span");
tag.textContent = def.label;
tag.style.cssText = "width:96px;opacity:0.85;";
const s = document.createElement("input");
s.type = "range";
s.min = def.kind === "bi" ? "-100" : "0";
s.max = "100";
s.value = String(Math.round(def.default * 100));
s.style.cssText = "width:96px;";
s.addEventListener("input", () => {
const eng = rig?.customEngine;
if (!eng) return;
eng.values[def.id] = +s.value / 100;
eng.requestRecompute(); // rAF-coalesced — macro drags re-sum everything
});
s.addEventListener("change", () => captureAndUpload(node)); // commit on release
row.append(tag, s);
bodyPanel.appendChild(row);
rows.push(row);
bodySliders.push({ custom: true, id: def.id, default: def.default, slider: s });
}
}
};
const buildBodySliders = (fromFig) => {
if (fromFig?.customEngine) {
const sig = "custom:" + fromFig.customEngine.manifest.version;
if (sig === bodySlidersSig) return;
bodySlidersSig = sig;
bodySliders.length = 0;
bodyPanel.innerHTML = "";
buildCustomSliderPanel(fromFig);
return;
}
bodyPanel.style.maxHeight = "";
bodyPanel.style.overflowY = "";
const names = fromFig?.morphMesh ? fromFig.morphNames : [];
const sig = fromFig?._packFailed ? "packfail" : names.join("|");
if (sig === bodySlidersSig) return;
bodySlidersSig = sig;
bodySliders.length = 0;
bodyPanel.innerHTML = "";
if (fromFig?._packFailed) {
const note = document.createElement("div");
note.textContent = "body slider pack unavailable — reload to retry";
note.style.cssText = "opacity:0.7;max-width:160px;";
bodyPanel.appendChild(note);
return;
}
if (!fromFig?.morphMesh) return;
for (const name of names) {
const i = fromFig.morphIndex[name];
const skeletal = fromFig.isSkeletalMorph(name);
const row = document.createElement("label");
row.style.cssText = "display:flex;align-items:center;gap:6px;margin:2px 0;cursor:pointer;";
const tag = document.createElement("span");
tag.textContent = name.replace(/[_-]+/g, " ");
tag.style.cssText = "width:64px;opacity:0.85;text-transform:capitalize;";
const s = document.createElement("input");
s.type = "range"; s.min = "-100"; s.max = "100"; s.value = "0";
s.style.cssText = "width:96px;";
s.addEventListener("input", () => {
if (!rig?.morphMesh) return;
if (skeletal) { rig.skeletalAmount[name] = +s.value / 100; applySkeletalMorph(); }
else { rig.morphMesh.morphTargetInfluences[i] = +s.value / 100; requestRender(); }
});
s.addEventListener("change", () => { debugMorphReport(); captureAndUpload(node); }); // commit on release
row.append(tag, s);
bodyPanel.appendChild(row);
bodySliders.push({ index: i, name, skeletal, slider: s });
}
};
buildBodySliders(rig);
container.appendChild(bodyPanel);
// Pull slider positions back from live state (after reset/restore/undo).
const syncBodySliders = () => {
if (rig?.customEngine) {
for (const e of bodySliders)
if (e.custom) e.slider.value = String(Math.round((rig.customEngine.values[e.id] ?? e.default) * 100));
return;
}
const morphMesh = rig?.morphMesh; // null rig = empty cast — panel is hidden anyway
if (!morphMesh) return;
for (const { index, name, skeletal, slider } of bodySliders)
slider.value = String(Math.round((skeletal ? (rig.skeletalAmount[name] || 0) : (morphMesh.morphTargetInfluences[index] || 0)) * 100));
};
// Skeletal-morph rest position per bone = neutral bind + Σ(amount · delta) over
// the bone-driven morphs. Moving the bones here stretches the skin through
// ordinary skinning, so no re-bind is needed and the joints stay where the
// handles are.
const computeSkelRest = (f = rig) => {
const out = new Map();
for (const b of f.skel.bones) out.set(b, f.boneRestPos.get(b).clone());
for (const name in f.skeletalAmount) {
const w = f.skeletalAmount[name]; if (!w) continue;
const d = f.morphBoneDeltas[name]; if (!d) continue;
for (const bn in d) {
const b = f.boneByName.get(bn); if (!b) continue;
const p = out.get(b), v = d[bn];
p.x += w * v[0]; p.y += w * v[1]; p.z += w * v[2];
}
}
return out;
};
// Apply the skeletal morph by relocating the bone rest positions. Fold/move
// offsets (the bone's displacement from its previous skeletal rest) ride along.
const applySkeletalMorph = () => {
if (!(rig?.morphMesh || rig?.customEngine) || !rig.skel) return;
const newRest = computeSkelRest();
const off = new THREE.Vector3();
for (const b of rig.skel.bones) {
off.copy(b.position).sub(rig.curSkelRest.get(b)); // fold/move delta from old rest
b.position.copy(newRest.get(b)).add(off);
}
rig.curSkelRest = newRest;
rig.root.updateMatrixWorld(true);
requestRender();
};
// TEMP DIAGNOSTIC: compare the actual morphed-mesh top to the live bone world
// positions so we can see whether the skeleton is tracking the skin.
const debugMorphReport = () => {
const morphMesh = rig?.morphMesh;
if (!morphMesh) return;
const g = morphMesh.geometry, pos = g.attributes.position;
const ma = g.morphAttributes.position || [], infl = morphMesh.morphTargetInfluences;
const si = g.attributes.skinIndex, sw = g.attributes.skinWeight;
morphMesh.updateMatrixWorld(true);
rig.skel.update(); // refresh boneMatrices from current bones + (rebound) inverses
const bm = morphMesh.skeleton.boneMatrices;
const v = new THREE.Vector3(), sk = new THREE.Vector3(), tmp = new THREE.Vector3(), M = new THREE.Matrix4();
const idx = new THREE.Vector4(), wt = new THREE.Vector4();
let maxYgeo = -1e9, maxYskin = -1e9; // geometry (morph only) vs actually-skinned
for (let i = 0; i < pos.count; i++) {
v.fromBufferAttribute(pos, i);
for (let m = 0; m < ma.length; m++) if (infl[m]) { const d = ma[m]; v.x += infl[m] * d.getX(i); v.y += infl[m] * d.getY(i); v.z += infl[m] * d.getZ(i); }
tmp.copy(v).applyMatrix4(morphMesh.matrixWorld);
if (tmp.y > maxYgeo) maxYgeo = tmp.y;
// actual skinning: bindMatrix -> Σ w·boneMatrix -> bindMatrixInverse -> world
const base = v.clone().applyMatrix4(morphMesh.bindMatrix);
idx.fromBufferAttribute(si, i); wt.fromBufferAttribute(sw, i);
sk.set(0, 0, 0);
for (let k = 0; k < 4; k++) {
const w = wt.getComponent(k); if (!w) continue;
M.fromArray(bm, idx.getComponent(k) * 16);
sk.add(tmp.copy(base).applyMatrix4(M).multiplyScalar(w));
}
sk.applyMatrix4(morphMesh.bindMatrixInverse).applyMatrix4(morphMesh.matrixWorld);
if (sk.y > maxYskin) maxYskin = sk.y;
}
const bw = (n) => { const b = rig.boneByName.get(n); return b ? b.getWorldPosition(new THREE.Vector3()) : null; };
const head = bw("head"), ua = bw("upperarm_l"), pelvis = bw("pelvis");
console.log("[PoseStudio DBG] infl=", Array.from(infl).map((x) => +x.toFixed(2)),
"skel=", rig.skeletalAmount,
"| geoTopY=", maxYgeo.toFixed(3), "| SKINNED topY=", maxYskin.toFixed(3),
"| head bone=", head && head.toArray().map((x) => +x.toFixed(3)),
"| upperarm_l=", ua && ua.toArray().map((x) => +x.toFixed(3)),
"| pelvis=", pelvis && pelvis.toArray().map((x) => +x.toFixed(3)));
};
node._pcrPoseDebugMorph = debugMorphReport;
// After applyFigureData has restored the skeletal amounts AND set every bone to its
// saved absolute position (jointPos covers all skeleton bones), the skeleton is
// already correctly placed. We only re-baseline curSkelRest to those amounts so a
// subsequent slider move computes its fold/move offset correctly. Bones aren't moved.
const restoreSkeletalMorph = (f = rig) => {
if (!(f.morphMesh || f.customEngine) || !f.skel) return;
f.curSkelRest = computeSkelRest(f);
};
// ── custom-body slider engine ───────────────────────────────────────────────
// values -> macro factors -> per-target weights (one uniform formula) -> CPU
// vertex re-sum + bone-rest deltas fed to the EXISTING skeletal-morph pipeline
// as a single synthetic morph. Each figure owns its geometry (spawnFigure
// parses a fresh GLB), so engines never share position buffers; the pack
// itself is shared read-only.
const makeCustomEngine = (fig, pack) => {
const { manifest } = pack;
const mesh = (fig.meshes || []).find((m) => m.isSkinnedMesh);
if (!mesh || !fig.skel) return null;
const posAttr = mesh.geometry.attributes.position;
if (posAttr.count !== manifest.vertCount) {
console.warn(`[PoseStudio] pack/GLB vertex mismatch (${manifest.vertCount} vs ${posAttr.count}) — pack stale?`);
return null;
}
const basePos = posAttr.array.slice(); // GLB rest = the baked default blend
// Skinning re-applies bone-rest movement to every weighted vertex, so the
// engine must SUBTRACT that displacement from the (full-delta) morphed
// positions or the mesh outruns the skeleton ~2:1. Captured at bind:
// skeleton bone order (skinIndex space) + each bone's parent world
// rotation (local rest deltas are expressed in the parent frame).
const skelBones = mesh.skeleton.bones;
const boneIdxByName = new Map(skelBones.map((b, i) => [b.name, i]));
const parentBindQuat = skelBones.map((b) => b.parent.getWorldQuaternion(new THREE.Quaternion()));
const values = {};
for (const s of manifest.sliders) values[s.id] = s.default;
const computeWeights = () => {
const macros = {};
for (const s of manifest.sliders) {
if (s.kind !== "macro") continue;
let v = values[s.id] ?? s.default;
if (s.uiToValue) v = s.uiToValue[0] + v * (s.uiToValue[1] - s.uiToValue[0]);
macros[s.var] = v;
}
const f = computeMacroFactors(macros);
const w = new Float64Array(manifest.targets.length);
for (let i = 0; i < manifest.targets.length; i++) {
let x = 1;
for (const tok of pack.targets[i].tokens) x *= f[tok] || 0;
w[i] = x;
}
// detail targets: weight *= the slider's one-sided factor (grid targets
// belong to no slider and keep the pure factor product)
for (const s of manifest.sliders) {
if (!s.targets) continue;
const v = values[s.id] || 0;
for (const r of s.targets) w[r.i] *= Math.max(0, r.sign * v);
}
return w;
};
const eng = { values, manifest };
eng.recompute = ({ restore = false } = {}) => {
const w = computeWeights();
const arr = posAttr.array;
arr.set(basePos);
const boneAcc = {};
for (let ti = 0; ti < pack.targets.length; ti++) {
const wt = w[ti];
if (Math.abs(wt) < 0.01) continue;
const t = pack.targets[ti];
const idx = t.vIdx, d = t.vDelta, k = wt * manifest.quant;
for (let j = 0; j < idx.length; j++) {
const o = idx[j] * 3, jj = j * 3;
arr[o] += k * d[jj]; arr[o + 1] += k * d[jj + 1]; arr[o + 2] += k * d[jj + 2];
}
if (t.bones) {
const bi = t.bones.idx, bd = t.bones.delta;
for (let j = 0; j < bi.length; j++) {
const acc = boneAcc[bi[j]] || (boneAcc[bi[j]] = [0, 0, 0]);
acc[0] += wt * bd[j * 3]; acc[1] += wt * bd[j * 3 + 1]; acc[2] += wt * bd[j * 3 + 2];
}
}
}
const dvv = pack.defaultVerts; // baked into the GLB — subtract it back out
for (let i = 0; i < arr.length; i++) arr[i] -= dvv[i];
// Freehand sculpt edits ride on the TRUE morphed surface (here, before normals +
// skin-compensation) so they shade correctly and skinning carries the new shape into
// any pose. It is NOT a bone-driven morph, so it must stay out of the skeletal-morph
// path below — the compensation subtraction is a fixed per-vertex offset that leaves
// this delta untouched. Added after the slider sum so moving a slider composes with
// (never erases) a sculpt.
if (fig.sculptMap && fig.sculptMap.size) for (const [vi, dv] of fig.sculptMap) { const o = vi * 3; arr[o] += dv[0]; arr[o + 1] += dv[1]; arr[o + 2] += dv[2]; }
// Bones: one synthetic skeletal morph carries the net rest deltas through
// the shared computeSkelRest/applySkeletalMorph machinery.
const db = pack.defaultBones;
const bd = {};
for (let b = 0; b < manifest.boneCount; b++) {
const acc = boneAcc[b] || [0, 0, 0];
const x = acc[0] - db[b * 3], y = acc[1] - db[b * 3 + 1], z = acc[2] - db[b * 3 + 2];
if (x || y || z) bd[manifest.bones[b]] = [x, y, z];
}
fig.morphBoneDeltas = { __custom: bd };
fig.skeletalAmount = { __custom: 1 };
// Normals + bounds come from the TRUE morphed surface (positions currently
// hold it) — computing them after the skin compensation below shades dark
// bands at every joint transition.
posAttr.needsUpdate = true;
mesh.geometry.computeVertexNormals();
mesh.geometry.computeBoundingSphere();
if (mesh.geometry.boundingSphere) mesh.geometry.boundingSphere.radius *= 3; // raycast slack, mirrors loadGlbFigure
// Skin compensation: world REST delta per bone (local deltas propagated
// through the bind hierarchy), then positions -= Σ skinWeight·boneDelta —
// skinning adds it back, landing the mesh exactly on the moved skeleton.
const wdelta = new Float64Array(skelBones.length * 3);
const done = new Uint8Array(skelBones.length);
const tmpV = new THREE.Vector3();
const walk = (i) => {
if (done[i]) return;
done[i] = 1;
const b = skelBones[i];
const pi = boneIdxByName.get(b.parent?.name);
let x = 0, y = 0, z = 0;
if (pi != null) { walk(pi); x = wdelta[pi * 3]; y = wdelta[pi * 3 + 1]; z = wdelta[pi * 3 + 2]; }
const ld = bd[b.name];
if (ld) {
tmpV.set(ld[0], ld[1], ld[2]).applyQuaternion(parentBindQuat[i]);
x += tmpV.x; y += tmpV.y; z += tmpV.z;
}
wdelta[i * 3] = x; wdelta[i * 3 + 1] = y; wdelta[i * 3 + 2] = z;
};
for (let i = 0; i < skelBones.length; i++) walk(i);
const siA = mesh.geometry.attributes.skinIndex.array;
const swA = mesh.geometry.attributes.skinWeight.array;
for (let i = 0; i < posAttr.count; i++) {
const o4 = i * 4, o3 = i * 3;
let dx = 0, dy = 0, dz = 0;
for (let k = 0; k < 4; k++) {
const w = swA[o4 + k];
if (!w) continue;
const bo = siA[o4 + k] * 3;
dx += w * wdelta[bo]; dy += w * wdelta[bo + 1]; dz += w * wdelta[bo + 2];
}
arr[o3] -= dx; arr[o3 + 1] -= dy; arr[o3 + 2] -= dz;
}
// Live slider edits relocate bone rests now (fig IS the active rig — the
// panel only drives the selection). Restores skip it: applyFigureData has
// already placed every bone at its saved jointPos; restoreSkeletalMorph
// re-baselines curSkelRest right after.
if (!restore) applySkeletalMorph();
reprojectFigureGarments(fig); // conformed garments follow body-shape sliders
requestRender();
};
let queued = false;
eng.requestRecompute = () => {
if (queued) return;
queued = true;
requestAnimationFrame(() => { queued = false; eng.recompute(); });
};
// Restore path: replace ALL values (absent ids -> defaults, so an undo past
// a slider change actually reverts it), then one restore-mode recompute.
eng.applyValues = (vals, opts) => {
for (const s of manifest.sliders)
values[s.id] = (vals && Number.isFinite(vals[s.id])) ? vals[s.id] : s.default;
eng.recompute(opts);
};
return eng;
};
// Restore a figure's planted-foot anchors from serialized arrays (applyFigureData is
// module-scope without THREE, so foot pins are rehydrated here).
const restoreFootPins = (f, data) => {
if (!f.footPins) return;
for (const k of Object.keys(f.footPins))
f.footPins[k] = (data?.footPins && Array.isArray(data.footPins[k])) ? new THREE.Vector3().fromArray(data.footPins[k]) : null;
};
const applyOneFigure = (f, fd) => {
applyFigureData(f, fd);
// Sculpt layer: set BEFORE the custom-engine recompute so its restore pass folds the
// saved delta into the rest buffer in one shot (no un-sculpted first frame).
f.sculptMap = fd?.sculpt ? sculptDataToMap(fd.sculpt) : null;
// Custom-body sliders: rebuild geometry + the synthetic bone-delta morph from
// the saved dict BEFORE restoreSkeletalMorph re-baselines curSkelRest. Restore
// mode leaves the bones at their saved jointPos (already applied above).
if (f.customEngine) f.customEngine.applyValues(fd?.sliders, { restore: true });
// Baked-morph bodies have no re-sum engine, so apply the sculpt delta straight into
// the (idempotent base-snapshot) position buffer.
else if (f.sculptMap && f.sculptMap.size) { const sm = (f.meshes || []).find((m) => m.isSkinnedMesh) || f.morphMesh; if (sm) applySculpt(sm, f.sculptMap); }
restoreSkeletalMorph(f);
restoreFootPins(f, fd);
f.root.updateMatrixWorld(true);
restoreShoes(f, fd);
};
// Reconcile the live cast to a serialized one. By STABLE UID when the snapshot has them
// (so undo of a middle-figure removal restores poses onto the right bodies), else by
// position for old saves. Returns false if the node was torn down mid-spawn.
const reconcileCast = async (figData) => {
// [] takes the uid path too (vacuous every): a v3 scene saved with zero
// figures restores as zero — the legacy branch's min-1 is only for old
// saves that predate uids (which always carried at least one figure).
const haveUids = figData.every((fd) => fd && fd.uid != null);
if (haveUids) {
for (const fd of figData) {
figureUidSeq = Math.max(figureUidSeq, fd.uid + 1); // never reissue a restored uid
// Same uid but a different BODY = a different mesh entirely (e.g. the
// mount's default human colliding with a saved heavy man) — replace it,
// or the restore silently pours this figure's pose onto the wrong body.
const existing = figures.find((f) => f.uid === fd.uid);
if (existing && bodyKeyOf(existing.body) !== bodyKeyOf(fd.body)) removeFigure(existing, { capture: false });
if (!figures.some((f) => f.uid === fd.uid)) {
const nf = await spawnFigure({ select: false, capture: false, body: fd.body });
if (!node._pcrAlive) return false;
if (nf) nf.uid = fd.uid;
}
}
const wanted = new Set(figData.map((fd) => fd.uid));
for (let i = figures.length - 1; i >= 0; i--)
if (!wanted.has(figures[i].uid)) removeFigure(figures[i], { capture: false });
for (const fd of figData) { const f = figures.find((x) => x.uid === fd.uid); if (f) applyOneFigure(f, fd); }
} else {
for (let i = figures.length; i < figData.length; i++) { await spawnFigure({ select: false, capture: false, body: figData[i]?.body }); if (!node._pcrAlive) return false; }
while (figures.length > Math.max(1, figData.length)) removeFigure(figures[figures.length - 1], { capture: false });
figData.forEach((fd, i) => { const f = figures[i]; if (f) applyOneFigure(f, fd); });
}
updateFigureLabels(); // restored custom names (undo of a rename included) reach the sprites
return true;
};
// The live figure that the snapshot marked active (by uid when available, else index).
const activeFromSnapshot = (data, figData) => {
const idx = Math.min(data.activeIndex || 0, figData.length - 1);
const fd = figData[idx];
return (fd && fd.uid != null ? figures.find((f) => f.uid === fd.uid) : figures[Math.min(data.activeIndex || 0, figures.length - 1)]) || figures[0];
};
const setSliders = (amts) => {
for (const [key] of HAND_SLIDERS) fingerSliders[key].value = String(Math.round((amts[key] ?? 0) * 100));
};
presetSelect.addEventListener("change", () => {
const name = selectedJointName; // not transformControls.object — in move mode the gizmo is on the IK proxy
if (!name || !presetSelect.value) return;
if (name.startsWith("wrist")) {
if (POSE_PRESETS[presetSelect.value]) {
applyHandPose(name, POSE_PRESETS[presetSelect.value]); // captured raw pose (incl. IK thumb)
setSliders(HAND_PRESETS[presetSelect.value] || {}); // sliders show the closest amounts
} else {
const amts = { ...HAND_PRESETS[presetSelect.value] };
applyHandAmounts(name, amts);
setSliders(amts);
}
} else if (name.startsWith("ankle")) {
applyFootPreset(name, presetSelect.value);
}
presetSelect.selectedIndex = 0; // back to the label so re-picking the same fires change
requestRender();
captureAndUpload(node);
});
// Shoe controls in the foot-selection context — assigned with the shoe block below,
// called here so they track every joint selection change. Users reach for shoes by
// clicking a foot, not the whole figure, so the toggle + style picker live here too.
let updateFootShoeUI = () => {};
const updatePresetUI = () => {
updateFootShoeUI();
const name = selectedJointName; // tracked separately so it survives move-mode (gizmo on the IK proxy)
// Bound imports follow whole-hand/-foot: finger curls / foot poses move
// invisible mannequin bones with no skin influence — hide the dead panels.
const isHand = name?.startsWith("wrist") && rig?.fingers?.[name] && !rig.skinBind;
const isFoot = name?.startsWith("ankle") && rig?.toes?.[name] && !rig.skinBind;
const hasToes = false; // per-toe SLIDER removed — toes are posed directly via their tip IK grab handles
if (!isHand && !isFoot) { presetSelect.style.display = "none"; handPanel.style.display = "none"; footPanel.style.display = "none"; return; }
const keys = isHand ? Object.keys(HAND_PRESETS) : Object.keys(FOOT_PRESETS);
const label = isHand ? "✋ hand preset…" : "🦶 foot pose…";
presetSelect.innerHTML = "";
for (const [i, k] of [label, ...keys].entries()) {
const o = document.createElement("option");
o.textContent = k; o.value = i === 0 ? "" : k;
presetSelect.appendChild(o);
}
presetSelect.style.display = "block";
if (isHand) { setSliders(rig.handAmounts.get(name) || {}); handPanel.style.display = "block"; footPanel.style.display = "none"; }
else { handPanel.style.display = "none"; if (hasToes) { setToeSliders(rig.footAmounts.get(name)); footPanel.style.display = "block"; } else footPanel.style.display = "none"; }
};
// Prop selection lives alongside joint selection on the one gizmo: selectedProp
// is the currently-gizmo'd prop mesh (null while a joint or nothing is selected),
// and propMode is its transform mode. updatePropUI/selectProp are defined with
// the props block below; referenced here only at interaction time (post-mount).
let selectedProp = null;
let propMode = "translate"; // translate | rotate | scale
let pinPickProp = null; // a prop awaiting a pin target (next click picks the leader)
let pinPickGroup = null; // a GROUP (array of member props) awaiting a target → attach all members
let pinPickJointIndex = null; // when set, the pending pin is a CHAIN-JOINT combine (this joint), not a whole-prop link
let selectedChainJoint = null; // { prop, jointIndex } — a chain joint clicked for combine/FK (drives the combine button)
let updatePropUI = () => {}; // assigned once the prop panel exists (props block below)
// Disarm a pending link AND re-enable the gizmo. Must run on every path that ends
// pin-pick (Esc, resolve, deleting the prop/figure), or transformControls.enabled
// stays false and the gizmo is globally dead for all selections.
const cancelPinPick = () => {
if (!pinPickProp && !pinPickGroup) return;
pinPickProp = null; pinPickGroup = null; pinPickJointIndex = null;
transformControls.enabled = true;
updatePropUI();
};
// Whole-figure selection — the prop-style "object" grip for a person (the model the
// research landed on: body-click selects the whole figure, joints are posed via the
// hover handles). selectedFigure is the figure selected as an OBJECT: gizmo on its
// scene root, orange body tint, Del removes it. figureMode is that gizmo's transform.
// A figure, a prop, and a joint are mutually exclusive selections. updateFigureUI is
// assigned when the figure panel exists (toolbar block below).
let selectedFigure = null;
let figureMode = "translate"; // translate | rotate
let bodyPanelOpen = false; // body-shape sliders toggled from the selected-figure panel
let updateFigureUI = () => {};
// Selection visuals are the screen-space outline pass (renderOutlinePass,
// render block above) — selection state alone drives them; nothing to build
// or toggle per object here.
const clearFigureSelection = () => {
if (!selectedFigure) return;
selectedFigure = null;
updateFigureUI();
};
// IK-tip joints (wrist/ankle) can be posed two ways: ROTATE (the FK twist gizmo, default
// on click) or MOVE — an XYZ translate gizmo whose drag drives two-bone IK so the
// hand/foot slides on an axis while staying attached to the elbow/knee (it can't stretch).
// The free diamond-drag IK stays for quick posing; this is the precise edge-case path.
// ikMoveMode is remembered across selections like propMode/figureMode.
let ikMoveMode = false;
let selectedJointName = null; // selected joint; gizmo sits on its BONE in rotate, on ikMoveProxy in move
let selectedIkLimb = null; // two-bone limb of the selected wrist/ankle, else null (non-tip joints)
const ikMoveProxy = new THREE.Object3D(); // the move gizmo's target; dragging it → IK solve
scene.add(ikMoveProxy);
// Segmented-chain tip IK: a DIRECT-DRAG gesture (grab the green tip dot, drag it
// on a camera-facing plane → FABRIK), mirroring the figure's free-drag IK diamond —
// not a gizmo. Armed in pointerdown, solved in pointermove, captured in pointerup.
const chainDrag = { active: false, dragging: false, settling: false, canIk: false, prop: null, jointIndex: 0, driveCount: 0, anchorIndex: 0, pinIdx: 0, bone: null, plane: new THREE.Plane(), hit: new THREE.Vector3(), offset: new THREE.Vector3(), desired: new THREE.Vector3(), grabEase: new THREE.Vector3(), pinWorld: new THREE.Vector3(), rope: null, ropePrev: null, ropeLens: null };
let updateIkUI = () => {}; // assigned when the ik move/rotate panel exists (toolbar block)
const _ikTargetV = new THREE.Vector3();
// Selecting/deselecting a joint goes through these so the controls track it. Attaching
// to a joint drops any prop/whole-figure selection. A wrist/ankle gets the move OR rotate
// gizmo per ikMoveMode; every other joint is rotate/local only (FK).
const attachGizmo = (joint) => {
clearBand();
selectedProp = null;
clearFigureSelection();
selectedJointName = jointName(joint);
selectedIkLimb = limbFor(rig, selectedJointName); // non-null only for wrist/ankle (two-bone tips)
if (selectedIkLimb && ikMoveMode) {
joint.getWorldPosition(ikMoveProxy.position); // gizmo target starts at the joint
ikMoveProxy.quaternion.identity();
ikMoveProxy.updateMatrixWorld(true);
transformControls.setMode("translate");
transformControls.setSpace("world");
transformControls.attach(ikMoveProxy);
} else {
transformControls.setMode("rotate");
transformControls.setSpace("local");
transformControls.attach(joint);
}
updatePresetUI();
updatePropUI();
updateIkUI();
};
const detachGizmo = () => {
clearBand();
selectedProp = null;
selectedChainJoint = null;
clearFigureSelection();
selectedJointName = null;
selectedIkLimb = null;
transformControls.detach();
updatePresetUI();
updatePropUI();
updateIkUI();
};
// Toggle the selected IK tip between the move gizmo and the rotate gizmo (panel + W/E).
const setIkJointMode = (mode) => {
ikMoveMode = mode === "move";
if (selectedIkLimb && selectedJointName) attachGizmo(rig.joints[selectedJointName]); // re-attach in the new mode
else updateIkUI();
requestRender();
};
// Select a whole figure as an object: make it active, tint it, and put the gizmo on
// its scene root in the current move/rotate mode — exactly how a prop is selected.
// Rotate uses the figure's own axes (local), move uses world axes.
const figureGizmoSpace = () => (figureMode === "rotate" ? "local" : "world");
// Creatures take a UNIFORM gizmo scale. The human is sized by the skeleton-fit
// and body sliders instead — its root scale is load-bearing for skeleton/skin
// binding — so the human stays gizmo-unscalable. A non-human profile = creature.
const figureCanScale = (fig) => !!fig && figProfile(fig) !== RIG_PROFILES.human;
const figureGizmoMode = (fig) => (transformTool === "scale" && figureCanScale(fig)) ? "scale" : figureMode;
const _figBox = new THREE.Box3(), _figCtr = new THREE.Vector3();
let figScaleAnchor = null; // { s0, cx, cz } captured at the start of a scale drag
const figScaleAnchorOf = (fig) => {
_figBox.setFromObject(fig.root); _figBox.getCenter(_figCtr);
return { s0: fig.root.scale.x, cx: _figCtr.x, cz: _figCtr.z };
};
// After a scale: drop the feet back to the floor and hold the figure over the
// ground spot it had before the drag (the gizmo scales about the root, not the base).
const groundFigureAfterScale = (fig, anchor) => {
fig.root.updateMatrixWorld(true);
_figBox.setFromObject(fig.root); _figBox.getCenter(_figCtr);
fig.root.position.y -= _figBox.min.y;
if (anchor) { fig.root.position.x += anchor.cx - _figCtr.x; fig.root.position.z += anchor.cz - _figCtr.z; }
fig.root.updateMatrixWorld(true);
};
const selectFigure = (fig) => {
if (!fig) return;
clearBand();
setActiveFigure(fig); // detaches any prior joint/figure selection, shows this figure's handles
selectedProp = null;
selectedFigure = fig;
const mode = figureGizmoMode(fig);
transformControls.setMode(mode);
transformControls.setSpace(mode === "scale" ? "local" : figureGizmoSpace());
transformControls.attach(fig.root);
updatePresetUI();
updatePropUI();
updateFigureUI();
};
const setFigureMode = (mode) => {
figureMode = mode;
if (selectedFigure) {
transformControls.setMode(mode);
transformControls.setSpace(figureGizmoSpace());
}
updateFigureUI();
requestRender();
};
// Interaction. Hovering a handle highlights it (so what's grabbable is obvious);
// clicking a handle selects its joint for the rotate gizmo (FK); dragging a tip
// diamond reaches the hand/foot via two-bone IK. Clicking the bare body selects the
// WHOLE figure as an object (move/rotate/Del); clicking empty space deselects. A drag
// over empty space still orbits.
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let downX = 0, downY = 0, downOnGizmo = false, downHandle = null;
const setPointer = (e) => {
const rect = canvas.getBoundingClientRect();
pointer.set(((e.clientX - rect.left) / rect.width) * 2 - 1,
-((e.clientY - rect.top) / rect.height) * 2 + 1);
raycaster.setFromCamera(pointer, camera);
};
const pickHandle = (e) => {
setPointer(e);
if (!rig?.handles) return null;
const pickable = rig.skinBind ? rig.handles.filter((h) => !h.userData.finger) : rig.handles; // fingertip IK dead on bound imports
// Screen-space disc picking, sized to each handle's PROJECTED radius: the
// old 3D-mesh raycast hit anywhere on the (heavily overlapping) handle
// spheres, which on the five-joint torso blanketed the body — selecting the
// figure itself was nearly impossible. The hit zone is now exactly the dot
// you see (8px floor so distant handles stay grabbable). Visibility games
// are gone too: projection doesn't care that the group is hidden.
const rect = canvas.getBoundingClientRect();
const px = e.clientX - rect.left, py = e.clientY - rect.top;
const focalPx = (rect.height / 2) / Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2);
const v = new THREE.Vector3();
let best = null, bestD = Infinity;
for (const h of pickable) {
if (!h.visible) continue;
h.userData.joint.getWorldPosition(v);
const dist = v.distanceTo(camera.position);
v.project(camera);
if (v.z > 1 || v.z < -1) continue; // behind the camera
const hx = ((v.x + 1) / 2) * rect.width, hy = ((1 - v.y) / 2) * rect.height;
const r = Math.min(40, Math.max(8, (h.scale.x / dist) * focalPx)); // 40px cap guards the pre-first-render default scale
const d2 = (hx - px) ** 2 + (hy - py) ** 2;
if (d2 <= r * r && d2 < bestD) { best = h; bestD = d2; }
}
return best;
};
let hoveredHandle = null;
const setHover = (h) => {
if (hoveredHandle === h) return;
hoveredHandle = h; // opacity/color applied in updateHandles on the next render
canvas.style.cursor = h ? (h.userData.kind === "ik" ? "grab" : "pointer") : "";
requestRender();
};
// Pole hint in world space (the figure faces +Z) — only steers the bend when a
// limb starts dead-straight; otherwise solveTwoBoneIK keeps the current plane.
const poleWorld = (limb, aJoint) =>
aJoint.getWorldPosition(new THREE.Vector3())
.addScaledVector(new THREE.Vector3(...limb.pole).normalize(), 2);
// Drive a two-bone limb so its tip reaches a world target — the gizmo-IK move (and the
// shared math behind the free diamond drag). Bones rotate; the limb can't stretch, so
// a target past full reach just extends it.
const solveIkTo = (limb, targetW) => {
const [a, b, c] = limb.chain.map((n) => rig.joints[n]);
if (a && b && c) solveTwoBoneIK(THREE, a, b, c, targetW, poleWorld(limb, a));
};
// Hold each pinned foot at its planted world position via two-bone leg IK. Called
// after anything that moves the body above the legs (moving/rotating the whole figure).
const anyFootPinned = () => !!(rig && Object.values(rig.footPins).some(Boolean));
const enforceFootPins = () => {
for (const tip of figProfile(rig).plantedTips) {
const pin = rig.footPins[tip], limb = limbFor(rig, tip);
if (!pin || !limb) continue;
const [a, b, c] = limb.chain.map((n) => rig.joints[n]);
solveTwoBoneIK(THREE, a, b, c, pin, poleWorld(limb, a));
}
};
// FK-rotating/translating the pelvis or whole figure drags the legs along; re-plant.
transformControls.addEventListener("objectChange", () => {
const o = transformControls.object;
if (bandActive()) { bandDragApply(); return; } // move/rotate the whole multi-selection together
if (o === ikMoveProxy) { if (selectedIkLimb) solveIkTo(selectedIkLimb, ikMoveProxy.getWorldPosition(_ikTargetV)); return; } // gizmo-IK
// Creature scale: the gizmo may have moved one axis (or the uniform handle) —
// lock all three to the axis that moved most so the figure scales uniformly.
if (selectedFigure && o === selectedFigure.root && transformControls.mode === "scale" && figScaleAnchor) {
const s0 = figScaleAnchor.s0, s = selectedFigure.root.scale;
let r = [s.x / s0, s.y / s0, s.z / s0].reduce((a, b) => Math.abs(b - 1) > Math.abs(a - 1) ? b : a, 1);
r = Math.min(5, Math.max(0.2, r)); // clamp ONE drag to 0.2x–5x; repeat the drag to go further
s.setScalar(s0 * r);
return;
}
if (anyFootPinned() && rig && (o === rig.root || o === rig.joints.root)) enforceFootPins();
// Dragging a LINKED prop's gizmo: re-freeze its offset to the new pose so the edit
// sticks instead of the per-frame link snapping it back. It stays linked.
const att = o && attachments.find((a) => a.follower.kind === "prop" && a.follower.mesh === o);
if (att) {
const lW = leaderRigidMatrix(att.leader);
if (lW) {
if (att.follower.jointIndex != null) { // chain-joint pin: re-freeze the joint's spot so moving the chain repositions the pin
o.updateWorldMatrix(true, true);
chainJointWorld(o, att.follower.jointIndex, _atJ);
att.offset.setPosition(_atJ.applyMatrix4(lW.clone().invert()));
} else { o.updateWorldMatrix(true, false); att.offset.copy(lW).invert().multiply(o.matrixWorld); }
}
}
});
let pinBtn = null;
const updatePinBtn = () => {
if (!pinBtn) return;
setToolActive(pinBtn, anyFootPinned()); // 📌 lit blue while pinned (Blender-style toggle)
};
const toggleFootPins = () => {
if (!rig) return; // empty cast — nothing to pin
if (anyFootPinned()) { for (const tip of Object.keys(rig.footPins)) rig.footPins[tip] = null; }
else for (const tip of figProfile(rig).plantedTips) {
if (rig.joints[tip] && limbFor(rig, tip)) rig.footPins[tip] = rig.joints[tip].getWorldPosition(new THREE.Vector3());
}
updatePinBtn(); requestRender();
};
const ik = { active: false, dragging: false, move: false, limb: null, handle: null,
moveJoint: null, moveOffset: new THREE.Vector3(), plane: new THREE.Plane(), hit: new THREE.Vector3() };
// End an IK/move gesture and release everything it held. Shared by pointerup AND
// pointercancel/lostpointercapture, so a cancelled gesture can never strand
// ik.active=true / controls.enabled=false / a held pointer capture (which deadlocks
// the viewport).
const endIkGesture = (pointerId) => {
ik.active = false; ik.dragging = false; ik.move = false;
controls.enabled = true;
try { canvas.releasePointerCapture?.(pointerId); } catch {}
};
canvas.addEventListener("pointerdown", (e) => {
downX = e.clientX; downY = e.clientY;
// Clothing paint: LEFT-drag paints (brush dabs / lasso loop); right-drag orbits
// (mouseButtons were remapped on enter). Let right/middle fall through to controls.
if (clothingPaint.active && clothingPaint.imgStamp && e.button === 0) { // dragging the placed image
const s = clothingPaint.imgStamp; s._dx = e.clientX - s.cx; s._dy = e.clientY - s.cy; s.dragging = true;
canvas.setPointerCapture?.(e.pointerId);
return;
}
if (clothingPaint.active && e.button === 0) {
canvas.setPointerCapture?.(e.pointerId);
positionLassoCanvas(); // keep the feedback overlay aligned to the canvas (handles panel resizes)
if (clothingPaint.mode === "lasso") {
clothingPaint.lasso = [[e.clientX, e.clientY]]; // screen coords (converted per-surface later)
clothingPaint.painting = true; drawLassoLine();
} else { clothingPaint.painting = true; ensureMaskPiece(); maskUndoSnap(); paintMaskAt(e); } // brush stamps the coverage texture
return;
}
// Sculpt: left-drag runs the brush, right-drag orbits, LEFT+RIGHT held = pan. With the
// ✋ pan tool armed, left-drag pans instead of sculpting.
if (sculpt.active) {
if (sculpt.panTool && e.button === 0) { canvas.setPointerCapture?.(e.pointerId); beginSculptPan(e); return; }
if ((e.buttons & 1) && (e.buttons & 2)) { sculptStrokeAbort(); beginSculptPan(e); } // both down → pan
else if (e.button === 0) { canvas.setPointerCapture?.(e.pointerId); sculptStrokeStart(e); }
else { downOnGizmo = false; downHandle = null; } // right alone → OrbitControls orbits
return;
}
// Right/middle buttons belong to orbit-pan and the context menu — they must
// never start a pick/IK gesture, and (in pointerup below) never run the
// click-selection cascade: a right-click on empty space used to
// detachGizmo() and clear the very selection the menu was opened to act on.
if (e.button !== 0) { downOnGizmo = false; downHandle = null; return; }
// Pin-pick: the prop awaiting a pin takes THIS click to choose its leader — a joint
// handle on the active figure, or another prop. Resolved here on pointerdown so none
// of the joint-posing logic downstream can swallow it. Clicking nothing cancels.
if (pinPickProp || pinPickGroup) {
try {
setPointer(e);
let leaderRef = null;
const exclude = pinPickGroup ? pinPickGroup : [pinPickProp]; // never link the group/prop to itself
const hHit = pickHandle(e);
if (hHit && hHit.userData.name) leaderRef = { kind: "joint", fig: rig, joint: hHit.userData.name };
else {
const tHit = raycaster.intersectObjects(propsGroup.children.filter((m) => !exclude.includes(m)), true)[0];
const tProp = tHit && propRootOf(tHit.object);
if (tProp) leaderRef = { kind: "prop", mesh: tProp };
}
if (leaderRef) {
if (pinPickGroup) for (const m of pinPickGroup) createPropAttach(m, leaderRef); // whole group rides the leader → all enter its mask
else if (pinPickJointIndex != null) createChainPin(pinPickProp, pinPickJointIndex, leaderRef); // combine THIS chain joint
else createPropAttach(pinPickProp, leaderRef);
captureAndUpload(node);
}
} catch (err) {
console.error("[PoseStudio] link pick failed", err);
}
pinPickProp = null; pinPickGroup = null; pinPickJointIndex = null;
transformControls.enabled = true; // re-arm the gizmo now that linking is done
updatePropUI();
requestRender();
downOnGizmo = false; downHandle = null;
return;
}
// Hold Shift + drag = rubber-band multi-select (plain drag still orbits).
if (e.shiftKey) { beginBand(e); downOnGizmo = false; downHandle = null; return; }
downOnGizmo = transformControls.axis != null; // started on a gizmo ring
downHandle = downOnGizmo ? null : pickHandle(e);
if (downHandle && (e.ctrlKey || e.metaKey)) {
// Ctrl/Cmd + grab any joint handle → translate that joint directly, no need
// to select it first. Camera-facing drag plane through the handle; the joint
// tracks the cursor, keeping the offset from where you grabbed. Grabbing a joint
// leaves whole-figure/prop selection (we're posing now), so drop the object gizmo.
detachGizmo();
ik.active = true; ik.dragging = false; ik.move = true;
ik.limb = null; ik.handle = downHandle; ik.moveJoint = downHandle.userData.joint;
ik.plane.setFromNormalAndCoplanarPoint(camera.getWorldDirection(new THREE.Vector3()), downHandle.position.clone());
setPointer(e);
ik.moveOffset.set(0, 0, 0);
if (raycaster.ray.intersectPlane(ik.plane, ik.hit)) {
ik.moveOffset.copy(ik.moveJoint.getWorldPosition(new THREE.Vector3())).sub(ik.hit);
}
controls.enabled = false;
canvas.setPointerCapture?.(e.pointerId);
} else if (downHandle && downHandle.userData.kind === "ik") {
// Arm an IK reach: lock orbit, remember the camera-facing drag plane through
// the handle. The solve only starts once the pointer moves, so a plain click
// instead selects the tip for rotation (twist the hand/foot). Posing a joint
// leaves object selection, so drop the whole-figure/prop gizmo.
detachGizmo();
ik.active = true; ik.dragging = false;
ik.limb = downHandle.userData.limb; ik.handle = downHandle;
ik.plane.setFromNormalAndCoplanarPoint(camera.getWorldDirection(new THREE.Vector3()), downHandle.position.clone());
controls.enabled = false;
canvas.setPointerCapture?.(e.pointerId);
} else if (!downHandle && !downOnGizmo && bonePoseProp?.userData?._chain && boneHandlesGroup.children.length) {
// Grab ANY chain joint (amber diamond) → arm a direct bend-drag. The grabbed joint
// follows the cursor; the chain bends between it and a fixed ANCHOR. Which end is the
// anchor: if a joint is combined to a mannequin, that pinned joint is always the anchor
// (it stays glued). Otherwise the anchor is whichever END is FARTHER from the joint you
// grabbed — so you can wave EITHER end into shape (grab the tip → base holds; grab the
// base → tip holds). Solve starts on move, so a plain click just selects for FK rotate.
setPointer(e);
const o = raycaster.intersectObjects(boneHandlesGroup.children, false)[0]?.object;
const bones = bonePoseProp.userData._bones;
let jointIndex = null, bone = null;
if (o?.userData.isChainTip) jointIndex = bones.length; // tip endpoint
else if (o?.userData.isBoneHandle) { jointIndex = bones.indexOf(o.userData.bone); bone = o.userData.bone; }
if (jointIndex != null) {
const n = bones.length, pinned = bonePoseProp.userData._pinIndex;
const anchor = pinned != null ? pinned : (jointIndex <= n / 2 ? n : 0); // pin wins; else hold the far end
chainDrag.active = true; chainDrag.dragging = false; chainDrag.prop = bonePoseProp;
chainDrag.jointIndex = jointIndex; chainDrag.driveCount = jointIndex; chainDrag.anchorIndex = anchor;
chainDrag.bone = bone; chainDrag.canIk = jointIndex !== anchor; // grabbing the anchor itself: select only
if (chainDrag.canIk) { detachGizmo(); initChainRope(bonePoseProp, anchor); } // seed the rope from the current pose
chainDrag.plane.setFromNormalAndCoplanarPoint(camera.getWorldDirection(new THREE.Vector3()), o.position.clone());
chainDrag.offset.set(0, 0, 0);
if (raycaster.ray.intersectPlane(chainDrag.plane, chainDrag.hit)) {
chainDrag.offset.copy(o.getWorldPosition(new THREE.Vector3())).sub(chainDrag.hit);
}
controls.enabled = false;
canvas.setPointerCapture?.(e.pointerId);
}
}
});
canvas.addEventListener("pointermove", (e) => {
if (clothingPaint.imgStamp?.dragging) { const s = clothingPaint.imgStamp; s.cx = e.clientX - s._dx; s.cy = e.clientY - s._dy; drawImageOverlay(); return; }
if (clothingPaint.painting) {
if (clothingPaint.mode === "lasso") { clothingPaint.lasso.push([e.clientX, e.clientY]); drawLassoLine(); }
else { paintMaskAt(e); updateBrushCursor(e); } // 3D ring tracks the surface hit so you see where paint lands
return;
}
if (clothingPaint.active) { if (clothingPaint.mode === "brush") updateBrushCursor(e); return; } // 3D brush footprint ring on the surface while hovering
if (sculpt.active) {
if (sculpt.panning) { if (sculpt.panTool ? (e.buttons & 1) : ((e.buttons & 1) && (e.buttons & 2))) sculptPanMove(e); else endSculptPan(); return; }
if (sculpt.painting) sculptStrokeMove(e); else sculptHover(e);
return;
}
if (band.active) { updateBand(e); return; }
if (chainDrag.active) { // dragging a chain joint → ease toward the cursor (settle loop), never snap
if (!chainDrag.canIk) return; // anchor or at/below a pinned joint: no bend (selection happens on pointerup)
if (!chainDrag.dragging && Math.hypot(e.clientX - downX, e.clientY - downY) <= 4) return;
chainDrag.dragging = true;
setPointer(e);
if (!raycaster.ray.intersectPlane(chainDrag.plane, chainDrag.hit)) return;
chainDrag.desired.copy(chainDrag.hit).add(chainDrag.offset); // where the cursor wants the grabbed joint
if (!chainDrag.settling) { chainDrag.settling = true; chainRopeStep(); } // rope sim eases toward it (anti-spasm)
return;
}
if (ik.active) {
if (!ik.dragging && Math.hypot(e.clientX - downX, e.clientY - downY) <= 4) return;
ik.dragging = true;
setPointer(e);
if (!raycaster.ray.intersectPlane(ik.plane, ik.hit)) return;
if (ik.move) {
// Ctrl-drag: translate the grabbed joint to follow the cursor on the plane.
const targetW = ik.hit.clone().add(ik.moveOffset);
const j = ik.moveJoint, parent = j.parent;
if (parent) { parent.updateMatrixWorld(true); j.position.copy(parent.worldToLocal(targetW)); }
else j.position.copy(targetW);
j.updateMatrixWorld(true);
} else if (ik.limb.spine) {
bendSpineToward(THREE, ik.limb.bones, rig.rest, ik.hit); // head → bend the whole spine
} else if (ik.limb.aim) {
aimBoneAt(THREE, rig.joints[ik.limb.parentName], rig.joints[ik.limb.jointName], ik.hit);
} else {
// limb.bones (thumb / 2-bone toe) or limb.chain of joint names (arms/legs/fingers).
const [a, b, c] = ik.limb.bones || ik.limb.chain.map((n) => rig.joints[n]);
solveTwoBoneIK(THREE, a, b, c, ik.hit, poleWorld(ik.limb, a));
}
requestRender();
return;
}
if (transformControls.dragging) return;
// Raycast the handles even while they're hidden (pickHandle force-shows them for the
// test). Otherwise, crossing the empty gap between the torso and a far-flung foot/hand
// handle hides the handles, and a hidden handle can't be re-acquired — you'd be stuck
// unable to grab the tip of an extended limb. pickHandle also sets the ray for the
// body test below.
const handleHit = pickHandle(e);
const overBody = !!handleHit || (rig ? raycaster.intersectObjects(rig.meshes, true).length > 0 : false);
if (overBody !== bodyHovered) { bodyHovered = overBody; requestRender(); }
setHover(handleHit);
});
canvas.addEventListener("pointerup", (e) => {
if (clothingPaint.imgStamp?.dragging) { clothingPaint.imgStamp.dragging = false; try { canvas.releasePointerCapture?.(e.pointerId); } catch {} return; }
if (clothingPaint.painting) {
clothingPaint.painting = false;
try { canvas.releasePointerCapture?.(e.pointerId); } catch {}
if (clothingPaint.mode === "lasso") {
if (clothingPaint.lasso && clothingPaint.lasso.length > 3) finishLasso(); // a drawn loop → new piece
else { clothingPaint.lasso = null; lassoCanvas.getContext("2d").clearRect(0, 0, lassoCanvas.width, lassoCanvas.height); selectGarmentPiece(pickGarmentPiece(e)); } // a click → select a piece
} else { lassoCanvas.getContext("2d").clearRect(0, 0, lassoCanvas.width, lassoCanvas.height); captureAndUpload(node); } // brush release → mask already live, just record
return;
}
if (sculpt.active) {
if (sculpt.panning) { if (!(sculpt.panTool ? (e.buttons & 1) : ((e.buttons & 1) && (e.buttons & 2)))) { endSculptPan(); try { canvas.releasePointerCapture?.(e.pointerId); } catch {} } return; }
if (e.button === 0 && sculpt.painting) { sculptStrokeEnd(); try { canvas.releasePointerCapture?.(e.pointerId); } catch {} }
return;
}
if (e.button !== 0) return; // selection is left-click only (gestures are left-started too)
if (band.active) { endBand(e); return; }
if (chainDrag.active) {
const wasDrag = chainDrag.dragging, bone = chainDrag.bone, prop = chainDrag.prop, ji = chainDrag.jointIndex;
chainDrag.active = false; chainDrag.dragging = false; controls.enabled = true;
try { canvas.releasePointerCapture?.(e.pointerId); } catch {}
if (wasDrag) captureAndUpload(node); // dragged → IK pose committed
else { // plain click on a joint → select it for combine (and FK-rotate if it's a bone)
selectedChainJoint = { prop, jointIndex: ji };
selectedProp = prop; // surface the prop panel so 🔗 combine is reachable
if (bone) { selectedBone = bone; transformControls.attach(bone); transformControls.setMode("rotate"); transformControls.setSpace("local"); }
updatePropUI();
requestRender();
}
return;
}
if (ik.active) {
const wasDrag = ik.dragging;
const handle = ik.handle;
endIkGesture(e.pointerId);
if (wasDrag) {
// Moving a foot — or aiming its knee, which carries the foot — re-anchors
// that foot's pin to where it ended up.
const nm = handle?.userData?.name;
const ankle = nm === "ankleL" || nm === "kneeL" ? "ankleL"
: nm === "ankleR" || nm === "kneeR" ? "ankleR" : null;
if (ankle && rig.footPins[ankle]) rig.footPins[ankle] = rig.joints[ankle].getWorldPosition(new THREE.Vector3());
captureAndUpload(node);
} else if (handle) { attachGizmo(handle.userData.toe ? handle.userData.limb.bones[0] : handle.userData.joint); requestRender(); } // plain click selects for FK rotate (toe → its base bone, not the tip marker)
return;
}
if (downOnGizmo || transformControls.dragging) return;
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 4) return; // was an orbit drag
// Explicit handles beat the body: clicking a joint handle selects it for FK. Handles
// sit over the body, so posing a limb never accidentally grabs the whole figure.
if (downHandle) { attachGizmo(downHandle.userData.joint); requestRender(); return; }
setPointer(e);
// Imported-prop bone handles beat everything prop-shaped (they float over
// the mesh exactly like figure joint handles beat the body).
if (boneHandlesGroup.children.length) {
const bHit = raycaster.intersectObjects(boneHandlesGroup.children, false)[0];
if (bHit && !bHit.object.userData.isChainTip) { // segment dot → FK rotate that joint (tip is drag-armed in pointerdown)
selectedBone = bHit.object.userData.bone;
transformControls.attach(selectedBone);
transformControls.setMode("rotate");
transformControls.setSpace("local");
requestRender();
return;
}
}
// A prop is its own object (carries its own transform mode).
const propHit = raycaster.intersectObjects(propsGroup.children, true)[0]; // recursive: model props are Groups
const propObj = propHit && propRootOf(propHit.object);
if (propObj) { selectPropOrGroup(propObj); requestRender(); return; } // a grouped member selects its whole group
// Clicking a body selects that WHOLE person as an object (move/rotate gizmo + Del),
// like a prop; its joint handles stay visible so you can drop into posing. Clicking
// empty space deselects everything.
const hit = raycaster.intersectObjects(figures.flatMap((f) => f.meshes), true)[0];
const fig = hit ? figures.find((f) => f.meshes.includes(hit.object)) : null;
if (fig) selectFigure(fig); else detachGizmo();
requestRender();
});
canvas.addEventListener("pointerleave", () => {
if (sculpt.active && sculptRing && sculptRing.visible && !sculpt.painting) { sculptRing.visible = false; requestRender(); }
if (brushCursor && brushCursor.visible) { brushCursor.visible = false; requestRender(); }
if (clothingPaint.active && clothingPaint.mode === "brush" && !clothingPaint.painting) lassoCanvas.getContext("2d").clearRect(0, 0, lassoCanvas.width, lassoCanvas.height);
if (ik.active) return;
bodyHovered = false;
setHover(null);
requestRender(); // hide the handles once the cursor leaves (unless still posing)
});
// If the browser cancels/steals the pointer mid-gesture (touch interruption, OS,
// context menu), pointerup never fires — without this the viewport would deadlock with
// orbit disabled and IK still solving. Reset the gesture and re-render.
const onPointerAbort = (e) => {
if (sculpt.active && sculpt.panning) { endSculptPan(); return; }
if (sculpt.active && sculpt.painting) { sculptStrokeEnd(); return; }
if (clothingPaint.painting) { clothingPaint.painting = false; if (clothingPaint.mode === "lasso") finishLasso(); return; }
if (band.active) { endBand(e); return; }
if (ik.active) { endIkGesture(e.pointerId); requestRender(); }
};
canvas.addEventListener("pointercancel", onPointerAbort);
canvas.addEventListener("lostpointercapture", onPointerAbort);
// ── toolbar + resets ────────────────────────────────────────────────────
// Posing is selection-driven, not mode-driven: click a body = select the whole
// figure (move/rotate gizmo); click a joint handle = pose that joint (FK rings /
// IK drag); click a prop = move/rotate/scale it. There is no global gizmo mode.
const resetView = () => {
camera.position.copy(HOME.pos);
controls.target.copy(HOME.target);
controls.update();
requestRender();
captureAndUpload(node);
};
const resetPose = () => {
if (!rig) return; // empty cast — nothing to reset
if (rig.rest) for (const [j, q] of rig.rest) j.quaternion.copy(q);
if (rig.restPos) for (const [j, p] of rig.restPos) j.position.copy(p); // undo fold + Ctrl-move
// Joints rest at the skeletal-morph positions (not the neutral bind) so a pose
// reset keeps the current body shape while clearing fold/Ctrl-move.
if (rig.curSkelRest) for (const [b, p] of rig.curSkelRest) b.position.copy(p);
if (rig.rootRest) rig.root.position.copy(rig.rootRest);
for (const k of Object.keys(rig.footPins)) rig.footPins[k] = null;
updatePinBtn();
detachGizmo();
requestRender();
captureAndUpload(node);
};
// Apply a captured full-body pose (BODY_POSES) to one figure: rest the skeleton
// first (clears Ctrl-moves/folds and anything the capture doesn't cover), then
// set the captured local quats. Placement (fig.root) and body shape (bone
// positions/sliders) stay the figure's own — a pose is pure posture.
const applyBodyPose = (f, pose) => {
if (!f || !pose) return;
if (f.rest) for (const [j, q] of f.rest) j.quaternion.copy(q);
if (f.restPos) for (const [j, p] of f.restPos) j.position.copy(p);
if (f.curSkelRest) for (const [b, p] of f.curSkelRest) b.position.copy(p);
if (f.footPins) for (const k of Object.keys(f.footPins)) f.footPins[k] = null; // planted feet can't survive new legs
if (f === rig) updatePinBtn();
const setQuats = (map, lookup) => {
for (const [name, q] of Object.entries(map || {})) {
const b = lookup(name);
if (b && Array.isArray(q) && q.length === 4) b.quaternion.fromArray(q);
}
};
setQuats(pose.joints, (n) => f.joints[n]);
const spineByName = new Map((f.spineChain || []).map((b) => [b.name, b]));
setQuats(pose.spine, (n) => spineByName.get(n));
const digitByName = new Map(digitBones(f).map((b) => [b.name, b]));
setQuats(pose.digits, (n) => digitByName.get(n));
detachGizmo();
requestRender();
captureAndUpload(node);
};
// ── L↔R mirror ──────────────────────────────────────────────────────────
// Mirror a joint's pose onto its opposite-side joint across the figure's
// sagittal plane (left-right = world X, since the figure is stood facing +Z).
// We reflect the bone's MOTION FROM REST in world space, then re-apply it
// relative to the opposite bone's OWN rest world orientation. Routing through
// each bone's real rest (rig.restWorld) cancels out the rig's per-bone local-
// axis convention, so this is correct on any symmetric skeleton — reflecting
// absolute orientations (the previous approach) only worked on axis-aligned rigs.
// MIRROR_PAIRS / LIMB_MIRROR_SETS live on the rig profile (module scope).
const reflectWorldX = (q) => new THREE.Quaternion(q.x, -q.y, -q.z, q.w); // conj. by world-X reflection
// Name → bone for everything mirrorable on the ACTIVE figure (canonical joints +
// finger/toe bones). Rebuilt at the start of mirror() so it follows the figure.
const buildMirrorBones = () => {
const m = new Map(Object.entries(rig.joints));
for (const b of digitBones(rig)) m.set(b.name, b);
return m;
};
let mirrorBones = null;
const applyMirror = (srcWorldQ, srcWorldP, srcName, dstName) => {
const dst = mirrorBones.get(dstName);
const restS = rig.restWorld.get(srcName), restD = rig.restWorld.get(dstName);
if (!dst || !restS || !restD) return;
const parent = dst.parent || rig.root;
const deltaW = srcWorldQ.clone().multiply(restS.clone().invert()); // world rotation away from rest
const dstWorld = reflectWorldX(deltaW).multiply(restD); // mirrored, on dst's rest
dst.quaternion.copy(parent.getWorldQuaternion(new THREE.Quaternion()).invert().multiply(dstWorld));
// Position: carry ONLY a real local translation (a Ctrl-move/fold on the bone),
// NEVER the rotation-induced world swing. The old code diffed the bone's posed
// world position against its REST-WORLD (T-pose) position, so a rotation/IK pose
// — where the bone has swung far from T-pose but its LOCAL position is unchanged —
// was misread as a huge translation and stacked on top of the mirrored rotation,
// stretching the limb. A bone whose local position still equals its rest is purely
// rotation-posed → leave dst at its rest-local so plain FK places it.
const src = mirrorBones.get(srcName);
const srcRestLocal = rig.restPos?.get(src), dstRestLocal = rig.restPos?.get(dst);
if (srcWorldP && src && srcRestLocal && dstRestLocal) {
if (src.position.distanceToSquared(srcRestLocal) < 1e-8) {
dst.position.copy(dstRestLocal); // rotation/IK only → no translation to mirror
} else {
// Real Ctrl-move: the world offset from where pure FK would put the bone is the
// translation; reflect it across world-X onto dst's FK position.
const sp = src.parent || rig.root; sp.updateMatrixWorld(true);
const transW = srcWorldP.clone().sub(srcRestLocal.clone().applyMatrix4(sp.matrixWorld));
parent.updateMatrixWorld(true);
const dstW = dstRestLocal.clone().applyMatrix4(parent.matrixWorld);
dstW.set(dstW.x - transW.x, dstW.y + transW.y, dstW.z + transW.z);
dst.position.copy(parent.worldToLocal(dstW));
}
}
dst.updateMatrixWorld(true); // child reads fresh parent world next (parent-first order)
};
const limbPairsFor = (name) => {
for (const set of figProfile(rig).limbMirrorSets) {
if (!set.some((base) => name === base + "L" || name === base + "R")) continue;
const side = name.endsWith("L") ? "L" : "R", other = side === "L" ? "R" : "L";
return set.map((base) => [base + side, base + other]); // [src, dst], parent-first order
}
return null;
};
// Finger/toe bone name on the opposite side (index_03_l ↔ index_03_r).
const digitMirrorName = (name) => {
const m = name.match(/^(.*)_(l|r)$/i);
return m ? `${m[1]}_${m[2].toLowerCase() === "l" ? "r" : "l"}` : name;
};
const mirror = () => {
if (!rig) return; // empty cast — nothing to mirror
mirrorBones = buildMirrorBones();
const sel = transformControls.object;
const selName = sel && Object.keys(rig.joints).find((k) => rig.joints[k] === sel);
const pairs = selName && limbPairsFor(selName);
if (pairs) {
// A limb is selected → mirror that limb (joints first, parent→child) to the
// other side, then carry its hand/foot pose across (digits live below the
// wrist/ankle, so the limb joints must already be mirrored).
for (const [srcN, dstN] of pairs) {
const src = rig.joints[srcN];
if (src) applyMirror(src.getWorldQuaternion(new THREE.Quaternion()), src.getWorldPosition(new THREE.Vector3()), srcN, dstN);
}
const side = selName.endsWith("L") ? "L" : "R", other = side === "L" ? "R" : "L";
for (const b of digitBones(rig)) {
if (!b.name.match(new RegExp(`_${side.toLowerCase()}$`, "i"))) continue;
const dstName = digitMirrorName(b.name);
if (mirrorBones.has(dstName)) applyMirror(b.getWorldQuaternion(new THREE.Quaternion()), null, b.name, dstName);
}
} else {
// Nothing (or a centerline joint) selected → flip the whole pose L↔R. Snapshot
// first since it's a swap, then assign parent-first (joints then digits, which
// hang below the wrists/ankles).
const profile = figProfile(rig);
const snap = new Map(), snapPos = new Map();
for (const n of profile.joints) {
const j = rig.joints[n];
if (j) { snap.set(n, j.getWorldQuaternion(new THREE.Quaternion())); snapPos.set(n, j.getWorldPosition(new THREE.Vector3())); }
}
for (const b of digitBones(rig)) snap.set(b.name, b.getWorldQuaternion(new THREE.Quaternion()));
for (const n of profile.joints) {
if (!rig.joints[n]) continue;
const pair = profile.mirrorPairs.find((p) => p.includes(n));
const srcN = pair ? (pair[0] === n ? pair[1] : pair[0]) : n;
if (snap.has(srcN)) applyMirror(snap.get(srcN), snapPos.get(srcN), srcN, n);
}
for (const b of digitBones(rig)) {
const srcN = digitMirrorName(b.name);
if (snap.has(srcN)) applyMirror(snap.get(srcN), null, srcN, b.name);
}
}
requestRender();
captureAndUpload(node);
};
// Icon-only buttons (label is a single glyph; the tooltip carries the meaning).
// mkBtn = the bordered style the floating panels use; mkToolBtn = the Blender-
// style flat icon used inside the left strip's grouped pills.
const mkBtn = (label, title, onClick) => {
const b = document.createElement("button");
b.textContent = label;
b.title = title;
b.style.cssText =
"font:14px/1 system-ui,sans-serif;display:flex;align-items:center;justify-content:center;" +
"width:30px;height:26px;padding:0;background:#2a2a35;color:#ccd;pointer-events:auto;" +
"border:1px solid #4a4a55;border-radius:5px;cursor:pointer;opacity:0.85;";
b.addEventListener("pointerdown", (e) => e.stopPropagation()); // a button press must not start an orbit
b.addEventListener("click", (e) => { e.stopPropagation(); onClick(b); });
return b;
};
const mkToolBtn = (label, title, onClick) => {
const b = document.createElement("button");
b.textContent = label;
b.title = title;
// Edge-to-edge squares (no padding, no own radius) — the active blue fills
// the button completely; the GROUP's rounded corners clip the end buttons.
b.style.cssText =
"font:15px/1 system-ui,sans-serif;display:flex;align-items:center;justify-content:center;" +
"width:30px;height:30px;padding:0;background:transparent;color:#d8d8de;pointer-events:auto;" +
"border:none;border-radius:0;cursor:pointer;";
b.addEventListener("pointerdown", (e) => e.stopPropagation());
b.addEventListener("click", (e) => { e.stopPropagation(); onClick(b); });
b.addEventListener("pointerenter", () => { if (!b._pcrOn) b.style.background = "#3d3d48"; });
b.addEventListener("pointerleave", () => { if (!b._pcrOn) b.style.background = "transparent"; });
return b;
};
// Active/toggled tool = Blender's selection blue, filling the button.
const setToolActive = (b, on) => {
b._pcrOn = !!on;
b.style.background = on ? "#4772b3" : "transparent";
b.style.color = on ? "#ffffff" : "#d8d8de";
};
const mkToolGroup = () => {
const g = document.createElement("div");
g.style.cssText =
"display:flex;flex-direction:column;gap:0;padding:0;pointer-events:auto;" +
"background:rgba(26,26,32,0.92);border-radius:5px;overflow:hidden;"; // rounded pill clips the square buttons
return g;
};
// Vertical tool strip down the left edge, Blender-style: a TRANSFORM group
// (move/rotate/scale — the single home for gizmo modes, the per-selection
// panels no longer repeat them) above a group with everything else. The
// groups ALWAYS stack; when the viewport is too short, the main pill wraps
// its buttons into extra columns INSIDE itself (flex-shrink + wrap) — the
// old toolbar-level wrap bounced the entire second pill sideways the moment
// it didn't fit, leaving a dead gap under the transform group.
const toolbar = document.createElement("div");
toolbar.className = "pcr-pose-toolbar"; // hover-gated opacity (see installPoseStyles)
// pointer-events:none so the container's empty area (it lays out wider than the button
// column) is click-through to orbit; the groups re-enable pointer-events themselves.
toolbar.style.cssText =
"position:absolute;top:6px;left:6px;z-index:2;display:flex;flex-direction:column;gap:6px;" +
"height:calc(100% - 12px);align-items:flex-start;pointer-events:none;";
const toolGroup = mkToolGroup();
toolGroup.style.flex = "none";
const mainGroup = mkToolGroup();
mainGroup.style.flex = "0 1 auto"; // hug content, but shrink when short…
mainGroup.style.minHeight = "0";
mainGroup.style.flexWrap = "wrap"; // …and wrap into a second column inside the pill
mainGroup.style.alignContent = "flex-start";
toolbar.appendChild(toolGroup);
toolbar.appendChild(mainGroup);
// ── transform tools (the Blender tool column) ───────────────────────────────
// A GLOBAL armed mode, Blender-style: clicking a tool always works and stays
// lit; every selection adopts it where it can. Scale applies to props and to
// CREATURE figures (uniform); the HUMAN sizes via body sliders instead (its
// root scale is load-bearing for skeleton binding) — with scale armed, a human
// figure / IK simply keeps its last move/rotate gizmo until a scalable target
// comes under the tool.
const toolBtns = [];
let transformTool = "translate";
const updateToolUI = () => {
for (const { tool, btn } of toolBtns) setToolActive(btn, tool === transformTool);
};
const setTransformTool = (tool) => {
transformTool = tool;
// arm the per-kind defaults so the NEXT selection of each kind adopts it
if (tool !== "scale") {
figureMode = tool;
ikMoveMode = tool === "translate";
}
propMode = tool;
// apply to the current selection where compatible
if (bandActive()) {
// Scale works on a group too: the proxy delta (newProxy·startProxy⁻¹) is decomposed
// per member, so a scale about the median grows each item + its distance from centre.
transformControls.setMode(tool);
transformControls.setSpace(tool === "scale" ? "local" : "world"); // proxy is axis-aligned → local == world
} else if (selectedIkLimb) {
if (tool !== "scale") setIkJointMode(tool === "translate" ? "move" : "rotate");
} else if (selectedProp) {
setPropMode(tool);
} else if (selectedFigure) {
if (tool !== "scale") setFigureMode(tool);
else if (figureCanScale(selectedFigure)) { transformControls.setMode("scale"); transformControls.setSpace("local"); requestRender(); }
}
updateToolUI();
requestRender();
};
for (const [tool, label, title] of [
["translate", "✥", "Move (W)"],
["rotate", "⟲", "Rotate (E)"],
["scale", "⤢", "Scale — props & creatures (R)"],
]) {
const b = mkToolBtn(label, title, () => setTransformTool(tool));
toolBtns.push({ tool, btn: b });
toolGroup.appendChild(b);
}
updateToolUI();
// The strip stays lean (it must fit one column even in a short node):
// - adding people/props/floor → the ➕ menu ("🧑 Subject ▸" etc.)
// - mirror / reset pose / foot pins / reset camera → the right-click menu
// - deletion → the selected panels (🗑) and the Del key
// - body-shape (💪) → per-person, on the selected-figure panel
// ── scene props ───────────────────────────────────────────────────────────
// Spawn primitive props and pose them with the same gizmo. Props are REAL matte
// geometry (unlike the grid/handles/gizmo, which are stripped from the capture),
// so they land in the control map and DepthAnything reads them as part of the
// scene — a box to sit on, a ball to hold, a pole to lean against. Selected by
// raycast → gizmo, with a per-prop translate/rotate/scale mode; round-tripped
// through pose_state so they survive save/reload and undo/redo. Default sizes are
// in the figure's scale (~1.8 units tall); `y` rests the prop on the floor (y=0).
const PROP_DEFS = {
box: { label: "📦 box", geo: () => new THREE.BoxGeometry(0.5, 0.5, 0.5), y: 0.25 },
platform: { label: "▭ platform", geo: () => new THREE.BoxGeometry(1.0, 0.12, 1.0), y: 0.06 },
sphere: { label: "⚪ sphere", geo: () => new THREE.SphereGeometry(0.22, 24, 16), y: 0.22 },
cylinder: { label: "🛢 cylinder", geo: () => new THREE.CylinderGeometry(0.13, 0.13, 0.8, 24), y: 0.40 },
pole: { label: "📏 pole", geo: () => new THREE.CylinderGeometry(0.04, 0.04, 1.6, 16), y: 0.80 },
cone: { label: "🔺 cone", geo: () => new THREE.ConeGeometry(0.3, 0.6, 24), y: 0.30 },
};
// Segmented, articulated chain props — braids, tails, ropes, tentacles. Each is a
// tapered tube SkinnedMesh over a serial bone chain along +Y, built procedurally so
// short/long are just segment-count + length variants. The chain plugs into the same
// 🦴 FK system imported skinned props use (per-segment handle, rotate with the gizmo)
// AND carries a tip handle: drag it and a FABRIK solve ripples the whole chain. Bone
// poses round-trip through the existing rest-diff serialization (_bones/_boneRest).
const CHAIN_DEFS = {
chainShort: { label: "🪢 chain — short", segs: 7, len: 0.7, r0: 0.045, r1: 0.045 }, // uniform radius = a true cylinder, not a cone
chainMedium: { label: "🪢 chain — medium", segs: 11, len: 1.2, r0: 0.037, r1: 0.037 },
chainLong: { label: "🪢 chain — long", segs: 16, len: 1.7, r0: 0.03, r1: 0.03 },
};
// One shared matte material for all props (teardown's scene.traverse dispose is
// idempotent, so sharing is safe). Distinct from skin so props read as separate.
// DoubleSide so photoscanned / split GLBs with inverted or open winding don't render
// see-through (front faces culled → you'd see the inside/floor through them).
const propMat = new THREE.MeshStandardMaterial({ color: 0x9aa0a8, roughness: 0.9, metalness: 0.0, side: THREE.DoubleSide });
const propsGroup = new THREE.Group();
scene.add(propsGroup);
// Stable per-prop identity, mirroring figureUidSeq: undo/restore re-applies a
// prop's LIVE name by uid (names are identity, not posture), and paste strips
// uids for fresh ones. Restore overrides with the saved uid.
let propUidSeq = 1;
// NAMED props are region entities (own mask row + $name{} block); load-failed
// placeholders are excluded — they have no mesh to render a mask from.
function namedPropMeshes() {
return propsGroup.children.filter((m) => m.userData.customName && !m.userData._failedRecord);
}
// Region-entity names in mask-row order: figures (custom or mannequinN), then
// named props. THE name list every block-sync and uniqueness check reads.
function liveEntityNames() {
return [...effectiveFigureNames(figures), ...namedPropMeshes().map((m) => m.userData.customName)];
}
// Bundled CC0 model props (Kenney Furniture Kit, kenney.nl, public domain). Loaded as
// GLB, normalized to a real-world height, re-pivoted so the base rests on the floor, and
// re-materialed to the shared matte so they read like the primitives + give clean depth.
// `h` = target world height in metres (figure is ~1.8m); serialization keys on the type
// string, so a model prop round-trips by reloading its GLB. Stay real geometry in the
// capture (DepthAnything needs them), unlike grid/handles/gizmo which captureBlob strips.
const MODEL_PROPS = {
schoolDesk: { file: "schoolDeskOnly.glb", label: "🏫 school desk",h: 0.70 }, // split from the combined model (CC-BY, 3DGhost903)
schoolChair: { file: "schoolChair.glb", label: "🪑 school chair",h: 0.80 }, // node transforms baked in → upright; h = real height
desk: { file: "desk.glb", label: "🪑 desk", h: 0.75 },
deskChair: { file: "chairDesk.glb", label: "💺 desk chair", h: 1.05 },
chair: { file: "chair.glb", label: "🪑 chair", h: 0.90 },
table: { file: "table.glb", label: "🍽 table", h: 0.74 },
tableRound: { file: "tableRound.glb", label: "🍽 round table",h: 0.74 },
stool: { file: "stoolBar.glb", label: "🪑 stool", h: 0.85 },
bookcase: { file: "bookcaseOpen.glb", label: "📚 bookcase", h: 1.10 },
crate: { file: "cardboardBoxClosed.glb", label: "📦 crate", h: 0.50 },
bed: { file: "bedSingle.glb", label: "🛏 bed", h: 0.45 },
sofa: { file: "loungeSofa.glb", label: "🛋 sofa", h: 0.75 },
sideTable: { file: "sideTable.glb", label: "🛍 side table", h: 0.55 },
trashcan: { file: "trashcan.glb", label: "🗑 trash can", h: 0.55 },
plant: { file: "pottedPlant.glb", label: "🪴 plant", h: 0.60 },
stairs: { file: "stairs.glb", label: "🪜 stairs", h: 1.10 },
// ── Kenney CC0 kits (Blaster / Survival / Food / Car / Mini-Skate / City-Roads / Fantasy-Town) ──
gun: { file: "gun.glb", label: "🔫 gun", h: 0.18 },
bottle: { file: "bottle.glb", label: "🍾 bottle", h: 0.25 },
cup: { file: "cup.glb", label: "☕ cup", h: 0.10 },
mug: { file: "mug.glb", label: "🍺 mug", h: 0.11 },
wineglass: { file: "wineglass.glb", label: "🍷 wine glass", h: 0.18 },
axe: { file: "axe.glb", label: "🪓 axe", h: 0.70 },
hammer: { file: "hammer.glb", label: "🔨 hammer", h: 0.35 },
pickaxe: { file: "pickaxe.glb", label: "⛏ pickaxe", h: 0.70 },
shovel: { file: "shovel.glb", label: "🥄 shovel", h: 1.00 },
carSedan: { file: "carSedan.glb", label: "🚗 sedan", h: 1.45 },
carSuv: { file: "carSuv.glb", label: "🚙 SUV", h: 1.70 },
van: { file: "van.glb", label: "🚐 van", h: 2.00 },
truck: { file: "truck.glb", label: "🚚 truck", h: 2.60 },
kart: { file: "kart.glb", label: "🏎 kart", h: 0.80 },
skateboard: { file: "skateboard.glb", label: "🛹 skateboard", h: 0.12 },
lamppost: { file: "lamppost.glb", label: "🏮 lamppost", h: 3.00 },
trafficCone: { file: "trafficCone.glb", label: "🚧 traffic cone",h: 0.50 },
barrier: { file: "barrier.glb", label: "🚧 barrier", h: 1.00 },
roadSign: { file: "roadSign.glb", label: "🛑 road sign", h: 2.50 },
barrel: { file: "barrel.glb", label: "🛢 barrel", h: 0.90 },
chest: { file: "chest.glb", label: "🧰 chest", h: 0.60 },
crateWood: { file: "crateWood.glb", label: "📦 wood crate", h: 0.60 },
fence: { file: "fence.glb", label: "🚧 fence", h: 1.00 },
signpost: { file: "signpost.glb", label: "🪧 signpost", h: 1.80 },
rock: { file: "rock.glb", label: "🪨 rock", h: 0.60 },
tree: { file: "tree.glb", label: "🌲 tree", h: 3.50 },
campfire: { file: "campfire.glb", label: "🔥 campfire", h: 0.40 },
workbench: { file: "workbench.glb", label: "🛠 workbench", h: 1.00 },
doorway: { file: "doorway.glb", label: "🚪 doorway", h: 2.20 },
windowframe: { file: "windowframe.glb", label: "🪟 window", h: 1.20 },
pillar: { file: "pillar.glb", label: "🏛 pillar", h: 2.50 },
fountain: { file: "fountain.glb", label: "⛲ fountain", h: 0.80 },
bench: { file: "bench.glb", label: "🪑 bench", h: 0.90 },
// ── Quaternius CC0 (RPG Items / misc) ──
sword: { file: "sword.glb", label: "⚔ sword", h: 0.90 },
dagger: { file: "dagger.glb", label: "🗡 dagger", h: 0.32 },
spear: { file: "spear.glb", label: "🔱 spear", h: 1.80 },
bow: { file: "bow.glb", label: "🏹 bow", h: 1.20 },
shield: { file: "shield.glb", label: "🛡 shield", h: 0.60 },
book: { file: "book.glb", label: "📖 book", h: 0.24 },
key: { file: "key.glb", label: "🔑 key", h: 0.12 },
guitar: { file: "guitar.glb", label: "🎸 guitar", h: 1.00 },
bag: { file: "bag.glb", label: "🎒 bag", h: 0.45 },
// ── CC-BY (Poly by Google / creators) — attribution in CREDITS ──
motorcycle: { file: "motorcycle.glb", label: "🏍 motorcycle", h: 1.10 },
bicycle: { file: "bicycle.glb", label: "🚲 bicycle", h: 1.10 },
scooter: { file: "scooter.glb", label: "🛵 scooter", h: 1.10 },
// Footwear: only the calibrated boots ship (loose props for undressing scenes, and the
// calibration source for the wearable "boots" style). True L/R, split from the pair.
// Other shoes get added one at a time as each is calibrated. CC-BY DanlyVostok.
tacticalBootR:{ file: "boot_tactical_right.glb", label: "🥾 boot (R)", h: 0.34 },
tacticalBootL:{ file: "boot_tactical_left.glb", label: "🥾 boot (L)", h: 0.34 },
heelR: { file: "heel_right.glb", label: "👠 heel (R)", h: 0.13 }, // CC-BY Andrey (Earl_1) classic pump, split from the pair (uncalibrated — position to fit)
heelL: { file: "heel_left.glb", label: "👠 heel (L)", h: 0.13 },
sneakerR: { file: "sneaker_right.glb", label: "👟 sneaker (R)", h: 0.12 }, // CC-BY AntonCla photoscan, split from the pair (uncalibrated — position to fit)
sneakerL: { file: "sneaker_left.glb", label: "👟 sneaker (L)", h: 0.12 },
};
const propLoader = new GLTFLoader();
const _mpBox = new THREE.Box3(), _mpV = new THREE.Vector3();
// Resolve to a propsGroup CHILD: model props are a Group, so a raycast/select can land
// on a deep sub-mesh — walk up to the object the gizmo/serializer addresses.
const propRootOf = (obj) => { let o = obj; while (o && o.parent !== propsGroup) o = o.parent; return o?.parent === propsGroup ? o : null; };
// "Missing mesh" marker: a translucent warning-orange box + crisp edges, base at
// y=0 like a real prop. Stands in for an imported mesh whose file isn't on THIS
// machine (a workflow carries only the content-hash reference, not the bytes), so
// the prop is VISIBLE, selectable and locatable instead of silently absent. Owns
// unique geometry/material (disposed below, unlike the shared prop material), and
// is hidden from every capture so it can't poison the ControlNet hint.
const MISSING_MARKER_COLOR = 0xff7a1a;
const makeMissingMarker = () => {
const g = new THREE.Group();
const box = new THREE.BoxGeometry(1, 1, 1).translate(0, 0.5, 0); // base at y=0, like a prop
g.add(new THREE.Mesh(box, new THREE.MeshBasicMaterial({ color: MISSING_MARKER_COLOR, transparent: true, opacity: 0.12, depthWrite: false })));
g.add(new THREE.LineSegments(new THREE.EdgesGeometry(box), new THREE.LineBasicMaterial({ color: MISSING_MARKER_COLOR })));
return g;
};
const disposeProp = (m) => { // model = a Group of meshes; primitive = one mesh. Material is shared, never disposed.
if (m._pcrLabel) { m._pcrLabel.material.map?.dispose(); m._pcrLabel.material.dispose(); } // name label owns its texture
m.userData._chainSkeleton?.dispose?.(); // procedural chain owns a bone texture
if (m.userData._failedRecord) { m.traverse((o) => { o.geometry?.dispose?.(); o.material?.dispose?.(); }); return; } // missing-mesh marker owns unique geo + material
if (m.userData.isModel) m.traverse((o) => o.geometry?.dispose?.());
else m.geometry?.dispose?.();
};
// Normalize a loaded model into prop space: shared matte material, missing
// normals computed, optionally scaled to targetH (bbox height), re-pivoted so
// the base rests at y=0 centred on X/Z, and wrapped in an identity root (the
// saved pos/quat/scale stay independent of the normalization, so restore
// reproduces it exactly). Shared by bundled model props and imported meshes.
const normalizePropModel = (inner, targetH) => {
inner.traverse((o) => {
if (!o.isMesh) return;
o.material = propMat;
if (!o.geometry.attributes.normal) o.geometry.computeVertexNormals(); // split props ship positions-only
});
_mpBox.setFromObject(inner);
const sy = _mpBox.getSize(_mpV).y || 1;
if (targetH) inner.scale.setScalar(targetH / sy);
inner.updateMatrixWorld(true);
_mpBox.setFromObject(inner);
const ctr = _mpBox.getCenter(new THREE.Vector3());
inner.position.set(-ctr.x, -_mpBox.min.y, -ctr.z); // centre X/Z, base at y=0 (rests on floor)
const root = new THREE.Group();
root.add(inner);
return root;
};
// Load + normalize a bundled model prop, scaled to its curated real height.
const loadModelProp = (type) => new Promise((resolve, reject) => {
const def = MODEL_PROPS[type];
if (!def) return reject(new Error("unknown model prop " + type));
const url = new URL(`./assets/props/${def.file}?v=1`, import.meta.url).href;
propLoader.load(url, (gltf) => {
const root = normalizePropModel(gltf.scene, def.h);
root.userData = { isProp: true, propType: type, isModel: true, uid: propUidSeq++ };
resolve(root);
}, undefined, reject);
});
// Imported mesh (user .glb/.gltf/.obj) — fetched from the server's content-
// addressed store, so the SAME pose_state restores it after reload, undo,
// cross-workflow paste, and scene export/import. The file reference (not the
// mesh data) is what serializes; a blob/object URL would die with the page.
//
// AUTO-CAPABILITIES — the import is usable with whatever the file came with:
// multi-mesh static file → splittable into part-props (✂️, crude articulation)
// skinned file → raw FK bone posing (🦴; no canonical mapping, so
// ANY rig works — creature, multi-arm, whatever)
// animation clips → scrub a clip to a frame and freeze it (🎬)
// Parts and bones are exclusive: pulling one mesh out of a skinned model
// would orphan its skeleton, and a rigged model articulates better by bone.
// `part` (re)builds just mesh #part of the file, pivoted at its own centroid;
// traversal order is stable for a given file, so part indices persist.
const loadImportedMesh = async (file, part = null) => {
const resp = await api.fetchApi(`/promptchain/pose-mesh/${file}`);
if (!resp.ok) throw new Error(`mesh fetch failed (${resp.status})`);
let inner, clips = [];
if (/\.obj$/i.test(file)) {
if (!OBJLoader) throw new Error("OBJ support needs the updated three bundle — refresh the page");
inner = new OBJLoader().parse(await resp.text());
} else {
const gltf = await new GLTFLoader().parseAsync(await resp.arrayBuffer(), "");
inner = gltf.scene;
clips = gltf.animations || [];
}
// Plausible native size keeps the user's authored scale; absurd units
// (mm/inch exports land km tall or ant sized) normalize to 1m and the
// scale gizmo takes it from there.
_mpBox.setFromObject(inner);
const sy = _mpBox.getSize(_mpV).y;
const root = normalizePropModel(inner, sy >= 0.05 && sy <= 3.5 ? 0 : 1.0);
const meshList = [];
inner.traverse((o) => { if (o.isMesh) meshList.push(o); });
const skinned = meshList.some((o) => o.isSkinnedMesh);
if (part != null) {
// Part rebuild: keep ONLY mesh #part, re-pivoted at its centroid so the
// gizmo rotates the part about itself, not the whole model's floor point.
// _partCentroid = that pivot in the whole-model frame — the splitter
// places the part-prop at wholeMatrix × centroid so the assembly lands
// exactly where it stood.
const src = meshList[part];
if (!src) throw new Error(`part ${part} missing in ${file}`);
root.updateMatrixWorld(true);
const rel = new THREE.Matrix4().copy(root.matrixWorld).invert().multiply(src.matrixWorld);
const m = src.clone(false); // children are other parts — never duplicate them in
rel.decompose(m.position, m.quaternion, m.scale);
const holder = new THREE.Group();
holder.add(m);
const c = new THREE.Box3().setFromObject(holder).getCenter(new THREE.Vector3());
holder.position.set(-c.x, -c.y, -c.z);
const partRoot = new THREE.Group();
partRoot.add(holder);
partRoot.userData = { isProp: true, propType: "importedMesh", isModel: true, file, part, uid: propUidSeq++, _partCentroid: c };
return partRoot;
}
// Skeleton inventory (deduped across meshes sharing one skeleton) + rest
// snapshot — bone poses serialize as diffs against this rest, and the 🎬
// rest button returns here.
const bones = [];
if (skinned) {
const seen = new Set();
for (const sm of meshList) {
if (!sm.isSkinnedMesh || !sm.skeleton) continue;
for (const b of sm.skeleton.bones) if (!seen.has(b)) { seen.add(b); bones.push(b); }
}
}
root.userData = {
isProp: true, propType: "importedMesh", isModel: true, file, uid: propUidSeq++,
// Runtime capabilities (underscore = never serialized; re-derived per load):
_capParts: (!skinned && meshList.length > 1 && meshList.length <= 12) ? meshList.length : 0,
_bones: bones.length ? bones : null,
_boneRest: bones.length ? new Map(bones.map((b) => [b, { q: b.quaternion.clone(), p: b.position.clone() }])) : null,
// Clips only when there's a skeleton to freeze INTO: a frozen frame
// persists via the bone rest-diffs, but rigid-node animation tracks have
// nowhere to serialize — offering the scrubber there would silently lose
// the pose on reload. Zero-length clips (Character Creator exports ship
// "Default" placeholders) are dropped — nothing to scrub.
_clips: bones.length ? clips.filter((c) => c.duration > 0.01) : [],
_animRoot: inner,
};
return root;
};
const spawnImportedMesh = async (file) => {
try {
const root = await loadImportedMesh(file);
if (!node._pcrAlive) { disposeProp(root); return; }
root.position.set(0, 0, 0.4); // base already at y=0 → rests on the floor in front
propsGroup.add(root);
selectProp(root);
requestRender();
captureAndUpload(node);
} catch (e) {
console.error("[PoseStudio] imported mesh failed:", e);
showClipToast("mesh import failed — " + (e.message || e), true);
}
};
// ✂️ Split a multi-mesh import into per-mesh part-props: each part gets its
// own gizmo (crude articulation on STATIC files — turn the head, raise the
// tail), can be 🔗-relinked, even named into its own region. Parts spawn
// exactly where they stood in the assembly and start linked to part 0 so the
// model still moves as one until rearranged. Each part is a fresh parse, so
// no geometry is shared and per-part dispose stays safe.
const splitImportedProp = async (whole) => {
const n = whole.userData._capParts;
if (!n || !whole.userData.file) return;
whole.updateMatrixWorld(true);
const baseM = whole.matrixWorld.clone();
const parts = [];
for (let k = 0; k < n; k++) {
let p = null;
try { p = await loadImportedMesh(whole.userData.file, k); } catch (e) { console.warn("[PoseStudio] part load failed:", k, e); }
if (!node._pcrAlive) { if (p) disposeProp(p); parts.forEach(disposeProp); return; }
if (!p) continue;
p.position.copy(p.userData._partCentroid.clone().applyMatrix4(baseM));
p.quaternion.copy(whole.quaternion);
p.scale.copy(whole.scale);
parts.push(p);
}
if (parts.length < 2) { parts.forEach(disposeProp); showClipToast("couldn't split this model", true); return; }
if (selectedProp === whole) { transformControls.detach(); selectedProp = null; }
// Part 0 inherits the whole's identity: uid (undo continuity), region name,
// and any 🔗 links that pointed at the whole.
parts[0].userData.uid = whole.userData.uid;
parts[0].userData.customName = whole.userData.customName;
for (const a of attachments) {
if (a.follower.mesh === whole) a.follower.mesh = parts[0];
if (a.leader.kind === "prop" && a.leader.mesh === whole) a.leader.mesh = parts[0];
}
propsGroup.remove(whole);
disposeProp(whole);
for (const p of parts) propsGroup.add(p);
for (let k = 1; k < parts.length; k++) createPropAttach(parts[k], { kind: "prop", mesh: parts[0] });
updatePropLabels();
selectProp(parts[0]);
showClipToast(`split into ${parts.length} parts — each part has its own gizmo (🔗 to relink)`);
requestRender();
captureAndUpload(node);
};
// 🦴 Raw FK bone posing for skinned imports. NO canonical mapping — every
// bone the rig ships gets a small handle; click one, rotate it with the
// gizmo. That's why it works on any rig (creature/multi-arm/whatever) where
// mapping-to-humanoid couldn't. Handles live under handlesRoot, so captures
// hide them for free.
let bonePoseProp = null;
let selectedBone = null;
const boneHandlesGroup = new THREE.Group();
handlesRoot.add(boneHandlesGroup);
const boneHandleGeo = new THREE.SphereGeometry(1, 10, 8);
const setBonePoseProp = (prop) => {
bonePoseProp = (bonePoseProp === prop) ? null : prop; // toggle
if (selectedBone) {
if (transformControls.object === selectedBone) transformControls.detach();
selectedBone = null;
}
for (const h of [...boneHandlesGroup.children]) { boneHandlesGroup.remove(h); h.material.dispose(); }
if (bonePoseProp?.userData._bones) {
const isChain = !!bonePoseProp.userData._chain;
for (const b of bonePoseProp.userData._bones) {
// Handles only for POSE-meaningful bones. Production rigs (Character
// Creator etc.) carry swarms of helper bones — twist correctives,
// *_end leaf markers, ShareBones — that bury the model in dots no one
// should click. They still deform (skinning + clip scrub use the full
// _bones list); they just don't get a handle.
if (/_end(_|\d|$)|twist|share/i.test(b.name)) continue;
// Chain joints are amber IK DIAMONDS (every joint is grab-to-bend, base→joint
// FABRIK); imported-rig bones stay blue FK spheres (rotate-only).
const h = new THREE.Mesh(isChain ? ikGeo : boneHandleGeo, new THREE.MeshBasicMaterial({
color: isChain ? HANDLE.ik : 0x57b1ff, transparent: true, opacity: HANDLE_DIM, depthTest: false,
}));
h.renderOrder = 998;
h.userData = { isBoneHandle: true, bone: b, ...(isChain ? { sizeMul: 1.1 } : {}) };
boneHandlesGroup.add(h);
}
// The TIP gets its own slightly larger diamond (the whole-chain reach).
if (bonePoseProp.userData._chain) {
const tip = new THREE.Mesh(ikGeo, new THREE.MeshBasicMaterial({
color: HANDLE.ik, transparent: true, opacity: HANDLE_DIM, depthTest: false,
}));
tip.renderOrder = 998;
tip.userData = { isChainTip: true, prop: bonePoseProp, sizeMul: 1.3 }; // match the figure IK diamond's 1.3×
boneHandlesGroup.add(tip);
}
}
updatePropUI();
requestRender();
};
// World position of a chain prop's tip = the last bone's origin + one segment up it.
const chainTipWorld = (prop, out) => {
const ud = prop.userData;
return ud._bones[ud._bones.length - 1].localToWorld(out.set(0, ud._chain.segLen, 0));
};
// World position of chain joint `idx` (0..bones.length): a bone origin, or the tip
// endpoint when idx === bones.length. Used by the pin (combine) machinery.
const chainJointWorld = (prop, idx, out) => {
const ud = prop.userData;
return idx < ud._bones.length ? ud._bones[idx].getWorldPosition(out) : chainTipWorld(prop, out);
};
// Per-render sync: handles track their bones (and the tip dot tracks the chain
// end); mode dies with its prop (delete/restore rebuilds props, so the stale
// reference self-clears), and a gizmo retargeted elsewhere drops the bone selection.
const updatePropBoneHandles = () => {
if (bonePoseProp && !propsGroup.children.includes(bonePoseProp)) {
// The prop instance we were posing is gone. A capture/state round-trip REBUILDS the
// props (new objects, same uid), so the bonePoseProp ref goes stale and the joint
// handles vanish — the "lose the dots until I reload" bug. Re-bind to the rebuilt prop
// with the same uid so bone mode survives; only truly clear if it was actually deleted.
const uid = bonePoseProp.userData?.uid;
const rebuilt = uid != null ? propsGroup.children.find((p) => p.userData?.uid === uid && p.userData?._bones) : null;
bonePoseProp = null;
setBonePoseProp(rebuilt || null);
return;
}
if (selectedBone && transformControls.object !== selectedBone) selectedBone = null;
for (const h of boneHandlesGroup.children) {
if (h.userData.isChainTip) chainTipWorld(h.userData.prop, h.position);
else h.userData.bone.getWorldPosition(h.position);
const d = h.position.distanceTo(camera.position);
const vpScale = Math.min(1, HANDLE_REF_H / Math.max(1, frameH));
h.scale.setScalar(Math.max(0.004, d * HANDLE_SIZE * 0.8 * vpScale * (h.userData.sizeMul || 1)));
const sel = h.userData.isChainTip
? (chainDrag.active && chainDrag.driveCount >= (h.userData.prop.userData._bones.length))
: (h.userData.bone === selectedBone || (chainDrag.active && chainDrag.bone === h.userData.bone));
h.material.opacity = sel ? HANDLE_BRIGHT : HANDLE_DIM;
}
};
// 🎬 Animation-clip scrubber: many rigged downloads ship posed for you
// (idle/walk/attack). Pick a clip, scrub to a frame, done — the bone state
// it leaves behind persists through the same rest-diff serialization as
// hand-posed bones, and 🦴 tweaks layer on top.
let clipOpen = false;
const clipPanel = document.createElement("div");
clipPanel.style.cssText =
"position:absolute;top:36px;right:6px;z-index:3;display:none;gap:4px;align-items:center;" +
"background:rgba(20,20,26,0.82);border:1px solid #4a4a55;border-radius:5px;padding:3px 5px;";
clipPanel.addEventListener("pointerdown", (e) => e.stopPropagation());
const clipSelect = document.createElement("select");
clipSelect.style.cssText = "font:11px system-ui,sans-serif;background:#2a2a35;color:#ccd;border:1px solid #4a4a55;border-radius:4px;padding:1px 3px;max-width:110px;";
const clipScrub = document.createElement("input");
clipScrub.type = "range"; clipScrub.min = "0"; clipScrub.max = "1"; clipScrub.step = "0.001"; clipScrub.value = "0";
clipScrub.style.cssText = "width:110px;";
const scrubClip = () => {
const ud = selectedProp?.userData;
const clip = ud?._clips?.[+clipSelect.value];
if (!clip || !ud._animRoot) return;
if (!ud._mixer) ud._mixer = new THREE.AnimationMixer(ud._animRoot);
if (ud._action?.getClip() !== clip) {
ud._mixer.stopAllAction();
ud._action = ud._mixer.clipAction(clip);
ud._action.play();
}
ud._mixer.setTime(+clipScrub.value * clip.duration);
requestRender();
};
clipScrub.addEventListener("input", scrubClip);
clipScrub.addEventListener("change", () => captureAndUpload(node)); // freeze the frame on release
clipSelect.addEventListener("change", () => { scrubClip(); captureAndUpload(node); }); // a new clip poses the model — re-process
const clipRestBtn = mkBtn("⏮", "Back to the model's rest pose", () => {
const ud = selectedProp?.userData;
if (!ud?._boneRest) return;
ud._mixer?.stopAllAction();
ud._action = null;
for (const [b, r] of ud._boneRest) { b.quaternion.copy(r.q); b.position.copy(r.p); }
clipScrub.value = "0";
requestRender();
captureAndUpload(node);
});
clipPanel.append(clipSelect, clipScrub, clipRestBtn);
container.appendChild(clipPanel);
let clipPanelProp = null; // whose clips the <select> currently lists
const syncClipPanel = () => {
const ud = selectedProp?.userData;
const show = !!(clipOpen && ud?._clips?.length);
clipPanel.style.display = show ? "flex" : "none";
if (show && clipPanelProp !== selectedProp) {
clipPanelProp = selectedProp;
clipSelect.innerHTML = "";
ud._clips.forEach((c, i) => {
const o = document.createElement("option");
o.value = String(i); o.textContent = c.name || `clip ${i + 1}`;
clipSelect.appendChild(o);
});
clipScrub.value = "0";
}
};
// ── skeleton binding: make a static import POSABLE ──────────────────────────
// 🧍 on an imported prop spawns a normal mannequin figure with its body
// hidden — that's the skeleton overlay. The user lines the limbs up with the
// SAME IK/FK handles used for posing, then ✔ bind auto-weights every vertex
// of the prop to the nearest skeleton segments and converts its meshes to
// SkinnedMeshes on that skeleton. The prop is consumed; the figure now IS
// the monster — posing, masks, regions, undo, save/reload, paste all work on
// it unchanged. Quality is automatic-weights tier: elbows can crease, which
// control maps don't care about.
// SKIN_SEGMENTS / SKIN_STUBS live on the rig profile (module scope).
// Canonical bone capsules in WORLD space at the skeleton's current pose.
// Torso segments carry a volume RADIUS (≈ half the shoulder span) so the
// chest/spine claim the torso barrel instead of losing bulky pecs/belly to
// the nearer shoulder/hip bones (which tore the sternum on wide poses).
const skinSegmentsFor = (fig) => {
const segs = [];
const wp = (n) => fig.joints[n] && fig.joints[n].getWorldPosition(new THREE.Vector3());
const idx = (n) => fig.skel.bones.indexOf(fig.joints[n]);
const shL = wp("shoulderL"), shR = wp("shoulderR");
const torsoR = shL && shR ? shL.distanceTo(shR) * 0.55 : 0;
// Chest tapers (its bubble must not reach the skull); the head stub gets
// its own volume so the skull is HEAD-owned even when the fitted head
// joint sits low between massive traps.
const RADII = { root: torsoR, spine: torsoR, chest: torsoR * 0.6, neck: torsoR * 0.15 };
const nk = wp("neck"), hd = wp("head");
const headR = nk && hd ? nk.distanceTo(hd) * 0.7 : 0;
for (const [a, b] of figProfile(fig).skinSegments) {
const pa = wp(a), pb = wp(b), bi = idx(a);
if (pa && pb && bi >= 0) segs.push({ bone: bi, a: pa, b: pb, r: RADII[a] || 0 });
}
for (const [tip, from, len] of figProfile(fig).skinStubs) {
const pt = wp(tip), pf = wp(from), bi = idx(tip);
if (pt && pf && bi >= 0) segs.push({ bone: bi, a: pt, b: pt.clone().add(pt.clone().sub(pf).multiplyScalar(len)), r: tip === "head" ? headR : 0 });
}
return segs;
};
// Convert every Mesh inside `container` into a SkinnedMesh on fig's skeleton,
// weighted against the skeleton's CURRENT pose with GEODESIC voxel weights
// (module-scope voxelSkinWeights — distances travel through the flesh, so an
// arm bone can't capture belly vertices across the air gap; the straight-line
// version did exactly that on bulky bodies and tore them apart).
const skinContainerToFig = (fig, container) => {
container.updateMatrixWorld(true);
const segs = skinSegmentsFor(fig).map((s) => ({ bone: s.bone, a: s.a.toArray(), b: s.b.toArray(), r: s.r || 0 }));
const meshes = [];
container.traverse((o) => { if (o.isMesh && !o.isSkinnedMesh) meshes.push(o); });
const datas = meshes.map((m) => {
const src = m.geometry.attributes.position;
const arr = new Float32Array(src.count * 3);
const v = new THREE.Vector3();
for (let i = 0; i < src.count; i++) {
v.fromBufferAttribute(src, i).applyMatrix4(m.matrixWorld);
arr[i * 3] = v.x; arr[i * 3 + 1] = v.y; arr[i * 3 + 2] = v.z;
}
return { pos: arr, index: m.geometry.index ? m.geometry.index.array : null };
});
const weights = voxelSkinWeights(datas, segs, 64);
// CRITICAL: the skin gets its OWN Skeleton over the same bones, whose
// boneInverses are computed at THIS (fit) pose — the no-args constructor
// calls calculateInverses() against the bones' current world matrices.
// Reusing fig.skel kept the mannequin's T-POSE inverses, so the skin
// deformed the instant it bound: every vertex offset by exactly its bone's
// fit-vs-T-pose delta. That was the "instant mutilation", independent of
// weights or alignment. Same bone objects → posing drives both skeletons.
fig._skinSkel?.dispose?.();
const skinSkel = new THREE.Skeleton(fig.skel.bones.slice());
fig._skinSkel = skinSkel;
meshes.forEach((m, k) => {
m.geometry.setAttribute("skinIndex", new THREE.BufferAttribute(weights[k].sIdx, 4));
m.geometry.setAttribute("skinWeight", new THREE.BufferAttribute(weights[k].sWgt, 4));
const sm = new THREE.SkinnedMesh(m.geometry, m.material);
sm.position.copy(m.position); sm.quaternion.copy(m.quaternion); sm.scale.copy(m.scale);
sm.frustumCulled = false; // bind-pose bounds lie once posed — never cull the deformed mesh
m.parent.add(sm);
m.parent.remove(m);
sm.updateWorldMatrix(true, false);
sm.bind(skinSkel, sm.matrixWorld.clone());
});
};
const removeSkinBindMesh = (fig) => {
if (!fig._skinRoot) return;
fig.root.remove(fig._skinRoot);
fig._skinRoot.traverse((o) => o.geometry?.dispose?.());
fig._skinRoot = null;
fig._skinSkel?.dispose?.(); // frees the skin skeleton's bone texture
fig._skinSkel = null;
};
// Undo the fit-time ghosting (and any undo that resurrects the mannequin body).
const unghostFigure = (fig) => {
for (const m of fig.meshes) {
if (m.material) { m.material.transparent = false; m.material.opacity = 1; m.material.depthWrite = true; }
}
};
// Build (or rebuild) a figure's bound skin from its serialized record: place
// the mesh, snap the skeleton to the recorded BIND pose, weight + bind, then
// return the skeleton to the pose it was holding. Weights are deterministic
// from geometry + bind pose, so they never serialize.
const materializeSkinBind = async (fig) => {
const sb = fig.skinBind;
if (!sb?.file || !fig.skel) return;
const root = await loadImportedMesh(sb.file, sb.part ?? null).catch((e) => { console.warn("[PoseStudio] skin mesh load failed:", e); return null; });
if (!root) return;
if (!node._pcrAlive || !figures.includes(fig)) { disposeProp(root); return; }
removeSkinBindMesh(fig);
root.userData = { _skinFor: fig.uid }; // figure skin now, not a prop
fig.root.add(root);
if (Array.isArray(sb.meshLocal) && sb.meshLocal.length === 16) {
new THREE.Matrix4().fromArray(sb.meshLocal).decompose(root.position, root.quaternion, root.scale);
}
const live = fig.skel.bones.map((b) => ({ b, p: b.position.clone(), q: b.quaternion.clone() }));
if (sb.bindPose) for (const b of fig.skel.bones) {
const d = sb.bindPose[b.name];
if (d) { if (Array.isArray(d.p)) b.position.fromArray(d.p); if (Array.isArray(d.q)) b.quaternion.fromArray(d.q); }
}
fig.root.updateMatrixWorld(true);
skinContainerToFig(fig, root);
for (const { b, p, q } of live) { b.position.copy(p); b.quaternion.copy(q); }
fig.root.updateMatrixWorld(true);
fig._skinRoot = root;
unghostFigure(fig); // clear any fit-time translucency before hiding
for (const m of fig.meshes) m.visible = false; // the import IS the body now
};
// Reconcile every figure's serialized skinBind with what's on screen — the
// async half of applyFigureData. Runs after any cast restore (load, undo,
// paste). Unchanged binds are kept; an undo past the bind removes the skin
// and brings the mannequin body (and, via the props snapshot, the prop) back.
const syncSkinBinds = async () => {
for (const fig of [...figures]) {
const sb = fig.skinBind;
const key = sb ? `${sb.file}|${sb.part ?? ""}` : null;
if (key === (fig._skinBindKey || null)) continue;
if (!sb) {
removeSkinBindMesh(fig);
unghostFigure(fig);
for (const m of fig.meshes) m.visible = true; // mannequin body returns
fig._skinBindKey = null;
continue;
}
await materializeSkinBind(fig);
if (!node._pcrAlive) return;
fig._skinBindKey = key;
}
requestRender();
};
// Geometric auto-fit: the mesh itself says where the hands and feet are —
// IK the skeleton's limbs there so "line it up" is usually just "check it".
// Heuristics assume an upright biped (the only shape worth binding to this
// rig): feet = the lowest 8% of vertices split left/right; hands = each
// side's most lateral point in the 25–78% height band (hanging and
// outstretched arms both put the hand near the lateral extreme). Targets
// assign to L/R joints by proximity, so the model's facing doesn't matter.
const autoAlignSkeleton = (fig, prop) => {
const pts = [];
prop.updateMatrixWorld(true);
const v = new THREE.Vector3();
prop.traverse((o) => {
if (!o.isMesh) return;
const pos = o.geometry.attributes.position;
if (!pos) return;
const step = Math.max(1, Math.floor(pos.count / 4000)); // ~4k samples is plenty
for (let i = 0; i < pos.count; i += step) pts.push(v.fromBufferAttribute(pos, i).applyMatrix4(o.matrixWorld).clone());
});
if (pts.length < 100) return;
let minY = Infinity, maxY = -Infinity, cx = 0, cz = 0;
for (const p of pts) { minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y); cx += p.x; cz += p.z; }
cx /= pts.length; cz /= pts.length;
const H = Math.max(1e-3, maxY - minY);
const at = (p) => (p.y - minY) / H;
const feetA = [], feetB = [];
for (const p of pts) if (at(p) < 0.08) (p.x < cx ? feetA : feetB).push(p);
const centroid = (arr) => {
if (arr.length < 5) return null;
const c = new THREE.Vector3();
for (const p of arr) c.add(p);
return c.multiplyScalar(1 / arr.length);
};
const handA = { d: 0, p: null }, handB = { d: 0, p: null };
for (const p of pts) {
const a = at(p);
if (a < 0.25 || a > 0.78) continue;
const d = Math.hypot(p.x - cx, p.z - cz);
const side = p.x < cx ? handA : handB;
if (d > side.d) { side.d = d; side.p = p; }
}
const assignPair = (tipL, tipR, tA, tB) => {
const jL = fig.joints[tipL], jR = fig.joints[tipR];
if (!jL || !jR) return;
let a = tA, b = tB;
if (a && b) {
const lw = jL.getWorldPosition(new THREE.Vector3());
if (lw.distanceToSquared(a) > lw.distanceToSquared(b)) { a = tB; b = tA; }
}
for (const [tip, t] of [[tipL, a], [tipR, b]]) {
if (!t) continue;
const limb = limbFor(fig, tip);
if (limb) solveIkTo(limb, t);
}
};
assignPair("ankleL", "ankleR", centroid(feetA), centroid(feetB));
assignPair("wristL", "wristR", handA.p, handB.p);
fig.root.updateMatrixWorld(true);
};
// Fit-skeleton flow: spawn the overlay figure inside the prop, hand the user
// the normal posing tools, and a bottom bar carries ✔ bind / ✖ cancel.
let skinFit = null; // { prop, fig } while aligning
const bindBar = document.createElement("div");
bindBar.style.cssText =
"position:absolute;bottom:8px;left:50%;transform:translateX(-50%);z-index:4;display:none;" +
"gap:6px;align-items:center;padding:4px 10px;background:#1e3a2c;color:#cfe;border:1px solid #2e5d44;" +
"border-radius:5px;font:11px/1.4 system-ui,sans-serif;white-space:nowrap;";
bindBar.addEventListener("pointerdown", (e) => e.stopPropagation());
const bindBarText = document.createElement("span");
bindBarText.textContent = "🧍 skeleton auto-fitted — if the ghost's hands/feet missed, drag its ◆ diamonds onto them · then ✔";
bindBar.appendChild(bindBarText);
const cancelSkinFit = () => {
if (!skinFit) return;
const { fig } = skinFit;
skinFit = null;
bindBar.style.display = "none";
removeFigure(fig, { capture: false });
requestRender();
};
const performSkinBind = () => {
if (!skinFit) return;
const { prop, fig } = skinFit;
skinFit = null;
bindBar.style.display = "none";
if (!figures.includes(fig)) return; // skeleton deleted mid-fit — nothing to bind to
if (!propsGroup.children.includes(prop)) { removeFigure(fig, { capture: false }); return; } // prop deleted mid-fit
fig.root.updateMatrixWorld(true);
prop.updateMatrixWorld(true);
const meshLocal = new THREE.Matrix4().copy(fig.root.matrixWorld).invert().multiply(prop.matrixWorld);
const bindPose = {};
for (const b of fig.skel.bones) bindPose[b.name] = { p: b.position.toArray(), q: b.quaternion.toArray() };
fig.skinBind = {
file: prop.userData.file,
...(prop.userData.part != null ? { part: prop.userData.part } : {}),
meshLocal: meshLocal.toArray(),
bindPose,
};
// The figure inherits the prop's region name — the $block keeps binding by
// name, its mask row just moves from the prop list to the figure list.
if (prop.userData.customName && !fig.customName) fig.customName = prop.userData.customName;
if (selectedProp === prop) { transformControls.detach(); selectedProp = null; updatePropUI(); }
removeAttachmentsFor(prop);
propsGroup.remove(prop);
disposeProp(prop);
updatePropLabels();
updateFigureLabels();
syncSkinBinds().then(() => {
if (!node._pcrAlive) return;
requestRender();
captureAndUpload(node); // pre-bind history snapshot still lists the prop → ↶ undoes the whole bind
});
};
bindBar.appendChild(mkBtn("✔", "Bind the model to this skeleton — it becomes a posable figure", performSkinBind));
bindBar.appendChild(mkBtn("✖", "Cancel — remove the skeleton, keep the model as a prop", cancelSkinFit));
container.appendChild(bindBar);
const startSkinFit = async (prop) => {
if (skinFit) cancelSkinFit();
const fig = await spawnFigure({ select: true, capture: false });
if (!node._pcrAlive || !fig) return;
if (!fig.skel) {
removeFigure(fig, { capture: false });
showClipToast("skeleton binding needs the GLB figure (capsule fallback is active)", true);
return;
}
// Stand the skeleton inside the prop: scaled to its height, centred on its
// footprint, in the model's CURRENT stance (no T-pose needed — we pose
// FROM wherever it was sculpted).
prop.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(prop);
const c = box.getCenter(new THREE.Vector3());
const h = Math.max(0.2, box.max.y - box.min.y);
fig.root.scale.multiplyScalar(h / 1.8);
fig.root.position.set(c.x, box.min.y, c.z);
fig.root.updateMatrixWorld(true);
fig.rootRest.copy(fig.root.position); // reset must not yank it back to the spawn slot
// GHOST body, not invisible: floating dots alone read as flat overlays —
// the first camera move shattered the illusion ("wtf"). A translucent gray
// figure makes "stand this INSIDE the model" self-evident, parallax included.
for (const m of fig.meshes) {
if (m.material) { m.material.transparent = true; m.material.opacity = 0.35; m.material.depthWrite = false; }
}
// Auto-align: hands/feet IK'd onto the mesh's own extremities — the manual
// step is "check it and nudge", not "rig it".
try { autoAlignSkeleton(fig, prop); } catch (e) { console.warn("[PoseStudio] auto-align skipped:", e); }
if (selectedProp === prop) { transformControls.detach(); selectedProp = null; updatePropUI(); }
skinFit = { prop, fig };
bindBar.style.display = "flex";
requestRender();
};
// (Prop selection highlight = the screen-space outline pass; selectedProp alone drives it.)
// Name labels for NAMED props — same sprite as figure labels, floated above the
// prop. A label marks the prop as a region entity (own mask + $name{} block).
// Runs on rename/restore/paste/undo, not per-frame; capture hides it via
// setLabelsVisible so it never reaches the control map or the prop's own mask.
const _plBox = new THREE.Box3(), _plV = new THREE.Vector3();
const updatePropLabels = () => {
for (const m of propsGroup.children) {
const name = m.userData.customName;
if (!name) {
if (m._pcrLabel) { m.remove(m._pcrLabel); m._pcrLabel.material.map?.dispose(); m._pcrLabel.material.dispose(); m._pcrLabel = null; m._pcrLabelText = null; }
continue;
}
if (!m._pcrLabel || m._pcrLabelText !== name) {
if (m._pcrLabel) { m.remove(m._pcrLabel); m._pcrLabel.material.map?.dispose(); m._pcrLabel.material.dispose(); }
m._pcrLabel = makeLabelSprite(name); m._pcrLabelText = name;
}
// Measure WITHOUT the label (it's a child — including it would feed back into
// the box and walk the label upward on every pass), then place it just above
// the prop's top, converted to prop-local space so it tracks moves for free.
if (m._pcrLabel.parent === m) m.remove(m._pcrLabel);
m.updateMatrixWorld(true);
_plBox.setFromObject(m);
m.add(m._pcrLabel);
if (!_plBox.isEmpty()) {
_plV.set((_plBox.min.x + _plBox.max.x) / 2, _plBox.max.y + 0.12, (_plBox.min.z + _plBox.max.z) / 2);
m.worldToLocal(_plV);
m._pcrLabel.position.copy(_plV);
}
// Counter-scale so the gizmo-scaled prop doesn't stretch the label.
const ws = m.getWorldScale(new THREE.Vector3());
m._pcrLabel.scale.set(0.7 / (ws.x || 1), 0.175 / (ws.y || 1), 1);
}
};
const makeProp = (type) => {
const def = PROP_DEFS[type];
if (!def) return null;
const m = new THREE.Mesh(def.geo(), propMat);
m.userData = { isProp: true, propType: type, uid: propUidSeq++ };
return m;
};
// Build a segmented articulated chain prop (see CHAIN_DEFS). A tapered tube
// SkinnedMesh over a serial +Y bone chain, wrapped in an identity root so the
// prop's pos/quat/scale stay independent of the geometry (like normalizePropModel).
// _bones/_boneRest light up the existing 🦴 handles + rest-diff serialization;
// _chain feeds the tip-IK solver.
const makeChainProp = (type) => {
const def = CHAIN_DEFS[type];
if (!def) return null;
const { segs, len, r0, r1 } = def;
const segLen = len / segs;
// Lathe a CAPSULE profile (hemisphere → cylinder → hemisphere) instead of a flat-capped
// cylinder, so both ends are ROUNDED. The profile is the silhouette revolved around +Y:
// base hemisphere bulges below y=0, the body runs 0→len, the tip hemisphere caps past len.
// Skin weights are still assigned by height below, so the caps deform with the end bones.
const RADIAL = 14, BODY_DIV = segs * 4, CAP_DIV = 6;
const prof = [];
for (let i = 0; i <= CAP_DIV; i++) { const a = -Math.PI / 2 + (Math.PI / 2) * (i / CAP_DIV); prof.push(new THREE.Vector2(Math.max(1e-4, r0 * Math.cos(a)), r0 * Math.sin(a))); } // base cap: pole → rim
for (let i = 1; i <= BODY_DIV; i++) { const f = i / BODY_DIV; prof.push(new THREE.Vector2(r0 + (r1 - r0) * f, len * f)); } // cylinder body, radius tapers r0→r1
for (let i = 1; i <= CAP_DIV; i++) { const a = (Math.PI / 2) * (i / CAP_DIV); prof.push(new THREE.Vector2(Math.max(1e-4, r1 * Math.cos(a)), len + r1 * Math.sin(a))); } // tip cap: rim → pole
const geo = new THREE.LatheGeometry(prof, RADIAL);
geo.computeVertexNormals();
// Skin each vertex to the two bones bracketing its height, so the tube deforms
// continuously across every joint instead of kinking at segment boundaries.
const pos = geo.attributes.position;
const sIdx = new Uint16Array(pos.count * 4);
const sWgt = new Float32Array(pos.count * 4);
for (let i = 0; i < pos.count; i++) {
const t = Math.min(segs - 1e-4, Math.max(0, pos.getY(i) / segLen));
const k = Math.floor(t), frac = t - k;
sIdx[i * 4] = Math.min(segs - 1, k);
sIdx[i * 4 + 1] = Math.min(segs - 1, k + 1);
sWgt[i * 4] = 1 - frac;
sWgt[i * 4 + 1] = frac;
}
geo.setAttribute("skinIndex", new THREE.BufferAttribute(sIdx, 4));
geo.setAttribute("skinWeight", new THREE.BufferAttribute(sWgt, 4));
// Serial chain: bone k at height k*segLen, parented to k-1 so rotating one
// carries everything above it (FK), exactly like a limb. Names dodge the 🦴
// helper-bone cull (_end/twist/share).
const bones = [];
for (let k = 0; k < segs; k++) {
const b = new THREE.Bone();
b.name = "seg" + k;
b.position.set(0, k === 0 ? 0 : segLen, 0);
if (k > 0) bones[k - 1].add(b);
bones.push(b);
}
const mesh = new THREE.SkinnedMesh(geo, propMat);
mesh.frustumCulled = false; // posed bounds diverge from the bind pose → never cull
mesh.add(bones[0]);
mesh.updateMatrixWorld(true); // bones need rest world matrices before the skeleton inverts them
const skeleton = new THREE.Skeleton(bones);
mesh.bind(skeleton, new THREE.Matrix4()); // identity bind in mesh-local space (straight rest pose)
const root = new THREE.Group();
root.add(mesh);
root.userData = {
isProp: true, propType: type, isModel: true, uid: propUidSeq++,
_bones: bones,
_boneRest: new Map(bones.map((b) => [b, { q: b.quaternion.clone(), p: b.position.clone() }])),
_chainSkeleton: skeleton,
_chain: { segs, segLen }, // tip-IK + tip-handle placement read this
};
return root;
};
// FABRIK IK for a chain, drivable from ANY joint. `driveCount` = how many bones
// from the base move so the grabbed point reaches the target; the effector is
// joint[driveCount] (an intermediate joint) or, when driveCount === bone count,
// the tip endpoint past the last bone. Bones past driveCount keep their local
// rotations → the rest of the chain hangs naturally off the grabbed joint. Root
// pinned at the base; lengths measured live (scale-safe). Only quaternions change
// → serializes as rest-diffs.
const _chainUp = new THREE.Vector3(0, 1, 0);
const _qIdent = new THREE.Quaternion(); // identity, for damping slerp
const solveChainTo = (prop, targetW, driveCount, anchorIndex = 0) => {
const ud = prop.userData;
const bones = ud._bones, n = bones?.length || 0;
const dc = Math.min(driveCount, n);
const m = dc - anchorIndex; // bones that move: [anchorIndex .. dc-1]
if (m < 1) return;
bones[0].updateMatrixWorld(true);
// pts = world joints [anchorIndex .. dc], the anchor end fixed, the dc end the effector.
const pts = [];
for (let i = anchorIndex; i < dc; i++) pts.push(bones[i].getWorldPosition(new THREE.Vector3()));
pts.push(dc < n ? bones[dc].getWorldPosition(new THREE.Vector3()) // intermediate joint = origin of the next bone
: bones[n - 1].localToWorld(new THREE.Vector3(0, ud._chain.segLen, 0))); // tip endpoint
const lens = [];
for (let i = 0; i < m; i++) lens.push(Math.max(1e-6, pts[i].distanceTo(pts[i + 1])));
const base = pts[0].clone(), dir = new THREE.Vector3(); // base = the anchor joint, held fixed
for (let iter = 0; iter < 12; iter++) {
pts[m].copy(targetW); // backward reach: pull the effector to target, walk down keeping lengths
for (let i = m - 1; i >= 0; i--) {
dir.subVectors(pts[i], pts[i + 1]);
pts[i].copy(pts[i + 1]).addScaledVector(dir, lens[i] / (dir.length() || 1e-6));
}
pts[0].copy(base); // forward reach: re-pin the anchor, walk up
for (let i = 1; i <= m; i++) {
dir.subVectors(pts[i], pts[i - 1]);
pts[i].copy(pts[i - 1]).addScaledVector(dir, lens[i - 1] / (dir.length() || 1e-6));
}
if (pts[m].distanceToSquared(targetW) < 1e-8) break;
}
// Positions → rotations, top-down so each parent is already oriented. Rotate each
// bone INCREMENTALLY from its CURRENT world forward to the solved segment, not
// absolutely from +Y: an absolute setFromUnitVectors(+Y, dir) is singular when the
// segment points near -Y (chain hanging straight down from a pinned top), so tiny
// cursor moves there flipped the rotation axis and whipped the tube. Frame-to-frame
// the delta stays tiny, so it stays stable through the fold.
const parentQ = new THREE.Quaternion(), curQ = new THREE.Quaternion(), delta = new THREE.Quaternion();
const curFwd = new THREE.Vector3(), worldDir = new THREE.Vector3();
const MAX_STEP = 0.05; // rad/solve per bone — TIGHT angular cap: near a folded config the IK gain is huge (tiny tip move ⇒ big unfold), so this bounds the per-frame chain swing and the fold spreads over ~10 frames as smooth motion instead of whipping. Normal (non-singular) drags rarely hit it.
for (let i = 0; i < m; i++) {
const b = bones[anchorIndex + i];
if ((pts[i + 1].distanceToSquared(pts[i])) < 1e-12) continue; // degenerate segment → leave bone as-is (no NaN)
worldDir.subVectors(pts[i + 1], pts[i]).normalize();
b.getWorldQuaternion(curQ);
curFwd.copy(_chainUp).applyQuaternion(curQ).normalize(); // bone's current +Y in world
delta.setFromUnitVectors(curFwd, worldDir);
const ang = 2 * Math.acos(Math.min(1, Math.abs(delta.w))); // angle of this step
if (ang > MAX_STEP) delta.slerp(_qIdent, 1 - MAX_STEP / ang); // damp big swings → smooth, never spasm
curQ.premultiply(delta); // desired (damped) world quaternion
if (b.parent) { b.parent.getWorldQuaternion(parentQ); b.quaternion.copy(parentQ.invert().multiply(curQ)); }
else b.quaternion.copy(curQ);
b.updateMatrixWorld(true);
}
requestRender();
};
const solveChainIk = (prop, targetW) => solveChainTo(prop, targetW, prop.userData._bones?.length || 0); // full chain → tip (used by _dbgChainIk)
// ── chain DRAG = position-based rope physics (Verlet) ────────────────────────
// Analytical IK (FABRIK) snaps to a fully-solved pose every frame, so near a folded
// config its gain explodes and the chain whips ("all the IKs fighting"). A rope sim
// is ONE unified solver that can't whip: joints are point masses, segments are
// distance constraints, the pinned joint and the grabbed joint are just fixed points
// the relaxation respects. It eases (inertia) and holds its shape — exactly "grab the
// loose end and throw slack on it". pinTarget is read live so the pin follows the
// mannequin. Resulting positions convert to bone rotations + a prop translation, so
// the SAME serialization (bone diffs) round-trips the pose.
const _ropeTmp = new THREE.Vector3();
const chainPinTarget = (prop, out) => {
const a = attachments.find((x) => x.follower.kind === "prop" && x.follower.mesh === prop && x.follower.jointIndex != null);
if (a) { const lW = leaderRigidMatrix(a.leader); if (lW) return _atM.multiplyMatrices(lW, a.offset).decompose(out, _atQ, _atS) && out; }
return out.copy(chainDrag.pinWorld); // no combine → hold the anchor end exactly where it was when the drag began
};
const initChainRope = (prop, anchorIdx) => {
const n = prop.userData._bones.length;
const rope = [], prev = [], lens = [];
for (let j = 0; j <= n; j++) { const p = chainJointWorld(prop, j, new THREE.Vector3()); rope.push(p); prev.push(p.clone()); }
for (let j = 0; j < n; j++) lens.push(rope[j].distanceTo(rope[j + 1]));
chainDrag.rope = rope; chainDrag.ropePrev = prev; chainDrag.ropeLens = lens; chainDrag.pinIdx = anchorIdx;
chainDrag.grabEase.copy(rope[chainDrag.driveCount] || rope[n]); // eased grab target starts at the grabbed joint
chainDrag.pinWorld.copy(rope[anchorIdx]); // freeze the anchor end's world position for the whole drag
};
const _ropeGrab = new THREE.Vector3(), _ropeSpan = new THREE.Vector3();
const MAX_FOLD = 0.95; // rad (~54°) max bend at any single joint — generous enough for tight corkscrews, tight enough to stop the inside-out kink / tangent-flip singularity
const COS_HALF_FOLD = Math.cos(MAX_FOLD / 2); // min next-nearest distance = (len[i]+len[i+1]) * this
const chainRopeStep = () => {
// Liveness guard FIRST: a node torn down mid chain-drag (Ctrl+Z rebuild,
// workflow switch) never fires pointerup, so chainDrag.active stays true and
// the prop stays in propsGroup — without this the rAF loop re-arms forever on
// disposed three.js objects. No cancelAnimationFrame exists, so the loop must
// self-terminate on the teardown flag.
if (!node._pcrAlive || !chainDrag.active || !chainDrag.prop || !propsGroup.children.includes(chainDrag.prop)) { chainDrag.settling = false; return; }
const prop = chainDrag.prop, rope = chainDrag.rope, lens = chainDrag.ropeLens;
const n = rope.length - 1, pin = chainDrag.pinIdx, grab = chainDrag.driveCount;
const pinTarget = chainPinTarget(prop, _ropeTmp).clone();
// Clamp the cursor target to what the chain can physically reach from the pin. Asking
// for a point past full extension IS the spasm: the grab drags the end out, the length
// constraints yank it back, and the two oscillate — worse the longer the span (exactly
// "the farther to the end, the more it spazzes"). A reachable target can be satisfied
// with BOTH ends held, so the relaxation settles into an arc instead of fighting itself.
let reach = 0; for (let i = Math.min(pin, grab); i < Math.max(pin, grab); i++) reach += lens[i];
_ropeGrab.copy(chainDrag.desired);
_ropeSpan.copy(_ropeGrab).sub(pinTarget);
const spanLen = _ropeSpan.length();
if (spanLen > reach * 0.999) _ropeGrab.copy(pinTarget).addScaledVector(_ropeSpan, (reach * 0.999) / (spanLen || 1e-6));
chainDrag.grabEase.lerp(_ropeGrab, 0.5); // ease the HELD point toward the (reachable) cursor — smooth follow, no fling
// QUASI-STATIC relaxation — deliberately NO momentum/Verlet. With BOTH ends held there
// are two mirror-image bend solutions; a dynamic rope carries velocity, so a slack chain
// flops and POPS to the mirror pose on a tiny input — the 1-pixel flip you saw, and only
// when pinned (one free end has no mirror to flip to). Relaxing from the CURRENT shape
// each frame keeps it in the SAME bend basin, so it can't teleport; no velocity = no flop.
// Two constraint families, no bending ENERGY term (an energy minimiser buckles a slack
// chain into a coil and creeps there over time — the old sinusoidal corkscrew). (1) length
// constraints keep segments rigid. (2) a ONE-SIDED fold limit: a min-distance between
// next-nearest joints (i, i+2) so no single joint can fold past ~MAX_FOLD. It's one-sided
// (only pushes apart when over-folded, never pulls toward straight), so it does NOT prevent
// a deliberate corkscrew — smooth coils stay well under the cap — it only stops the sharp
// 180° kink that (a) pinches the skinned tube inside-out and (b) makes consecutive tangents
// antiparallel, which is the singularity that spun a short chain into a spasm.
const iters = Math.max(24, n * 4); // enough passes to propagate BOTH end constraints along the full span
for (let it = 0; it < iters; it++) { // Jakobsen relaxation; pin AND grabbed joint are both immovable
rope[pin].copy(pinTarget); rope[grab].copy(chainDrag.grabEase);
for (let i = 0; i < n; i++) { // length constraints
const a = rope[i], b = rope[i + 1], L = lens[i];
const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z, d = Math.hypot(dx, dy, dz) || 1e-6;
const fa = (i === pin || i === grab) ? 0 : 1, fb = (i + 1 === pin || i + 1 === grab) ? 0 : 1, tw = fa + fb || 1;
const k = (d - L) / d;
a.x += dx * k * (fa / tw); a.y += dy * k * (fa / tw); a.z += dz * k * (fa / tw);
b.x -= dx * k * (fb / tw); b.y -= dy * k * (fb / tw); b.z -= dz * k * (fb / tw);
}
for (let i = 0; i < n - 1; i++) { // one-sided fold limit (joint i+1): keep i and i+2 from collapsing together
const minD = (lens[i] + lens[i + 1]) * COS_HALF_FOLD;
const a = rope[i], c = rope[i + 2];
const dx = c.x - a.x, dy = c.y - a.y, dz = c.z - a.z, d = Math.hypot(dx, dy, dz) || 1e-6;
if (d >= minD) continue; // not over-folded → leave it (one-sided)
const k = (d - minD) / d; // < 0 → push apart to open the joint
const fa = (i === pin || i === grab) ? 0 : 1, fc = (i + 2 === pin || i + 2 === grab) ? 0 : 1, tw = fa + fc || 1;
a.x += dx * k * (fa / tw); a.y += dy * k * (fa / tw); a.z += dz * k * (fa / tw);
c.x -= dx * k * (fc / tw); c.y -= dy * k * (fc / tw); c.z -= dz * k * (fc / tw);
}
}
rope[pin].copy(pinTarget); rope[grab].copy(chainDrag.grabEase);
applyRopeToChain(prop, rope); // positions → bone rotations (enforceAttachments owns the prop position)
requestAnimationFrame(chainRopeStep);
};
// Reproduce the rope's world joint positions on the bone hierarchy: place the prop so
// joint 0 sits at rope[0], then aim each bone along its rope segment. Pin satisfied for
// free (rope already has the pinned joint at its target).
const _rcA = new THREE.Quaternion(), _rcP = new THREE.Quaternion(), _rcRun = new THREE.Quaternion(), _rcF = new THREE.Vector3(), _rcD = new THREE.Vector3(), _rcT = new THREE.Vector3(), _rcJ = new THREE.Vector3(), _rcShift = new THREE.Vector3();
const applyRopeToChain = (prop, rope) => {
const bones = prop.userData._bones, n = bones.length;
// SINGLE WRITER of the chain's transform during a drag: orient every bone from the rope,
// THEN translate the whole prop so the anchored joint lands exactly on rope[anchor]. The
// anchor's rope position IS the pin target, so enforceAttachments computes a zero
// correction and the two can't fight (that fight was the spasm).
//
// Orientation uses a ROTATION-MINIMIZING FRAME (parallel transport): bone 0 aligns to its
// tangent keeping its current roll, then each next bone is carried forward by ONLY the
// minimal rotation between consecutive rope tangents. Roll is a property of the curve alone
// and never accumulates, so the tube can curl into a corkscrew/toroid without its
// cross-section winding up. This is well-conditioned because the rope's one-sided fold
// limit guarantees consecutive tangents are never near-antiparallel (that 180° reversal is
// the setFromUnitVectors singularity that spun a short chain into a spasm).
prop.updateWorldMatrix(true, true);
let started = false;
for (let i = 0; i < n; i++) {
const b = bones[i];
_rcD.subVectors(rope[i + 1], rope[i]);
if (_rcD.lengthSq() < 1e-12) { if (!started) continue; _rcD.copy(_rcT); } // degenerate segment → keep previous tangent
else _rcD.normalize();
if (!started) {
b.getWorldQuaternion(_rcRun);
_rcF.copy(_chainUp).applyQuaternion(_rcRun).normalize();
_rcRun.premultiply(_rcP.setFromUnitVectors(_rcF, _rcD)); // align bone0 +Y to its tangent, preserve its roll
started = true;
} else {
_rcRun.premultiply(_rcP.setFromUnitVectors(_rcT, _rcD)); // parallel transport: minimal rotation between tangents → no twist
}
_rcT.copy(_rcD); // this rope tangent → base for the next transport
if (b.parent) { b.parent.getWorldQuaternion(_rcA); b.quaternion.copy(_rcA.invert().multiply(_rcRun)); }
else b.quaternion.copy(_rcRun);
b.updateMatrixWorld(true);
}
chainJointWorld(prop, chainDrag.pinIdx, _rcJ); // where the anchored joint ended up after rotating
prop.position.add(_rcShift.copy(rope[chainDrag.pinIdx]).sub(_rcJ)); // shift the whole prop so it sits exactly on the anchor target
prop.updateMatrixWorld(true);
requestRender();
};
// ── freehand "draw a flat shape" prop ───────────────────────────────────────
// Sketch a 2D outline; it becomes a flat double-sided polygon you stand up in the
// scene (e.g. robe panels behind a figure). It's just another prop — select/move/
// rotate/scale/save/export all work. The outline points round-trip via pose_state.
const drawMat = new THREE.MeshStandardMaterial({ color: 0x9aa0a8, roughness: 0.9, metalness: 0.0, side: THREE.DoubleSide });
let drawingShape = false;
// A drawn outline (2D points, centred + scaled, world units, Y-up) becomes a prop in one
// of four shapes, all rebuilt from the SAME points so the mode is just a live toggle:
// flat — the original double-sided polygon (a panel).
// extrude — pushed back to a depth, with an optional bevel that rounds the edges.
// revolve — the right silhouette spun around the vertical centre axis (triangle→cone).
// inflate — the filled outline ballooned out in ±Z (circle→sphere) — the "puff" tool.
const DRAWN_DEFAULTS = { mode: "flat", amount: 0.25, round: 0.5 };
const drawnShapeFrom = (points, holes) => {
const shape = new THREE.Shape();
shape.moveTo(points[0][0], points[0][1]);
for (let i = 1; i < points.length; i++) shape.lineTo(points[i][0], points[i][1]);
shape.closePath();
for (const h of holes || []) { // enclosed regions (e.g. the Q inside a spade) → real holes
if (!h || h.length < 3) continue;
const path = new THREE.Path();
path.moveTo(h[0][0], h[0][1]);
for (let i = 1; i < h.length; i++) path.lineTo(h[i][0], h[i][1]);
path.closePath();
shape.holes.push(path);
}
return shape;
};
const drawnBBox = (points) => {
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const p of points) { if (p[0] < minX) minX = p[0]; if (p[0] > maxX) maxX = p[0]; if (p[1] < minY) minY = p[1]; if (p[1] > maxY) maxY = p[1]; }
return { minX, maxX, minY, maxY, w: maxX - minX, h: maxY - minY };
};
const _distPtSeg = (px, py, ax, ay, bx, by) => {
const dx = bx - ax, dy = by - ay, l2 = dx * dx + dy * dy;
let t = l2 ? ((px - ax) * dx + (py - ay) * dy) / l2 : 0; t = Math.max(0, Math.min(1, t));
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
};
// Triangulate a filled outline (+ optional holes) and midpoint-subdivide `levels` times so
// the interior carries enough vertices to sculpt / displace smoothly — ear-clipping alone
// produces boundary-only triangles with nothing to grab inside. Holes follow the contour in
// index space (matching THREE.ShapeUtils.triangulateShape). Shared by flat + inflate.
const triangulateDrawn = (points, holes, levels) => {
const contour = points.map((p) => new THREE.Vector2(p[0], p[1]));
const holeVecs = (holes || []).map((h) => h.map((p) => new THREE.Vector2(p[0], p[1])));
const verts = points.map((p) => [p[0], p[1]]);
for (const h of holes || []) for (const p of h) verts.push([p[0], p[1]]);
let tris = THREE.ShapeUtils.triangulateShape(contour, holeVecs).map((f) => [f[0], f[1], f[2]]);
if (!tris.length) throw new Error("triangulation failed");
for (let lvl = 0; lvl < levels; lvl++) {
const mid = new Map(), getMid = (a, b) => {
const key = a < b ? a + "_" + b : b + "_" + a; let m = mid.get(key);
if (m == null) { m = verts.length; verts.push([(verts[a][0] + verts[b][0]) / 2, (verts[a][1] + verts[b][1]) / 2]); mid.set(key, m); }
return m;
};
const nt = [];
for (const [a, b, c] of tris) { const ab = getMid(a, b), bc = getMid(b, c), ca = getMid(c, a); nt.push([a, ab, ca], [ab, b, bc], [ca, bc, c], [ab, bc, ca]); }
tris = nt;
}
return { verts, tris };
};
// Split every triangle into four by edge midpoints (×4 polygons), so a coarse mesh gains
// enough vertices to sculpt. Original vertices keep their indices 0..n-1 (a sculpt delta
// keyed by index stays valid); midpoints are appended, each with its [a,b] parents so a
// caller can interpolate per-vertex data. Welds shared edges when the input is indexed;
// coincident midpoints on a non-indexed mesh are merged at sculpt time by sculptGroups.
// Deterministic given the same input — so a stored subdiv count rebuilds identical indices.
const subdivideGeometry = (geo) => {
const srcPos = geo.attributes.position, idx = geo.index ? geo.index.array : null;
const verts = [], parents = [];
for (let i = 0; i < srcPos.count; i++) { verts.push([srcPos.getX(i), srcPos.getY(i), srcPos.getZ(i)]); parents.push(null); }
const tris = [];
if (idx) for (let i = 0; i < idx.length; i += 3) tris.push([idx[i], idx[i + 1], idx[i + 2]]);
else for (let i = 0; i + 2 < srcPos.count; i += 3) tris.push([i, i + 1, i + 2]);
const mid = new Map(), getMid = (a, b) => {
const key = a < b ? a + "_" + b : b + "_" + a; let m = mid.get(key);
if (m == null) { m = verts.length; verts.push([(verts[a][0] + verts[b][0]) / 2, (verts[a][1] + verts[b][1]) / 2, (verts[a][2] + verts[b][2]) / 2]); parents.push([a, b]); mid.set(key, m); }
return m;
};
const out = [];
for (const [a, b, c] of tris) { const ab = getMid(a, b), bc = getMid(b, c), ca = getMid(c, a); out.push(a, ab, ca, ab, b, bc, ca, bc, c, ab, bc, ca); }
const pos = new Float32Array(verts.length * 3);
for (let i = 0; i < verts.length; i++) { pos[i * 3] = verts[i][0]; pos[i * 3 + 1] = verts[i][1]; pos[i * 3 + 2] = verts[i][2]; }
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.BufferAttribute(pos, 3));
g.setIndex(out); g.computeVertexNormals();
return { geometry: g, parents };
};
const DRAWN_FLAT_SUBDIV = 2; // dense enough to sculpt out of the box; the 🗿 mesh editor's subdivide adds more
const buildDrawnFlat = (points, holes) => {
// A flat panel, but tessellated rather than a boundary-only ShapeGeometry, so the mesh
// editor has interior vertices to shape (e.g. an underwear panel you then warp to fit).
const { verts, tris } = triangulateDrawn(points, holes, DRAWN_FLAT_SUBDIV);
const pos = new Float32Array(verts.length * 3);
for (let i = 0; i < verts.length; i++) { pos[i * 3] = verts[i][0]; pos[i * 3 + 1] = verts[i][1]; }
const idx = []; for (const [a, b, c] of tris) idx.push(a, b, c);
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.BufferAttribute(pos, 3));
g.setIndex(idx); g.computeVertexNormals();
return g;
};
const buildDrawnExtrude = (points, amount, round, holes) => {
const bb = drawnBBox(points);
const depth = Math.max(0.01, amount);
const bevel = round > 0.01;
const bt = bevel ? Math.min(depth * 0.49, round * Math.min(bb.w, bb.h) * 0.18) : 0; // keep small or a concave shape self-intersects
const g = new THREE.ExtrudeGeometry(drawnShapeFrom(points, holes), {
depth, bevelEnabled: bevel, bevelThickness: bt, bevelSize: bt, bevelSegments: bevel ? 3 : 0, steps: 1, curveSegments: 12,
});
g.translate(0, 0, -depth / 2); // centre the slab on the draw plane
g.computeVertexNormals();
// ExtrudeGeometry's caps are boundary-only ear-clip triangles; subdivide so the broad
// front/back faces have interior vertices the mesh editor can shape.
const dense = subdivideGeometry(g).geometry;
g.dispose();
return dense;
};
const buildDrawnRevolve = (points) => {
// Right silhouette = max |x| at each height, revolved around x=0. Capped at both poles
// (r→0) so it's a closed solid. Triangle→cone, pencil profile→cylinder + cone tip.
const bb = drawnBBox(points), N = 48, prof = [new THREE.Vector2(1e-4, bb.minY)];
for (let i = 0; i <= N; i++) {
const y = bb.minY + bb.h * (i / N); let maxX = 0;
for (let j = 0; j < points.length; j++) {
const a = points[j], b = points[(j + 1) % points.length], y0 = a[1], y1 = b[1];
if ((y0 <= y && y1 >= y) || (y1 <= y && y0 >= y)) {
const x = Math.abs(y1 - y0) < 1e-9 ? Math.max(Math.abs(a[0]), Math.abs(b[0])) : a[0] + (b[0] - a[0]) * ((y - y0) / (y1 - y0));
maxX = Math.max(maxX, Math.abs(x));
}
}
prof.push(new THREE.Vector2(Math.max(1e-4, maxX), y));
}
prof.push(new THREE.Vector2(1e-4, bb.maxY));
const g = new THREE.LatheGeometry(prof, 36);
g.computeVertexNormals();
return g;
};
const buildDrawnInflate = (points, amount, round, holes = []) => {
// Triangulate the filled outline, densify by midpoint subdivision, then displace each
// vertex ±Z by a function of its distance from the outline → a rounded pillow/balloon.
const { verts, tris } = triangulateDrawn(points, holes, 3); // 3 levels keeps the dome smooth, not faceted
const edges = []; for (let i = 0; i < points.length; i++) edges.push([points[i], points[(i + 1) % points.length]]);
for (const h of holes || []) for (let i = 0; i < h.length; i++) edges.push([h[i], h[(i + 1) % h.length]]); // puff also tapers to 0 at hole rims
const dArr = new Float32Array(verts.length); let dMax = 1e-6;
for (let i = 0; i < verts.length; i++) {
let dmin = Infinity; for (const [a, b] of edges) { const d = _distPtSeg(verts[i][0], verts[i][1], a[0], a[1], b[0], b[1]); if (d < dmin) dmin = d; }
dArr[i] = dmin; if (dmin > dMax) dMax = dmin;
}
const height = (d) => { // round=0 → tent (linear), round=1 → dome (circular profile)
const t = Math.min(1, d / dMax), dome = Math.sqrt(Math.max(0, 1 - (1 - t) * (1 - t)));
return amount * (t + (dome - t) * round);
};
const V = verts.length, pos = new Float32Array(V * 2 * 3), idx = [];
for (let i = 0; i < V; i++) { const z = height(dArr[i]); pos[i * 3] = verts[i][0]; pos[i * 3 + 1] = verts[i][1]; pos[i * 3 + 2] = z; pos[(V + i) * 3] = verts[i][0]; pos[(V + i) * 3 + 1] = verts[i][1]; pos[(V + i) * 3 + 2] = -z; }
for (const [a, b, c] of tris) { idx.push(a, b, c); idx.push(V + a, V + c, V + b); } // front + reversed back shell
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.BufferAttribute(pos, 3));
g.setIndex(idx); g.computeVertexNormals();
return g;
};
const buildDrawnGeometry = (points, mode, amount, round, holes) => {
try {
if (mode === "extrude") return buildDrawnExtrude(points, amount, round, holes);
if (mode === "revolve") return buildDrawnRevolve(points); // holes don't apply to a revolve
if (mode === "inflate") return buildDrawnInflate(points, amount, round, holes);
return buildDrawnFlat(points, holes); // dense tessellated panel (sculpt-ready), not boundary-only
} catch (e) { console.warn("[PoseStudio] drawn geometry build failed → flat:", e); }
return new THREE.ShapeGeometry(drawnShapeFrom(points, holes)); // last-ditch fallback if triangulation throws
};
const makeDrawnProp = (points, opts = {}) => {
if (!Array.isArray(points) || points.length < 3) return null;
const mode = opts.mode || DRAWN_DEFAULTS.mode;
const amount = opts.amount != null ? opts.amount : DRAWN_DEFAULTS.amount;
const round = opts.round != null ? opts.round : DRAWN_DEFAULTS.round;
const holes = Array.isArray(opts.holes) ? opts.holes.filter((h) => Array.isArray(h) && h.length >= 3) : [];
const m = new THREE.Mesh(buildDrawnGeometry(points, mode, amount, round, holes), drawMat);
m.userData = { isProp: true, propType: "drawnShape", points, holes, drawMode: mode, drawAmount: amount, drawRound: round, uid: propUidSeq++ };
return m;
};
// Swap a drawn prop's geometry in place when its mode/amount/round changes (live panel).
// Re-applies any mesh-editor subdivisions + sculpt delta so a panel tweak doesn't wipe them.
const rebuildDrawnGeometry = (prop) => {
const ud = prop.userData;
let g = buildDrawnGeometry(ud.points, ud.drawMode, ud.drawAmount, ud.drawRound, ud.holes);
for (let i = 0; i < (ud.subdivLevel || 0); i++) { const ng = subdivideGeometry(g).geometry; g.dispose(); g = ng; }
prop.geometry.dispose();
prop.geometry = g;
delete ud._sculptBase; // base geometry changed → recapture the sculpt base on next apply
ud._sculptOwned = (ud.subdivLevel || 0) > 0; // a subdivided drawn geo is already a private copy
if (ud.sculptMap && ud.sculptMap.size) applySculpt(prop, ud.sculptMap);
requestRender();
};
// Ramer–Douglas–Peucker: thin a freehand stroke to its meaningful corners.
const simplifyRDP = (pts, eps) => {
if (pts.length < 3) return pts.slice();
let dmax = 0, idx = 0;
const [ax, ay] = pts[0], [bx, by] = pts[pts.length - 1];
const dx = bx - ax, dy = by - ay, len = Math.hypot(dx, dy) || 1;
for (let i = 1; i < pts.length - 1; i++) {
const d = Math.abs((pts[i][0] - ax) * dy - (pts[i][1] - ay) * dx) / len;
if (d > dmax) { dmax = d; idx = i; }
}
if (dmax > eps) return simplifyRDP(pts.slice(0, idx + 1), eps).slice(0, -1).concat(simplifyRDP(pts.slice(idx), eps));
return [pts[0], pts[pts.length - 1]];
};
// Draw overlay: a 2D canvas over the viewport, shown only while drawing a shape.
const drawCanvas = document.createElement("canvas");
drawCanvas.style.cssText = "position:absolute;inset:0;z-index:7;cursor:crosshair;display:none;touch-action:none;background:rgba(20,20,26,0.22);";
container.appendChild(drawCanvas);
const drawHint = document.createElement("div");
drawHint.textContent = "✏️ drag to draw a shape — release to create · Esc to cancel";
drawHint.style.cssText = "position:absolute;top:6px;left:50%;transform:translateX(-50%);z-index:8;display:none;padding:3px 10px;background:#2e5d44;color:#fff;border:1px solid #5fbf8c;border-radius:5px;font:11px/1.4 system-ui,sans-serif;pointer-events:none;white-space:nowrap;";
container.appendChild(drawHint);
let drawPts = [], drawingStroke = false, drawDefaultMode = "flat";
const enterDrawMode = (defaultMode = "flat") => {
drawingShape = true; drawDefaultMode = defaultMode;
detachGizmo();
controls.enabled = false; transformControls.enabled = false; // modal: no orbit/gizmo while drawing
drawCanvas.width = Math.max(1, container.clientWidth);
drawCanvas.height = Math.max(1, container.clientHeight);
drawCanvas.style.display = "block"; drawHint.style.display = "block";
drawHint.textContent = defaultMode === "flat"
? "✏️ drag to draw a shape — release to create · Esc to cancel"
: "🧊 drag to draw a 3D shape — release, then adjust it bottom-left · Esc to cancel";
drawPts = []; drawingStroke = false;
};
const exitDrawMode = () => {
drawingShape = false; drawingStroke = false; drawPts = [];
controls.enabled = true; transformControls.enabled = true;
drawCanvas.style.display = "none"; drawHint.style.display = "none";
};
const finishDraw = () => {
const raw = drawPts;
exitDrawMode();
const simp = simplifyRDP(raw, 3);
if (simp.length < 3) { requestRender(); return; }
// px outline → local shape: flip Y (screen-down → world-up), centre, scale so height ≈ 0.8m
let cx = 0, cy = 0, minY = 1e9, maxY = -1e9;
for (const p of simp) { cx += p[0]; cy += p[1]; minY = Math.min(minY, p[1]); maxY = Math.max(maxY, p[1]); }
cx /= simp.length; cy /= simp.length;
const s = 0.8 / Math.max(1, maxY - minY);
const pts = simp.map((p) => [ +((p[0] - cx) * s).toFixed(4), +(-(p[1] - cy) * s).toFixed(4) ]);
const m = makeDrawnProp(pts, { mode: drawDefaultMode });
if (!m) { requestRender(); return; }
m.position.set(0, 1.0, 0); // a vertical panel at torso height, facing the camera; user repositions
propsGroup.add(m);
selectProp(m);
requestRender();
captureAndUpload(node);
};
// ── Image → 3D shape (import a stencil) ──────────────────────────────────────
// Trace the DARK blobs of an imported image and turn EACH into a drawn-shape prop
// (default = inflate, a puffed solid like the freehand 3D tool), preserving their
// relative layout. A stencil (one logo, or many sparkles) becomes real solids you
// can extrude/inflate/position with the same bottom-left panel — standalone, not
// tied to clothing paint.
const traceImageBlobs = (img) => {
const maxDim = 480; // cap trace resolution for speed; outlines are simplified anyway
const scale = Math.min(1, maxDim / Math.max(img.width, img.height));
const W = Math.max(1, Math.round(img.width * scale)), H = Math.max(1, Math.round(img.height * scale));
const cnv = document.createElement("canvas"); cnv.width = W; cnv.height = H;
const ctx = cnv.getContext("2d", { willReadFrequently: true });
ctx.drawImage(img, 0, 0, W, H);
const data = ctx.getImageData(0, 0, W, H).data;
const g = new Uint8Array(W * H);
for (let i = 0; i < W * H; i++) { const a = data[i * 4 + 3], lum = (data[i * 4] + data[i * 4 + 1] + data[i * 4 + 2]) / 3; g[i] = (a > 40 && lum < 110) ? 1 : 0; } // dark = filled
// Mark the OUTSIDE background: light pixels reachable from the image border. Light
// pixels NOT reachable are ENCLOSED → holes (e.g. the white Q inside a black spade).
const outside = new Uint8Array(W * H), bfs = [];
const seedBg = (i) => { if (!g[i] && !outside[i]) { outside[i] = 1; bfs.push(i); } };
for (let x = 0; x < W; x++) { seedBg(x); seedBg((H - 1) * W + x); }
for (let y = 0; y < H; y++) { seedBg(y * W); seedBg(y * W + W - 1); }
const N4 = [[1, 0], [-1, 0], [0, 1], [0, -1]];
while (bfs.length) { const i = bfs.pop(), x = i % W, y = (i / W) | 0; for (const [ox, oy] of N4) { const nx = x + ox, ny = y + oy; if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue; seedBg(ny * W + nx); } }
const dark = (x, y) => (x < 0 || y < 0 || x >= W || y >= H) ? 0 : g[y * W + x];
const hole = (x, y) => (x < 0 || y < 0 || x >= W || y >= H) ? 0 : (!g[y * W + x] && !outside[y * W + x]) ? 1 : 0;
const dirIdx = (fx, fy, px, py) => { const dx = fx - px, dy = fy - py; for (let i = 0; i < 8; i++) if (_MN[i][0] === dx && _MN[i][1] === dy) return i; return 4; };
const moore = (fill, sx, sy) => { // Moore-neighbour boundary trace of a region
let p = [sx, sy], b = [sx - 1, sy]; const out = [[sx, sy]];
for (let guard = 0; guard < W * H * 8; guard++) {
const bd = dirIdx(b[0], b[1], p[0], p[1]); let moved = false;
for (let k = 1; k <= 8; k++) { const d = (bd + k) % 8, nx = p[0] + _MN[d][0], ny = p[1] + _MN[d][1]; if (fill(nx, ny)) { b = p; p = [nx, ny]; out.push(p); moved = true; break; } }
if (!moved) break; if (p[0] === sx && p[1] === sy) break;
}
return out;
};
const flood = (fill, sx, sy, vis) => { const s = [[sx, sy]]; vis[sy * W + sx] = 1; while (s.length) { const [x, y] = s.pop(); for (const [ox, oy] of _MN) { const nx = x + ox, ny = y + oy; if (fill(nx, ny) && !vis[ny * W + nx]) { vis[ny * W + nx] = 1; s.push([nx, ny]); } } } };
const dvis = new Uint8Array(W * H), blobs = [];
for (let sy = 0; sy < H; sy++) for (let sx = 0; sx < W; sx++) {
if (!dark(sx, sy) || dvis[sy * W + sx]) continue;
const outer = moore(dark, sx, sy); flood(dark, sx, sy, dvis);
if (outer.length >= 16) blobs.push({ outer, holes: [] });
}
const hvis = new Uint8Array(W * H);
for (let sy = 0; sy < H; sy++) for (let sx = 0; sx < W; sx++) {
if (!hole(sx, sy) || hvis[sy * W + sx]) continue;
const hc = moore(hole, sx, sy); flood(hole, sx, sy, hvis);
if (hc.length < 12) continue;
let cxh = 0, cyh = 0; for (const p of hc) { cxh += p[0]; cyh += p[1]; } cxh /= hc.length; cyh /= hc.length;
const host = blobs.find((bl) => pointInPoly(cxh, cyh, bl.outer)) || blobs[0]; // the blob enclosing this hole
if (host) host.holes.push(hc);
}
return { blobs, W, H };
};
// Uniformly decimate a CLOSED contour to ~target points. (RDP can't be used: it collapses
// a closed loop — start≈end → near-zero baseline — down to 2 points.)
const decimateClosed = (pts, target) => {
if (pts.length <= target) return pts;
const out = [], step = pts.length / target;
for (let i = 0; i < target; i++) out.push(pts[Math.floor(i * step)]);
return out;
};
const imageToShapes = (img) => {
const { blobs, W, H } = traceImageBlobs(img);
if (!blobs.length) { showClipToast("no dark shapes found in that image", true); return 0; }
const s = 0.8 / Math.max(W, H), cx = W / 2, cy = H / 2; // one scale → relative layout preserved
// px → local shape: centre on the image, flip Y (screen-down → world-up), shared scale
const toLocal = (pts) => decimateClosed(pts, 72).map((p) => [+((p[0] - cx) * s).toFixed(4), +(-(p[1] - cy) * s).toFixed(4)]);
const made = [];
for (const bl of blobs) {
const outer = toLocal(bl.outer);
if (outer.length < 3) continue;
const holes = bl.holes.map(toLocal).filter((h) => h.length >= 3);
const m = makeDrawnProp(outer, { mode: "inflate", amount: 0.12, round: 0.6, holes });
if (!m) continue;
m.position.set(0, 1.0, 0); // each blob's offset is baked into its points → layout reassembles
propsGroup.add(m); made.push(m);
}
if (!made.length) { showClipToast("couldn't build shapes from that image", true); return 0; }
selectProp(made[0]);
requestRender(); captureAndUpload(node);
showClipToast(`imported ${made.length} shape${made.length > 1 ? "s" : ""} — adjust bottom-left, drag to place`);
return made.length;
};
const pickImageForShape = () => {
const inp = document.createElement("input"); inp.type = "file"; inp.accept = "image/*";
inp.addEventListener("change", () => { const f = inp.files?.[0]; if (!f) return; const url = URL.createObjectURL(f); const im = new Image(); im.onload = () => { imageToShapes(im); URL.revokeObjectURL(url); }; im.src = url; });
inp.click();
};
// ── bottom-left live panel: turn the selected drawn shape into a 3D solid ─────
// Shows only when a drawn-shape prop is selected. Mode toggles + two sliders rebuild the
// geometry from the same outline in real time; release commits (re-captures the depth map).
const DRAW_MODES = [["flat", "▭ flat"], ["extrude", "📐 extrude"], ["revolve", "🧊 revolve"], ["inflate", "🎈 inflate"]];
const drawPanel = document.createElement("div");
drawPanel.style.cssText = "position:absolute;left:6px;bottom:6px;z-index:4;display:none;flex-direction:column;gap:5px;min-width:172px;" +
"background:rgba(20,20,26,0.9);border:1px solid #4a4a55;border-radius:6px;padding:7px 8px;font:11px system-ui,sans-serif;color:#cdd;";
drawPanel.addEventListener("pointerdown", (e) => e.stopPropagation());
const drawModeRow = document.createElement("div");
drawModeRow.style.cssText = "display:flex;flex-wrap:wrap;gap:3px;";
const drawModeBtns = {};
for (const [key, label] of DRAW_MODES) {
const b = document.createElement("button");
b.textContent = label;
b.style.cssText = "flex:1 0 auto;padding:3px 6px;background:#2a2a35;color:#cdd;border:1px solid #4a4a55;border-radius:4px;cursor:pointer;font:11px system-ui,sans-serif;white-space:nowrap;";
b.addEventListener("click", () => {
if (!selectedProp?.userData?.points) return;
const ud = selectedProp.userData;
if (ud.drawMode !== key && ud.sculptMap?.size) { ud.sculptMap.clear(); delete ud._sculptBase; } // new mode = different topology; the old sculpt delta no longer maps
ud.drawMode = key;
rebuildDrawnGeometry(selectedProp);
syncDrawPanel();
captureAndUpload(node);
});
drawModeBtns[key] = b; drawModeRow.appendChild(b);
}
drawPanel.appendChild(drawModeRow);
const mkDrawSlider = (labelText) => {
const wrap = document.createElement("label");
wrap.style.cssText = "display:flex;align-items:center;gap:6px;";
const lab = document.createElement("span"); lab.textContent = labelText; lab.style.cssText = "width:46px;flex:0 0 auto;color:#9aa;";
const sl = document.createElement("input"); sl.type = "range";
sl.style.cssText = "flex:1;accent-color:#5fbf8c;cursor:pointer;";
wrap.appendChild(lab); wrap.appendChild(sl);
return { wrap, lab, sl };
};
const drawAmtCtl = mkDrawSlider("Depth"); // relabelled per mode
drawAmtCtl.sl.min = "0.02"; drawAmtCtl.sl.max = "0.7"; drawAmtCtl.sl.step = "0.01";
const drawRndCtl = mkDrawSlider("Round");
drawRndCtl.sl.min = "0"; drawRndCtl.sl.max = "1"; drawRndCtl.sl.step = "0.01";
drawPanel.appendChild(drawAmtCtl.wrap); drawPanel.appendChild(drawRndCtl.wrap);
drawAmtCtl.sl.addEventListener("input", () => { if (!selectedProp?.userData?.points) return; selectedProp.userData.drawAmount = parseFloat(drawAmtCtl.sl.value); rebuildDrawnGeometry(selectedProp); });
drawRndCtl.sl.addEventListener("input", () => { if (!selectedProp?.userData?.points) return; selectedProp.userData.drawRound = parseFloat(drawRndCtl.sl.value); rebuildDrawnGeometry(selectedProp); });
const commitDraw = () => { if (selectedProp?.userData?.points) captureAndUpload(node); };
drawAmtCtl.sl.addEventListener("change", commitDraw); drawRndCtl.sl.addEventListener("change", commitDraw);
container.appendChild(drawPanel);
// Which sliders are meaningful per mode (flat/revolve are fully determined by the outline).
const DRAW_SLIDERS = { flat: {}, extrude: { amount: "Depth", round: "Bevel" }, revolve: {}, inflate: { amount: "Puff", round: "Round" } };
const syncDrawPanel = () => {
const ud = selectedProp?.userData;
const isDrawn = ud?.propType === "drawnShape" && Array.isArray(ud.points);
drawPanel.style.display = isDrawn ? "flex" : "none";
if (!isDrawn) return;
for (const [key, b] of Object.entries(drawModeBtns)) {
const on = (ud.drawMode || "flat") === key;
b.style.background = on ? "#2e5d44" : "#2a2a35";
b.style.borderColor = on ? "#5fbf8c" : "#4a4a55";
}
const cfg = DRAW_SLIDERS[ud.drawMode || "flat"] || {};
drawAmtCtl.wrap.style.display = cfg.amount ? "flex" : "none";
drawRndCtl.wrap.style.display = cfg.round ? "flex" : "none";
if (cfg.amount) { drawAmtCtl.lab.textContent = cfg.amount; drawAmtCtl.sl.value = ud.drawAmount ?? DRAWN_DEFAULTS.amount; }
if (cfg.round) { drawRndCtl.lab.textContent = cfg.round; drawRndCtl.sl.value = ud.drawRound ?? DRAWN_DEFAULTS.round; }
};
drawCanvas.addEventListener("pointerdown", (e) => {
e.stopPropagation();
drawingStroke = true; drawPts = [[e.offsetX, e.offsetY]];
drawCanvas.setPointerCapture?.(e.pointerId);
});
drawCanvas.addEventListener("pointermove", (e) => {
if (!drawingStroke) return;
drawPts.push([e.offsetX, e.offsetY]);
const c = drawCanvas.getContext("2d");
c.clearRect(0, 0, drawCanvas.width, drawCanvas.height);
c.strokeStyle = "#7fffb0"; c.lineWidth = 2; c.lineJoin = "round"; c.lineCap = "round";
c.beginPath(); c.moveTo(drawPts[0][0], drawPts[0][1]);
for (const p of drawPts) c.lineTo(p[0], p[1]);
c.stroke();
});
drawCanvas.addEventListener("pointerup", (e) => {
if (!drawingStroke) return;
drawingStroke = false;
try { drawCanvas.releasePointerCapture?.(e.pointerId); } catch {}
finishDraw();
});
const selectProp = (mesh) => {
clearBand();
clearFigureSelection(); // a prop and a whole figure are never selected at once
selectedJointName = null; selectedIkLimb = null; // drop any joint selection
if (selectedChainJoint?.prop !== mesh) selectedChainJoint = null; // a different selection drops the combine target
selectedProp = mesh;
transformControls.setMode(propMode);
transformControls.setSpace(propMode === "scale" ? "local" : "world"); // scale must be local
transformControls.attach(mesh);
updatePresetUI(); // not a wrist/ankle → hides the hand/foot panel
updatePropUI();
updateIkUI();
};
const setPropMode = (mode) => {
propMode = mode;
if (selectedProp) {
transformControls.setMode(mode);
transformControls.setSpace(mode === "scale" ? "local" : "world");
}
updatePropUI();
requestRender();
};
const deleteSelectedProp = () => {
if (!selectedProp) return;
transformControls.detach();
removeAttachmentsFor(selectedProp); // drop any pin to/from this prop
propsGroup.remove(selectedProp);
disposeProp(selectedProp); // model = traverse-dispose; primitive = single geometry (shared material kept)
cancelPinPick(); // re-enables the gizmo if this prop was mid-link
selectedProp = null;
updatePropUI();
requestRender();
captureAndUpload(node);
};
const spawnProp = (type) => {
if (type === "__draw__") { enterDrawMode("flat"); return; } // freehand flat panel — spawns on draw-finish
if (type === "__draw3d__") { enterDrawMode("inflate"); return; } // freehand 3D shape — defaults to inflate, tweak via bottom-left panel
if (MODEL_PROPS[type]) { // bundled GLB model — loads async
loadModelProp(type).then((root) => {
if (!node._pcrAlive) { disposeProp(root); return; }
root.position.set(0, 0, 0.4); // base already at y=0 → rests on the floor in front
propsGroup.add(root);
selectProp(root);
requestRender();
captureAndUpload(node);
}).catch((e) => console.error("[PoseStudio] model prop load failed:", type, e));
return;
}
if (CHAIN_DEFS[type]) { // procedural articulated chain
const m = makeChainProp(type);
if (!m) return;
m.position.set(0, 0, 0.4); // base at y=0 → stands on the floor in front
propsGroup.add(m);
selectProp(m); // selected as a normal prop → move/rotate/SCALE gizmo all work
setBonePoseProp(m); // + show the segment dots and the green tip dot (grab the tip to bend)
requestRender();
captureAndUpload(node);
return;
}
const def = PROP_DEFS[type];
const m = makeProp(type);
if (!m) return;
m.position.set(0, def.y, 0.4); // on the floor, just in front of the figure (+Z faces camera)
propsGroup.add(m);
selectProp(m);
requestRender();
captureAndUpload(node);
};
// Prop transform panel — appears only while a prop is selected.
const propPanel = document.createElement("div");
propPanel.style.cssText =
"position:absolute;top:6px;right:6px;z-index:3;display:none;gap:3px;" +
"background:rgba(20,20,26,0.82);border:1px solid #4a4a55;border-radius:5px;padding:3px 4px;";
propPanel.addEventListener("pointerdown", (e) => e.stopPropagation());
// (Move/rotate/scale live in the left transform group now — not repeated here.)
// Give this prop a REGION: its own silhouette mask row + a $name{} block in
// the bound prompt (same machinery as figure renames). "$" because that's
// the region syntax users already type — clearer than any name/tag icon.
const propRegionBtn = mkBtn("$", "Give this prop its own prompt region — a $name{} block + silhouette mask (click to set/change the region name; clear it to remove)", () => {
if (selectedProp) openRenameProp(selectedProp);
});
propPanel.appendChild(propRegionBtn);
// Auto-capability buttons — each appears only when the imported file actually
// carries that capability (multi-mesh / skeleton / animation clips).
const propSplitBtn = mkBtn("✂️", "Split this model into its parts — each gets its own gizmo (they spawn linked to part 1)", () => {
if (selectedProp) splitImportedProp(selectedProp);
});
propPanel.appendChild(propSplitBtn);
const propBonesBtn = mkBtn("🦴", "Pose this model's bones — click a bone dot, rotate it with the gizmo (toggle)", () => {
if (selectedProp) setBonePoseProp(selectedProp);
});
propPanel.appendChild(propBonesBtn);
const propClipsBtn = mkBtn("🎬", "Pose from the model's own animations — pick a clip, scrub to a frame, it freezes there", () => {
clipOpen = !clipOpen;
updatePropUI();
requestRender();
});
propPanel.appendChild(propClipsBtn);
const propFitBtn = mkBtn("🧍", "Make this model POSABLE: fit a skeleton inside it (drag its handles to line up the limbs), then bind", () => {
if (selectedProp) startSkinFit(selectedProp);
});
propPanel.appendChild(propFitBtn);
// Pin this prop to a hand/joint or another prop: click 📌, then click the target. The
// prop becomes the follower (rides the target). Click 📌 again to unpin.
const propPinBtn = mkBtn("🔗", "Combine: stick this prop (or the selected chain joint) to a hand/joint or another prop — then click the target (🔗 again to unlink)", () => {
if (!selectedProp) return;
if (propAttachment(selectedProp)) { removeAttachmentsFor(selectedProp); requestRender(); captureAndUpload(node); }
else {
pinPickProp = (pinPickProp === selectedProp) ? null : selectedProp; // toggle pick-mode
// On a chain with a joint selected, combine THAT joint (it becomes the held
// anchor); otherwise the whole prop rides the target by its origin.
pinPickJointIndex = (pinPickProp && selectedProp.userData._chain && selectedChainJoint?.prop === selectedProp) ? selectedChainJoint.jointIndex : null;
}
transformControls.enabled = !pinPickProp; // gizmo inert while waiting to pick a link target (its drag-planes block clicks/orbit otherwise)
updatePropUI();
requestRender();
});
propPanel.appendChild(propPinBtn);
const propSculptBtn = mkBtn("🗿", "Sculpt this object — grab / inflate / smooth its surface", () => {
if (!selectedProp) return;
if (sculpt.active) exitSculpt(); else enterSculpt("prop", selectedProp);
});
propPanel.appendChild(propSculptBtn);
// Shown only for a "missing mesh" marker — upload the file to restore the prop.
const propLocateBtn = mkBtn("🔍", "Locate this prop's missing mesh file — its content is verified before the prop is restored", () => {
if (selectedProp?.userData._failedRecord) locateMissingMesh(selectedProp);
});
propPanel.appendChild(propLocateBtn);
propPanel.appendChild(mkBtn("🗑", "Delete prop (Del)", deleteSelectedProp));
container.appendChild(propPanel);
// The IK-tip move/rotate toggle moved into the left transform group (Move =
// gizmo-driven IK drag, Rotate = FK twist) — its old floating panel is gone;
// selection changes just re-sync the tool highlight.
updateIkUI = () => updateToolUI();
// Armed-pin banner — unmistakable feedback that the next click picks the pin target.
const pinHint = document.createElement("div");
pinHint.textContent = "🔗 click a hand / joint / prop to link this prop to — Esc to cancel";
pinHint.style.cssText =
"position:absolute;top:6px;left:50%;transform:translateX(-50%);z-index:4;display:none;" +
"padding:3px 10px;background:#7a5a1e;color:#fff;border:1px solid #caa14a;border-radius:5px;" +
"font:11px/1.4 system-ui,sans-serif;pointer-events:none;white-space:nowrap;";
container.appendChild(pinHint);
// "This prop's mesh is missing" banner — shown while a missing-mesh marker is selected.
const missingHint = document.createElement("div");
missingHint.style.cssText =
"position:absolute;top:6px;left:50%;transform:translateX(-50%);z-index:4;display:none;max-width:88%;" +
"padding:3px 10px;background:#7a3a1e;color:#fff;border:1px solid #caa14a;border-radius:5px;" +
"font:11px/1.4 system-ui,sans-serif;pointer-events:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
container.appendChild(missingHint);
// Proactive scene-level warning shown on LOAD when this workflow references
// imported meshes whose files aren't on this machine (a shared workflow carries
// meshes by content-hash reference, not the bytes). Dismissible; re-shown on the
// next load. The per-marker `missingHint` above names a specific file on select;
// this banner gives the count when nothing is selected, so the warning can't be
// missed. The two never show at once (refreshMissingUI gates on selection).
let missingDismissed = false;
const loadBanner = document.createElement("div");
loadBanner.style.cssText =
"position:absolute;top:6px;left:50%;transform:translateX(-50%);z-index:5;display:none;align-items:center;gap:8px;max-width:92%;" +
"padding:4px 6px 4px 10px;background:#7a3a1e;color:#fff;border:1px solid #caa14a;border-radius:5px;" +
"font:11px/1.4 system-ui,sans-serif;box-shadow:0 2px 10px rgba(0,0,0,0.45);";
loadBanner.addEventListener("pointerdown", (e) => e.stopPropagation());
const loadBannerText = document.createElement("span");
loadBannerText.style.cssText = "white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
const loadBannerClose = document.createElement("button");
loadBannerClose.textContent = "✕"; loadBannerClose.title = "Dismiss";
loadBannerClose.style.cssText = "flex:0 0 auto;width:18px;height:18px;padding:0;background:transparent;border:none;color:#fff;cursor:pointer;font:12px/1 system-ui,sans-serif;opacity:0.85;";
loadBannerClose.addEventListener("click", (e) => { e.stopPropagation(); missingDismissed = true; loadBanner.style.display = "none"; });
loadBanner.append(loadBannerText, loadBannerClose);
container.appendChild(loadBanner);
const refreshMissingUI = ({ reset = false } = {}) => {
if (reset) missingDismissed = false;
const sel = selectedProp?.userData._failedRecord;
if (sel) { // a missing marker is selected → name its specific file
missingHint.textContent = `⚠ Missing mesh "${sel.file || sel.type || "mesh"}" — click 🔍 to locate the file`;
missingHint.style.display = "block";
} else missingHint.style.display = "none";
const n = propsGroup.children.filter((m) => m.userData._failedRecord).length;
const show = !sel && n > 0 && !missingDismissed;
if (show) loadBannerText.textContent = `⚠ ${n} imported mesh${n > 1 ? "es" : ""} missing on this machine — select the orange marker${n > 1 ? "s" : ""}, then 🔍 Locate the file${n > 1 ? "s" : ""} (a shared workflow carries meshes by reference, not the file)`;
loadBanner.style.display = show ? "flex" : "none";
};
// 🔍 Locate: pick the missing mesh file, upload it to the content-addressed store,
// and accept it ONLY if its content hash matches the one the prop references (the
// same digest-verified contract as the lineage "Locate file…" reattach). On a
// match, every marker that wanted that mesh adopts the stored name and the props +
// pins are rebuilt from the live scene — the marker turns back into the real mesh.
const meshHashOf = (f) => (f || "").replace(/\.(glb|gltf|obj)$/i, "");
const locateInput = document.createElement("input");
locateInput.type = "file"; locateInput.accept = ".glb,.gltf,.obj"; locateInput.style.display = "none";
locateInput.className = "pcr-locate-mesh-input";
container.appendChild(locateInput);
let locateWantHash = null;
const locateMissingMesh = (placeholder) => {
locateWantHash = meshHashOf(placeholder?.userData?._failedRecord?.file);
if (!locateWantHash) { showClipToast("this prop has no mesh reference to locate", true); return; }
locateInput.click();
};
locateInput.addEventListener("change", async () => {
const file = locateInput.files?.[0];
locateInput.value = ""; // same file re-selectable later
const wantHash = locateWantHash; locateWantHash = null;
if (!file || !wantHash) return;
try {
const form = new FormData(); form.append("mesh", file);
const resp = await api.fetchApi("/promptchain/pose-mesh/upload", { method: "POST", body: form });
const data = await resp.json().catch(() => ({}));
if (!resp.ok || data.error || !data.file) throw new Error(data.error || `upload failed (${resp.status})`);
if (meshHashOf(data.file) !== wantHash) {
showClipToast("that's not the right file — its contents don't match this prop", true);
return;
}
// Content matches: adopt the actually-stored name on every marker that wanted it
// (a different extension, or the same mesh reused across props), then rebuild the
// props + pins from the live scene — the same path a scene load takes.
for (const m of propsGroup.children) {
const fr = m.userData._failedRecord;
if (fr?.file && meshHashOf(fr.file) === wantHash) m.userData._failedRecord = { ...fr, file: data.file };
}
const props = propsApi.serialize(), atts = attachApi.serialize();
await propsApi.restoreFrom(props);
if (!node._pcrAlive) return;
attachApi.restoreFrom(atts);
await syncSkinBinds();
updatePropLabels();
const restored = propsGroup.children.find((m) => !m.userData._failedRecord && m.userData.file === data.file);
if (restored) selectProp(restored); else updatePropUI();
requestRender();
captureAndUpload(node);
showClipToast("mesh located — prop restored");
} catch (e) { showClipToast("locate failed — " + (e.message || e), true); }
});
updatePropUI = () => {
propPanel.style.display = selectedProp ? "flex" : "none";
syncDrawPanel(); // bottom-left 3D-shape controls (drawn props only)
updateToolUI(); // the left transform group reflects this selection's mode
// $ green = this prop has its own region; tooltip carries the live name.
const regionName = selectedProp?.userData.customName;
propRegionBtn.style.background = regionName ? "#2e5d44" : "#2a2a35";
propRegionBtn.title = regionName
? `Region $${regionName} — click to rename (clear the name to remove the region)`
: "Give this prop its own prompt region — a $name{} block + silhouette mask";
// Capability buttons only exist for files that came with the capability.
const ud = selectedProp?.userData;
propSplitBtn.style.display = ud?._capParts ? "" : "none";
propBonesBtn.style.display = ud?._bones ? "" : "none";
propClipsBtn.style.display = ud?._clips?.length ? "" : "none";
propFitBtn.style.display = (ud?.file && !ud._bones) ? "" : "none"; // static imports: rigged ones pose via 🦴 already
// A missing-mesh marker can only be located or deleted — hide the controls that
// need real geometry, surface 🔍 Locate + a banner naming the missing file.
const missingMesh = !!ud?._failedRecord;
propLocateBtn.style.display = missingMesh ? "" : "none";
propRegionBtn.style.display = missingMesh ? "none" : "";
propSculptBtn.style.display = missingMesh ? "none" : "";
propPinBtn.style.display = missingMesh ? "none" : "";
refreshMissingUI(); // per-select file hint + scene-level load banner (mutually exclusive)
propBonesBtn.style.background = (bonePoseProp && bonePoseProp === selectedProp) ? "#2e5d44" : "#2a2a35";
propClipsBtn.style.background = (clipOpen && ud?._clips?.length) ? "#2e5d44" : "#2a2a35";
syncClipPanel();
// 🔗 green = linked; amber = waiting for you to click a target.
const att = selectedProp ? propAttachment(selectedProp) : null;
propPinBtn.style.background = att ? "#2e5d44" : pinPickProp === selectedProp ? "#7a5a1e" : "#2a2a35";
propPinBtn.title = att ? "Linked — click to unlink"
: "Link this prop to a hand/joint or another prop — then click the target";
pinHint.style.display = pinPickProp ? "block" : "none";
// Persistent "this prop is linked + how to undo" line (hidden while picking a target).
if (att && !pinPickProp) {
const tgt = att.leader.kind === "joint" ? jointFriendly(att.leader.joint) : "another prop";
linkStatus.textContent = "🔗 linked to " + tgt + " — click 🔗 to unlink · ↶ to undo";
linkStatus.style.display = "block";
} else linkStatus.style.display = "none";
};
// ── rubber-band (marquee) multi-select ──────────────────────────────────────
// Hold Shift + drag a rectangle to select multiple figures + props at once and
// move/rotate them together (a single gizmo on a proxy at the group median applies
// the same delta to all). Plain drag still orbits — Shift arms the band and turns
// OrbitControls + the gizmo off for the gesture. Touch/intersect containment via
// screen-space AABB overlap, select-through (no occlusion) — the DCC convention.
// v1 is replace-only (Shift already arms the band, so it can't also mean "add").
// (bandSel itself is declared with the capture forward-decls — the outline pass
// reads it; selection membership alone drives the members' outlines.)
const bandObj3d = (s) => (s.kind === "figure" ? s.o.root : s.o);
const bandProxy = new THREE.Object3D(); // gizmo target; sits at the group median
scene.add(bandProxy);
const bandStartMat = new Map(); // obj3d -> world Matrix4 at drag start
const bandStartPins = new Map(); // fig -> {tip: world Vec3|null} at drag start (profile plantedTips)
const _bpInv = new THREE.Matrix4(), _bDelta = new THREE.Matrix4(), _bM = new THREE.Matrix4();
const _bV = new THREE.Vector3(), _bBox = new THREE.Box3();
bandActive = () => bandSel.length > 0 && transformControls.object === bandProxy;
clearBand = () => {
if (!bandSel.length) return;
if (transformControls.object === bandProxy) transformControls.detach();
bandSel.length = 0;
};
const placeBandProxy = () => {
bandProxy.position.set(0, 0, 0);
for (const s of bandSel) bandProxy.position.add(bandObj3d(s).getWorldPosition(_bV));
if (bandSel.length) bandProxy.position.multiplyScalar(1 / bandSel.length);
bandProxy.quaternion.identity();
bandProxy.scale.setScalar(1);
bandProxy.updateMatrixWorld(true);
};
const bandDragStart = () => {
bandProxy.updateMatrixWorld(true);
_bpInv.copy(bandProxy.matrixWorld).invert();
bandStartMat.clear(); bandStartPins.clear();
for (const s of bandSel) {
const o3 = bandObj3d(s);
o3.updateWorldMatrix(true, false);
bandStartMat.set(o3, o3.matrixWorld.clone());
if (s.kind === "figure" && s.o.footPins) bandStartPins.set(s.o,
Object.fromEntries(Object.entries(s.o.footPins).map(([k, v]) => [k, v ? v.clone() : null])));
}
};
const bandDragApply = () => {
// Self-seed: if dragging-changed didn't capture the start matrices first (event-order
// race — the gizmo's drag-start can fire before bandActive() reads true), bandStartMat is
// empty and every object below would hit `if (!start) continue` → the proxy moves but the
// objects don't. Seed here so the move always has a baseline (this frame is then a no-op,
// the next applies the real delta) — the group can never silently fail to follow again.
if (!bandStartMat.size) bandDragStart();
bandProxy.updateMatrixWorld(true);
_bDelta.multiplyMatrices(bandProxy.matrixWorld, _bpInv); // D such that newProxy = D·startProxy
for (const s of bandSel) {
const o3 = bandObj3d(s), start = bandStartMat.get(o3);
if (!start) continue;
_bM.multiplyMatrices(_bDelta, start); // newWorld = D·startWorld
_bM.decompose(o3.position, o3.quaternion, o3.scale); // fig.root in scene / props in identity group → world == local
o3.updateMatrixWorld(true);
if (s.kind === "figure") { // carry the figure's foot pins along so they don't snap the feet back
const sp = bandStartPins.get(s.o);
if (sp) for (const k of Object.keys(sp)) s.o.footPins[k] = sp[k] ? sp[k].clone().applyMatrix4(_bDelta) : null;
}
}
};
// Screen-space AABB of an object overlaps the marquee rect (client px)?
const screenAabbHit = (object3d, minX, minY, maxX, maxY) => {
_bBox.setFromObject(object3d);
if (_bBox.isEmpty()) return false;
const cr = canvas.getBoundingClientRect();
let sMinX = Infinity, sMinY = Infinity, sMaxX = -Infinity, sMaxY = -Infinity, anyFront = false;
for (let i = 0; i < 8; i++) {
_bV.set(i & 1 ? _bBox.max.x : _bBox.min.x, i & 2 ? _bBox.max.y : _bBox.min.y, i & 4 ? _bBox.max.z : _bBox.min.z);
_bV.project(camera);
if (_bV.z < 1) anyFront = true; // at least one corner is in front of the camera
const sx = (_bV.x * 0.5 + 0.5) * cr.width + cr.left;
const sy = (-_bV.y * 0.5 + 0.5) * cr.height + cr.top;
sMinX = Math.min(sMinX, sx); sMaxX = Math.max(sMaxX, sx);
sMinY = Math.min(sMinY, sy); sMaxY = Math.max(sMaxY, sy);
}
if (!anyFront) return false;
return !(sMaxX < minX || sMinX > maxX || sMaxY < minY || sMinY > maxY);
};
const collectBandHits = (minX, minY, maxX, maxY) => {
const hits = [];
for (const f of figures) if (screenAabbHit(f.root, minX, minY, maxX, maxY)) hits.push({ kind: "figure", o: f });
for (const m of propsGroup.children) if (screenAabbHit(m, minX, minY, maxX, maxY)) hits.push({ kind: "prop", o: m });
return hits;
};
// Establish the multi-selection. 0 → clear; 1 → defer to normal single-select so the
// gizmo/panels behave exactly as a click; ≥2 → proxy gizmo at the median.
const selectBand = (list) => {
clearBand();
detachGizmo();
if (!list.length) { requestRender(); return; }
if (list.length === 1) {
const s = list[0];
if (s.kind === "figure") selectFigure(s.o); else selectProp(s.o);
requestRender();
return;
}
for (const s of list) bandSel.push(s); // membership alone outlines them (screen-space pass)
placeBandProxy();
transformControls.setMode(figureMode === "rotate" ? "rotate" : "translate");
transformControls.setSpace("world");
transformControls.attach(bandProxy);
updatePropUI(); updateFigureUI();
requestRender();
};
// Delete every object in the multi-selection (props + figures, never the last figure).
const removeBandProp = (mesh) => {
if (transformControls.object === mesh) transformControls.detach();
removeAttachmentsFor(mesh);
propsGroup.remove(mesh);
disposeProp(mesh); // model = traverse-dispose; primitive = single geometry (shared material kept)
};
const deleteBand = () => {
const props = bandSel.filter((s) => s.kind === "prop").map((s) => s.o);
const figs = bandSel.filter((s) => s.kind === "figure").map((s) => s.o);
clearBand(); // drop highlights + detach the proxy before mutating the scene
for (const m of props) removeBandProp(m);
for (const f of figs) if (figures.length > 1) removeFigure(f, { capture: false });
requestRender();
captureAndUpload(node);
};
// Marquee rectangle (a CSS overlay, never WebGL — the three.js SelectionHelper trick).
const marquee = document.createElement("div");
marquee.style.cssText =
"position:absolute;border:1px solid #55aaff;background:rgba(75,160,255,0.18);" +
"z-index:6;pointer-events:none;display:none;";
container.appendChild(marquee);
const band = { active: false, x0: 0, y0: 0 };
const beginBand = (e) => {
band.active = true; band.x0 = e.clientX; band.y0 = e.clientY;
controls.enabled = false;
transformControls.enabled = false; // gizmo inert during the band drag
const r = container.getBoundingClientRect();
marquee.style.left = (e.clientX - r.left) + "px";
marquee.style.top = (e.clientY - r.top) + "px";
marquee.style.width = "0px"; marquee.style.height = "0px";
marquee.style.display = "block";
canvas.setPointerCapture?.(e.pointerId);
};
const updateBand = (e) => {
const r = container.getBoundingClientRect();
marquee.style.left = (Math.min(band.x0, e.clientX) - r.left) + "px";
marquee.style.top = (Math.min(band.y0, e.clientY) - r.top) + "px";
marquee.style.width = Math.abs(e.clientX - band.x0) + "px";
marquee.style.height = Math.abs(e.clientY - band.y0) + "px";
};
// Object (figure/prop) under the cursor, no selection side effects — the
// additive Shift-click picker. Props first, same precedence as pickAtPointer.
const pickObjectAt = (e) => {
setPointer(e);
const propHit = raycaster.intersectObjects(propsGroup.children, true)[0];
const propObj = propHit && propRootOf(propHit.object);
if (propObj) return { kind: "prop", o: propObj };
const hit = raycaster.intersectObjects(figures.flatMap((f) => f.meshes), true)[0];
const fig = hit ? figures.find((f) => f.meshes.includes(hit.object)) : null;
return fig ? { kind: "figure", o: fig } : null;
};
// The live selection as a band-style list, whatever form it currently takes.
const currentSelectionList = () => {
if (bandSel.length) return bandSel.map((s) => ({ kind: s.kind, o: s.o }));
if (selectedFigure) return [{ kind: "figure", o: selectedFigure }];
if (selectedProp) return [{ kind: "prop", o: selectedProp }];
return [];
};
// ── Object grouping ──────────────────────────────────────────────────────────
// A group is just a shared `groupId` tag on props. They stay FLAT children of
// propsGroup, so serialization-by-index, 🔗 attachments and per-prop mask ownership
// all keep working untouched. Clicking any member selects the whole group as a band
// multi-selection → the existing bandProxy moves them together. Combining a group to
// a mannequin attaches EVERY member, so the whole group lands in that figure's mask.
let groupIdSeq = 1;
const groupMembers = (gid) => gid ? propsGroup.children.filter((m) => m.userData.groupId === gid) : [];
const selectionProps = () => currentSelectionList().filter((s) => s.kind === "prop").map((s) => s.o);
const selectionGroupIds = () => new Set(selectionProps().map((m) => m.userData.groupId).filter(Boolean));
// Click selection that respects groups: a member selects its whole group.
const selectPropOrGroup = (mesh) => {
const mem = groupMembers(mesh?.userData?.groupId);
if (mem.length > 1) { selectBand(mem.map((o) => ({ kind: "prop", o }))); return; }
selectProp(mesh);
};
const groupSelection = () => {
const props = selectionProps();
if (props.length < 2) { showClipToast("select 2 or more objects to group (Shift-click or drag a box)", true); return; }
const gid = "g" + (groupIdSeq++);
for (const m of props) m.userData.groupId = gid;
selectBand(props.map((o) => ({ kind: "prop", o })));
requestRender(); captureAndUpload(node);
showClipToast(`grouped ${props.length} objects · Ctrl+Shift+G to ungroup`);
};
const ungroupSelection = () => {
const gids = selectionGroupIds();
if (!gids.size) { showClipToast("no group selected", true); return; }
let n = 0;
for (const m of propsGroup.children) if (gids.has(m.userData.groupId)) { m.userData.groupId = null; n++; }
requestRender(); captureAndUpload(node);
showClipToast(`ungrouped ${n} objects`);
};
// Arm a pick whose target attaches EVERY group member — they ride the leader AND all
// enter its region mask (resolved per-prop in captureRegionMasks via the 🔗 chain).
const combineGroupToTarget = () => {
const props = selectionProps();
if (!props.length) return;
pinPickGroup = props.slice(); pinPickProp = null; pinPickJointIndex = null;
transformControls.enabled = false; // gizmo inert while waiting to pick the target
requestRender();
showClipToast("🔗 click a hand / joint / prop to combine the group to — Esc to cancel");
};
const endBand = (e) => {
if (!band.active) return;
band.active = false;
marquee.style.display = "none";
controls.enabled = true;
transformControls.enabled = true;
try { canvas.releasePointerCapture?.(e.pointerId); } catch {}
const minX = Math.min(band.x0, e.clientX), maxX = Math.max(band.x0, e.clientX);
const minY = Math.min(band.y0, e.clientY), maxY = Math.max(band.y0, e.clientY);
if (maxX - minX < 3 && maxY - minY < 3) {
// Shift-CLICK (no drag) = additive toggle, the Blender convention: add the
// object under the cursor to the selection, or drop it if already in.
// Empty space keeps the selection (plain click already deselects).
const hit = pickObjectAt(e);
if (hit) {
const list = currentSelectionList();
const i = list.findIndex((s) => s.o === hit.o);
if (i >= 0) list.splice(i, 1); else list.push(hit);
selectBand(list);
}
requestRender();
return;
}
selectBand(collectBandHits(minX, minY, maxX, maxY));
};
// Whole-figure transform panel — shown while a person is selected as an object (no
// prop, no joint). Mirrors the prop panel: move / rotate the whole person, or remove
// it. Top-right, sharing the slot with the prop panel and hand/foot picker — all are
// selection-contextual and mutually exclusive, so only one ever shows.
// ── shoes ───────────────────────────────────────────────────────────────────
// Per-figure toggle that puts a shoe-shaped mesh on each ankle (parented to the ankle
// bone, so it follows the foot pose) — covers the bare foot so the depth/control map
// reads as footwear when generating for shoes. Matte, stays in the capture; serialized.
const shoeMat = new THREE.MeshStandardMaterial({ color: 0x6b6f76, roughness: 0.85, metalness: 0.0, side: THREE.DoubleSide });
// Shoe STYLES. "procedural" = the built-in extruded shape (no asset, always works); the
// rest are CC0/CC-BY GLBs (CREDITS) baked into the same canonical unit frame as the
// procedural shoe so the per-ankle placement below is style-agnostic. Per-style fields:
// fwd = the toe direction in the RAW GLB's own axes (so flipping the sign flips the toe)
// up = the GLB's up axis; drop = where the ankle sits, as a fraction of shoe height
// back = how far behind the ankle the heel sits (unit-length); mirror = mirror the
// width on the LEFT foot (single-foot GLBs read better mirrored into a pair).
// Only calibrated styles ship: each is fit per-foot from a hand-placed prop (the
// generic auto-fit GLBs never sat right). procedural is the always-works fallback.
// New styles get added one at a time as they're calibrated, like boots was.
const SHOE_STYLES = {
procedural: { label: "👟 default" },
// Calibrated from a hand-placed prop (rig-shoes.json): each foot's boot pose in the
// ankle's rigid frame. CC-BY DanlyVostok. See placeCalibratedShoe.
boots: { label: "🥾 boots", fit: {
ankleR: { prop: "tacticalBootL", offset: [1.183896, -0.041055, -0.098529, 0, -0.090012, -0.480269, -0.881443, 0, -0.009293, 0.878473, -0.477701, 0, 0.018443, 0.10315, 0.04347, 1] },
ankleL: { prop: "tacticalBootR", offset: [1.183896, 0.041055, 0.098529, 0, 0.090012, -0.480269, -0.881443, 0, 0.009293, 0.878473, -0.477701, 0, -0.018443, 0.10315, 0.04347, 1] },
} },
// Heels also set the foot angle (a heel can't sit flat-footed). CC-BY Andrey (Earl_1).
heels: { label: "👠 heels",
ankle: { ankleR: [-0.300556, -0.051419, -0.07434, 0.949471], ankleL: [-0.283384, 0.018363, 0.040812, 0.957962] },
fit: {
ankleR: { prop: "heelR", offset: [0.188145, 0.813829, -1.406336, 0, 0.037982, -1.366574, -0.785738, 0, -1.635714, 0.060297, -0.183939, 0, -0.004956, 0.172388, 0.049266, 1.0] },
ankleL: { prop: "heelL", offset: [-0.320488, 0.774083, -1.404843, 0, -0.013742, -1.382313, -0.758534, 0, -1.643896, -0.145466, 0.29487, 0, 0.001825, 0.177854, 0.046676, 1] },
} },
// Sneakers sit flat — per-foot fit only, no ankle pose. CC-BY AntonCla (photoscan).
sneakers: { label: "👟 sneakers", fit: {
ankleR: { prop: "sneakerR", offset: [1.177759, 0.026303, 0.051394, 0, 0.055834, -0.520172, -1.013296, 0, 0.000073, 1.074308, -0.551488, 0, -0.002422, 0.111585, 0.04788, 1] },
ankleL: { prop: "sneakerL", offset: [1.177692, -0.02835, -0.051855, 0, -0.05714, -0.523426, -1.011546, 0, 0.001379, 1.07248, -0.555034, 0, 0.002623, 0.111738, 0.04751, 1] },
} },
};
// Live calibration overrides (per style) from the 🔧 panel — the blind defaults above need a
// visual tuning pass per model. styleMeta merges them; the panel reads back the final values.
const shoeCalib = {};
const styleMeta = (s) => ({ ...SHOE_STYLES[s], ...(shoeCalib[s] || {}) });
const shoeLoader = new GLTFLoader();
const shoeTemplates = {}; // style → Promise<Object3D> (canonical unit-frame template, cloned per foot)
// Unit procedural shoe: length along +X (0→1 heel→toe), height +Y, width +Z (centred).
const makeShoeGeo = () => {
const prof = [[0, -0.45], [1.0, -0.45], [1.0, -0.20], [0.78, 0.0], [0.22, 0.06], [0.0, -0.05]];
const shape = new THREE.Shape();
shape.moveTo(prof[0][0], prof[0][1]);
for (let i = 1; i < prof.length; i++) shape.lineTo(prof[i][0], prof[i][1]);
shape.closePath();
const geo = new THREE.ExtrudeGeometry(shape, { depth: 0.42, bevelEnabled: false });
geo.translate(0, 0, -0.21); // centre the width on the foot
return geo;
};
// Reorient + normalize a raw shoe GLB into the procedural shoe's canonical frame: toe along
// +X with length 1, sole on the floor, ankle origin at (0, 0, 0). Returned as an inner-baked
// template so the per-foot clone's own transform stays free for the ankle-basis placement.
const bakeShoeTemplate = (style) => {
if (shoeTemplates[style]) return shoeTemplates[style];
const meta = styleMeta(style);
const url = new URL(`./assets/props/${meta.file}?v=1`, import.meta.url).href;
shoeTemplates[style] = new Promise((resolve, reject) => {
shoeLoader.load(url, (gltf) => {
const inner = gltf.scene;
inner.traverse((o) => {
if (!o.isMesh) return;
o.material = shoeMat;
if (!o.geometry.attributes.normal) o.geometry.computeVertexNormals();
});
const F = new THREE.Vector3().fromArray(meta.fwd).normalize();
const U = new THREE.Vector3().fromArray(meta.up).normalize();
if (Math.abs(F.dot(U)) > 0.99) U.set(F.y, F.z, F.x); // guard: Toe∥Up would give a NaN basis
U.addScaledVector(F, -U.dot(F)).normalize(); // re-orthogonalize up against forward
const W = new THREE.Vector3().crossVectors(F, U).normalize();
// Rotate the GLB so (F,U,W) → (X,Y,Z): the inverse of the basis that maps X→F.
const basis = new THREE.Matrix4().makeBasis(F, U, W);
const rot = new THREE.Group();
rot.quaternion.setFromRotationMatrix(basis).invert();
rot.add(inner);
rot.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(rot);
const size = box.getSize(new THREE.Vector3());
const s = 1 / (size.x || 1); // forward length → 1 (uniform, keeps aspect)
const ctrZ = (box.min.z + box.max.z) / 2;
const baked = new THREE.Group(); // carries the canonical-frame transform
baked.scale.setScalar(s);
baked.position.set(
-box.min.x * s - meta.back, // heel `back` behind the ankle
(-box.min.y - meta.drop * size.y) * s, // ankle `drop` up the shoe height
-ctrZ * s); // width centred
baked.add(rot);
const tmpl = new THREE.Group(); // identity outer; clone's transform = placement
tmpl.add(baked);
tmpl.userData = { mirror: !!meta.mirror };
resolve(tmpl);
}, undefined, reject);
});
return shoeTemplates[style];
};
// Calibrated styles place a per-foot boot at an offset captured from a hand-positioned
// PROP. The offset was captured against the prop loader's NORMALIZED geometry (scaled to
// `h`, grounded, centred) — so we MUST load the boot through that SAME normalization
// (loadModelProp). Using the raw GLB changes the baseline and the boot floats. `entry.prop`
// is the MODEL_PROPS key; the normalized template is cached and cloned per foot.
const calibPropCache = {};
const loadCalibProp = (type) => {
if (!calibPropCache[type]) calibPropCache[type] = loadModelProp(type);
return calibPropCache[type];
};
const _csA = new THREE.Matrix4(), _csRigid = new THREE.Matrix4(), _csOff = new THREE.Matrix4(), _csLocal = new THREE.Matrix4();
const _csP = new THREE.Vector3(), _csQ = new THREE.Quaternion(), _csS = new THREE.Vector3();
const placeCalibratedShoe = async (fig, ankleName, entry) => {
const ankle = fig.joints[ankleName];
if (!ankle) return;
const tmpl = await loadCalibProp(entry.prop); // same normalization the offset was captured against
if (!fig.shoes) return; // toggled off while the GLB was loading
const shoe = tmpl.clone(true);
shoe.traverse((o) => { if (o.isMesh) o.material = shoeMat; });
ankle.updateWorldMatrix(true, false);
_csA.copy(ankle.matrixWorld); _csA.decompose(_csP, _csQ, _csS);
_csRigid.compose(_csP, _csQ, _csS.set(1, 1, 1)); // ankle rigid frame (matches leaderRigidMatrix)
_csOff.fromArray(entry.offset); // boot pose in that rigid frame (column-major)
_csLocal.copy(_csA).invert().multiply(_csRigid).multiply(_csOff); // shoe_world = rigid(ankle)*offset (= where the prop sat)
shoe.matrixAutoUpdate = false;
shoe.matrix.copy(_csLocal);
ankle.add(shoe);
fig.shoeMeshes.push(shoe);
};
const _shoeUp = new THREE.Vector3(), _shoeDir = new THREE.Vector3(), _shoeX = new THREE.Vector3(), _shoeM = new THREE.Matrix4();
const _xAxis = new THREE.Vector3(1, 0, 0), _zAxis = new THREE.Vector3(0, 0, 1);
// Compute the ankle-local placement (basis quaternion + foot length) for one shoe, then
// call `place(mesh, basisM, L, ankle, isLeft)` to parent it. Shared by every style.
const placeShoe = (fig, ankleName, build) => {
const ankle = fig.joints[ankleName];
if (!ankle) return null;
ankle.updateWorldMatrix(true, false);
const ball = fig.toes?.[ankleName];
const toeLocal = ball ? ankle.worldToLocal(ball.getWorldPosition(new THREE.Vector3())) : new THREE.Vector3(0, -0.05, 0.22);
const L = Math.max(0.05, toeLocal.length());
_shoeDir.copy(toeLocal).normalize();
_shoeUp.set(0, 1, 0).applyQuaternion(ankle.getWorldQuaternion(new THREE.Quaternion()).invert());
_shoeUp.addScaledVector(_shoeDir, -_shoeUp.dot(_shoeDir));
if (_shoeUp.lengthSq() < 1e-6) _shoeUp.set(0, 1, 0);
_shoeUp.normalize();
_shoeX.crossVectors(_shoeDir, _shoeUp).normalize();
_shoeM.makeBasis(_shoeDir, _shoeUp, _shoeX); // geo X→dir(len), Y→up, Z→width
const shoe = build(L, ankleName === "ankleL");
const q = new THREE.Quaternion().setFromRotationMatrix(_shoeM);
// Optional per-shoe orientation nudges (procedural calibration): roll about the foot's
// length axis (fixes which way the sole faces), pitch about the width axis (toe up/down).
const roll = shoe.userData.roll || 0, pitch = shoe.userData.pitch || 0;
if (roll) q.multiply(new THREE.Quaternion().setFromAxisAngle(_xAxis, roll));
if (pitch) q.multiply(new THREE.Quaternion().setFromAxisAngle(_zAxis, pitch));
shoe.quaternion.copy(q);
const drop = shoe.userData.drop || 0;
if (drop) shoe.position.copy(_shoeUp).multiplyScalar(-drop * L); // slide along the sole-up axis
shoe.userData.isShoe = true;
ankle.add(shoe);
fig.shoeMeshes.push(shoe);
return shoe;
};
// Add this figure's shoes in its current style. Procedural is synchronous; GLB styles await
// the (cached) template, so this is async — callers requestRender/capture when it settles.
const addShoes = async (fig) => {
if (!fig) return;
const style = SHOE_STYLES[fig.shoeStyle] ? fig.shoeStyle : "procedural";
fig.shoeStyle = style;
if (fig.shoeMeshes?.length) { fig.shoes = true; return; }
fig.shoeMeshes = [];
fig.shoes = true;
// Which feet get shod — "pair" (both), "L" or "R" only (undressing / half-shod scenes).
const ankles = fig.shoeScope === "L" ? ["ankleL"]
: fig.shoeScope === "R" ? ["ankleR"] : ["ankleL", "ankleR"];
if (style === "procedural") {
const c = { lenScale: 1, widthScale: 1, drop: 0, roll: 0, pitch: 0, ...(shoeCalib.procedural || {}) };
for (const ankleName of ankles)
placeShoe(fig, ankleName, (L) => {
const m = new THREE.Mesh(makeShoeGeo(), shoeMat);
m.userData.ownGeo = true;
m.scale.set(L * c.lenScale, L * c.lenScale, L * c.widthScale);
m.userData.roll = c.roll; m.userData.pitch = c.pitch; m.userData.drop = c.drop;
return m;
});
requestRender();
return;
}
const fit = SHOE_STYLES[style].fit;
if (fit) { // hand-calibrated per-foot placement (captured from a positioned prop)
// Some styles (heels) also carry the foot angle — set it BEFORE placing so the
// calibrated offset (captured at that angle) lands the shoe correctly.
const anklePose = SHOE_STYLES[style].ankle;
if (anklePose) for (const an of ankles) {
if (anklePose[an] && fig.joints[an]) fig.joints[an].quaternion.fromArray(anklePose[an]);
}
for (const ankleName of ankles) {
if (fit[ankleName]) await placeCalibratedShoe(fig, ankleName, fit[ankleName]);
}
requestRender();
return;
}
let tmpl;
try { tmpl = await bakeShoeTemplate(style); }
catch (e) { console.error("[PromptChain] shoe style failed, using procedural", style, e); fig.shoeStyle = "procedural"; if (fig.shoes) await addShoes(fig); return; }
if (!fig.shoes || fig.shoeStyle !== style) return; // toggled off / style changed mid-load
const k = (styleMeta(style).lenScale || 1); // overall size relative to foot length
for (const ankleName of ankles)
placeShoe(fig, ankleName, (L, isLeft) => {
const m = tmpl.clone(true);
const w = (isLeft && tmpl.userData.mirror) ? -L * k : L * k; // mirror single-foot GLB into a pair
m.scale.set(L * k, L * k, w);
return m;
});
requestRender();
};
const removeShoes = (fig) => {
// Only procedural shoes own their geometry; GLB clones share the cached template's, so
// disposing it would break every other figure's shoes and the template itself.
for (const s of fig?.shoeMeshes || []) { s.parent?.remove(s); s.traverse?.((o) => { if (o.userData?.ownGeo) o.geometry?.dispose?.(); }); }
if (fig) { fig.shoeMeshes = []; fig.shoes = false; }
};
const restoreShoes = (fig, data) => {
const want = data?.shoes;
if (want) {
// New saves store {style, scope}; old saves stored just the style string (= pair).
if (typeof want === "object") { fig.shoeStyle = want.style || "procedural"; fig.shoeScope = want.scope || "pair"; }
else { fig.shoeStyle = (typeof want === "string") ? want : "procedural"; fig.shoeScope = "pair"; }
addShoes(fig);
} else removeShoes(fig);
};
const setShoeStyle = (fig, style) => {
if (!fig || !SHOE_STYLES[style]) return;
fig.shoeStyle = style;
if (!fig.shoes) return;
removeShoes(fig);
fig.shoes = true;
addShoes(fig).then(() => captureAndUpload(node));
};
const setShoeScope = (fig, scope) => {
if (!fig) return;
fig.shoeScope = scope;
if (!fig.shoes) return;
removeShoes(fig);
fig.shoes = true;
addShoes(fig).then(() => captureAndUpload(node));
};
const toggleShoes = (fig) => {
if (!fig) return;
if (fig.shoes) { removeShoes(fig); requestRender(); captureAndUpload(node); }
else {
// sneakers are the default — "procedural" lives on only through old saves
if (!fig.shoeStyle || fig.shoeStyle === "procedural") fig.shoeStyle = "sneakers";
addShoes(fig).then(() => captureAndUpload(node));
}
};
// Drop the cached template so the next build re-bakes with the latest calibration, then
// rebuild the live shod figure(s). Used by the 🔧 panel while tuning a style's orientation.
const recalibrateShoe = (style) => {
delete shoeTemplates[style];
if (selectedFigure?.shoes && selectedFigure.shoeStyle === style) setShoeStyle(selectedFigure, style);
};
const figurePanel = document.createElement("div");
figurePanel.style.cssText =
"position:absolute;top:6px;right:6px;z-index:3;display:none;gap:3px;" +
"background:rgba(20,20,26,0.82);border:1px solid #4a4a55;border-radius:5px;padding:3px 4px;";
figurePanel.addEventListener("pointerdown", (e) => e.stopPropagation());
// (Move/rotate live in the left transform group now — not repeated here.)
// Full-body pose presets — bundled BODY_POSES (shipped in code, human rig)
// overlaid by USER poses from user/PromptChain/poses/*.json: same name = the
// user's version wins (the templates/prompts delta model). The list filters
// to the selected figure's rig kind, so horse poses never offer to a person.
// "💾 save pose…" captures the selected figure's current pose to the user
// layer, so the dropdown grows without a code round-trip. Hidden on capsule
// figures (captured quats only fit GLB rigs).
const bodyPoseSelect = document.createElement("select");
bodyPoseSelect.title = "Apply a ready-made pose to this person — or save the current one";
bodyPoseSelect.style.cssText =
"font:11px system-ui,sans-serif;background:#2a2a35;color:#ccd;" +
"border:1px solid #4a4a55;border-radius:4px;padding:1px 3px;cursor:pointer;max-width:110px;";
bodyPoseSelect.addEventListener("pointerdown", (e) => e.stopPropagation());
figurePanel.appendChild(bodyPoseSelect);
let userPoses = {}; // name -> {rig, pose} — the user layer, fetched once + updated on save
const rigKindOf = (f) => FIGURE_LIBRARY[bodyKeyOf(f?.body)]?.rig || "human";
const poseEntriesFor = (kind) => {
const merged = {};
for (const [n, p] of Object.entries(BODY_POSES)) merged[n] = { rig: "human", pose: p };
for (const [n, e] of Object.entries(userPoses)) merged[n] = e; // user shadows bundled by name
return Object.entries(merged).filter(([, e]) => e.rig === kind).sort(([a], [b]) => a.localeCompare(b));
};
let poseOptionsKind = null; // rig kind the options were last built for
const rebuildPoseOptions = () => {
const kind = rigKindOf(selectedFigure);
bodyPoseSelect.innerHTML = "";
const ph = document.createElement("option");
ph.value = ""; ph.textContent = "🤸 pose"; ph.disabled = true; ph.selected = true;
bodyPoseSelect.appendChild(ph);
for (const [name] of poseEntriesFor(kind)) {
const o = document.createElement("option");
o.value = name; o.textContent = name;
bodyPoseSelect.appendChild(o);
}
const save = document.createElement("option");
save.value = "__save__"; save.textContent = "💾 save pose…";
bodyPoseSelect.appendChild(save);
poseOptionsKind = kind;
};
const refreshUserPoses = async () => {
try {
const resp = await api.fetchApi("/promptchain/pose-presets/list");
const data = await resp.json();
userPoses = {};
for (const p of data.poses || []) userPoses[p.name] = { rig: p.rig || "human", pose: p.pose };
} catch (e) { console.warn("[PoseStudio] user poses unavailable:", e?.message || e); }
rebuildPoseOptions();
};
refreshUserPoses();
// Save flow: a naming modal (styled like the sidebar's rename modal) prompts for
// the pose name. A pose is named by display name — the server slugifies it to one
// <slug>.json file — so the name must be UNIQUE among the user's saved poses, or
// the write would silently clobber an earlier pose. A clash is reported inline
// (error line + highlighted field) and the modal stays open. Reusing a *bundled*
// pose's name is still allowed: that writes a user file which SHADOWS the bundled
// pose (the existing delta-overlay behaviour) and never overwrites a user file.
// Portaled to <body> so the viewport's overflow:hidden can't clip it; raised above
// this poser's fullscreen layer.
let poseSaveFig = null; // captured at open — the selection can change under the async save
let poseSaveModal = null; // built lazily on first save (after the teardown chain is in place)
const roundQuatMap = (map) => {
const out = {};
for (const [n, q] of Object.entries(map || {})) out[n] = q.map((v) => Math.round(v * 1e4) / 1e4);
return out;
};
// Mirror the server's slug so two display names that map to one file ("My Pose"
// vs "my pose") are caught as the same saved pose before the round-trip.
const poseSlug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
const closePoseSave = () => {
poseSaveFig = null;
if (poseSaveModal) poseSaveModal.backdrop.style.display = "none";
};
const commitPoseSave = async (name) => {
const fig = poseSaveFig;
if (!fig) return;
const data = serializeFigure(fig);
const pose = { joints: roundQuatMap(data.joints), spine: roundQuatMap(data.spine), digits: roundQuatMap(data.digits) };
const rig = rigKindOf(fig);
poseSaveModal.setBusy(true);
try {
const resp = await api.fetchApi("/promptchain/pose-presets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, rig, pose }),
});
const res = await resp.json().catch(() => ({}));
poseSaveModal.setBusy(false);
if (resp.status === 409) { // a parallel tab/process wrote this name after our pre-check
await refreshUserPoses();
poseSaveModal.showError(res.error || `A pose named "${name}" already exists`);
return;
}
if (!resp.ok || res.error) throw new Error(res.error || `save failed (${resp.status})`);
userPoses[name] = { rig, pose };
rebuildPoseOptions();
closePoseSave();
showClipToast(`pose "${name}" saved`);
} catch (e) {
poseSaveModal.setBusy(false);
poseSaveModal.showError("save failed — " + (e.message || e));
}
};
const attemptPoseSave = () => {
if (!poseSaveFig) { closePoseSave(); return; }
const name = poseSaveModal.input.value.trim();
if (!name) { poseSaveModal.showError("Enter a name for this pose"); return; }
const slug = poseSlug(name);
if (Object.keys(userPoses).some((n) => poseSlug(n) === slug)) {
poseSaveModal.showError(`A pose named "${name}" already exists — choose a unique name`);
return;
}
commitPoseSave(name);
};
const buildPoseSaveModal = () => {
const backdrop = document.createElement("div");
backdrop.className = "pcr-pose-save-backdrop";
backdrop.style.cssText =
"position:fixed;top:0;left:0;right:0;bottom:0;display:none;align-items:center;justify-content:center;" +
"background:rgba(0,0,0,0.6);font:14px system-ui,sans-serif;";
const dialog = document.createElement("div");
dialog.style.cssText =
"min-width:320px;max-width:480px;background:#262626;border:1px solid #3a3a3a;border-radius:8px;" +
"box-shadow:0 8px 32px rgba(0,0,0,0.5);";
const header = document.createElement("div");
header.style.cssText = "display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #3a3a3a;";
const title = document.createElement("span");
title.textContent = "Save Pose";
title.style.cssText = "font-size:16px;font-weight:600;color:#fff;";
const closeBtn = document.createElement("button");
closeBtn.textContent = "✕";
closeBtn.title = "Close";
closeBtn.style.cssText = "width:28px;height:28px;padding:0;background:transparent;border:none;border-radius:4px;color:#888;cursor:pointer;font-size:15px;line-height:1;";
closeBtn.addEventListener("mouseenter", () => { closeBtn.style.background = "rgba(255,255,255,0.1)"; closeBtn.style.color = "#fff"; });
closeBtn.addEventListener("mouseleave", () => { closeBtn.style.background = "transparent"; closeBtn.style.color = "#888"; });
header.append(title, closeBtn);
const body = document.createElement("div");
body.style.cssText = "padding:20px;";
const input = document.createElement("input");
input.type = "text";
input.maxLength = 64; // matches the server's 1–64 char limit
input.placeholder = "Pose name";
input.style.cssText =
"width:100%;box-sizing:border-box;padding:10px 12px;background:#1a1a1a;border:1px solid #3a3a3a;" +
"border-radius:6px;color:#fff;font-size:14px;outline:none;";
const error = document.createElement("div");
error.className = "pcr-pose-save-error";
error.style.cssText = "margin-top:8px;min-height:15px;font-size:12px;color:#ff6b6b;display:none;";
body.append(input, error);
const footer = document.createElement("div");
footer.style.cssText = "display:flex;justify-content:flex-end;gap:10px;padding:16px 20px;border-top:1px solid #3a3a3a;";
const mkFooterBtn = (label, bg, fg) => {
const b = document.createElement("button");
b.textContent = label;
b.style.cssText = `padding:8px 16px;border:none;border-radius:6px;font-size:14px;font-weight:500;cursor:pointer;background:${bg};color:${fg};`;
return b;
};
const cancelBtn = mkFooterBtn("Cancel", "#3a3a3a", "#ccc");
const confirmBtn = mkFooterBtn("Save", "#973f00", "#fff");
footer.append(cancelBtn, confirmBtn);
dialog.append(header, body, footer);
backdrop.appendChild(dialog);
const clearError = () => { error.style.display = "none"; input.style.borderColor = input === document.activeElement ? "#dd7634" : "#3a3a3a"; };
const showError = (msg) => {
error.textContent = msg;
error.style.display = "block";
input.style.borderColor = "#ff6b6b";
input.focus();
input.select();
};
const setBusy = (busy) => {
confirmBtn.disabled = cancelBtn.disabled = busy;
confirmBtn.style.opacity = busy ? "0.6" : "1";
confirmBtn.style.cursor = busy ? "default" : "pointer";
};
input.addEventListener("input", () => { if (error.style.display === "block") clearError(); });
input.addEventListener("focus", () => { if (error.style.display !== "block") input.style.borderColor = "#dd7634"; });
input.addEventListener("blur", () => { if (error.style.display !== "block") input.style.borderColor = "#3a3a3a"; });
backdrop.addEventListener("pointerdown", (e) => e.stopPropagation()); // keep canvas/selection handlers out
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) closePoseSave(); }); // click the dim to cancel
backdrop.addEventListener("keydown", (e) => {
e.stopPropagation(); // graph/viewport hotkeys must never fire while the modal is up
if (e.key === "Escape") { e.preventDefault(); closePoseSave(); }
else if (e.key === "Enter" && e.target === input) { e.preventDefault(); attemptPoseSave(); }
});
closeBtn.addEventListener("click", closePoseSave);
cancelBtn.addEventListener("click", closePoseSave);
confirmBtn.addEventListener("click", attemptPoseSave);
document.body.appendChild(backdrop);
// Ride the portaled-flyout teardown chain so the modal never outlives the node.
const prevCleanup = node._pcrMenuCleanup;
node._pcrMenuCleanup = () => { prevCleanup?.(); backdrop.remove(); };
poseSaveModal = { backdrop, input, showError, clearError, setBusy };
};
const openPoseSave = () => {
poseSaveFig = selectedFigure;
if (!poseSaveFig) return;
if (!poseSaveModal) buildPoseSaveModal();
poseSaveModal.backdrop.style.zIndex = String((node.properties?.pcrFullscreenZ || 10001) + 10); // above this poser's flyout menus
poseSaveModal.input.value = "";
poseSaveModal.clearError();
poseSaveModal.setBusy(false);
poseSaveModal.backdrop.style.display = "flex";
requestAnimationFrame(() => { poseSaveModal.input.focus(); poseSaveModal.input.select(); });
};
bodyPoseSelect.addEventListener("change", () => {
const picked = bodyPoseSelect.value;
bodyPoseSelect.value = "";
if (!selectedFigure) return;
if (picked === "__save__") { openPoseSave(); return; }
const entry = poseEntriesFor(rigKindOf(selectedFigure)).find(([n]) => n === picked);
if (entry) applyBodyPose(selectedFigure, entry[1].pose);
});
// Body-shape toggle — only if the GLB carries morphs. Per-person, so it lives here on
// the selected-figure panel; the sliders (bottom-right) follow the active figure.
let figureBodyBtn = null;
if (bodySliders.length) {
figureBodyBtn = mkBtn("💪", "Body-shape sliders — height, weight, muscle, breast, hips, glutes", () => {
bodyPanelOpen = !bodyPanelOpen;
updateFigureUI();
requestRender();
});
figurePanel.appendChild(figureBodyBtn);
}
// Segmented [ L | pair | R ] foot-scope control — which feet wear the shoe (undressing /
// half-shod scenes). Shown only while shoes are on. `_sync(fig)` updates visibility + the
// active segment highlight.
const mkScopeToggle = (getFig, after) => {
const wrap = document.createElement("div");
wrap.style.cssText = "display:none;border:1px solid #4a4a55;border-radius:4px;overflow:hidden;";
wrap.addEventListener("pointerdown", (e) => e.stopPropagation());
const btns = {};
const segs = [["L", "L", "Left foot only"], ["pair", "pair", "Both feet"], ["R", "R", "Right foot only"]];
segs.forEach(([val, label, title], i) => {
const b = document.createElement("button");
b.textContent = label; b.title = title;
b.style.cssText = "font:11px system-ui,sans-serif;background:#2a2a35;color:#ccd;border:0;padding:1px 6px;cursor:pointer;" +
(i < segs.length - 1 ? "border-right:1px solid #4a4a55;" : "");
b.addEventListener("click", (e) => { e.stopPropagation(); const f = getFig(); if (f) { setShoeScope(f, val); after(); } });
btns[val] = b; wrap.appendChild(b);
});
wrap._sync = (fig) => {
wrap.style.display = (fig && fig.shoes) ? "inline-flex" : "none";
const sc = fig?.shoeScope || "pair";
for (const [v, b] of Object.entries(btns)) b.style.background = (v === sc) ? "#2e5d44" : "#2a2a35";
};
return wrap;
};
// Shoes toggle — swap this person's bare feet for a shoe shape (and back).
const figureShoesBtn = mkBtn("👟", "Toggle shoes on this person's feet (so the depth map reads footwear)", () => {
if (selectedFigure) { toggleShoes(selectedFigure); updateFigureUI(); }
});
figurePanel.appendChild(figureShoesBtn);
// Sculpt this body — reshape a region freehand when a slider isn't enough (e.g. nudge a
// breast bigger / fix its shape). Hidden for skin-bound figures (reshaping under a bound
// mesh distorts it — same v1 limit as the body sliders).
const figureSculptBtn = mkBtn("🗿", "Sculpt this body — grab / inflate / smooth a region (e.g. nudge a breast bigger when the slider isn't enough)", () => {
if (!selectedFigure) return;
if (sculpt.active) exitSculpt(); else enterSculpt("figure", selectedFigure);
});
figurePanel.appendChild(figureSculptBtn);
container.appendChild(figurePanel);
// (The 🔧 calibration button and 🗑 are gone from this panel: calibration was
// a dev tool — the panel code stays for future style fitting — and deletion
// lives on the Del key / cut. The wrench's openShoeCalib hooks stay wired
// but nothing opens it.)
let openShoeCalib = () => {};
let shoeCalibOpen = false;
const shoesCalibAvail = () => !!selectedFigure?.shoes;
// Shoe OPTIONS live in a bottom-right panel (like the 💪 sliders): style
// picker + which-feet scope, shown while the selected figure wears shoes.
// "procedural" is deliberately absent from the picker — sneakers are the
// default; old saves with procedural shoes still restore it.
const shoeStyleSelect = document.createElement("select");
shoeStyleSelect.title = "Shoe style";
shoeStyleSelect.style.cssText =
"font:11px system-ui,sans-serif;background:#2a2a35;color:#ccd;" +
"border:1px solid #4a4a55;border-radius:4px;padding:1px 3px;cursor:pointer;max-width:96px;";
shoeStyleSelect.addEventListener("pointerdown", (e) => e.stopPropagation());
for (const [key, def] of Object.entries(SHOE_STYLES)) {
if (key === "procedural") continue;
const o = document.createElement("option");
o.value = key; o.textContent = def.label;
shoeStyleSelect.appendChild(o);
}
shoeStyleSelect.addEventListener("change", () => {
if (selectedFigure) { setShoeStyle(selectedFigure, shoeStyleSelect.value); updateFigureUI(); }
});
const figureScopeToggle = mkScopeToggle(() => selectedFigure, () => updateFigureUI());
// ✕ = barefoot — the discoverable "off" right where the options are (the 👟
// toggle on the top panel also works, but nothing down here said so).
const shoeOffBtn = mkBtn("✕", "Remove the shoes (barefoot)", () => {
if (selectedFigure?.shoes) { toggleShoes(selectedFigure); updateFigureUI(); }
});
shoeOffBtn.style.width = "22px";
shoeOffBtn.style.height = "22px";
const shoePanel = document.createElement("div");
shoePanel.style.cssText =
"position:absolute;bottom:6px;right:6px;z-index:3;display:none;gap:4px;align-items:center;" +
"background:rgba(20,20,26,0.82);border:1px solid #4a4a55;border-radius:5px;padding:3px 4px;";
shoePanel.addEventListener("pointerdown", (e) => e.stopPropagation());
shoePanel.append(shoeStyleSelect, figureScopeToggle, shoeOffBtn);
container.appendChild(shoePanel);
updateFigureUI = () => {
figurePanel.style.display = selectedFigure ? "flex" : "none";
figureSculptBtn.style.display = (selectedFigure && !selectedFigure.skinBind) ? "" : "none"; // bound meshes reshape via sliders only
updateToolUI(); // the left transform group reflects this selection's mode
bodyPoseSelect.style.display = selectedFigure?.skel ? "" : "none"; // captured quats fit GLB rigs only
if (selectedFigure && rigKindOf(selectedFigure) !== poseOptionsKind) rebuildPoseOptions(); // human↔creature swap relists
figureShoesBtn.style.background = (selectedFigure && selectedFigure.shoes) ? "#2e5d44" : "#2a2a35";
const shoeVisible = !!(selectedFigure && selectedFigure.shoes);
shoePanel.style.display = shoeVisible ? "flex" : "none";
if (shoeVisible) {
const st = selectedFigure.shoeStyle;
shoeStyleSelect.value = (SHOE_STYLES[st] && st !== "procedural") ? st : "sneakers";
}
figureScopeToggle._sync(selectedFigure);
// Body sliders belong to the selected figure: shown only while one is selected and
// the 💪 toggle is on (sticky across selections, like a property panel).
if (figureBodyBtn) figureBodyBtn.style.background = (selectedFigure && bodyPanelOpen) ? "#2e5d44" : "#2a2a35";
// skin-bound figures hide sliders (v1 limit — reshaping under a bound mesh
// distorts it; same family as the hidden hand/foot presets when bound)
bodyPanel.style.display = (selectedFigure && bodyPanelOpen && !selectedFigure.skinBind) ? "block" : "none";
// both bottom-right panels can be open at once — the sliders sit above the shoe row
bodyPanel.style.bottom = shoeVisible ? "38px" : "6px";
};
// Foot-context shoe controls — the same toggle + style picker as the figure panel, but
// surfaced when an ANKLE is selected (how users actually reach for footwear). Operates on
// the active figure (rig), which owns the selected ankle. Sits under the foot-pose dropdown.
const footShoePanel = document.createElement("div");
footShoePanel.style.cssText =
"position:absolute;top:36px;right:6px;z-index:3;display:none;gap:3px;align-items:center;" +
"background:rgba(20,20,26,0.82);border:1px solid #4a4a55;border-radius:5px;padding:3px 4px;";
footShoePanel.addEventListener("pointerdown", (e) => e.stopPropagation());
const footShoeBtn = mkBtn("👟", "Put shoes on this person's feet (so the depth map reads footwear)", () => {
toggleShoes(rig); updateFootShoeUI();
});
const footShoeSelect = document.createElement("select");
footShoeSelect.title = "Shoe style";
footShoeSelect.style.cssText =
"display:none;font:11px system-ui,sans-serif;background:#2a2a35;color:#ccd;" +
"border:1px solid #4a4a55;border-radius:4px;padding:1px 3px;cursor:pointer;max-width:96px;";
footShoeSelect.addEventListener("pointerdown", (e) => e.stopPropagation());
for (const [key, def] of Object.entries(SHOE_STYLES)) {
if (key === "procedural") continue; // hidden from pickers; legacy saves still restore it
const o = document.createElement("option");
o.value = key; o.textContent = def.label;
footShoeSelect.appendChild(o);
}
footShoeSelect.addEventListener("change", () => { setShoeStyle(rig, footShoeSelect.value); updateFootShoeUI(); });
const footScopeToggle = mkScopeToggle(() => rig, () => updateFootShoeUI());
footShoePanel.append(footShoeBtn, footShoeSelect, footScopeToggle);
container.appendChild(footShoePanel);
updateFootShoeUI = () => {
const isFoot = selectedJointName?.startsWith("ankle") && rig.toes?.[selectedJointName];
footShoePanel.style.display = isFoot ? "flex" : "none";
if (!isFoot) return;
footShoeBtn.style.background = rig.shoes ? "#2e5d44" : "#2a2a35";
footShoeSelect.style.display = rig.shoes ? "inline-block" : "none";
if (rig.shoes) {
const st = rig.shoeStyle;
footShoeSelect.value = (SHOE_STYLES[st] && st !== "procedural") ? st : "sneakers";
}
footScopeToggle._sync(rig);
};
// ── shoe-calibration overlay ────────────────────────────────────────────────
// Live controls for the selected shoe style — GLB styles expose model-axis orientation,
// the procedural shoe exposes roll/pitch/scale. Edits write into shoeCalib[style], rebuild
// the shod feet so you SEE the change, and the readout is the line to paste back to Claude
// so the dialed-in values become the baked defaults.
const AXES = [["+x", [1, 0, 0]], ["-x", [-1, 0, 0]], ["+y", [0, 1, 0]], ["-y", [0, -1, 0]], ["+z", [0, 0, 1]], ["-z", [0, 0, -1]]];
const vecToAxis = (v) => (AXES.find(([, a]) => a[0] === v[0] && a[1] === v[1] && a[2] === v[2]) || AXES[0])[0];
const axisToVec = (k) => (AXES.find(([n]) => n === k) || AXES[0])[1].slice();
const shoeCalibPanel = document.createElement("div");
shoeCalibPanel.style.cssText =
"position:absolute;left:6px;bottom:42px;z-index:4;display:none;flex-direction:column;gap:4px;width:210px;" +
"background:rgba(18,18,24,0.94);border:1px solid #4a4a55;border-radius:6px;padding:7px 8px;" +
"font:11px system-ui,sans-serif;color:#ccd;";
shoeCalibPanel.addEventListener("pointerdown", (e) => e.stopPropagation());
shoeCalibPanel.addEventListener("wheel", (e) => e.stopPropagation());
const calibRow = (label, ctrl) => {
const r = document.createElement("div");
r.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:6px;";
const l = document.createElement("span"); l.textContent = label; l.style.opacity = "0.85";
r.append(l, ctrl); shoeCalibPanel.appendChild(r); return r;
};
const mkAxisSel = () => {
const s = document.createElement("select");
s.style.cssText = "background:#2a2a35;color:#ccd;border:1px solid #4a4a55;border-radius:4px;font:11px system-ui;padding:1px 3px;";
for (const [n] of AXES) { const o = document.createElement("option"); o.value = n; o.textContent = n; s.appendChild(o); }
return s;
};
const mkNum = (min, max, step) => {
const n = document.createElement("input");
n.type = "number"; n.min = min; n.max = max; n.step = step;
n.style.cssText = "width:64px;background:#2a2a35;color:#ccd;border:1px solid #4a4a55;border-radius:4px;font:11px system-ui;padding:1px 3px;";
return n;
};
const R2D = 180 / Math.PI; // D2R already declared above (skeletal-morph helpers)
const procDefaults = { lenScale: 1, widthScale: 1, drop: 0, roll: 0, pitch: 0 };
const calTitle = document.createElement("div");
calTitle.style.cssText = "font-weight:600;opacity:0.9;margin-bottom:1px;";
shoeCalibPanel.appendChild(calTitle);
// GLB-style rows (real shoe meshes — orientation is a fixed model axis).
const calToe = mkAxisSel(); const rToe = calibRow("Toe points →", calToe);
const calUp = mkAxisSel(); const rUp = calibRow("Up ↑", calUp);
const calBack = mkNum(-0.4, 0.6, 0.02); const rBack = calibRow("Heel set-back", calBack);
const calMirror = document.createElement("input"); calMirror.type = "checkbox";
const rMirror = calibRow("Mirror L/R", calMirror);
// Procedural rows (auto-oriented to the foot — nudge orientation + proportions).
const calWidth = mkNum(0.3, 3, 0.05); const rWidth = calibRow("Width ×", calWidth);
const calRoll = mkNum(-180, 180, 5); const rRoll = calibRow("Roll °", calRoll);
const calPitch = mkNum(-90, 90, 5); const rPitch = calibRow("Pitch °", calPitch);
// Shared rows.
const calDrop = mkNum(-0.5, 1, 0.02); const rDrop = calibRow("Ankle drop", calDrop);
const calLen = mkNum(0.3, 3, 0.05); const rLen = calibRow("Length ×", calLen);
const calOut = document.createElement("textarea");
calOut.readOnly = true; calOut.rows = 3;
calOut.style.cssText = "width:100%;box-sizing:border-box;background:#11131a;color:#9fd0a0;border:1px solid #3a3a45;" +
"border-radius:4px;font:10px ui-monospace,monospace;resize:none;margin-top:2px;";
const calHint = document.createElement("div");
calHint.style.cssText = "opacity:0.7;font-size:10px;line-height:1.3;";
calHint.textContent = "Dial until it fits, then paste this line back to Claude.";
shoeCalibPanel.append(calOut, calHint);
container.appendChild(shoeCalibPanel);
const procCalib = () => ({ ...procDefaults, ...(shoeCalib.procedural || {}) });
const refreshCalibReadout = () => {
const style = selectedFigure?.shoeStyle;
if (!style) return;
if (style === "procedural") {
const c = procCalib();
calOut.value = `procedural shoe: lenScale ${c.lenScale}, widthScale ${c.widthScale}, ` +
`drop ${c.drop}, roll ${Math.round(c.roll * R2D)}°, pitch ${Math.round(c.pitch * R2D)}°`;
} else {
const m = styleMeta(style);
if (!m.fwd) { calOut.value = `${style}: hand-calibrated per-foot fit (positioned prop)`; return; }
const len = (m.lenScale && m.lenScale !== 1) ? `, lenScale: ${m.lenScale}` : "";
calOut.value = `${style}: fwd: [${m.fwd.join(", ")}], up: [${m.up.join(", ")}], ` +
`drop: ${m.drop}, back: ${m.back}, mirror: ${!!m.mirror}${len}`;
}
};
const applyCalib = () => {
const style = selectedFigure?.shoeStyle;
if (!style) return;
if (style === "procedural") {
shoeCalib.procedural = {
lenScale: parseFloat(calLen.value), widthScale: parseFloat(calWidth.value),
drop: parseFloat(calDrop.value),
roll: parseFloat(calRoll.value) * D2R, pitch: parseFloat(calPitch.value) * D2R,
};
} else {
shoeCalib[style] = {
fwd: axisToVec(calToe.value), up: axisToVec(calUp.value),
drop: parseFloat(calDrop.value), back: parseFloat(calBack.value),
lenScale: parseFloat(calLen.value), mirror: calMirror.checked,
};
}
recalibrateShoe(style);
refreshCalibReadout();
};
for (const c of [calToe, calUp, calMirror]) c.addEventListener("change", applyCalib);
for (const c of [calWidth, calRoll, calPitch, calBack, calDrop, calLen]) c.addEventListener("input", applyCalib);
openShoeCalib = () => {
const show = shoeCalibOpen && shoesCalibAvail();
shoeCalibPanel.style.display = show ? "flex" : "none";
if (!show) return;
const style = selectedFigure.shoeStyle, proc = style === "procedural";
for (const r of [rToe, rUp, rBack, rMirror]) r.style.display = proc ? "none" : "flex";
for (const r of [rWidth, rRoll, rPitch]) r.style.display = proc ? "flex" : "none";
if (proc) {
const c = procCalib();
calTitle.textContent = "🔧 default shoe fit";
calLen.value = c.lenScale; calWidth.value = c.widthScale; calDrop.value = c.drop;
calRoll.value = Math.round(c.roll * R2D); calPitch.value = Math.round(c.pitch * R2D);
} else {
const m = styleMeta(style);
calTitle.textContent = `🔧 ${SHOE_STYLES[style].label} fit`;
if (!m.fwd) { // calibrated style: no editable axis/offset rows, just the readout
for (const r of [rToe, rUp, rBack, rMirror, rWidth, rRoll, rPitch, rDrop, rLen]) r.style.display = "none";
refreshCalibReadout();
return;
}
calToe.value = vecToAxis(m.fwd); calUp.value = vecToAxis(m.up);
calDrop.value = m.drop; calBack.value = m.back; calLen.value = m.lenScale || 1; calMirror.checked = !!m.mirror;
}
refreshCalibReadout();
};
// Spawn dropdown in the strip — shows a single ➕ glyph closed, the named prop list
// when opened (resets to ➕ after each pick).
// Prop picker — a custom flyout menu (replaces a flat <select>) so the growing model
// library tucks into a "Library ▸" submenu instead of one long list. Categories map
// the MODEL_PROPS keys into groups; anything not listed here just won't appear.
const PROP_LIBRARY = [
{ cat: "Furniture", items: ["desk", "chair", "table", "tableRound", "stool", "bench", "bookcase", "sofa", "sideTable", "bed", "stairs"] },
{ cat: "Classroom", items: ["schoolDeskSet"] },
{ cat: "Handheld", items: ["sword", "dagger", "spear", "bow", "shield", "axe", "hammer", "pickaxe", "shovel", "gun", "guitar", "book", "key", "bag", "bottle", "cup", "mug", "wineglass"] },
{ cat: "Vehicles", items: ["carSedan", "carSuv", "van", "truck", "kart", "motorcycle", "bicycle", "scooter", "skateboard"] },
{ cat: "Environment", items: ["lamppost", "trafficCone", "barrier", "roadSign", "signpost", "fence", "barrel", "crateWood", "chest", "workbench", "campfire", "rock", "tree", "doorway", "windowframe", "pillar", "fountain"] },
{ cat: "Footwear", items: ["tacticalBootR", "tacticalBootL", "heelR", "heelL", "sneakerR", "sneakerL"] },
{ cat: "Articulated", items: ["chainShort", "chainMedium", "chainLong"] }, // procedural posable chains (braids/tails/ropes) — 🦴 per-segment + tip IK
{ cat: "Misc", items: ["crate", "trashcan", "plant"] },
];
// Prop SETS — one library entry that spawns several MODEL_PROPS in a captured
// arrangement (offsets read from a real scene save). Parts stay individual
// props: each serializes/moves/deletes on its own after spawn.
const PROP_SETS = {
schoolDeskSet: {
label: "🏫 school desk",
parts: [
{ type: "schoolDesk", pos: [0, 0, 0.4] }, // desk at the normal prop spawn spot…
{ type: "schoolChair", pos: [0, 0, -0.1706] }, // …chair tucked behind it (master-base-pos-2)
],
},
};
const spawnPropSet = (set) => {
Promise.all(set.parts.map((p) => loadModelProp(p.type))).then((roots) => {
if (!node._pcrAlive) { roots.forEach(disposeProp); return; }
roots.forEach((root, i) => {
root.position.fromArray(set.parts[i].pos);
propsGroup.add(root);
});
selectProp(roots[0]);
requestRender();
captureAndUpload(node); // one capture for the whole arrangement
}).catch((e) => console.error("[PoseStudio] prop set load failed:", e));
};
// Portaled to <body> (position:fixed) so the poser viewport's overflow:hidden can't
// clip them; positioned in viewport coords. z-index sits above the fullscreen
// container (9998).
// Fullscreen + menu z-index are per-instance so a poser hosted inside a high
// modal (the detached Re-pose poser) can still take over the screen. Defaults
// match the node context: fullscreen 10001 (above the PromptChain node
// fullscreen 9999), menus just above it. mountDetachedPoser raises both.
const fsZ = node.properties?.pcrFullscreenZ || 10001;
const menuZ = fsZ + 9;
const MENU_CSS = `position:fixed;z-index:${menuZ};display:none;flex-direction:column;min-width:128px;` + // above this poser's fullscreen
"overflow-y:auto;overflow-x:hidden;background:#23232b;border:1px solid #4a4a55;border-radius:6px;padding:3px;" +
"box-shadow:0 4px 16px rgba(0,0,0,0.55);font:12px/1.3 system-ui,sans-serif;";
const mkFlyoutShell = () => {
const m = document.createElement("div");
m.style.cssText = MENU_CSS;
m.addEventListener("pointerdown", (e) => e.stopPropagation());
document.body.appendChild(m);
return m;
};
const propMenu = mkFlyoutShell();
const libMenu = mkFlyoutShell();
const subjectMenu = mkFlyoutShell();
const creatureMenu = mkFlyoutShell();
const subFlyouts = []; // level-2 flyouts (one per library category)
let menuOpen = false;
const closeMenu = () => {
for (const m of [propMenu, libMenu, subjectMenu, creatureMenu, ...subFlyouts]) m.style.display = "none";
menuOpen = false;
};
// Flyouts are fixed-positioned on <body>, so positioning is in pure viewport
// coordinates. Cap height to the window (overflow-y:auto scrolls a too-tall list)
// and clamp the top so the whole menu stays on screen.
const placeFlyoutTop = (menu, anchorTopClient) => {
menu.style.maxHeight = Math.max(80, window.innerHeight - 16) + "px";
const top = Math.min(anchorTopClient, window.innerHeight - menu.offsetHeight - 8);
menu.style.top = Math.max(8, top) + "px";
};
const openSideFlyout = (menu, anchorItem, isSub = false) => {
// siblings at the same depth yield (hovering between ▸ rows swaps flyouts);
// opening a level-2 category keeps its parent libMenu up.
const siblings = isSub ? subFlyouts : [libMenu, subjectMenu, creatureMenu, ...subFlyouts];
for (const m of siblings) if (m !== menu) m.style.display = "none";
menu.style.display = "flex";
const ar = anchorItem.getBoundingClientRect();
placeFlyoutTop(menu, ar.top); // caps height first so offsetWidth includes any scrollbar
let left = ar.right + 2;
if (left + menu.offsetWidth > window.innerWidth) left = ar.left - menu.offsetWidth - 2; // flip if it would overflow
menu.style.left = Math.max(4, left) + "px";
};
const mkMenuItem = (label, onClick, opts = {}) => {
const it = document.createElement("div");
it.textContent = label;
it.style.cssText = "padding:4px 9px;border-radius:4px;cursor:pointer;color:#cdd;white-space:nowrap;" +
(opts.flyout ? "display:flex;align-items:center;justify-content:space-between;gap:12px;" : "");
if (opts.flyout) { const a = document.createElement("span"); a.textContent = "▸"; a.style.opacity = "0.6"; it.appendChild(a); }
it.addEventListener("pointerenter", () => {
it.style.background = "#3a3a46";
if (opts.flyout) openSideFlyout(opts.flyout, it, opts.sub);
else if (opts.closesFlyout) for (const m of [libMenu, subjectMenu, creatureMenu, ...subFlyouts]) m.style.display = "none";
});
it.addEventListener("pointerleave", () => { it.style.background = "transparent"; });
if (onClick) it.addEventListener("click", (e) => { e.stopPropagation(); onClick(); closeMenu(); });
return it;
};
// Library ▸ Category ▸ props — each category is its own nested flyout instead
// of one long header-sectioned list.
for (const { cat, items } of PROP_LIBRARY) {
const catMenu = mkFlyoutShell();
subFlyouts.push(catMenu);
for (const t of items) {
const s = PROP_SETS[t];
if (s) { catMenu.appendChild(mkMenuItem(s.label, () => spawnPropSet(s))); continue; }
const c = CHAIN_DEFS[t];
if (c) { catMenu.appendChild(mkMenuItem(c.label, () => spawnProp(t))); continue; }
const d = MODEL_PROPS[t];
if (d) catMenu.appendChild(mkMenuItem(d.label, () => spawnProp(t)));
}
libMenu.appendChild(mkMenuItem(cat, null, { flyout: catMenu, sub: true }));
}
// 🧑 Subject ▸ — the posable bodies, first thing in ➕ (the old standalone 👤
// button is gone; everything addable lives in one menu). Every entry is a
// TEMPLATE of the custom slider-engine body (the baked GLBs stay registered
// for old saves but aren't offered for new spawns). Capture is deferred until
// the preset is applied so the first control map isn't a default-shaped ghost.
for (const [, t] of Object.entries(BODY_TEMPLATES)) {
subjectMenu.appendChild(mkMenuItem(t.label, async () => {
const f = await spawnFigure({ body: "custom", capture: false });
if (f?.customEngine) {
f.customEngine.applyValues(t.sliders, {});
syncBodySliders();
}
captureAndUpload(node);
}));
}
propMenu.appendChild(mkMenuItem("🧑 Subject", null, { flyout: subjectMenu }));
// 🐾 Creatures ▸ — non-human FIGURES (full rig-profile toolkit: joint handles,
// leg IK, mirror, hoof pins), not props. Entries are FIGURE_LIBRARY bodies
// with a non-human `rig` key.
for (const [key, b] of Object.entries(FIGURE_LIBRARY)) {
if (!b.rig) continue;
creatureMenu.appendChild(mkMenuItem(b.label, () => spawnFigure({ body: key })));
}
propMenu.appendChild(mkMenuItem("🐾 Creatures", null, { flyout: creatureMenu }));
propMenu.appendChild(mkMenuItem("📚 Library", null, { flyout: libMenu }));
// 👕 paint clothing is cordoned off for now (jagged edges / color-binding issues) —
// re-enable here once the per-piece-region rework lands. enterClothingPaint + all its
// code stay intact, just unreachable from the menu.
propMenu.appendChild(mkMenuItem("✏️ draw shape", () => spawnProp("__draw__"), { closesFlyout: true }));
propMenu.appendChild(mkMenuItem("🧊 draw 3D shape", () => spawnProp("__draw3d__"), { closesFlyout: true }));
propMenu.appendChild(mkMenuItem("🖼 image → shape", () => pickImageForShape(), { closesFlyout: true }));
for (const [t, d] of Object.entries(PROP_DEFS)) propMenu.appendChild(mkMenuItem(d.label, () => spawnProp(t), { closesFlyout: true }));
// Ground plane toggle — scene furniture, so it lives with the other addables
// (it STAYS in the capture: gives DepthAnything a receding floor to read).
propMenu.appendChild(mkMenuItem("▦ ground floor (toggle)", () => {
floor.visible = !floor.visible;
requestRender();
captureAndUpload(node); // the floor exists FOR the capture — re-process immediately
}, { closesFlyout: true }));
// True-depth output moved to the right-click context menu ("🖼 output ▸" —
// default | white background | depth map), where the other always-available
// verbs live. The ➕ menu stays add-things-only.
// 📂 import a user mesh: upload to the server FIRST (content-addressed), then
// spawn from the stored copy — the picked file never enters the scene
// directly, so pose_state always references a refetchable name and the prop
// survives reload/undo/paste/export. Same content = same name (dedupe).
const meshInput = document.createElement("input");
meshInput.type = "file"; meshInput.accept = ".glb,.gltf,.obj"; meshInput.style.display = "none";
// Shared by the ➕ menu's import item AND the toolbar 📂 (which accepts a
// scene .json or a mesh and routes by extension).
const importMeshFile = async (file) => {
try {
const form = new FormData();
form.append("mesh", file);
const resp = await api.fetchApi("/promptchain/pose-mesh/upload", { method: "POST", body: form });
const data = await resp.json().catch(() => ({}));
if (!resp.ok || data.error || !data.file) throw new Error(data.error || `upload failed (${resp.status})`);
await spawnImportedMesh(data.file);
} catch (e) {
console.error("[PoseStudio] mesh upload failed:", e);
showClipToast("mesh import failed — " + (e.message || e), true);
}
};
meshInput.addEventListener("change", () => {
const file = meshInput.files?.[0];
meshInput.value = ""; // same file re-importable later
if (file) importMeshFile(file);
});
container.appendChild(meshInput); // torn down with the viewport container
propMenu.appendChild(mkMenuItem("📂 import model (.glb / .obj)", () => meshInput.click(), { closesFlyout: true }));
const propAddBtn = mkToolBtn("➕", "Add a subject, prop or model to the scene", () => {
if (menuOpen) { closeMenu(); return; }
propMenu.style.display = "flex"; libMenu.style.display = "none"; menuOpen = true;
const br = propAddBtn.getBoundingClientRect();
placeFlyoutTop(propMenu, br.top); // caps height first so offsetWidth includes any scrollbar
let left = br.right + 4;
if (left + propMenu.offsetWidth > window.innerWidth) left = br.left - propMenu.offsetWidth - 4;
propMenu.style.left = Math.max(4, left) + "px";
});
mainGroup.appendChild(propAddBtn);
// Any pointerdown outside the menu/button closes it. On window (capture) because the
// flyouts are portaled to <body>, outside the poser container. Removed on teardown.
const onMenuOutside = (e) => {
if (menuOpen && ![propMenu, libMenu, subjectMenu, creatureMenu, ...subFlyouts, propAddBtn].some((m) => m.contains(e.target))) closeMenu();
};
window.addEventListener("pointerdown", onMenuOutside, true);
// Teardown bridge: onRemoved lives in setupPoseStudio — a different scope —
// so hand it a closure over these locals (a bare reference there
// ReferenceErrors and aborts the rest of the teardown).
node._pcrMenuCleanup?.(); // re-mount: drop the previous mount's flyouts + listener
node._pcrMenuCleanup = () => {
window.removeEventListener("pointerdown", onMenuOutside, true);
for (const m of [propMenu, libMenu, subjectMenu, creatureMenu, ...subFlyouts]) m.remove(); // flyouts live on <body>, not inside container
};
// Build/destroy props from serialized state (workflow reload + undo/redo). Props
// are scene-level (shared by all figures), so propsApi lives at mount scope, not
// on a rig. The gizmo may point at a prop we're about to dispose — drop it first.
// Imported-mesh FK pose as a rest-diff: only bones the user (or a scrubbed
// clip) actually moved — a 60-bone rig at rest serializes to nothing.
const serializeBoneDiff = (ud) => {
const out = {};
for (const b of ud._bones) {
const r = ud._boneRest.get(b);
if (!r) continue;
const moved = b.position.distanceToSquared(r.p) > 1e-10;
const turned = 1 - Math.abs(b.quaternion.dot(r.q)) > 1e-7;
if (!moved && !turned) continue;
out[b.name] = { q: b.quaternion.toArray(), ...(moved ? { p: b.position.toArray() } : {}) };
}
return out;
};
const serializeProp = (m) => {
// A missing-mesh marker re-serializes its original record verbatim, but with the
// marker's CURRENT transform — so repositioning a missing prop persists and it
// lands correctly once the file is located.
if (m.userData._failedRecord) return { ...m.userData._failedRecord, pos: m.position.toArray(), quat: m.quaternion.toArray(), scale: m.scale.toArray() };
const boneDiff = m.userData._bones ? serializeBoneDiff(m.userData) : null;
return {
type: m.userData.propType,
uid: m.userData.uid,
...(m.userData.customName ? { name: m.userData.customName } : {}), // named prop = region entity
...(m.userData.groupId ? { groupId: m.userData.groupId } : {}), // grouping (shared id across members)
pos: m.position.toArray(),
quat: m.quaternion.toArray(),
scale: m.scale.toArray(),
...(m.userData.points ? { points: m.userData.points } : {}), // freehand outline → rebuild on restore
...(m.userData.holes?.length ? { holes: m.userData.holes } : {}), // enclosed cut-outs (image→shape)
...(m.userData.drawMode ? { drawMode: m.userData.drawMode, drawAmount: m.userData.drawAmount, drawRound: m.userData.drawRound } : {}), // 3D-shape mode + sliders
...(m.userData.file ? { file: m.userData.file } : {}), // imported mesh → refetch by content hash
...(m.userData.part != null ? { part: m.userData.part } : {}), // split part → rebuild just that mesh
...(boneDiff && Object.keys(boneDiff).length ? { bones: boneDiff } : {}), // FK pose survives reload
...(m.userData.subdivLevel ? { subdivLevel: m.userData.subdivLevel } : {}), // mesh-editor subdivisions (re-applied before the sculpt delta)
...(m.userData.sculptMap && m.userData.sculptMap.size ? { sculpt: sculptMapToData(m.userData.sculptMap) } : {}), // freehand sculpt delta
};
};
// Rebuild one prop from its serialized form (drawn shape / imported mesh /
// GLB model / primitive). Returns null on a failed/unknown load — restoreFrom
// holds the slot with a placeholder.
const buildPropFrom = async (p) => {
let m;
if (p.type === "drawnShape") m = makeDrawnProp(p.points, { mode: p.drawMode, amount: p.drawAmount, round: p.drawRound, holes: p.holes });
else if (p.type === "importedMesh") m = p.file ? await loadImportedMesh(p.file, p.part ?? null).catch(() => null) : null;
else if (CHAIN_DEFS[p.type]) m = makeChainProp(p.type); // procedural chain; FK pose restores via p.bones below
else if (MODEL_PROPS[p.type]) m = await loadModelProp(p.type).catch(() => null);
else m = makeProp(p.type);
if (!m) return null;
if (p.uid != null) { m.userData.uid = p.uid; propUidSeq = Math.max(propUidSeq, p.uid + 1); } // never reissue a restored uid
if (typeof p.groupId === "string" && p.groupId) { m.userData.groupId = p.groupId; const n = parseInt(p.groupId.slice(1), 10); if (Number.isFinite(n)) groupIdSeq = Math.max(groupIdSeq, n + 1); }
m.userData.customName = (typeof p.name === "string" && p.name.trim()) ? p.name.trim() : null;
if (Array.isArray(p.pos)) m.position.fromArray(p.pos);
if (Array.isArray(p.quat)) m.quaternion.fromArray(p.quat);
if (Array.isArray(p.scale)) m.scale.fromArray(p.scale);
// Re-apply a saved FK pose (bone rest-diffs, by bone name).
if (p.bones && m.userData._bones) {
for (const b of m.userData._bones) {
const d = p.bones[b.name];
if (!d) continue;
if (Array.isArray(d.q)) b.quaternion.fromArray(d.q);
if (Array.isArray(d.p)) b.position.fromArray(d.p);
}
}
// Re-apply mesh-editor subdivisions, then the freehand sculpt, onto the prop's primary
// mesh (own its geometry first so a shared template GLB instance isn't mutated per clone).
// subdivideGeometry is deterministic, so the rebuilt indices match the saved sculpt delta.
const cm = sculptPropMesh(m);
if (cm && p.subdivLevel) {
let g = cm.geometry, owned = false;
for (let i = 0; i < p.subdivLevel; i++) { const ng = subdivideGeometry(g).geometry; if (owned) g.dispose(); g = ng; owned = true; } // never dispose the shared template (first iteration)
cm.geometry = g; cm.userData._sculptOwned = true;
m.userData.subdivLevel = p.subdivLevel;
}
if (cm && p.sculpt) { if (!cm.userData._sculptOwned) ownPropGeo(m, cm); m.userData.sculptMap = sculptDataToMap(p.sculpt); applySculpt(cm, m.userData.sculptMap); }
return m;
};
const propsApi = {
serialize: () => propsGroup.children.map(serializeProp),
// Async because model props load their GLB. Built sequentially so propsGroup order
// matches the serialized order (attachApi resolves leaders/followers by index).
restoreFrom: async (list) => {
if (selectedProp) { transformControls.detach(); selectedProp = null; updatePropUI(); }
attachments.length = 0; // these props are about to be disposed; pins are rebuilt by attachApi
for (const m of [...propsGroup.children]) { propsGroup.remove(m); disposeProp(m); }
for (const p of list || []) {
const m = await buildPropFrom(p);
if (!node._pcrAlive) { if (m) disposeProp(m); return; }
if (m) { propsGroup.add(m); continue; }
// Failed/unknown load: a VISIBLE marker HOLDS the slot — attachApi resolves
// pins by prop index, so skipping would re-pin every later prop to the wrong
// target. _failedRecord re-serializes verbatim, so the prop survives the
// missing asset instead of vanishing; an imported mesh whose file isn't on
// this machine becomes a "missing mesh" marker the user can 🔍 Locate.
const placeholder = makeMissingMarker();
placeholder.userData.isProp = true;
placeholder.userData._failedRecord = p;
if (p.uid != null) { placeholder.userData.uid = p.uid; propUidSeq = Math.max(propUidSeq, p.uid + 1); }
if (typeof p.groupId === "string" && p.groupId) placeholder.userData.groupId = p.groupId;
if (Array.isArray(p.pos)) placeholder.position.fromArray(p.pos);
if (Array.isArray(p.quat)) placeholder.quaternion.fromArray(p.quat);
if (Array.isArray(p.scale)) placeholder.scale.fromArray(p.scale);
propsGroup.add(placeholder);
console.warn("[PoseStudio] prop mesh missing, showing locate marker:", p.file || p.type);
}
// Chains come back with their grab-handles LIVE (spawn does this too) — otherwise
// after a reload the joints have no diamonds to click and the chain looks frozen.
const chainProp = propsGroup.children.find((p) => p.userData?._chain);
if (chainProp && bonePoseProp !== chainProp) setBonePoseProp(chainProp);
},
};
// Pin constraints round-trip through pose_state by prop INDEX (position in propsGroup,
// stable since props rebuild in order) and the leader figure's stable UID + canonical
// joint name. Restored AFTER props + figures exist. (Older saves used figIndex; restore
// still accepts that for back-compat.)
const attachApi = {
serialize: () => attachments.map((a) => ({
follower: { propIndex: propsGroup.children.indexOf(a.follower.mesh), ...(a.follower.jointIndex != null ? { jointIndex: a.follower.jointIndex } : {}) },
leader: a.leader.kind === "prop"
? { kind: "prop", propIndex: propsGroup.children.indexOf(a.leader.mesh) }
: { kind: "joint", figUid: a.leader.fig.uid, joint: a.leader.joint },
offset: a.offset.toArray(),
})).filter((a) => a.follower.propIndex >= 0 &&
(a.leader.kind === "joint" ? a.leader.figUid != null : a.leader.propIndex >= 0)),
restoreFrom: (list) => {
attachments.length = 0;
for (const a of list || []) {
const fMesh = propsGroup.children[a?.follower?.propIndex];
if (!fMesh) continue;
let leader = null;
if (a.leader?.kind === "prop") { const lM = propsGroup.children[a.leader.propIndex]; if (lM) leader = { kind: "prop", mesh: lM }; }
else if (a.leader?.kind === "joint") {
const fig = a.leader.figUid != null ? figures.find((f) => f.uid === a.leader.figUid) : figures[a.leader.figIndex];
if (fig) leader = { kind: "joint", fig, joint: a.leader.joint };
}
if (!leader) continue;
const follower = { kind: "prop", mesh: fMesh };
if (a.follower?.jointIndex != null) { follower.jointIndex = a.follower.jointIndex; fMesh.userData._pinIndex = a.follower.jointIndex; } // chain-joint pin
attachments.push({ follower, leader, offset: new THREE.Matrix4().fromArray(a.offset) });
}
},
};
// Fullscreen: blow the viewport up to fill the window for detailed posing.
// ComfyUI's DOM-widget layer carries a CSS transform (canvas zoom/pan), and a
// transformed ancestor makes position:fixed resolve relative to IT, not the
// window — so fixed-fullscreen in place only filled the zoomed node region. We
// must reparent to document.body to escape that transform; then fixed = the real
// viewport. !important still beats ComfyUI's per-frame inline style writes (it
// holds the element ref and styles it even at body level). The original DOM slot
// is restored on exit/teardown so nothing orphans.
let fsActive = false, fsSavedCss = "", fsParent = null, fsNextSibling = null, fullBtn = null;
const enterFullscreen = () => {
if (fsActive) return;
fsActive = true;
fsSavedCss = container.style.cssText;
fsParent = container.parentNode;
fsNextSibling = container.nextSibling;
document.body.appendChild(container);
container.style.cssText =
"position:fixed !important;inset:0 !important;width:100vw !important;height:100vh !important;" +
"transform:none !important;margin:0 !important;max-width:none !important;max-height:none !important;" +
"display:block !important;visibility:visible !important;opacity:1 !important;" +
`z-index:${fsZ};background:#141418;overflow:hidden;touch-action:none;`; // above the PromptChain fullscreen overlay (9999); raised for the detached Re-pose poser
if (fullBtn) fullBtn.textContent = "🗗";
fitCanvas(); requestRender();
};
const exitFullscreen = () => {
if (!fsActive) return;
fsActive = false;
if (fsParent) {
if (fsNextSibling && fsNextSibling.parentNode === fsParent) fsParent.insertBefore(container, fsNextSibling);
else fsParent.appendChild(container);
}
fsParent = fsNextSibling = null;
container.style.cssText = fsSavedCss;
if (fullBtn) fullBtn.textContent = "⛶";
fitCanvas(); requestRender();
};
// Dock: relocate the viewport into a PromptChain node's left "3D Poser" panel
// so the figure is posed from one control surface (and survives PromptChain's
// own fullscreen). Same reparent-and-restore contract as fullscreen, but the
// container flows to fill the panel instead of going position:fixed. The
// !important flow styles beat ComfyUI's per-frame DOM-widget writes (it still
// holds this element's ref and would otherwise re-absolute-position/hide it).
let dockActive = false, dockSavedCss = "", dockParent = null, dockNextSibling = null;
const enterDock = (panelBodyEl) => {
if (!panelBodyEl) return;
if (fsActive) exitFullscreen(); // can't be window-fullscreen and docked at once
if (dockActive) exitDock(); // re-dock into a different panel cleanly
dockActive = true;
dockSavedCss = container.style.cssText;
dockParent = container.parentNode;
dockNextSibling = container.nextSibling;
panelBodyEl.appendChild(container);
container.style.cssText =
"position:relative !important;left:0 !important;top:0 !important;" +
"width:100% !important;height:100% !important;transform:none !important;" +
"margin:0 !important;max-width:none !important;max-height:none !important;" +
"display:block !important;visibility:visible !important;opacity:1 !important;" +
"background:#141418;border-radius:0;overflow:hidden;touch-action:none;";
fitCanvas(); requestRender();
};
const exitDock = () => {
if (!dockActive) return;
dockActive = false;
if (dockParent && dockParent.isConnected) {
if (dockNextSibling && dockNextSibling.parentNode === dockParent) dockParent.insertBefore(container, dockNextSibling);
else dockParent.appendChild(container);
} else {
// Home slot is gone (Poser node removed while docked) — drop the element
// so it doesn't orphan inside the panel; teardown disposes the rest.
container.remove();
}
dockParent = dockNextSibling = null;
container.style.cssText = dockSavedCss;
fitCanvas(); requestRender();
};
// Match the control-map size to the generation latent (manual escape hatch; auto-sync
// covers mount + connection changes). Moved into the strip from a separate top button.
// (The manual "match latent" ⟳ is gone: syncToLatent runs automatically on
// mount, on this node's re-wiring, and live when the latent's w/h widgets
// change. Only swapping the latent NODE itself waits for the next sync.)
fullBtn = mkToolBtn("⛶", "Expand the viewport to fill the window (Esc or the icon to restore)",
() => (fsActive ? exitFullscreen() : enterFullscreen()));
mainGroup.appendChild(fullBtn);
container.appendChild(toolbar);
// Escape deselects/exits fullscreen, but only when the cursor is over this viewport.
let hovered = false;
container.addEventListener("pointerenter", () => { hovered = true; });
container.addEventListener("pointerleave", () => { hovered = false; });
// Undo/redo for pose edits. Snapshots are full serialized poses (camera dropped
// so undo never moves the view). recordHistory() runs from captureAndUpload after
// every committed change and dedupes, so non-pose captures (orbit/resize/seed)
// don't pollute the stack. Ctrl+Z is captured here and stopped from propagating
// so it doesn't also trigger ComfyUI's graph undo (which can desync the network).
const history = [];
let histIndex = -1, applyingHistory = false;
const poseSnapshot = () => { const d = serializePose(node); delete d.camera; return JSON.stringify(d); };
const recordHistory = () => {
if (applyingHistory) return;
const snap = poseSnapshot();
if (snap === history[histIndex]) return; // pose unchanged — skip
history.splice(histIndex + 1); // drop the redo tail
history.push(snap);
if (history.length > 200) history.shift();
histIndex = history.length - 1;
};
const applyHistory = async () => {
applyingHistory = true;
try {
const data = JSON.parse(history[histIndex]);
// Reconcile the cast (by uid) so figure spawn/removal is undoable without scrambling
// poses. v1 flat saves → a single figure. capture/select off so replay stays silent.
const figData = Array.isArray(data.figures) ? data.figures : [data];
// Names are identity, not posture — pose undo must not time-travel them.
// Snapshots carry the names of their era, so replay would silently revert a
// rename while the prompt keeps the custom $blocks; the next capture then
// re-seeds ghost default-named blocks. Keep each surviving figure's LIVE name.
const liveNames = new Map(figures.map((f) => [f.uid, f.customName]));
// Same identity-vs-posture rule for props: a pose-undo must not revert a
// prop's name (and ghost-seed its old $block on the next reconcile).
const livePropNames = new Map(propsGroup.children.filter((m) => m.userData.uid != null)
.map((m) => [m.userData.uid, m.userData.customName || null]));
if (!(await reconcileCast(figData))) return;
for (const f of figures) if (liveNames.has(f.uid)) f.customName = liveNames.get(f.uid);
updateFigureLabels();
setActiveFigure(activeFromSnapshot(data, figData));
if (Array.isArray(data.props)) await propsApi.restoreFrom(data.props);
for (const m of propsGroup.children) {
const u = m.userData.uid;
if (u != null && livePropNames.has(u)) m.userData.customName = livePropNames.get(u);
}
updatePropLabels();
attachApi.restoreFrom(data.attachments); // after props + figures exist (indices/uids resolve)
await syncSkinBinds(); // undo across a bind re-skins / un-skins to match the snapshot
updatePinBtn();
syncBodySliders();
requestRender();
captureAndUpload(node);
} finally { applyingHistory = false; }
};
// While clothing paint is open, ↶ undoes paint actions (add/erase/brush) — those
// aren't in the pose history yet (Phase 4). Otherwise it's the normal pose undo.
const undo = () => { if (clothingPaint?.active && clothingPaint.undo.length) { paintUndo(); return; } if (histIndex > 0) { histIndex--; applyHistory(); } };
const redo = () => { if (histIndex < history.length - 1) { histIndex++; applyHistory(); } };
// Undo/redo buttons — the reliable pose-history controls (no keyboard hijack).
mainGroup.appendChild(mkToolBtn("↶", "Undo the last pose change", undo));
mainGroup.appendChild(mkToolBtn("↷", "Redo", redo));
// Export / import the whole scene (figures, props, links, foot-pins, camera) as a .json
// file, so a posed scene can be carried between workflows. Reuses serializePose +
// restoreSavedState — the same path that round-trips pose_state inside a workflow.
// The 💾 button opens a naming modal (styled like Save Pose) so the author picks the
// file name; confirming runs the download below.
const downloadScene = (filename) => {
try {
const blob = new Blob([JSON.stringify(serializePose(node), null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
// The scene JSON (like the workflow) carries imported meshes only by content
// hash, not the bytes — warn so the author knows recipients need the file(s).
const imported = propsGroup.children.filter((m) => m.userData.file || m.userData._failedRecord?.file).length;
if (imported) showClipToast(`scene exported — ⚠ ${imported} imported mesh${imported > 1 ? "es" : ""} won't travel; recipients need the file(s) or 🔍 Locate`, true);
else showClipToast("scene exported");
} catch (e) { console.error("[PoseStudio] scene export failed", e); }
};
// Coerce whatever the author types into one safe download name: drop a trailing
// .json (re-added below), neutralise path separators / illegal filename chars and
// leading dots so the browser never rejects the download, and fall back to a default.
const sanitizeSceneFilename = (raw) => {
const base = (raw || "").trim().replace(/\.json$/i, "").replace(/[\\/:*?"<>|]+/g, "_").replace(/^\.+/, "").trim();
return (base || "promptchain-pose-scene") + ".json";
};
// Visual twin of the sidebar's Rename modal (PromptModal.svelte + modal-shared.css):
// same chrome, plain text field, SVG ✕ close, secondary/primary footer buttons with
// the primary disabled while the field is empty. Styles are inlined (not the shared
// global CSS classes) so the modal renders correctly even on a graph page where the
// Svelte bundle's CSS isn't present.
let exportModal = null; // built lazily on first export (after the teardown chain is in place)
const closeExport = () => { if (exportModal) exportModal.backdrop.style.display = "none"; };
// Remember the last name the author exported as (device-local), so the next
// export defaults to it instead of the generic placeholder. The browser still
// auto-suffixes " (1)" etc. on its own if that file already exists on disk.
const EXPORT_NAME_KEY = "pcr.pose-export-name";
const attemptExport = () => {
const raw = exportModal.input.value.trim();
if (!raw) return; // confirm is disabled while empty; guard the Enter path too
closeExport();
const name = sanitizeSceneFilename(raw);
try { localStorage.setItem(EXPORT_NAME_KEY, name); } catch { /* localStorage blocked */ }
downloadScene(name);
};
const buildExportModal = () => {
const backdrop = document.createElement("div");
backdrop.className = "pcr-scene-export-backdrop";
backdrop.style.cssText =
"position:fixed;top:0;left:0;right:0;bottom:0;display:none;align-items:center;justify-content:center;" +
"background:rgba(0,0,0,0.6);font:14px system-ui,sans-serif;";
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
dialog.style.cssText =
"min-width:320px;max-width:480px;background:#262626;border:1px solid #3a3a3a;border-radius:8px;" +
"box-shadow:0 8px 32px rgba(0,0,0,0.5);";
const header = document.createElement("div");
header.style.cssText = "display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #3a3a3a;";
const title = document.createElement("span");
title.textContent = "Export Scene";
title.style.cssText = "font-size:16px;font-weight:600;color:#fff;";
const closeBtn = document.createElement("button");
closeBtn.setAttribute("aria-label", "Close");
closeBtn.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
closeBtn.style.cssText = "display:flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;background:transparent;border:none;border-radius:4px;color:#888;cursor:pointer;";
closeBtn.addEventListener("mouseenter", () => { closeBtn.style.background = "rgba(255,255,255,0.1)"; closeBtn.style.color = "#fff"; });
closeBtn.addEventListener("mouseleave", () => { closeBtn.style.background = "transparent"; closeBtn.style.color = "#888"; });
header.append(title, closeBtn);
const body = document.createElement("div");
body.style.cssText = "padding:20px;";
const input = document.createElement("input");
input.type = "text";
input.maxLength = 80;
input.placeholder = "File name";
input.style.cssText =
"width:100%;box-sizing:border-box;padding:10px 12px;background:#1a1a1a;border:1px solid #3a3a3a;" +
"border-radius:6px;color:#fff;font-size:14px;outline:none;";
body.append(input);
const footer = document.createElement("div");
footer.style.cssText = "display:flex;justify-content:flex-end;gap:10px;padding:16px 20px;border-top:1px solid #3a3a3a;";
const mkFooterBtn = (label, bg, fg) => {
const b = document.createElement("button");
b.textContent = label;
b.style.cssText = `padding:8px 16px;border:none;border-radius:6px;font-size:14px;font-weight:500;cursor:pointer;background:${bg};color:${fg};`;
return b;
};
const cancelBtn = mkFooterBtn("Cancel", "#3a3a3a", "#ccc");
cancelBtn.addEventListener("mouseenter", () => { cancelBtn.style.background = "#4a4a4a"; });
cancelBtn.addEventListener("mouseleave", () => { cancelBtn.style.background = "#3a3a3a"; });
const confirmBtn = mkFooterBtn("Export", "#973f00", "#fff");
confirmBtn.addEventListener("mouseenter", () => { if (!confirmBtn.disabled) confirmBtn.style.background = "#c85909"; });
confirmBtn.addEventListener("mouseleave", () => { confirmBtn.style.background = "#973f00"; });
// Mirror PromptModal: the primary action is disabled while the field is empty.
const refreshConfirm = () => {
const empty = !input.value.trim();
confirmBtn.disabled = empty;
confirmBtn.style.opacity = empty ? "0.5" : "1";
confirmBtn.style.cursor = empty ? "default" : "pointer";
};
footer.append(cancelBtn, confirmBtn);
dialog.append(header, body, footer);
backdrop.appendChild(dialog);
input.addEventListener("input", refreshConfirm);
input.addEventListener("focus", () => { input.style.borderColor = "#dd7634"; });
input.addEventListener("blur", () => { input.style.borderColor = "#3a3a3a"; });
backdrop.addEventListener("pointerdown", (e) => e.stopPropagation()); // keep canvas/selection handlers out
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) closeExport(); }); // click the dim to cancel
backdrop.addEventListener("keydown", (e) => {
e.stopPropagation(); // graph/viewport hotkeys must never fire while the modal is up
if (e.key === "Escape") { e.preventDefault(); closeExport(); }
else if (e.key === "Enter" && e.target === input) { e.preventDefault(); attemptExport(); }
});
closeBtn.addEventListener("click", closeExport);
cancelBtn.addEventListener("click", closeExport);
confirmBtn.addEventListener("click", attemptExport);
document.body.appendChild(backdrop);
// Ride the portaled-flyout teardown chain so the modal never outlives the node.
const prevCleanup = node._pcrMenuCleanup;
node._pcrMenuCleanup = () => { prevCleanup?.(); backdrop.remove(); };
exportModal = { backdrop, input, refreshConfirm };
};
const openExportScene = () => {
if (!exportModal) buildExportModal();
exportModal.backdrop.style.zIndex = String((node.properties?.pcrFullscreenZ || 10001) + 10); // above this poser's flyout menus
let defaultName = "promptchain-pose-scene.json";
try { const saved = localStorage.getItem(EXPORT_NAME_KEY); if (saved) defaultName = saved; } catch { /* localStorage blocked */ }
exportModal.input.value = defaultName;
exportModal.refreshConfirm();
exportModal.backdrop.style.display = "flex";
requestAnimationFrame(() => {
exportModal.input.focus();
// Pre-select the base name only (leaves ".json") so typing replaces the name
// without clobbering the extension — same trick the sidebar Rename modal uses.
exportModal.input.setSelectionRange(0, defaultName.length - ".json".length);
});
};
const importInput = document.createElement("input");
importInput.type = "file"; importInput.accept = ".json,.glb,.gltf,.obj"; importInput.style.display = "none";
importInput.addEventListener("change", async () => {
const file = importInput.files?.[0];
importInput.value = ""; // let the same file be re-imported later
if (!file) return;
// One open button, routed by extension: a mesh ADDS a prop to the scene,
// a .json REPLACES the scene (same paths as the ➕ import / viewport drop).
if (/\.(glb|gltf|obj)$/i.test(file.name)) { importMeshFile(file); return; }
try {
const text = await file.text();
JSON.parse(text); // validate before committing it to the widget
setWidgetValue(node, "pose_state", text);
await restoreSavedState();
if (!node._pcrAlive) return;
syncBodySliders();
requestRender();
captureAndUpload(node);
} catch (e) { console.error("[PoseStudio] scene import failed (not a valid pose JSON?)", e); }
});
container.appendChild(importInput);
mainGroup.appendChild(mkToolBtn("💾", "Export this scene to a .json file (transfer between workflows)", openExportScene));
mainGroup.appendChild(mkToolBtn("📂", "Open: a scene .json (replaces the scene) or a .glb/.gltf/.obj model (adds it as a prop)", () => importInput.click()));
// ── copy / paste people + props ─────────────────────────────────────────────
// Copies the current selection (band group > whole person > prop > the person
// owning a selected joint) to the system clipboard, pasteable into this or any
// other 3D Poser — other workflows included. Unlike 📂 import, paste is ADDITIVE.
const clipToast = document.createElement("div");
clipToast.style.cssText =
"position:absolute;bottom:30px;left:50%;transform:translateX(-50%);z-index:6;display:none;" +
"padding:3px 10px;background:rgba(20,20,26,0.92);border:1px solid #4a4a55;border-radius:5px;" +
"font:11px/1.4 system-ui,sans-serif;pointer-events:none;white-space:nowrap;";
container.appendChild(clipToast);
const showClipToast = (msg, isError) => {
clipToast.textContent = msg;
clipToast.style.color = isError ? "#ff9d5c" : "#9fdf9f";
clipToast.style.display = "block";
// Web Animations API: the fade-out hides itself on 'finish' — no timers.
clipToast.getAnimations().forEach((a) => a.cancel());
const anim = clipToast.animate(
[{ opacity: 1 }, { opacity: 1, offset: 0.7 }, { opacity: 0 }],
{ duration: 2200, easing: "ease-in" });
anim.onfinish = () => { clipToast.style.display = "none"; };
};
const clipCountLabel = (clip) => [
clip.figures.length && `${clip.figures.length} ${clip.figures.length > 1 ? "people" : "person"}`,
clip.props.length && `${clip.props.length} prop${clip.props.length > 1 ? "s" : ""}`,
].filter(Boolean).join(" + ");
// Attachments are carried only when BOTH ends are in the selection; indices are
// remapped to clip-local order (offsets are leader-relative, so they paste as-is).
const serializeClip = (sel) => {
const figs = sel.filter((s) => s.kind === "figure").map((s) => s.o);
const props = sel.filter((s) => s.kind === "prop").map((s) => s.o);
const clip = { format: CLIP_FORMAT, version: 1, figures: figs.map(serializeFigure), props: props.map(serializeProp), attachments: [] };
for (const a of attachments) {
const fi = props.indexOf(a.follower.mesh);
if (fi === -1) continue;
let leader = null;
if (a.leader.kind === "prop") { const li = props.indexOf(a.leader.mesh); if (li !== -1) leader = { kind: "prop", propIndex: li }; }
else { const gi = figs.indexOf(a.leader.fig); if (gi !== -1) leader = { kind: "joint", figIndex: gi, joint: a.leader.joint }; }
if (leader) clip.attachments.push({ follower: { propIndex: fi, ...(a.follower.jointIndex != null ? { jointIndex: a.follower.jointIndex } : {}) }, leader, offset: a.offset.toArray() });
}
return clip;
};
const copySelection = async () => {
const sel = bandSel.length ? [...bandSel]
: selectedFigure ? [{ kind: "figure", o: selectedFigure }]
: selectedProp ? [{ kind: "prop", o: selectedProp }]
: selectedJointName ? [{ kind: "figure", o: rig }] // a joint is selected → its person
: [];
if (!sel.length) { showClipToast("nothing selected — click a person or prop first", true); return; }
// Props combined onto a selected person/prop travel with the copy — walk the
// attachment graph leader→follower until closed (prop-on-prop chains too), so
// serializeClip sees both ends and keeps the links.
const inSel = new Set(sel.map((s) => s.o));
for (let grew = true; grew; ) {
grew = false;
for (const a of attachments) {
if (inSel.has(a.follower.mesh)) continue;
if (inSel.has(a.leader.kind === "prop" ? a.leader.mesh : a.leader.fig)) {
sel.push({ kind: "prop", o: a.follower.mesh });
inSel.add(a.follower.mesh);
grew = true;
}
}
}
const clip = serializeClip(sel);
// Carry each selected NAMED entity's region prompt ($name{ body }) so a
// character built here keeps its prompt when pasted into another scene.
const selNames = sel.map((s) => (s.kind === "figure" ? s.o.customName : s.o.userData?.customName)).filter(Boolean);
const regions = readRegionBlockBodies(node, selNames);
if (Object.keys(regions).length) clip.regions = regions;
const json = JSON.stringify(clip);
poseClipMemory = json;
writePoseClipStore(json); // cross-tab/-reload bridge, no clipboard permission needed
let system = false;
// Bounded: in some contexts the clipboard promise never settles (no
// permission UI to resolve it) — cut/copy must not wedge behind it; the
// tab-local + localStorage fallbacks are already written above.
try {
await Promise.race([
navigator.clipboard.writeText(json).then(() => { system = true; }),
new Promise((res) => setTimeout(res, 1500)),
]);
} catch {} // http-non-localhost / permission denied → localStorage bridge still carries it
// System clipboard reaches other browsers/profiles; without it the
// localStorage bridge still reaches every tab/window of THIS browser.
showClipToast(`copied ${clipCountLabel(clip)}${system ? "" : " (this browser only)"}`);
};
const parseClip = (text) => {
if (!text) return null;
try {
const d = JSON.parse(text);
return d && d.format === CLIP_FORMAT && Array.isArray(d.figures) && Array.isArray(d.props) ? d : null;
} catch { return null; }
};
const pasteClip = async () => {
let sysText = null;
try {
sysText = await Promise.race([
navigator.clipboard.readText(),
new Promise((res) => setTimeout(() => res(null), 1500)), // never-settling read → tab-local fallback
]);
} catch {}
const clip = parseClip(sysText) || parseClip(poseClipMemory) || parseClip(readPoseClipStore());
if (!clip) { showClipToast("nothing to paste — copy a person or prop first (right-click → copy)", true); return; }
if (!node._pcrAlive) return;
const data = JSON.parse(JSON.stringify(clip)); // position nudges below must not mutate the stored clipboard
// groupId is per-node (groupIdSeq starts at 1 in every Poser), so a pasted
// "g1" would silently fuse with an unrelated "g1" already in this scene.
// Remap each distinct clip group to a fresh destination-unique id before
// rebuild — buildPropFrom then restores the already-fresh id verbatim.
const groupRemap = new Map();
for (const p of data.props) {
if (typeof p.groupId !== "string" || !p.groupId) continue;
if (!groupRemap.has(p.groupId)) groupRemap.set(p.groupId, "g" + (groupIdSeq++));
p.groupId = groupRemap.get(p.groupId);
}
// Nudge the whole group sideways onto free ground — pasting in place would
// stack the duplicate exactly inside the original. Relative arrangement is
// preserved (one shared dx); a paste into open space (other workflow) lands
// at the copied position unmoved.
const taken = (x, z) =>
figures.some((f) => Math.hypot(f.root.position.x - x, f.root.position.z - z) < 0.3) ||
propsGroup.children.some((m) => Math.hypot(m.position.x - x, m.position.z - z) < 0.15);
const spots = [...data.figures.map((fd) => fd.root || [0, 0, 0]), ...data.props.map((p) => p.pos || [0, 0, 0])];
let dx = 0;
while (dx < 6 && spots.some(([x, , z]) => taken(x + dx, z))) dx += 0.5;
// A custom name already live in this scene belongs to a DIFFERENT character
// (two people independently built as "maid1" must stay distinct), so suffix
// the paste to the next free "<name>_<n>" instead of dropping it — the
// region and its carried prompt travel intact. Stays within
// validateEntityName's grammar (letters/digits/_, starts with a letter;
// never "mannequinN", which only auto-default figures use — and a default
// figure carries no clip name, so collisions here are always custom↔custom).
const usedNames = new Set(liveEntityNames().map((n) => n.toLowerCase()));
const clipRegions = data.regions || {}; // { sourceName: bodyText }
const pastedRegions = []; // { name: finalName, body } to inject into the bound prompt
const renamedNames = []; // "old → new" notes for the toast
const carriedBodies = new Set(); // source names whose body is already claimed
const resolvePastedName = (raw) => {
// Defensive: a clip name should already be grammar-valid, but never let an
// odd one through to become a $scope. null → treat as unnamed/default.
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(raw) || /^mannequin\d+$/i.test(raw)) return null;
let nm = raw;
if (usedNames.has(nm.toLowerCase())) {
let n = 2;
while (usedNames.has((raw + "_" + n).toLowerCase())) n++;
nm = raw + "_" + n;
renamedNames.push(raw + " → " + nm);
}
usedNames.add(nm.toLowerCase());
// Carry the source body once per source name — figures are processed before
// props, so the entity that actually owns the $block claims it; a second
// entity sharing the raw name (only via a malformed clip) gets no body.
// typeof guard: a region literally named "__proto__"/"toString" must read as
// empty, not as an inherited Object.prototype member.
const carriedBody = !carriedBodies.has(raw) && typeof clipRegions[raw] === "string" ? clipRegions[raw] : "";
carriedBodies.add(raw);
pastedRegions.push({ name: nm, body: carriedBody });
return nm;
};
for (const fd of data.figures) {
delete fd.uid; // pasted person gets a fresh identity — never collide with the source's
if (typeof fd.name === "string" && fd.name) {
const nm = resolvePastedName(fd.name);
if (nm) fd.name = nm; else delete fd.name;
}
if (Array.isArray(fd.root)) fd.root[0] += dx;
if (fd.footPins) for (const k of Object.keys(fd.footPins)) fd.footPins[k][0] += dx; // world anchors ride along
}
for (const p of data.props) {
delete p.uid; // fresh identity, same rule as figures
if (typeof p.name === "string" && p.name) {
const nm = resolvePastedName(p.name);
if (nm) p.name = nm; else delete p.name;
}
if (Array.isArray(p.pos)) p.pos[0] += dx;
}
const pastedFigs = [];
for (const fd of data.figures) {
const f = await spawnFigure({ select: false, capture: false, body: fd.body });
if (!node._pcrAlive) return;
if (!f) continue;
applyOneFigure(f, fd);
f.rootRest = f.root.position.clone(); // reset returns it HERE, not to the spawn slot
pastedFigs.push(f);
}
updateFigureLabels(); // pasted custom names land after spawnFigure already labeled
const pastedProps = [];
for (const p of data.props) {
const m = await buildPropFrom(p);
if (!node._pcrAlive) { if (m) disposeProp(m); return; }
pastedProps.push(m); // null on a failed load keeps clip indices aligned for attachments
if (m) propsGroup.add(m);
}
updatePropLabels(); // pasted named props get their region labels
for (const a of data.attachments || []) {
const fMesh = pastedProps[a?.follower?.propIndex];
if (!fMesh) continue;
let leader = null;
if (a.leader?.kind === "prop") { const lM = pastedProps[a.leader.propIndex]; if (lM) leader = { kind: "prop", mesh: lM }; }
else if (a.leader?.kind === "joint") { const fig = pastedFigs[a.leader.figIndex]; if (fig) leader = { kind: "joint", fig, joint: a.leader.joint }; }
if (leader) {
const follower = { kind: "prop", mesh: fMesh };
if (a.follower?.jointIndex != null) { follower.jointIndex = a.follower.jointIndex; fMesh.userData._pinIndex = a.follower.jointIndex; }
attachments.push({ follower, leader, offset: new THREE.Matrix4().fromArray(a.offset) });
}
}
await syncSkinBinds(); // a pasted bound-monster figure rebuilds its skin
// Select what landed (band for a group, plain select for one) so it can be
// dragged into place immediately.
selectBand([
...pastedFigs.map((o) => ({ kind: "figure", o })),
...pastedProps.filter(Boolean).map((o) => ({ kind: "prop", o })),
]);
updatePinBtn();
syncBodySliders();
// Carry each pasted character's region + prompt into the bound prompt: stash
// the bodies and inject now (flushed again on the next capture if the bound
// editor isn't built yet), and ensure the POSE_JSON→consumer wire so the
// server can bind each $name to its mask row. No-op when not region-wired.
if (pastedRegions.length) {
node._pcrPendingRegions = (node._pcrPendingRegions || []).concat(pastedRegions);
flushPendingRegions(node);
ensureConsumerPoseWire(node);
}
showClipToast(`pasted ${clipCountLabel(data)}` +
(renamedNames.length ? ` — renamed ${renamedNames.map((n) => `"${n}"`).join(", ")} (already in scene)` : ""));
requestRender();
captureAndUpload(node);
};
// (Copy/paste moved off the toolbar: right-click context menu + Ctrl+C/V.)
// ── rename a person (double-click their name label) ─────────────────────────
// The floating name bar becomes a text input. A custom name flows into
// pose_state (server binds the region to its figure BY NAME), the bound
// prompt's $block is rewritten so typed tags travel with the rename, and the
// POSE_JSON→couple wire is added so the server can see the names.
const renameInput = document.createElement("input");
renameInput.type = "text";
renameInput.maxLength = 24;
renameInput.spellcheck = false;
renameInput.style.cssText =
"position:absolute;z-index:7;display:none;width:140px;transform:translateX(-50%);" +
"background:#1a1a22;color:#ff9d5c;border:1px solid #caa14a;border-radius:4px;" +
"padding:2px 6px;font:bold 12px system-ui,sans-serif;text-align:center;outline:none;";
renameInput.addEventListener("pointerdown", (e) => e.stopPropagation());
container.appendChild(renameInput);
let renameTarget = null; // { kind: "figure", fig } | { kind: "prop", mesh }
const closeRename = () => { renameTarget = null; renameInput.style.display = "none"; };
// Valid = $block-safe (\w, starts with a letter), unique across ALL region
// entities — figures (defaults included) and named props — so a name can
// never point at two masks. Digit endings ARE allowed ("girl1"): the region
// binder matches the entity list by exact name first (compiler.py
// region_figure_indices), so a digit suffix is never read positionally. The
// ONE reserved namespace is "mannequinN" — that's the auto-default for figure
// N, so a custom "mannequin3" would silently collide with a 3rd figure added
// later (the uniqueness check below only sees figures that exist NOW).
const validateEntityName = (self, raw) => {
const name = raw.trim();
if (!name) return { name: null }; // empty = default mannequinN (figure) / not-a-region (prop)
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return { error: "letters, digits and _ only — must start with a letter" };
if (/^mannequin\d+$/i.test(name)) return { error: "\"mannequinN\" is reserved for default mannequin names — pick another" };
const taken = [
...effectiveFigureNames(figures).filter((_, i) => figures[i] !== self),
...namedPropMeshes().filter((m) => m !== self).map((m) => m.userData.customName),
];
if (taken.some((t) => t.toLowerCase() === name.toLowerCase())) return { error: `"${name}" is already another figure's or prop's name` };
return { name };
};
const commitRename = (fromBlur = false) => {
if (!renameTarget) return;
const target = renameTarget;
const res = validateEntityName(target.kind === "figure" ? target.fig : target.mesh, renameInput.value);
if (res.error) {
showClipToast(res.error, true);
if (fromBlur) closeRename(); else renameInput.focus();
return;
}
closeRename();
if (target.kind === "figure") {
const fig = target.fig;
const idx = figures.indexOf(fig);
const oldName = effectiveFigureNames(figures)[idx];
fig.customName = res.name;
const newName = effectiveFigureNames(figures)[idx];
if (newName === oldName) return;
renameRegionBlock(node, oldName, newName);
ensureCouplePoseWire(node);
updateFigureLabels();
} else {
const mesh = target.mesh;
const oldName = mesh.userData.customName; // null = wasn't a region yet
if (res.name === oldName) return;
mesh.userData.customName = res.name;
// A rename travels to the existing $block; a FIRST naming seeds its block
// via the reconcile inside the capture below. Clearing the name demotes
// the prop from regions — its block stays (add-only, like a removed
// figure's; the server clamps an unbound name harmlessly).
if (oldName && res.name) renameRegionBlock(node, oldName, res.name);
if (res.name) ensureCouplePoseWire(node);
updatePropLabels();
updatePropUI(); // $ button reflects the region state immediately
}
requestRender();
captureAndUpload(node); // pose_state + masks + regionEntities carry the change
};
renameInput.addEventListener("keydown", (e) => {
e.stopPropagation(); // graph/viewport hotkeys must never fire while typing
if (e.key === "Enter") commitRename();
else if (e.key === "Escape") closeRename();
});
renameInput.addEventListener("blur", () => commitRename(true));
// Sit the input on the entity's label screen position (same projection the
// sprite renders through, container-relative for absolute positioning).
const placeRenameInput = (p) => {
p.project(camera);
const r = canvas.getBoundingClientRect(), cr = container.getBoundingClientRect();
renameInput.style.left = ((p.x * 0.5 + 0.5) * r.width + r.left - cr.left) + "px";
renameInput.style.top = (((-p.y) * 0.5 + 0.5) * r.height + r.top - cr.top - 12) + "px";
renameInput.style.display = "block";
renameInput.focus();
renameInput.select();
};
const openRename = (fig) => {
renameTarget = { kind: "figure", fig };
renameInput.value = fig.customName || "";
renameInput.placeholder = effectiveFigureNames(figures)[figures.indexOf(fig)];
placeRenameInput(fig._label ? fig._label.getWorldPosition(new THREE.Vector3()) : fig.root.getWorldPosition(new THREE.Vector3()).setY(1.8));
};
const openRenameProp = (mesh) => {
renameTarget = { kind: "prop", mesh };
renameInput.value = mesh.userData.customName || "";
renameInput.placeholder = "region name";
const p = new THREE.Vector3();
if (mesh._pcrLabel) mesh._pcrLabel.getWorldPosition(p);
else { _plBox.setFromObject(mesh); _plBox.isEmpty() ? mesh.getWorldPosition(p) : _plBox.getCenter(p).setY(_plBox.max.y + 0.12); }
placeRenameInput(p);
};
canvas.addEventListener("dblclick", (e) => {
setPointer(e); // shared raycaster, same NDC math as every other pick
const sprites = figures.map((f) => f._label).filter(Boolean);
const hit = raycaster.intersectObjects(sprites, false)[0];
if (hit) {
e.preventDefault();
e.stopPropagation();
const fig = figures.find((f) => f._label === hit.object);
if (fig) openRename(fig);
return;
}
// Dblclick a prop (or its label) = name it — mirrors the figure gesture and
// is the discoverable path; the 🏷 panel button covers tiny/occluded props.
const pHit = raycaster.intersectObjects(propsGroup.children, true)[0];
const prop = pHit && propRootOf(pHit.object);
if (prop) {
e.preventDefault();
e.stopPropagation();
openRenameProp(prop);
}
});
// Text → Poser: the bound prompt's `$oldName {` block was renamed to
// `$newName {` (see syncRegionRenamesFromText). Apply it to the matching
// entity so a rename typed in the prompt renames the actual mannequin/prop.
// Mirrors commitRename's apply branches but DELIBERATELY skips renameRegionBlock
// (the editor already holds newName — rewriting it would loop) and validates
// through the same gate (reserved / duplicate / identifier), so a bad typed
// name is rejected here instead of corrupting figure state. Returns
// {applied, error?} — the caller leaves the user's text untouched either way.
const renameEntityByName = (oldName, newName) => {
const from = String(oldName || "").toLowerCase();
const to = String(newName || "");
if (!from || !to) return { applied: false };
// Figures match on their EFFECTIVE name (custom, else mannequinN).
const fIdx = effectiveFigureNames(figures).findIndex((n) => n.toLowerCase() === from);
if (fIdx >= 0) {
const fig = figures[fIdx];
// Reverting a block to this figure's OWN default name clears the custom
// name — validateEntityName forbids typing "mannequinN", so handle the
// legitimate self-revert before that gate (other indices stay rejected).
if (to.toLowerCase() === "mannequin" + (fIdx + 1)) {
if (fig.customName == null) return { applied: false };
fig.customName = null;
updateFigureLabels(); requestRender(); captureAndUpload(node);
return { applied: true };
}
const res = validateEntityName(fig, to);
if (res.error) return { applied: false, error: res.error };
if (!res.name || res.name === fig.customName) return { applied: false };
fig.customName = res.name;
ensureCouplePoseWire(node);
updateFigureLabels(); requestRender(); captureAndUpload(node);
return { applied: true };
}
// Named props match on their customName.
const mesh = namedPropMeshes().find((m) => (m.userData.customName || "").toLowerCase() === from);
if (mesh) {
const res = validateEntityName(mesh, to);
if (res.error) return { applied: false, error: res.error };
if (!res.name || res.name === mesh.userData.customName) return { applied: false };
mesh.userData.customName = res.name;
ensureCouplePoseWire(node);
updatePropLabels(); updatePropUI(); requestRender(); captureAndUpload(node);
return { applied: true };
}
return { applied: false }; // no entity by that name (e.g. an orphan $block)
};
// ── drag-and-drop scene import ──────────────────────────────────────────────
// Drop a finished render (PNG/WebP with embedded workflow), a workflow .json,
// or an exported scene .json onto the viewport — docked, side-panel, or
// fullscreen (the handlers ride the container) — and the scene it carries
// REPLACES this Poser's, same path as 📂 import. preventDefault is load-
// bearing: ComfyUI's document-level drop handler skips defaultPrevented
// events, so the dropped workflow never replaces the whole graph.
const poseStateFromWorkflow = (data) => {
if (Array.isArray(data?.nodes)) { // saved-workflow format
for (const n of data.nodes) {
const ps = n?.type === "PromptChain_PoseStudio" ? n.widgets_values?.[1] : null;
if (typeof ps === "string" && ps.trim()) return ps;
}
return null;
}
for (const v of Object.values(data || {})) { // API prompt format
const ps = v?.class_type === "PromptChain_PoseStudio" ? v.inputs?.pose_state : null;
if (typeof ps === "string" && ps.trim()) return ps;
}
return null;
};
// Accepts a scene export / bare pose_state directly, or digs one out of a
// workflow JSON. Returns the scene JSON string, or null.
const sceneJsonFromText = (text) => {
let data; try { data = JSON.parse(text); } catch { return null; }
if (!data) return null;
if (Array.isArray(data.figures) || data.joints) return text; // v2 scene | v1 flat pose
const ps = poseStateFromWorkflow(data);
return ps ? sceneJsonFromText(ps) : null;
};
const applyDroppedScene = async (sceneJson, sourceLabel) => {
setWidgetValue(node, "pose_state", sceneJson);
await restoreSavedState();
if (!node._pcrAlive) return;
syncBodySliders();
requestRender();
captureAndUpload(node);
showClipToast(`scene loaded from ${sourceLabel} — ` +
`${figures.length} ${figures.length > 1 ? "people" : "person"}, ` +
`${propsGroup.children.length} prop${propsGroup.children.length === 1 ? "" : "s"}`);
};
const sceneFromDroppedFile = async (file) => {
const fname = (file.name || "").toLowerCase();
if (fname.endsWith(".json")) return sceneJsonFromText(await file.text());
const pnginfo = window.comfyAPI?.pnginfo;
let meta = null;
try {
meta = fname.endsWith(".webp")
? await pnginfo?.getWebpMetadata?.(file)
: await pnginfo?.getPngMetadata?.(file);
} catch { /* unparseable image — treated as no scene below */ }
for (const key of ["workflow", "prompt"]) {
const scene = meta?.[key] ? sceneJsonFromText(meta[key]) : null;
if (scene) return scene;
}
return null;
};
container.addEventListener("dragover", (e) => {
if (!(e.dataTransfer?.types || []).some((t) => t === "Files" || t === "text/uri-list")) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
});
container.addEventListener("drop", async (e) => {
const dt = e.dataTransfer;
if (!dt) return;
e.preventDefault();
e.stopPropagation();
try {
const file = dt.files?.[0];
if (file) {
// A dropped mesh imports as a prop (same path as 📂 import model);
// anything else is treated as a scene carrier (render/.json).
if (/\.(glb|gltf|obj)$/i.test(file.name || "")) {
const form = new FormData();
form.append("mesh", file);
const resp = await api.fetchApi("/promptchain/pose-mesh/upload", { method: "POST", body: form });
const data = await resp.json().catch(() => ({}));
if (!resp.ok || data.error || !data.file) throw new Error(data.error || `upload failed (${resp.status})`);
await spawnImportedMesh(data.file);
return;
}
const scene = await sceneFromDroppedFile(file);
if (scene) await applyDroppedScene(scene, file.name || "file");
else showClipToast("no 3D Poser scene found in that file", true);
return;
}
// dragged from a ComfyUI gallery — fetch the same-origin image, read its metadata
const uri = dt.getData("text/uri-list") || dt.getData("text/plain");
if (uri && /\/(api\/)?view\?/.test(uri)) {
const blob = await (await fetch(uri)).blob();
const scene = await sceneFromDroppedFile(new File([blob], /webp/i.test(uri) ? "drop.webp" : "drop.png"));
if (scene) await applyDroppedScene(scene, "image");
else showClipToast("no 3D Poser scene found in that image", true);
}
} catch (err) {
console.error("[PoseStudio] scene drop failed", err);
showClipToast("couldn't read a scene from that drop", true);
}
});
// Undo/redo are the ↶ ↷ toolbar buttons ONLY — we intentionally do NOT hijack
// Ctrl+Z. ComfyUI owns Ctrl+Z (graph undo), and intercepting it reliably across
// focus states proved fragile; when it slipped through, the graph reload rebuilt
// and emptied the WebGL viewport. The buttons call undo()/redo() directly and
// never touch ComfyUI's undo, so the viewport can't be killed.
const onKey = (e) => {
// Typing in a text field (the rename box, panel inputs)? None of the viewport
// hotkeys may fire — Delete/W/E/Ctrl+V while naming a figure would delete or
// move it. The field owns its own Enter/Esc handling.
const t = e.target;
if (t && ((t.tagName === "INPUT" && !/^(range|checkbox|button)$/.test(t.type)) ||
t.tagName === "TEXTAREA" || t.isContentEditable)) return;
if (e.key === "Escape") {
if (menuOpen) { closeMenu(); return; } // close the prop menu first
if (sculpt.active) { exitSculpt(); return; } // finish sculpting
if (clothingPaint.active) { if (clothingPaint.imgStamp) cancelImageStamp(); else exitClothingPaint(); return; } // cancel image placement, else finish paint
if (drawingShape) { exitDrawMode(); requestRender(); return; } // cancel a shape draw first
if (fsActive) { exitFullscreen(); return; } // Esc leaves fullscreen first
if (pinPickProp) { cancelPinPick(); requestRender(); return; } // cancel a pending link
if (hovered) { detachGizmo(); requestRender(); }
return;
}
// Sculpt mode owns the viewport keys (Ctrl+Z = undo a stroke). Handled BEFORE the
// hover gate below — during sculpt the joint-hover state is never set, so anything
// gated on `hovered` would never fire.
if (sculpt.active) {
if ((e.ctrlKey || e.metaKey) && !e.altKey) {
const tag = e.target?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || e.target?.isContentEditable) return;
if (e.key.toLowerCase() === "z") { e.preventDefault(); e.stopPropagation(); sculptUndo(); }
}
return; // swallow Delete/W/E/R/other chords so they can't act on the target mid-sculpt
}
// Everything below acts on the viewport under the cursor.
if (!hovered) return;
// Ctrl/Cmd+C / +V — copy/paste people + props (here or in another workflow's
// Poser). Captured BEFORE ComfyUI so the graph doesn't also copy/paste the
// node itself; typing in a panel input keeps native clipboard behavior.
if ((e.ctrlKey || e.metaKey) && !e.altKey) {
const tag = e.target?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || e.target?.isContentEditable) return;
const ck = e.key.toLowerCase();
if (ck === "z" && clothingPaint.active) { e.preventDefault(); e.stopPropagation(); paintUndo(); } // undo a paint stroke (not the whole graph)
else if (ck === "c") { e.preventDefault(); e.stopPropagation(); copySelection(); }
else if (ck === "v") { e.preventDefault(); e.stopPropagation(); pasteClip(); }
else if (ck === "a") {
// Select ALL objects (figures + props) as a band group — gizmo at the
// median, Del/cut/copy act on everything. Captured before the browser's
// select-all and ComfyUI's node select-all.
e.preventDefault(); e.stopPropagation();
selectBand([
...figures.map((f) => ({ kind: "figure", o: f })),
...propsGroup.children.map((m) => ({ kind: "prop", o: m })),
]);
}
else if (ck === "g") { e.preventDefault(); e.stopPropagation(); if (e.shiftKey) ungroupSelection(); else groupSelection(); } // group / ungroup
return; // other ctrl-chords are not ours — but never fall into the W/E handlers
}
if (e.key === "Delete" || e.key === "Backspace") {
if (clothingPaint.active && clothingPaint.selected) { // remove the picked garment piece
e.preventDefault(); e.stopPropagation();
removeGarment(clothingPaint.fig, clothingPaint.selected); clothingPaint.selected = null;
requestRender(); captureAndUpload(node); return;
}
// Del removes the selected OBJECT — a prop, or the selected whole person
// (the LAST one included: figure-less scenes are legal now, and the panel
// 🗑 already allows it — the key must match). Never mid-drag, and never a
// joint (joints aren't objects).
if (bandActive()) { e.preventDefault(); e.stopPropagation(); deleteBand(); return; }
if (selectedProp) { e.preventDefault(); e.stopPropagation(); deleteSelectedProp(); return; }
if (selectedFigure && !ik.active && !transformControls.dragging) {
e.preventDefault(); e.stopPropagation();
removeFigure(selectedFigure);
}
return;
}
// W/E/R = move/rotate/scale — the Maya/Unity/Unreal/Cascadeur convention
// (G/T alias translate for Blender muscle memory). One path with the left
// transform group: setTransformTool dispatches per selection (band / IK tip /
// figure / prop) and arms the defaults when nothing is selected.
const k = e.key.toLowerCase();
if (k === "w" || k === "g" || k === "t") { e.preventDefault(); e.stopPropagation(); setTransformTool("translate"); }
else if (k === "e") { e.preventDefault(); e.stopPropagation(); setTransformTool("rotate"); }
else if (k === "r") { e.preventDefault(); e.stopPropagation(); setTransformTool("scale"); }
};
window.addEventListener("keydown", onKey, true); // capture: intercept Ctrl+Z before ComfyUI
// Right-button handling: a right-DRAG pans the orbit (LiteGraph's menu is
// suppressed even when the release lands outside the canvas — window-capture
// beats its handler), while a right-CLICK (<5px travel) opens the poser's own
// context menu at the cursor: cut / copy / paste, which used to crowd the
// toolbar strip as 📋/📥 buttons.
const ctxMenu = document.createElement("div");
ctxMenu.style.cssText = MENU_CSS;
ctxMenu.addEventListener("pointerdown", (e) => e.stopPropagation());
document.body.appendChild(ctxMenu);
// "🖼 output ▸" hover flyout — self-contained rather than riding the ➕ menus'
// openSideFlyout machinery, whose close chain is scoped to those menus.
const ctxOutMenu = document.createElement("div");
ctxOutMenu.style.cssText = MENU_CSS;
ctxOutMenu.addEventListener("pointerdown", (e) => e.stopPropagation());
document.body.appendChild(ctxOutMenu);
const closeCtxMenu = () => { ctxMenu.style.display = "none"; ctxOutMenu.style.display = "none"; };
// Hovering any other ctx item closes the flyout (items are flat divs, so
// e.target is the item itself; the marked output item keeps it open).
ctxMenu.addEventListener("pointerover", (e) => {
if (ctxOutMenu.style.display === "flex" && !e.target.dataset?.pcrOut) ctxOutMenu.style.display = "none";
});
const deleteSelection = () => {
if (bandActive()) deleteBand();
else if (selectedProp) deleteSelectedProp();
else if (selectedFigure) removeFigure(selectedFigure);
else if (selectedJointName && rig) removeFigure(rig); // a joint is selected → its person
};
const mkCtxItem = (label, enabled, onClick) => {
const it = document.createElement("div");
it.textContent = label;
it.style.cssText = "padding:4px 9px;border-radius:4px;white-space:nowrap;" +
(enabled ? "cursor:pointer;color:#cdd;" : "color:#62656e;cursor:default;");
if (enabled) {
it.addEventListener("pointerenter", () => { it.style.background = "#3a3a46"; });
it.addEventListener("pointerleave", () => { it.style.background = "transparent"; });
it.addEventListener("click", (e) => { e.stopPropagation(); closeCtxMenu(); onClick(); });
}
return it;
};
// Right-click SELECTS what's under the cursor first (joint handle > prop >
// figure — same priority as a left click), so the menu's verbs act on the
// thing you clicked. Empty space keeps the current selection: deselect-on-
// right-click would strip the selection paste/copy was opened for.
const pickAtPointer = (e) => {
setPointer(e);
const h = pickHandle(e);
if (h) { attachGizmo(h.userData.joint); requestRender(); return; }
const propHit = raycaster.intersectObjects(propsGroup.children, true)[0];
const propObj = propHit && propRootOf(propHit.object);
if (propObj) {
// Already part of the current multi-selection? KEEP it — so the menu's group verbs
// see all of it instead of collapsing to the single thing under the cursor.
if (bandSel.some((s) => s.o === propObj)) { requestRender(); return; }
selectPropOrGroup(propObj); requestRender(); return; // a grouped member selects its whole group
}
const hit = raycaster.intersectObjects(figures.flatMap((f) => f.meshes), true)[0];
const fig = hit ? figures.find((f) => f.meshes.includes(hit.object)) : null;
if (fig) {
if (bandSel.some((s) => s.o === fig)) { requestRender(); return; } // keep the multi-selection
selectFigure(fig); requestRender();
}
};
// Capture output mode — what the node's IMAGE output emits. "default" = clay render
// over the dark frame (DepthAnything pipeline downstream), "white" = isolated figure
// on white (pose-anchor exports; mannequin-pose LoRAs are trained on white-background
// anchors), "depth" = true geometric z-depth (DepthAnything bypassed).
const OUTPUT_MODES = [["default", "default"], ["white", "white background"], ["depth", "depth map"]];
const getOutputMode = () => node.properties?.pcrOutputMode || (node.properties?.pcrTrueDepth ? "depth" : "default");
const setOutputMode = (mode) => {
node.properties = node.properties || {};
node.properties.pcrOutputMode = mode;
node.properties.pcrTrueDepth = mode === "depth"; // legacy flag mirrored for old saves/readers
setDepthBypass(mode === "depth");
showClipToast(mode === "depth" ? "output: depth map — DepthAnything bypassed"
: mode === "white" ? "output: figure on white background" : "output: default render");
requestRender();
captureAndUpload(node);
};
const openCtxOutFlyout = (anchor) => {
ctxOutMenu.innerHTML = "";
const cur = getOutputMode();
for (const [mode, label] of OUTPUT_MODES) {
ctxOutMenu.appendChild(mkCtxItem((cur === mode ? "● " : "○ ") + label, true, () => setOutputMode(mode)));
}
const r = anchor.getBoundingClientRect();
ctxOutMenu.style.display = "flex";
ctxOutMenu.style.left = Math.max(4, Math.min(r.right + 2, window.innerWidth - ctxOutMenu.offsetWidth - 8)) + "px";
ctxOutMenu.style.top = Math.max(4, Math.min(r.top, window.innerHeight - ctxOutMenu.offsetHeight - 8)) + "px";
};
const openCtxMenu = (x, y) => {
ctxMenu.innerHTML = ""; // rebuilt per open — items reflect the live selection
ctxOutMenu.style.display = "none";
const can = !!(bandSel.length || selectedFigure || selectedProp || selectedJointName);
ctxMenu.appendChild(mkCtxItem("✂️ cut", can, async () => { await copySelection(); deleteSelection(); }));
ctxMenu.appendChild(mkCtxItem("📋 copy", can, copySelection));
ctxMenu.appendChild(mkCtxItem("📥 paste", true, pasteClip));
// Grouping — group a multi-selection (Ctrl+G), ungroup a selected group, or combine
// a whole group onto a mannequin (every member rides it + enters its mask).
const grpSel = selectionProps(), grpIds = selectionGroupIds();
if (grpSel.length >= 2) ctxMenu.appendChild(mkCtxItem("🧩 group (Ctrl+G)", true, groupSelection));
if (grpIds.size) {
ctxMenu.appendChild(mkCtxItem("🧩 ungroup (Ctrl+Shift+G)", true, ungroupSelection));
if (figures.length) ctxMenu.appendChild(mkCtxItem("🔗 combine group to mannequin", true, combineGroupToTarget));
}
// Person verbs — on a joint or a whole figure (they act on the active rig).
if (rig && (selectedJointName || selectedFigure)) {
ctxMenu.appendChild(mkCtxItem("⇄ mirror", true, mirror));
ctxMenu.appendChild(mkCtxItem("↺ reset pose", true, resetPose));
}
// Foot pins live on the foot: right-click an ankle (or its knee — it carries
// the foot) to plant/release both feet.
const footish = /^(ankle|knee)[LR]$/.test(selectedJointName || "");
if (rig && footish && legsAreIkable(rig)) {
ctxMenu.appendChild(mkCtxItem(anyFootPinned() ? "📌 unpin feet" : "📌 pin feet", true, toggleFootPins));
}
// 🖼 output ▸ — hover (or click) opens the mode flyout; doesn't close the menu.
const outItem = document.createElement("div");
outItem.textContent = "🖼 output ▸";
outItem.dataset.pcrOut = "1";
outItem.style.cssText = "padding:4px 9px;border-radius:4px;white-space:nowrap;cursor:pointer;color:#cdd;";
outItem.addEventListener("pointerenter", () => { outItem.style.background = "#3a3a46"; openCtxOutFlyout(outItem); });
outItem.addEventListener("pointerleave", () => { outItem.style.background = "transparent"; });
outItem.addEventListener("click", (e) => { e.stopPropagation(); openCtxOutFlyout(outItem); });
ctxMenu.appendChild(outItem);
ctxMenu.appendChild(mkCtxItem("⌂ reset camera", true, resetView));
ctxMenu.style.display = "flex";
ctxMenu.style.left = Math.max(4, Math.min(x, window.innerWidth - ctxMenu.offsetWidth - 8)) + "px";
ctxMenu.style.top = Math.max(4, Math.min(y, window.innerHeight - ctxMenu.offsetHeight - 8)) + "px";
};
let rightPressInViewport = false;
let rightPressAt = null;
container.addEventListener("pointerdown", (e) => {
if (e.button === 2) { rightPressInViewport = true; rightPressAt = [e.clientX, e.clientY]; }
});
const onWinPointerDown = (e) => {
if (e.button !== 2) rightPressInViewport = false;
if (ctxMenu.style.display === "flex" && !ctxMenu.contains(e.target) && !ctxOutMenu.contains(e.target)) closeCtxMenu();
};
const onContextMenu = (e) => {
if (!rightPressInViewport) return;
rightPressInViewport = false;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const moved = rightPressAt ? Math.hypot(e.clientX - rightPressAt[0], e.clientY - rightPressAt[1]) : 99;
if (moved < 5) {
pickAtPointer(e);
openCtxMenu(e.clientX, e.clientY);
}
};
window.addEventListener("pointerdown", onWinPointerDown, true);
window.addEventListener("contextmenu", onContextMenu, true);
// Ride the existing flyout cleanup chain (re-mount calls it before reassigning;
// teardown calls it last) so the portaled menu never outlives the node.
const prevMenuCleanup = node._pcrMenuCleanup;
node._pcrMenuCleanup = () => { prevMenuCleanup?.(); ctxMenu.remove(); ctxOutMenu.remove(); };
container._pcrDolly = (deltaY) => {
const factor = Math.pow(0.95, -deltaY / 100);
camera.position.sub(controls.target).multiplyScalar(factor).add(controls.target);
controls.update();
requestRender();
captureAndUpload(node);
};
let lastCW = 0, lastCH = 0;
const resize = () => {
const w = Math.max(1, container.clientWidth);
const h = Math.max(1, container.clientHeight);
if (w === lastCW && h === lastCH) return; // ignore no-op observer ticks
lastCW = w; lastCH = h;
fitCanvas(); // re-fit the centered frame to the new node-body size
requestRender();
};
const ro = new ResizeObserver(resize);
ro.observe(container);
// Re-frame and re-capture when the target resolution changes (the frame aspect,
// hence the canvas fit, depends on width/height).
for (const w of [wWidget, hWidget]) {
if (!w) continue;
const prev = w.callback;
w.callback = function (...args) {
prev?.apply(this, args);
fitCanvas();
requestRender();
captureAndUpload(node);
};
}
// ── Clothing paint: body-hugging garment shells ──────────────────────────────
// A garment is a thin SkinnedMesh that REUSES the body's vertex attributes by
// reference (position/normal/skinIndex/skinWeight + morph deltas) with its OWN
// index buffer = only the painted triangles, pushed out along the surface normal
// by a thickness uniform. Because the attributes are shared instances, every body
// deformation tracks for free: skeletal pose (same skeleton), GPU shape-key morphs
// (shared morphAttributes + morphTargetInfluences, baked bodies), and the 🧬 custom
// engine (which re-sums position + recomputes normals IN PLACE — same buffers).
// Merge-on-touch is automatic: coverage is a vertex Set, connected coverage yields
// a connected submesh. See dev-promptchain/docs/plans/clothing-paint-shell.md.
const GARMENT_COLOR = 0x3a6fd8; // distinct blue so the garment reads apart from skin while editing
const garmentBodyOf = (fig) => (fig?.meshes || []).find((m) => m.isSkinnedMesh && m.skeleton) || null;
const garmentMat = () => {
const uThickness = { value: 0.012 };
const mat = new THREE.MeshStandardMaterial({ color: GARMENT_COLOR, roughness: 0.6, metalness: 0.0, side: THREE.DoubleSide });
mat.onBeforeCompile = (shader) => {
shader.uniforms.uThickness = uThickness;
// Offset along the RAW object-space normal (the `normal` attribute), not
// `objectNormal` — by begin_vertex the skinnormal include has already rotated
// objectNormal into posed space, so using it would double-skin the offset.
// Adding the object-space offset to `transformed` before skinning_vertex lets
// skinning transport it with the surface exactly once.
shader.vertexShader = "uniform float uThickness;\n" + shader.vertexShader.replace(
"#include <begin_vertex>",
"#include <begin_vertex>\n transformed += normalize(normal) * uThickness;"
);
};
return { mat, uThickness };
};
// Resolve the covered VERTEX SET into a triangle index over the body's topology:
// a face is kept when ANY of its corners is covered. (Requiring all three left
// holes where a press didn't happen to complete whole triangles — "I press and
// get no polygon"; "any" guarantees every painted vertex contributes its faces
// and fills interior gaps, at the cost of the patch edge running ~one ring wide.)
const garmentIndexFromVerts = (srcGeo, vertSet) => {
const out = [];
const keep = (a, b, c) => vertSet.has(a) || vertSet.has(b) || vertSet.has(c);
const idx = srcGeo.index;
if (idx) {
const a = idx.array;
for (let i = 0; i < a.length; i += 3) if (keep(a[i], a[i + 1], a[i + 2])) out.push(a[i], a[i + 1], a[i + 2]);
} else {
const n = srcGeo.attributes.position.count;
for (let i = 0; i + 2 < n; i += 3) if (keep(i, i + 1, i + 2)) out.push(i, i + 1, i + 2);
}
return out;
};
// Per-body vertex adjacency (cached) — the brush floods over real mesh edges so
// coverage is GEODESIC: it can't leak across a gap (between thighs, under an arm)
// the way a pure euclidean radius would, because there's no edge bridging them.
const garmentAdjacency = (fig, srcGeo) => {
if (fig._garmentAdjGeo === srcGeo && fig._garmentAdj) return fig._garmentAdj;
const n = srcGeo.attributes.position.count;
const adj = Array.from({ length: n }, () => []);
const link = (a, b) => { if (!adj[a].includes(b)) adj[a].push(b); if (!adj[b].includes(a)) adj[b].push(a); };
const idx = srcGeo.index;
if (idx) { const a = idx.array; for (let i = 0; i < a.length; i += 3) { link(a[i], a[i + 1]); link(a[i + 1], a[i + 2]); link(a[i + 2], a[i]); } }
else { for (let i = 0; i + 2 < n; i += 3) { link(i, i + 1); link(i + 1, i + 2); link(i + 2, i); } }
fig._garmentAdj = adj; fig._garmentAdjGeo = srcGeo;
return adj;
};
const rebuildShellIndex = (shell) => {
const g = shell.userData._garment;
const faces = garmentIndexFromVerts(g.srcGeo, g.vertSet);
shell.geometry.setIndex(faces);
shell.visible = faces.length > 0;
};
// Create a garment shell for `fig` covering `vertSet` (may be empty → grows via
// paint). thickness is the desired WORLD stand-off; the shader works in mesh-local
// units so we divide by the body's world scale.
const makeShellMesh = (fig, vertSet, thickness = 0.012) => {
const body = garmentBodyOf(fig);
if (!body) return null;
const srcGeo = body.geometry;
const geo = new THREE.BufferGeometry();
for (const k of ["position", "normal", "skinIndex", "skinWeight", "uv"]) {
if (srcGeo.attributes[k]) geo.setAttribute(k, srcGeo.attributes[k]); // share instance by reference
}
geo.morphAttributes = srcGeo.morphAttributes; // GPU shape-key bodies morph the shell too
geo.morphTargetsRelative = srcGeo.morphTargetsRelative;
geo.setIndex(garmentIndexFromVerts(srcGeo, vertSet));
geo.boundingSphere = srcGeo.boundingSphere; // already 3x-inflated for raycast slack
const { mat, uThickness } = garmentMat();
body.updateWorldMatrix(true, false);
const ws = new THREE.Vector3(); body.getWorldScale(ws);
const wsAvg = (ws.x + ws.y + ws.z) / 3 || 1;
uThickness.value = thickness / wsAvg;
const shell = new THREE.SkinnedMesh(geo, mat);
shell.frustumCulled = false;
shell.morphTargetInfluences = body.morphTargetInfluences; // share by ref → sliders move it
shell.morphTargetDictionary = body.morphTargetDictionary;
shell.bindMode = body.bindMode;
shell.raycast = () => {}; // never intercept body raycast (brush) or figure selection
// Child of the body with identity local transform → matrixWorld matches the
// body, so binding with the body's bindMatrix lands the skin identically. It
// also inherits the body's visibility (correct for per-figure mask captures).
body.add(shell);
shell.position.set(0, 0, 0); shell.quaternion.identity(); shell.scale.set(1, 1, 1);
shell.bind(body.skeleton, body.bindMatrix);
shell.visible = vertSet.size > 0;
shell.userData._garment = { uThickness, vertSet, srcGeo, wsAvg, thickness };
(fig.garments ||= []).push(shell);
return shell;
};
const setGarmentThickness = (shell, worldThickness) => {
const g = shell.userData._garment;
g.thickness = worldThickness; g.uThickness.value = worldThickness / g.wsAvg;
};
const removeGarment = (fig, shell) => {
shell.parent?.remove(shell); shell.geometry.dispose(); shell.material.dispose();
const arr = fig?.garments; const k = arr ? arr.indexOf(shell) : -1; if (k >= 0) arr.splice(k, 1);
};
const clearGarments = (fig) => {
for (const g of [...(fig?.garments || [])]) removeGarment(fig, g);
if (fig) fig.garments = [];
requestRender();
};
// ── Texture-coverage garment (fast paint, smooth HD edges, conforms for free) ──
// The shell above borrows the body's faces, so painting was either blocky (edge
// stuck at body polygons) or slow (rebuild an own-geometry mesh + per-vertex
// raycast every stroke). Instead: ONE body-hugging shell carrying ALL the body
// faces, with coverage stored in a MASK TEXTURE in the body's UV space. The
// material samples the mask as its alphaMap with alphaTest, so unpainted texels
// are cut away — including in the depth pass. Painting = raycast the body once per
// pointer event, stamp a disc into the mask canvas at the hit's UV. Edges are as
// smooth as the texture (not the mesh); no rebuild. Skin + morphs are shared by
// reference, so it tracks pose AND sliders with zero reprojection.
const MASK_RES = 2048;
const garmentMaskMat = (maskTex) => {
const uThickness = { value: 0.012 };
const mat = new THREE.MeshStandardMaterial({
color: GARMENT_COLOR, roughness: 0.6, metalness: 0.0, side: THREE.DoubleSide,
alphaMap: maskTex, alphaTest: 0.5, // painted texels keep, blank texels discard (depth too)
});
mat.onBeforeCompile = (shader) => { // same single-skin thickness offset as garmentMat()
shader.uniforms.uThickness = uThickness;
shader.vertexShader = "uniform float uThickness;\n" + shader.vertexShader.replace(
"#include <begin_vertex>",
"#include <begin_vertex>\n transformed += normalize(normal) * uThickness;"
);
};
return { mat, uThickness };
};
const makeMaskShell = (fig, thickness = 0.012) => {
const body = garmentBodyOf(fig);
if (!body || !body.geometry.attributes.uv) return null; // texture coverage needs body UVs
const srcGeo = body.geometry;
const geo = new THREE.BufferGeometry();
for (const k of ["position", "normal", "skinIndex", "skinWeight", "uv"]) {
if (srcGeo.attributes[k]) geo.setAttribute(k, srcGeo.attributes[k]); // share instance by reference
}
geo.morphAttributes = srcGeo.morphAttributes;
geo.morphTargetsRelative = srcGeo.morphTargetsRelative;
if (srcGeo.index) geo.setIndex(srcGeo.index);
geo.boundingSphere = srcGeo.boundingSphere;
const maskCanvas = document.createElement("canvas"); maskCanvas.width = maskCanvas.height = MASK_RES;
const maskCtx = maskCanvas.getContext("2d", { willReadFrequently: true });
const maskTex = new THREE.CanvasTexture(maskCanvas);
maskTex.flipY = false; // stamp at (u·W, v·H), sample at vUv → 1:1
maskTex.generateMipmaps = false; maskTex.minFilter = THREE.LinearFilter; maskTex.magFilter = THREE.LinearFilter;
if (THREE.NoColorSpace) maskTex.colorSpace = THREE.NoColorSpace; // it's a mask, not color
const { mat, uThickness } = garmentMaskMat(maskTex);
body.updateWorldMatrix(true, false);
const ws = new THREE.Vector3(); body.getWorldScale(ws);
const wsAvg = (ws.x + ws.y + ws.z) / 3 || 1;
uThickness.value = thickness / wsAvg;
const shell = new THREE.SkinnedMesh(geo, mat);
shell.frustumCulled = false;
shell.morphTargetInfluences = body.morphTargetInfluences;
shell.morphTargetDictionary = body.morphTargetDictionary;
shell.bindMode = body.bindMode;
body.add(shell);
shell.position.set(0, 0, 0); shell.quaternion.identity(); shell.scale.set(1, 1, 1);
shell.bind(body.skeleton, body.bindMatrix);
shell.userData._garment = { uThickness, wsAvg, thickness, isMaskShell: true, maskCanvas, maskCtx, maskTex };
(fig.garments ||= []).push(shell);
return shell;
};
const stampMask = (shell, uv, erase, radiusPx) => {
const g = shell.userData._garment, ctx = g.maskCtx;
ctx.save();
if (erase) ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "#fff";
ctx.beginPath(); ctx.arc(uv.x * MASK_RES, uv.y * MASK_RES, radiusPx, 0, Math.PI * 2); ctx.fill();
ctx.restore();
g.maskTex.needsUpdate = true;
};
// Screen-px brush → texture-px radius, via the hit face's local UV-per-world scale,
// so the brush feels the same size on the body wherever the unwrap stretches.
const _mrA = new THREE.Vector3(), _mrB = new THREE.Vector3(), _mrUA = new THREE.Vector2(), _mrUB = new THREE.Vector2();
const maskRadiusPxAtHit = (hit, body, shell) => {
const rect = canvas.getBoundingClientRect();
const focalPx = (rect.height / 2) / Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2);
const worldR = clothingPaint.brushPx * hit.distance / Math.max(1, focalPx);
const geo = body.geometry, f = hit.face; let uvPerLocal = 8;
if (f && geo.attributes.uv) {
_mrA.fromBufferAttribute(geo.attributes.position, f.a); _mrB.fromBufferAttribute(geo.attributes.position, f.b);
_mrUA.fromBufferAttribute(geo.attributes.uv, f.a); _mrUB.fromBufferAttribute(geo.attributes.uv, f.b);
const dPos = _mrB.distanceTo(_mrA); if (dPos > 1e-6) uvPerLocal = _mrUB.distanceTo(_mrUA) / dPos;
}
const localR = worldR / (shell.userData._garment.wsAvg || 1); // worldR → body-local units
return Math.max(1, Math.min(MASK_RES * 0.5, localR * uvPerLocal * MASK_RES));
};
const maskPaintedCount = () => { // diagnostic: how many texels of the active mask are painted
const g = clothingPaint.brushPiece?.userData?._garment; if (!g?.isMaskShell) return 0;
const d = g.maskCtx.getImageData(0, 0, MASK_RES, MASK_RES).data; let n = 0;
for (let i = 3; i < d.length; i += 4) if (d[i] > 0) n++;
return n;
};
const ensureMaskPiece = () => {
const cur = clothingPaint.brushPiece;
if (cur?.userData?._garment?.isMaskShell && clothingPaint.fig?.garments?.includes(cur)) return cur;
return (clothingPaint.brushPiece = makeMaskShell(clothingPaint.fig, clothingPaint.thickness));
};
const maskUndoSnap = () => { // snapshot the active mask before a stroke; undo restores it
const shell = clothingPaint.brushPiece; const g = shell?.userData?._garment; if (!g?.isMaskShell) return;
const snap = g.maskCtx.getImageData(0, 0, MASK_RES, MASK_RES);
pushUndo(() => { if (!clothingPaint.fig?.garments?.includes(shell)) return; g.maskCtx.putImageData(snap, 0, 0); g.maskTex.needsUpdate = true; requestRender(); });
};
const paintMaskAt = (e) => {
const body = garmentBodyOf(clothingPaint.fig); if (!body) return;
setPointer(e);
const hit = raycaster.intersectObject(body, false)[0];
if (!hit || !hit.uv) return; // missed the body (e.g. over empty space) — nothing to paint
const shell = ensureMaskPiece(); if (!shell) { showClipToast("this body has no UVs to paint on", true); return; }
stampMask(shell, hit.uv, clothingPaint.erase, maskRadiusPxAtHit(hit, body, shell));
requestRender();
};
// Debug/spike entry: a front-facing chest band (kept for the headless harness).
const buildGarmentShell = (fig, opts = {}) => {
const body = garmentBodyOf(fig);
if (!body) return null;
const srcGeo = body.geometry, pos = srcGeo.attributes.position;
let vertSet = opts.vertSet;
if (!vertSet) {
body.updateWorldMatrix(true, false);
const mw = body.matrixWorld, nm = new THREE.Matrix3().getNormalMatrix(mw);
const v = new THREE.Vector3(), nv = new THREE.Vector3();
const nrm = srcGeo.attributes.normal, front = opts.front ?? 0.25;
const wy0 = opts.wy0 ?? 1.15, wy1 = opts.wy1 ?? 1.45;
vertSet = new Set();
for (let i = 0; i < pos.count; i++) {
v.fromBufferAttribute(pos, i).applyMatrix4(mw);
if (v.y < wy0 || v.y > wy1) continue;
if (nrm) { nv.fromBufferAttribute(nrm, i).applyMatrix3(nm).normalize(); if (nv.z < front) continue; }
vertSet.add(i);
}
}
const shell = makeShellMesh(fig, vertSet, opts.thickness ?? 0.012);
if (shell) { requestRender(); captureAndUpload(node); }
return shell;
};
// ── Conformed garment (smooth own-geometry, projected onto the body) ──────────
// The shell above borrows the body's faces, so its EDGES are stuck at the body's
// coarse polygon resolution (blocky). This builds the garment from its OWN dense
// triangulation of a drawn outline, then projects each vertex onto the body by
// camera-ray, so the edge follows the smooth DRAWN outline — independent of the
// body mesh. Skin weights are sampled from the hit body triangle (dominant corner)
// so it still follows the pose. (Sliders not yet — that's the next step.)
// Dense triangulation of a closed 2D outline (NDC): triangulate then subdivide
// midpoints so the interior is sampled finely enough to hug body curvature.
const denseTriangulate = (poly, subdiv) => {
const verts = poly.map((p) => [p[0], p[1]]);
let tris = THREE.ShapeUtils.triangulateShape(poly.map((p) => new THREE.Vector2(p[0], p[1])), []);
for (let s = 0; s < subdiv; s++) {
const mid = new Map(), nt = [];
const getMid = (a, b) => {
const k = a < b ? a + "_" + b : b + "_" + a;
if (mid.has(k)) return mid.get(k);
const i = verts.length; verts.push([(verts[a][0] + verts[b][0]) / 2, (verts[a][1] + verts[b][1]) / 2]); mid.set(k, i); return i;
};
for (const [a, b, c] of tris) { const ab = getMid(a, b), bc = getMid(b, c), ca = getMid(c, a); nt.push([a, ab, ca], [ab, b, bc], [ca, bc, c], [ab, bc, ca]); }
tris = nt;
}
return { verts, tris };
};
const _baryCoord = (p, a, b, c, out) => {
const v0x = b.x - a.x, v0y = b.y - a.y, v0z = b.z - a.z;
const v1x = c.x - a.x, v1y = c.y - a.y, v1z = c.z - a.z;
const v2x = p.x - a.x, v2y = p.y - a.y, v2z = p.z - a.z;
const d00 = v0x * v0x + v0y * v0y + v0z * v0z, d01 = v0x * v1x + v0y * v1y + v0z * v1z, d11 = v1x * v1x + v1y * v1y + v1z * v1z;
const d20 = v2x * v0x + v2y * v0y + v2z * v0z, d21 = v2x * v1x + v2y * v1y + v2z * v1z;
const den = d00 * d11 - d01 * d01 || 1e-9, vv = (d11 * d20 - d01 * d21) / den, ww = (d00 * d21 - d01 * d20) / den;
out.set(1 - vv - ww, vv, ww);
};
// Core: turn a 2D mesh (verts in NDC + triangle list) into a smooth conformed garment
// by projecting each vertex onto the body. Used by both the lasso (outline → dense
// triangulation) and the brush (painted region → grid mesh).
const conformFromMesh2D = (fig, v2, tris, opts = {}) => {
const body = garmentBodyOf(fig); if (!body) return null;
const thickness = opts.thickness ?? 0.015;
body.updateWorldMatrix(true, false);
const ws = new THREE.Vector3(); body.getWorldScale(ws); const wsAvg = (ws.x + ws.y + ws.z) / 3 || 1;
const bgeo = body.geometry, bpos = bgeo.attributes.position, bN = bgeo.attributes.normal, bidx = bgeo.index ? bgeo.index.array : null;
const bSI = bgeo.attributes.skinIndex, bSW = bgeo.attributes.skinWeight;
const n = v2.length;
// Positions sit ON the body surface; the thickness raise is applied in the shader
// (garmentMat's uThickness along the stored normal) so thickness stays LIVE. Normals
// are barycentric-interpolated body normals (SMOOTH shading, not faceted). pins store
// each vertex's body triangle + barycentric coords so the garment can re-project when
// the body SHAPE changes (slider tracking) — see reprojectGarment.
const pos = new Float32Array(n * 3), nrm = new Float32Array(n * 3), si = new Float32Array(n * 4), sw = new Float32Array(n * 4), hitMask = new Uint8Array(n);
const pinTri = new Int32Array(n * 3).fill(-1), pinBary = new Float32Array(n * 3);
const A = new THREE.Vector3(), B = new THREE.Vector3(), C = new THREE.Vector3(), bary = new THREE.Vector3(), local = new THREE.Vector3();
const nm3 = new THREE.Matrix3().getNormalMatrix(body.matrixWorld), wN = new THREE.Vector3();
for (let i = 0; i < n; i++) {
raycaster.setFromCamera(new THREE.Vector2(v2[i][0], v2[i][1]), camera);
const hit = raycaster.intersectObject(body, false)[0];
if (!hit) continue;
// Reject only near-tangent/back-facing hits (true silhouette). A looser cut here
// used to punch holes over curved areas like breasts; the drawn outline already
// bounds the shape, so keep everything that faces the camera at all.
wN.copy(hit.face.normal).applyMatrix3(nm3).normalize();
if (wN.dot(raycaster.ray.direction) > -0.03) continue;
hitMask[i] = 1;
local.copy(hit.point); body.worldToLocal(local); // body-local hit point (bind ≈ pose at draw time)
pos[i * 3] = local.x; pos[i * 3 + 1] = local.y; pos[i * 3 + 2] = local.z;
const fi = hit.faceIndex;
const a = bidx ? bidx[fi * 3] : fi * 3, b = bidx ? bidx[fi * 3 + 1] : fi * 3 + 1, c = bidx ? bidx[fi * 3 + 2] : fi * 3 + 2;
A.fromBufferAttribute(bpos, a); B.fromBufferAttribute(bpos, b); C.fromBufferAttribute(bpos, c);
_baryCoord(local, A, B, C, bary);
const u = bary.x, vv = bary.y, w = bary.z;
nrm[i * 3] = bN.getX(a) * u + bN.getX(b) * vv + bN.getX(c) * w; // smooth interpolated normal
nrm[i * 3 + 1] = bN.getY(a) * u + bN.getY(b) * vv + bN.getY(c) * w;
nrm[i * 3 + 2] = bN.getZ(a) * u + bN.getZ(b) * vv + bN.getZ(c) * w;
pinTri[i * 3] = a; pinTri[i * 3 + 1] = b; pinTri[i * 3 + 2] = c;
pinBary[i * 3] = u; pinBary[i * 3 + 1] = vv; pinBary[i * 3 + 2] = w;
const dom = u >= vv && u >= w ? a : (vv >= w ? b : c); // dominant corner's bones
si[i * 4] = bSI.getX(dom); si[i * 4 + 1] = bSI.getY(dom); si[i * 4 + 2] = bSI.getZ(dom); si[i * 4 + 3] = bSI.getW(dom);
sw[i * 4] = bSW.getX(dom); sw[i * 4 + 1] = bSW.getY(dom); sw[i * 4 + 2] = bSW.getZ(dom); sw[i * 4 + 3] = bSW.getW(dom);
}
const outIdx = [];
for (const [a, b, c] of tris) if (hitMask[a] && hitMask[b] && hitMask[c]) outIdx.push(a, b, c);
if (!outIdx.length) { console.warn("[PoseStudio] conformed garment: outline missed the body"); return null; }
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
geo.setAttribute("normal", new THREE.BufferAttribute(nrm, 3));
geo.setAttribute("skinIndex", new THREE.BufferAttribute(si, 4));
geo.setAttribute("skinWeight", new THREE.BufferAttribute(sw, 4));
geo.setIndex(outIdx);
geo.computeBoundingSphere();
const { mat, uThickness } = garmentMat();
uThickness.value = thickness / wsAvg;
const mesh = new THREE.SkinnedMesh(geo, mat);
mesh.frustumCulled = false; mesh.bindMode = body.bindMode; mesh.raycast = () => {};
body.add(mesh); mesh.position.set(0, 0, 0); mesh.quaternion.identity(); mesh.scale.set(1, 1, 1);
mesh.bind(body.skeleton, body.bindMatrix);
mesh.userData._garment = { conformed: true, uThickness, wsAvg, thickness, pins: { tri: pinTri, bary: pinBary } };
(fig.garments ||= []).push(mesh);
return mesh;
};
// Lasso → conformed garment: dense-triangulate the outline, then project.
const buildConformedGarment = (fig, ndcPoly, opts = {}) => {
const { verts, tris } = denseTriangulate(ndcPoly, opts.subdiv ?? 2);
return conformFromMesh2D(fig, verts, tris, opts);
};
// Trace the OUTER boundary of the painted mask (Moore-neighbour tracing on a downsampled
// binary grid) → a smooth-ish closed outline in screen px. RDP later removes pixel noise.
const _MN = [[1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], [1, -1]]; // 8 neighbours, clockwise (y down)
const traceMaskOutline = (ctx) => {
const W = ctx.canvas.width, H = ctx.canvas.height, S = 2; // downsample for speed
const data = ctx.getImageData(0, 0, W, H).data;
const gw = Math.ceil(W / S), gh = Math.ceil(H / S), g = new Uint8Array(gw * gh);
let count = 0, sx = -1, sy = -1;
for (let y = 0; y < gh; y++) for (let x = 0; x < gw; x++) {
const px = Math.min(W - 1, x * S), py = Math.min(H - 1, y * S);
if (data[(py * W + px) * 4 + 3] > 128) { g[y * gw + x] = 1; count++; if (sy < 0) { sx = x; sy = y; } }
}
if (sx < 0 || count < 6) return null;
const at = (x, y) => (x < 0 || y < 0 || x >= gw || y >= gh) ? 0 : g[y * gw + x];
const dirIdx = (fx, fy, px, py) => { const dx = fx - px, dy = fy - py; for (let i = 0; i < 8; i++) if (_MN[i][0] === dx && _MN[i][1] === dy) return i; return 4; };
let p = [sx, sy], b = [sx - 1, sy]; const out = [[sx, sy]];
for (let guard = 0; guard < gw * gh * 8; guard++) {
const bd = dirIdx(b[0], b[1], p[0], p[1]); let moved = false;
for (let k = 1; k <= 8; k++) {
const d = (bd + k) % 8, nx = p[0] + _MN[d][0], ny = p[1] + _MN[d][1];
if (at(nx, ny)) { b = p; p = [nx, ny]; out.push(p); moved = true; break; }
}
if (!moved) break;
if (p[0] === sx && p[1] === sy) break;
}
const crect = canvas.getBoundingClientRect(); // mask px (×S) → screen (canvas-keyed)
return out.map(([x, y]) => [crect.left + x * S, crect.top + y * S]);
};
// A traced screen-px outline → conformed garment: decimate (RDP collapses closed loops),
// Chaikin-smooth, → NDC → buildConformedGarment.
const conformFromContour = (fig, outline, opts = {}) => {
if (!outline || outline.length < 8) return null;
let pts = outline.filter((_, i) => i % Math.max(1, Math.floor(outline.length / 56)) === 0);
if (pts.length < 4) return null;
for (let pass = 0; pass < (opts.smooth ?? 2); pass++) {
const np = [];
for (let i = 0; i < pts.length; i++) {
const a = pts[i], b = pts[(i + 1) % pts.length];
np.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25], [a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]);
}
pts = np;
}
const cr = canvas.getBoundingClientRect();
const ndc = pts.map(([x, y]) => [((x - cr.left) / cr.width) * 2 - 1, -(((y - cr.top) / cr.height) * 2 - 1)]);
return buildConformedGarment(fig, ndc, { ...opts, subdiv: 1 });
};
// Brush → one conformed garment from the painted region's outline.
const buildConformedFromMask = (fig, ctx, opts = {}) => conformFromContour(fig, traceMaskOutline(ctx), opts);
// Trace EVERY filled blob's outline (one per connected black region) — for image stamps
// where the picture has several separate shapes (e.g. sparkles).
const traceAllContours = (ctx) => {
const W = ctx.canvas.width, H = ctx.canvas.height, S = 2;
const data = ctx.getImageData(0, 0, W, H).data;
const gw = Math.ceil(W / S), gh = Math.ceil(H / S), g = new Uint8Array(gw * gh);
for (let y = 0; y < gh; y++) for (let x = 0; x < gw; x++) { const px = Math.min(W - 1, x * S), py = Math.min(H - 1, y * S); g[y * gw + x] = data[(py * W + px) * 4 + 3] > 128 ? 1 : 0; }
const at = (x, y) => (x < 0 || y < 0 || x >= gw || y >= gh) ? 0 : g[y * gw + x];
const dirIdx = (fx, fy, px, py) => { const dx = fx - px, dy = fy - py; for (let i = 0; i < 8; i++) if (_MN[i][0] === dx && _MN[i][1] === dy) return i; return 4; };
const visited = new Uint8Array(gw * gh), crect = canvas.getBoundingClientRect(), contours = [];
for (let sy = 0; sy < gh; sy++) for (let sx = 0; sx < gw; sx++) {
if (!g[sy * gw + sx] || visited[sy * gw + sx]) continue;
let p = [sx, sy], b = [sx - 1, sy]; const out = [[sx, sy]];
for (let guard = 0; guard < gw * gh * 8; guard++) {
const bd = dirIdx(b[0], b[1], p[0], p[1]); let moved = false;
for (let k = 1; k <= 8; k++) { const d = (bd + k) % 8, nx = p[0] + _MN[d][0], ny = p[1] + _MN[d][1]; if (at(nx, ny)) { b = p; p = [nx, ny]; out.push(p); moved = true; break; } }
if (!moved) break; if (p[0] === sx && p[1] === sy) break;
}
const stack = [[sx, sy]]; visited[sy * gw + sx] = 1; // flood-fill this blob so we don't re-trace it
while (stack.length) { const [x, y] = stack.pop(); for (const [ox, oy] of _MN) { const nx = x + ox, ny = y + oy; if (at(nx, ny) && !visited[ny * gw + nx]) { visited[ny * gw + nx] = 1; stack.push([nx, ny]); } } }
if (out.length >= 8) contours.push(out.map(([x, y]) => [crect.left + x * S, crect.top + y * S]));
}
return contours;
};
// Re-derive a conformed garment's surface positions + normals from its body-triangle
// pins against the body's CURRENT geometry — so it tracks body-shape sliders (breast,
// weight…) that move the body's vertices. Pose is already handled by skinning.
const reprojectGarment = (g) => {
const gd = g.userData._garment; if (!gd?.pins) return;
const body = g.parent; if (!body?.isSkinnedMesh) return;
const bpos = body.geometry.attributes.position, bN = body.geometry.attributes.normal;
const gp = g.geometry.attributes.position, gn = g.geometry.attributes.normal;
const tri = gd.pins.tri, bary = gd.pins.bary, n = gp.count;
for (let i = 0; i < n; i++) {
const a = tri[i * 3]; if (a < 0) continue;
const b = tri[i * 3 + 1], c = tri[i * 3 + 2], u = bary[i * 3], v = bary[i * 3 + 1], w = bary[i * 3 + 2];
gp.setXYZ(i, bpos.getX(a) * u + bpos.getX(b) * v + bpos.getX(c) * w, bpos.getY(a) * u + bpos.getY(b) * v + bpos.getY(c) * w, bpos.getZ(a) * u + bpos.getZ(b) * v + bpos.getZ(c) * w);
if (bN) gn.setXYZ(i, bN.getX(a) * u + bN.getX(b) * v + bN.getX(c) * w, bN.getY(a) * u + bN.getY(b) * v + bN.getY(c) * w, bN.getZ(a) * u + bN.getZ(b) * v + bN.getZ(c) * w);
}
gp.needsUpdate = true; if (bN) gn.needsUpdate = true;
};
const reprojectFigureGarments = (fig) => { for (const g of fig?.garments || []) if (g.userData._garment?.pins) reprojectGarment(g); };
// ── Paint interaction (brush + lasso) ────────────────────────────────────────
// Both modes drive off the main canvas pointer handlers: LEFT-drag paints, RIGHT-
// drag orbits (mouseButtons are remapped on enter so you can rotate the body while
// painting). The lasso draws onto a pointer-events:none overlay for feedback only.
const clothingPaint = { active: false, mode: "lasso", erase: false, radius: 0.05, thickness: 0.015, brushPx: 26, fig: null, shell: null, painting: false, lasso: null, savedMouseButtons: null, undo: [], selected: null, brushMask: null, brushMaskCtx: null, brushPiece: null, imgStamp: null };
// Brush = paint a 2D coverage MASK, then build a smooth conformed garment from it (same
// quality as the lasso, not body-polygon blocks). The mask is an offscreen canvas in
// container pixels; lassoCanvas shows live feedback.
const ensureBrushMask = () => {
if (!clothingPaint.brushMask) clothingPaint.brushMask = document.createElement("canvas");
const cr = canvas.getBoundingClientRect(); // mask is keyed to the WebGL canvas (same frame projection uses)
const m = clothingPaint.brushMask, w = Math.max(1, Math.round(cr.width)), h = Math.max(1, Math.round(cr.height));
if (m.width !== w || m.height !== h) { m.width = w; m.height = h; }
clothingPaint.brushMaskCtx = m.getContext("2d", { willReadFrequently: true });
};
const clearBrushMask = () => { ensureBrushMask(); clothingPaint.brushMaskCtx.clearRect(0, 0, clothingPaint.brushMask.width, clothingPaint.brushMask.height); };
const maskDab = (e) => {
if (!clothingPaint.brushMaskCtx) ensureBrushMask();
const crect = canvas.getBoundingClientRect(); // mask px = canvas-relative px (matches projection)
const ctx = clothingPaint.brushMaskCtx;
if (clothingPaint.erase) { ctx.save(); ctx.globalCompositeOperation = "destination-out"; ctx.beginPath(); ctx.arc(e.clientX - crect.left, e.clientY - crect.top, clothingPaint.brushPx, 0, Math.PI * 2); ctx.fill(); ctx.restore(); }
else { ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(e.clientX - crect.left, e.clientY - crect.top, clothingPaint.brushPx, 0, Math.PI * 2); ctx.fill(); }
const lr = lassoCanvas.getBoundingClientRect(), lc = lassoCanvas.getContext("2d"); // live feedback
lc.fillStyle = clothingPaint.erase ? "rgba(255,143,92,0.55)" : "rgba(111,159,224,0.55)";
lc.beginPath(); lc.arc(e.clientX - lr.left, e.clientY - lr.top, clothingPaint.brushPx, 0, Math.PI * 2); lc.fill();
};
const drawBrushRing = (e) => { // hover cursor: a 2D ring at the brush size
const lc = lassoCanvas.getContext("2d"); lc.clearRect(0, 0, lassoCanvas.width, lassoCanvas.height);
const lr = lassoCanvas.getBoundingClientRect();
lc.strokeStyle = clothingPaint.erase ? "#ff8f5c" : "#6f9fe0"; lc.lineWidth = 1.5; lc.setLineDash([]);
lc.beginPath(); lc.arc(e.clientX - lr.left, e.clientY - lr.top, clothingPaint.brushPx, 0, Math.PI * 2); lc.stroke();
};
const commitBrushPiece = () => { // rebuild the conformed garment from the accumulated mask
if (clothingPaint.brushPiece && clothingPaint.fig?.garments?.includes(clothingPaint.brushPiece)) removeGarment(clothingPaint.fig, clothingPaint.brushPiece);
clothingPaint.brushPiece = buildConformedFromMask(clothingPaint.fig, clothingPaint.brushMaskCtx, { thickness: clothingPaint.thickness });
};
const pushBrushUndo = () => { // snapshot the mask before a stroke; undo restores it + rebuilds
ensureBrushMask();
const m = clothingPaint.brushMask, snap = clothingPaint.brushMaskCtx.getImageData(0, 0, m.width, m.height);
pushUndo(() => { ensureBrushMask(); clothingPaint.brushMaskCtx.putImageData(snap, 0, 0); commitBrushPiece(); });
};
// ── Image stamp: upload a picture → build a conformed garment piece per black blob ──
// (e.g. a sheet of sparkles). Place/scale the image over the body, then stamp: dark
// pixels become the coverage, each separate blob → its own smooth conformed garment.
const drawImageOverlay = () => {
const s = clothingPaint.imgStamp; const lc = lassoCanvas.getContext("2d");
lc.clearRect(0, 0, lassoCanvas.width, lassoCanvas.height);
if (!s) return;
const lr = lassoCanvas.getBoundingClientRect();
const w = s.img.width * s.scale, h = s.img.height * s.scale;
lc.globalAlpha = 0.55; lc.drawImage(s.img, s.cx - lr.left - w / 2, s.cy - lr.top - h / 2, w, h); lc.globalAlpha = 1;
};
const enterImageStamp = (img) => {
const cr = canvas.getBoundingClientRect();
const scale = (cr.height * 0.4) / img.height; // default ~40% of the frame height
clothingPaint.imgStamp = { img, cx: cr.left + cr.width / 2, cy: cr.top + cr.height * 0.42, scale, base: scale };
imgBar.style.display = "flex"; imgScale.value = "1"; drawImageOverlay(); requestRender();
};
const cancelImageStamp = () => {
clothingPaint.imgStamp = null; imgBar.style.display = "none";
lassoCanvas.getContext("2d").clearRect(0, 0, lassoCanvas.width, lassoCanvas.height); requestRender();
};
const stampImage = () => {
const s = clothingPaint.imgStamp; if (!s) return;
ensureBrushMask(); const m = clothingPaint.brushMask, W = m.width, H = m.height;
// rasterize the placed image, threshold dark pixels into a fresh mask
const tmp = document.createElement("canvas"); tmp.width = W; tmp.height = H;
const tctx = tmp.getContext("2d"); const crect = canvas.getBoundingClientRect();
const w = s.img.width * s.scale, h = s.img.height * s.scale;
tctx.drawImage(s.img, (s.cx - crect.left) - w / 2, (s.cy - crect.top) - h / 2, w, h);
const td = tctx.getImageData(0, 0, W, H).data;
const mctx = clothingPaint.brushMaskCtx; const mid = mctx.createImageData(W, H), md = mid.data;
for (let i = 0; i < td.length; i += 4) { const lum = (td[i] + td[i + 1] + td[i + 2]) / 3; if (td[i + 3] > 40 && lum < 110) { md[i] = md[i + 1] = md[i + 2] = md[i + 3] = 255; } }
mctx.putImageData(mid, 0, 0);
const contours = traceAllContours(mctx), made = [];
for (const c of contours) { const g = conformFromContour(clothingPaint.fig, c, { thickness: clothingPaint.thickness, smooth: 1 }); if (g) made.push(g); }
clearBrushMask();
cancelImageStamp();
if (made.length) { pushUndo(() => { for (const g of made) if (clothingPaint.fig?.garments?.includes(g)) removeGarment(clothingPaint.fig, g); }); requestRender(); captureAndUpload(node); showClipToast(`stamped ${made.length} piece${made.length > 1 ? "s" : ""}`); }
else showClipToast("place the dark shapes over the body, then stamp", true);
return made.length;
};
const pickImageForStamp = () => {
const inp = document.createElement("input"); inp.type = "file"; inp.accept = "image/*";
inp.addEventListener("change", () => { const f = inp.files?.[0]; if (!f) return; const url = URL.createObjectURL(f); const img = new Image(); img.onload = () => { enterImageStamp(img); URL.revokeObjectURL(url); }; img.src = url; });
inp.click();
};
// Click an existing garment piece (in paint mode) to select it — the thickness slider
// then edits THAT piece, and Del removes it. Garments have their instance raycast
// disabled (so they never block the brush/figure picks), so pick via the prototype.
const pickGarmentPiece = (e) => {
setPointer(e);
const hits = [];
for (const g of clothingPaint.fig?.garments || []) {
if (!g.visible || !g.geometry.index) continue;
THREE.SkinnedMesh.prototype.raycast.call(g, raycaster, hits);
}
hits.sort((a, b) => a.distance - b.distance);
return hits[0] ? hits[0].object : null;
};
const setPieceHighlight = (g) => {
for (const x of clothingPaint.fig?.garments || []) if (x.material?.emissive) x.material.emissive.setHex(0x000000);
if (g?.material?.emissive) g.material.emissive.setHex(0x14306a);
};
const selectGarmentPiece = (g) => {
clothingPaint.selected = g || null;
setPieceHighlight(g);
if (g) { cpThick.sl.value = String(g.userData._garment.thickness ?? clothingPaint.thickness); showClipToast("piece selected — thickness slider edits it · Del removes it"); }
requestRender();
};
// Paint-local undo = a stack of reverse-closures (covers lasso add, erase, and brush
// strokes). The poser's ↶ button and Ctrl+Z both route here while paint mode is open.
// (Full pose-history/serialization is Phase 4.)
const pushUndo = (fn) => { clothingPaint.undo.push(fn); if (clothingPaint.undo.length > 50) clothingPaint.undo.shift(); };
const pushPaintUndo = () => { // brush stroke: snapshot the shell's coverage
const sh = clothingPaint.shell; if (!sh) return;
const snap = new Set(sh.userData._garment.vertSet);
pushUndo(() => { if (clothingPaint.fig?.garments?.includes(sh)) { sh.userData._garment.vertSet = snap; rebuildShellIndex(sh); clothingPaint.shell = sh; } });
};
const paintUndo = () => {
if (!clothingPaint.active) return false;
const fn = clothingPaint.undo.pop();
if (!fn) { showClipToast("nothing to undo"); return true; }
try { fn(); } catch (e) { console.warn("[PoseStudio] paint undo failed", e); }
requestRender();
captureAndUpload(node);
return true;
};
const lassoCanvas = document.createElement("canvas");
lassoCanvas.style.cssText = "position:absolute;inset:0;z-index:6;pointer-events:none;display:none;";
container.appendChild(lassoCanvas);
// Feedback overlay fills the container (inheriting the graph zoom like everything else).
// Its backing is sized from its OWN on-screen rect so 1px backing == 1px on screen at any
// zoom — never from clientWidth (layout px), which diverges from the rect under zoom and
// was clipping paint at the letterbox gap. Feedback uses lassoCanvas's rect; the coverage
// mask + projection use the canvas rect — both getBoundingClientRect, so zoom-consistent.
const positionLassoCanvas = () => {
// Size the backing from the CONTAINER (the overlay is inset:0, so it fills it). The
// container is always laid out, so this is correct even when lassoCanvas itself is
// still display:none on entry — sizing from the hidden overlay's own rect collapsed
// the backing to 1×1 and the brush cursor/feedback drew into nothing.
const r = container.getBoundingClientRect();
const w = Math.max(1, Math.round(r.width)), h = Math.max(1, Math.round(r.height));
if (lassoCanvas.width !== w || lassoCanvas.height !== h) { lassoCanvas.width = w; lassoCanvas.height = h; }
};
const drawLassoLine = () => {
const c = lassoCanvas.getContext("2d");
c.clearRect(0, 0, lassoCanvas.width, lassoCanvas.height);
const pts = clothingPaint.lasso;
if (!pts || pts.length < 2) return;
const lr = lassoCanvas.getBoundingClientRect();
c.strokeStyle = clothingPaint.erase ? "#ff8f5c" : "#6f9fe0";
c.lineWidth = 2; c.lineJoin = "round"; c.lineCap = "round"; c.setLineDash([5, 4]);
c.beginPath(); c.moveTo(pts[0][0] - lr.left, pts[0][1] - lr.top);
for (const p of pts) c.lineTo(p[0] - lr.left, p[1] - lr.top);
c.closePath(); c.stroke();
};
const pointInPoly = (x, y, poly) => {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const xi = poly[i][0], yi = poly[i][1], xj = poly[j][0], yj = poly[j][1];
if (((yi > y) !== (yj > y)) && (x < ((xj - xi) * (y - yi)) / (yj - yi) + xi)) inside = !inside;
}
return inside;
};
// Brush footprint ring — a thin annulus laid tangent to the body surface at the
// cursor, scaled to the brush radius, so you can see how big a dab will be.
brushCursor = new THREE.Mesh(
new THREE.RingGeometry(0.86, 1.0, 40),
new THREE.MeshBasicMaterial({ color: GARMENT_COLOR, transparent: true, opacity: 0.85, side: THREE.DoubleSide, depthTest: false })
);
brushCursor.renderOrder = 999; brushCursor.visible = false; brushCursor.raycast = () => {};
scene.add(brushCursor);
const _bcN = new THREE.Vector3(), _bcZ = new THREE.Vector3(0, 0, 1), _bcMat3 = new THREE.Matrix3();
const updateBrushCursor = (e) => {
if (!clothingPaint.active || clothingPaint.mode !== "brush") { if (brushCursor.visible) { brushCursor.visible = false; requestRender(); } return; }
const body = garmentBodyOf(clothingPaint.fig); if (!body) return;
setPointer(e);
const hit = raycaster.intersectObject(body, false)[0];
if (!hit) { if (brushCursor.visible) { brushCursor.visible = false; requestRender(); } return; }
_bcN.copy(hit.face.normal).applyMatrix3(_bcMat3.getNormalMatrix(body.matrixWorld)).normalize();
brushCursor.position.copy(hit.point).addScaledVector(_bcN, 0.004); // float just off the skin
brushCursor.quaternion.setFromUnitVectors(_bcZ, _bcN);
// size the ring to the ACTUAL stamp footprint (same screen-px→world as the paint),
// so what you see is what you paint
const rect = canvas.getBoundingClientRect();
const focalPx = (rect.height / 2) / Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2);
brushCursor.scale.setScalar(Math.max(0.004, clothingPaint.brushPx * hit.distance / Math.max(1, focalPx)));
brushCursor.material.color.setHex(clothingPaint.erase ? 0xff8f5c : GARMENT_COLOR);
brushCursor.visible = true;
requestRender();
};
// Flood the covered set outward from `seeds` over mesh edges, gated by a local-
// space radius around `centerLocal`. add (default) or erase based on the toggle.
const brushFloodLocal = (centerLocal, seeds) => {
const sh = clothingPaint.shell; if (!sh) return;
const fig = clothingPaint.fig, body = garmentBodyOf(fig); if (!body) return;
const srcGeo = body.geometry, pos = srcGeo.attributes.position;
const adj = garmentAdjacency(fig, srcGeo);
const rLocal = clothingPaint.radius / (sh.userData._garment.wsAvg || 1), r2 = rLocal * rLocal;
const v = new THREE.Vector3(), vertSet = sh.userData._garment.vertSet;
const within = (i) => { v.fromBufferAttribute(pos, i); return v.distanceToSquared(centerLocal) <= r2; };
const visited = new Set(seeds), queue = [...seeds];
while (queue.length) {
const i = queue.pop();
if (!within(i)) continue;
if (clothingPaint.erase) vertSet.delete(i); else vertSet.add(i);
for (const nb of adj[i]) if (!visited.has(nb)) { visited.add(nb); queue.push(nb); }
}
rebuildShellIndex(sh);
requestRender();
};
const brushDabAt = (e) => {
const body = garmentBodyOf(clothingPaint.fig); if (!body) return;
setPointer(e);
const hit = raycaster.intersectObject(body, false)[0];
if (!hit) return;
const idx = body.geometry.index, fi = hit.faceIndex;
const seeds = idx ? [idx.array[fi * 3], idx.array[fi * 3 + 1], idx.array[fi * 3 + 2]] : [fi * 3, fi * 3 + 1, fi * 3 + 2];
brushFloodLocal(body.worldToLocal(hit.point.clone()), seeds);
};
// Lasso: mark every front-facing body vertex whose SKINNED screen position falls
// inside the drawn polygon. Polygon points are in WebGL-canvas pixels (same space
// the verts project into), so no overlay-offset math is needed.
const applyLasso = (poly) => {
const sh = clothingPaint.shell; if (!sh) return;
const body = garmentBodyOf(clothingPaint.fig); if (!body) return;
const srcGeo = body.geometry, pos = srcGeo.attributes.position, nrm = srcGeo.attributes.normal;
body.updateWorldMatrix(true, false);
const mw = body.matrixWorld, nm = new THREE.Matrix3().getNormalMatrix(mw);
const cr = canvas.getBoundingClientRect();
const useGVP = typeof body.getVertexPosition === "function";
const v = new THREE.Vector3(), nv = new THREE.Vector3(), toCam = new THREE.Vector3(), tmp = new THREE.Vector3();
const vertSet = sh.userData._garment.vertSet;
for (let i = 0; i < pos.count; i++) {
if (useGVP) { body.getVertexPosition(i, tmp); v.copy(tmp).applyMatrix4(mw); } // skinned world (works posed)
else v.fromBufferAttribute(pos, i).applyMatrix4(mw);
if (nrm) { nv.fromBufferAttribute(nrm, i).applyMatrix3(nm).normalize(); toCam.subVectors(camera.position, v).normalize(); if (nv.dot(toCam) < 0.05) continue; }
const p = v.clone().project(camera);
if (p.z > 1) continue; // behind the camera
const sx = (p.x * 0.5 + 0.5) * cr.width, sy = (-p.y * 0.5 + 0.5) * cr.height;
if (pointInPoly(sx, sy, poly)) { if (clothingPaint.erase) vertSet.delete(i); else vertSet.add(i); }
}
rebuildShellIndex(sh);
requestRender();
captureAndUpload(node);
};
// Erase: drop every garment face whose 3 corners all project inside the lasso loop
// (carve away covered area; a piece fully enclosed is removed). Works across all of
// the figure's garments. Poly + projected verts compared in canvas pixels.
const eraseLasso = (screenPoly) => {
const fig = clothingPaint.fig; if (!fig?.garments?.length) return;
const cr = canvas.getBoundingClientRect();
const poly = screenPoly.map(([x, y]) => [x - cr.left, y - cr.top]);
const v = new THREE.Vector3(), tmp = new THREE.Vector3();
const changes = []; // {g, prevIndex, removed} — for undo
for (const g of [...fig.garments]) {
const geo = g.geometry, idx = geo.index; if (!idx) continue;
const pos = geo.attributes.position, count = pos.count, useGVP = typeof g.getVertexPosition === "function";
g.updateWorldMatrix(true, false);
const inside = new Uint8Array(count);
for (let i = 0; i < count; i++) {
if (useGVP) { g.getVertexPosition(i, tmp); v.copy(tmp); } else v.fromBufferAttribute(pos, i);
v.applyMatrix4(g.matrixWorld);
const p = v.project(camera);
if (p.z <= 1 && pointInPoly((p.x * 0.5 + 0.5) * cr.width, (-p.y * 0.5 + 0.5) * cr.height, poly)) inside[i] = 1;
}
const arr = idx.array, keep = [];
for (let i = 0; i < arr.length; i += 3) { if (inside[arr[i]] && inside[arr[i + 1]] && inside[arr[i + 2]]) continue; keep.push(arr[i], arr[i + 1], arr[i + 2]); }
if (keep.length === arr.length) continue;
const prevIndex = Array.from(arr);
if (!keep.length) { // emptied → hide + drop from active (keep the mesh so undo can restore it, don't dispose)
if (clothingPaint.selected === g) { setPieceHighlight(null); clothingPaint.selected = null; }
g.visible = false; const k = fig.garments.indexOf(g); if (k >= 0) fig.garments.splice(k, 1);
changes.push({ g, prevIndex, removed: true });
} else { geo.setIndex(keep); changes.push({ g, prevIndex, removed: false }); }
}
if (changes.length) {
pushUndo(() => { for (const c of changes) { c.g.geometry.setIndex(c.prevIndex); c.g.visible = true; if (c.removed && !fig.garments.includes(c.g)) fig.garments.push(c.g); } });
requestRender(); captureAndUpload(node);
} else showClipToast("nothing to erase there");
};
const finishLasso = () => {
const raw0 = clothingPaint.lasso || [];
clothingPaint.lasso = null;
lassoCanvas.getContext("2d").clearRect(0, 0, lassoCanvas.width, lassoCanvas.height);
if (raw0.length < 3) return;
if (clothingPaint.erase) { eraseLasso(simplifyRDP(raw0, 4)); return; } // erase mode: carve, don't build
// SIMPLIFY first: a freehand loop has hundreds of points → without this, the dense
// triangulation explodes into thousands of vertices and the per-vertex body raycast
// froze the tab. RDP to a clean ~outline; subdivision then refills interior density.
const raw = simplifyRDP(raw0, 4);
if (raw.length < 3) return;
// The outline becomes a SMOOTH clothing piece: its own dense mesh projected onto the
// body (edges follow the drawn outline, not the body's coarse polygons), skinned to
// the body skeleton. Cap subdivision by outline complexity to bound the raycast cost.
// Points are SCREEN coords → convert to NDC via the canvas's live rect.
const rect = canvas.getBoundingClientRect();
const ndc = raw.map(([x, y]) => [((x - rect.left) / rect.width) * 2 - 1, -(((y - rect.top) / rect.height) * 2 - 1)]);
const subdiv = raw.length > 60 ? 1 : 2;
const m = buildConformedGarment(clothingPaint.fig, ndc, { thickness: clothingPaint.thickness, subdiv });
if (m) { pushUndo(() => { if (clothingPaint.fig?.garments?.includes(m)) removeGarment(clothingPaint.fig, m); }); requestRender(); captureAndUpload(node); }
else showClipToast("draw the loop over the body", true);
};
// ── Paint mode lifecycle + bottom-left panel ─────────────────────────────────
const clothingHint = document.createElement("div");
clothingHint.style.cssText = "position:absolute;top:6px;left:50%;transform:translateX(-50%);z-index:8;display:none;padding:3px 10px;background:#2e4d7d;color:#fff;border:1px solid #6f9fe0;border-radius:5px;font:11px/1.4 system-ui,sans-serif;pointer-events:none;white-space:nowrap;";
container.appendChild(clothingHint);
const updateClothingHint = () => {
clothingHint.textContent = clothingPaint.mode === "lasso"
? "👕 draw a loop = new piece · click a piece to select (slider=thickness, Del=remove) · right-drag orbits · Esc done"
: "👕 brush to paint a smooth piece · - erase carves it · right-drag orbits · +new for another · Esc done";
};
const enterClothingPaint = () => {
const fig = rig || figures[0];
if (!garmentBodyOf(fig)) { showClipToast("add a person first to paint clothing on", true); return; }
if (clothingPaint.active) return;
clothingPaint.active = true; clothingPaint.fig = fig; clothingPaint.painting = false; clothingPaint.lasso = null;
detachGizmo(); clearBand();
// Left now paints; orbit (rotate) moves to right-drag so the body can be turned
// while painting. Pan/dolly stay reachable via the wheel + middle as before.
clothingPaint.savedMouseButtons = { ...controls.mouseButtons };
controls.mouseButtons = { LEFT: null, MIDDLE: controls.mouseButtons.MIDDLE, RIGHT: THREE.MOUSE.ROTATE };
lassoCanvas.style.display = "block";
positionLassoCanvas();
clearBrushMask(); clothingPaint.brushPiece = null; // fresh brush piece
clothingPanel.style.display = "flex"; clothingHint.style.display = "block";
syncClothingPanel(); updateClothingHint();
requestRender();
};
const exitClothingPaint = () => {
if (!clothingPaint.active) return;
if (clothingPaint.imgStamp) cancelImageStamp();
clothingPaint.active = false; clothingPaint.painting = false; clothingPaint.lasso = null;
if (clothingPaint.savedMouseButtons) { controls.mouseButtons = clothingPaint.savedMouseButtons; clothingPaint.savedMouseButtons = null; }
if (clothingPaint.selected) { setPieceHighlight(null); clothingPaint.selected = null; }
clothingPaint.brushPiece = null; clearBrushMask();
clothingPanel.style.display = "none"; clothingHint.style.display = "none";
lassoCanvas.style.display = "none"; lassoCanvas.getContext("2d").clearRect(0, 0, lassoCanvas.width, lassoCanvas.height);
if (brushCursor) brushCursor.visible = false;
clothingPaint.undo = [];
controls.enabled = true;
requestRender();
captureAndUpload(node);
};
const startNewGarment = () => {
if (!clothingPaint.active) return;
if (clothingPaint.selected) { setPieceHighlight(null); clothingPaint.selected = null; } // dropping the edit selection
clearBrushMask(); clothingPaint.brushPiece = null; // next brush strokes start a new piece (existing one stays)
requestRender();
};
// ── mesh sculpt (grab / inflate / smooth) ───────────────────────────────────
// A direct-vertex sculptor for reshaping a body region (nudge a breast bigger when the
// slider can't, fix a shape) or a prop. WHY it stays simple: a figure SNAPS TO REST POSE
// while sculpting, so bone rotations are identity and an edit written into the rest
// position buffer is screen-1:1 — no inverse-skinning math. The edit is stored as a sparse
// rest-space delta (fig.sculptMap / prop mesh.userData.sculptMap) that the custom engine
// re-adds on every recompute, so it survives slider changes, posing, and serialization.
const sculpt = {
active: false, kind: null, target: null, mesh: null, map: null, isEngine: false,
brush: "grab", radius: 0.06, strength: 0.5, symmetry: true, wire: true, panTool: false, inPose: false,
painting: false, panning: false, panLast: null, savedMouseButtons: null, poseSnap: null, stroke: null, undo: [],
};
let sculptWireBtn = null; // "show mesh" toggle (assigned when the panel is built)
let sculptPanBtn = null; // ✋ pan-tool toggle (assigned when the panel is built)
let sculptPoseBtn = null; // 🧍 edit-in-pose toggle (assigned when the panel is built)
const SCULPT_BRUSHES = [["grab", "✊ grab"], ["inflate", "🎈 inflate"], ["smooth", "〰 smooth"], ["revert", "↩ revert"]];
const _scA = new THREE.Vector3(), _scB = new THREE.Vector3(), _scDl = new THREE.Vector3(), _scC = new THREE.Vector3();
const _scN = new THREE.Vector3(), _scTmp = new THREE.Vector3(), _scAvg = new THREE.Vector3(), _scHit = new THREE.Vector3();
const _scD2 = new THREE.Vector3(), _scWS = new THREE.Vector3(), _scZ = new THREE.Vector3(0, 0, 1), _scMat3 = new THREE.Matrix3();
const _scGD = new THREE.Vector3(); // grab delta routed through a vertex's inverse skin (in-pose mode)
const _isB = new THREE.Vector3(), _isX = new THREE.Vector3(), _isY = new THREE.Vector3(), _isZ = new THREE.Vector3(), _isMat = new THREE.Matrix3();
// Skinned LOCAL position of vertex i. getVertexPosition seeds rest(+morph) then skins —
// applyBoneTransform must NOT be called bare: it uses the PASSED vector as the base, so a
// zero vector skins the ORIGIN (the bug that made the brush only bite where the bogus
// barycentric center happened to land on geometry).
const skinV = (mesh, i, out) => {
if (mesh.getVertexPosition) return mesh.getVertexPosition(i, out);
out.fromBufferAttribute(mesh.geometry.attributes.position, i);
return mesh.applyBoneTransform ? mesh.applyBoneTransform(i, out) : mesh.boneTransform(i, out);
};
const baryWeights = (p, a, b, c) => {
const v0x = b.x - a.x, v0y = b.y - a.y, v0z = b.z - a.z;
const v1x = c.x - a.x, v1y = c.y - a.y, v1z = c.z - a.z;
const v2x = p.x - a.x, v2y = p.y - a.y, v2z = p.z - a.z;
const d00 = v0x * v0x + v0y * v0y + v0z * v0z, d01 = v0x * v1x + v0y * v1y + v0z * v1z, d11 = v1x * v1x + v1y * v1y + v1z * v1z;
const d20 = v2x * v0x + v2y * v0y + v2z * v0z, d21 = v2x * v1x + v2y * v1y + v2z * v1z;
const den = d00 * d11 - d01 * d01 || 1e-9;
const v = (d11 * d20 - d01 * d21) / den, w = (d00 * d21 - d01 * d20) / den;
return [1 - v - w, v, w];
};
// Coincident-vertex GROUPS (UV-seam duplicates share a position): the brush moves whole
// groups so a seam never cracks. Built once per geometry from the original rest positions.
const sculptGroups = (mesh) => {
const geo = mesh.geometry;
if (geo.userData._sculptGroups) return geo.userData._sculptGroups;
const pos = geo.attributes.position, n = pos.count, byKey = new Map(), reps = [], groupOf = new Int32Array(n);
for (let i = 0; i < n; i++) {
const k = Math.round(pos.getX(i) * 1e5) + "_" + Math.round(pos.getY(i) * 1e5) + "_" + Math.round(pos.getZ(i) * 1e5);
let g = byKey.get(k);
if (g == null) { g = reps.length; byKey.set(k, g); reps.push({ members: [] }); }
reps[g].members.push(i); groupOf[i] = g;
}
const adjSet = Array.from({ length: reps.length }, () => new Set());
const edge = (a, b) => { const ga = groupOf[a], gb = groupOf[b]; if (ga !== gb) { adjSet[ga].add(gb); adjSet[gb].add(ga); } };
const idx = geo.index ? geo.index.array : null;
if (idx) for (let i = 0; i < idx.length; i += 3) { edge(idx[i], idx[i + 1]); edge(idx[i + 1], idx[i + 2]); edge(idx[i + 2], idx[i]); }
else for (let i = 0; i + 2 < n; i += 3) { edge(i, i + 1); edge(i + 1, i + 2); edge(i + 2, i); }
const out = { reps, adj: adjSet.map((s) => [...s]) };
geo.userData._sculptGroups = out;
return out;
};
// Capture/snap/restore a figure's pose around a sculpt session (snap to rest so edits are
// screen-1:1; restore the user's pose on exit — the sculpted rest buffer then rides it).
const sculptSnapPose = (f) => {
const snap = { q: new Map(), p: new Map(), root: f.root.position.clone() };
if (f.rest) for (const [j] of f.rest) snap.q.set(j, j.quaternion.clone());
if (f.restPos) for (const [j] of f.restPos) snap.p.set(j, j.position.clone());
if (f.curSkelRest) for (const [b] of f.curSkelRest) snap.p.set(b, b.position.clone());
return snap;
};
const sculptApplyRest = (f) => {
if (f.rest) for (const [j, q] of f.rest) j.quaternion.copy(q);
if (f.restPos) for (const [j, p] of f.restPos) j.position.copy(p);
if (f.curSkelRest) for (const [b, p] of f.curSkelRest) b.position.copy(p);
if (f.rootRest) f.root.position.copy(f.rootRest);
f.root.updateMatrixWorld(true);
};
const sculptRestorePose = (f, snap) => {
for (const [j, q] of snap.q) j.quaternion.copy(q);
for (const [o, p] of snap.p) o.position.copy(p);
f.root.position.copy(snap.root);
f.root.updateMatrixWorld(true);
};
// Set position = base + sculptMap, idempotently (the base snapshot is captured on the first
// call). Used by non-engine targets (baked-morph bodies + props) which have no re-sum.
const applySculpt = (mesh, map) => {
const pos = mesh.geometry.attributes.position, arr = pos.array;
let base = mesh.userData._sculptBase;
if (!base) base = mesh.userData._sculptBase = arr.slice();
arr.set(base);
if (map) for (const [vi, dv] of map) { const o = vi * 3; if (o + 2 >= arr.length) continue; arr[o] += dv[0]; arr[o + 1] += dv[1]; arr[o + 2] += dv[2]; }
pos.needsUpdate = true;
mesh.geometry.computeVertexNormals();
mesh.geometry.attributes.normal.needsUpdate = true;
mesh.geometry.computeBoundingSphere();
if (mesh.geometry.boundingSphere) mesh.geometry.boundingSphere.radius *= 3; // raycast slack
};
const sculptPropMesh = (root) => { let m = null; root.traverse((o) => { if (!m && o.isMesh && o.geometry?.attributes?.position) m = o; }); return m; };
// A prop instance may share a template geometry (model-prop clones); own a private copy
// before sculpting so other instances aren't mutated.
const ownPropGeo = (root, mesh) => {
if (mesh.userData._sculptOwned) return;
mesh.geometry = mesh.geometry.clone();
delete mesh.geometry.userData._sculptGroups;
mesh.userData._sculptOwned = true;
};
const sculptRadiusLocal = () => { sculpt.mesh.getWorldScale(_scWS); return sculpt.radius / (Math.abs(_scWS.x) || 1); };
const worldDirToLocal = (mesh, origin, D) => {
_scA.copy(origin); mesh.worldToLocal(_scA);
_scB.copy(origin).add(D); mesh.worldToLocal(_scB);
return _scDl.copy(_scB).sub(_scA);
};
// Inverse of vertex i's linear skinning map A (posed = A·rest + b), so a displacement the
// user makes on the POSED surface can be written back into the REST buffer (where the sculpt
// delta lives). Built by probing applyBoneTransform with the basis vectors — no dependency on
// this three build's internal bind/bone matrices. Returns null if not skinned or A is singular.
const sculptVertInvSkin = (mesh, i) => {
if (!mesh.isSkinnedMesh) return null;
const abt = mesh.applyBoneTransform ? (v) => mesh.applyBoneTransform(i, v) : (v) => mesh.boneTransform(i, v);
_isB.set(0, 0, 0); abt(_isB); const bx = _isB.x, by = _isB.y, bz = _isB.z;
_isX.set(1, 0, 0); abt(_isX); _isY.set(0, 1, 0); abt(_isY); _isZ.set(0, 0, 1); abt(_isZ);
_isMat.set(_isX.x - bx, _isY.x - bx, _isZ.x - bx, _isX.y - by, _isY.y - by, _isZ.y - by, _isX.z - bz, _isY.z - bz, _isZ.z - bz);
if (Math.abs(_isMat.determinant()) < 1e-9) return null;
return _isMat.clone().invert();
};
// A surface hit on the (rest-)posed mesh → its position in the rest buffer's local space.
// Skinned: pull the hit's barycentric coords back onto the rest triangle (3 skinning ops,
// cheap). Static prop: just inverse-transform the world point.
const restLocalOfHit = (mesh, hit) => {
if (!mesh.isSkinnedMesh || !hit.face) return mesh.worldToLocal(hit.point.clone());
const f = hit.face, pos = mesh.geometry.attributes.position;
const sa = skinV(mesh, f.a, new THREE.Vector3()), sb = skinV(mesh, f.b, new THREE.Vector3()), sc = skinV(mesh, f.c, new THREE.Vector3());
const hl = mesh.worldToLocal(hit.point.clone());
const [u, v, wgt] = baryWeights(hl, sa, sb, sc);
return new THREE.Vector3().fromBufferAttribute(pos, f.a).multiplyScalar(u)
.addScaledVector(new THREE.Vector3().fromBufferAttribute(pos, f.b), v)
.addScaledVector(new THREE.Vector3().fromBufferAttribute(pos, f.c), wgt);
};
const sculptRecordOrig = (i) => {
const rec = sculpt.stroke.rec;
if (rec.buf.has(i)) return;
const pos = sculpt.mesh.geometry.attributes.position;
rec.buf.set(i, [pos.getX(i), pos.getY(i), pos.getZ(i)]);
rec.mapBefore.set(i, sculpt.map.has(i) ? sculpt.map.get(i).slice() : null);
};
// Falloff-weighted groups whose live rep position is within the brush radius of `center`.
const sculptGatherMembers = (center, rLocal) => {
const GR = sculptGroups(sculpt.mesh), pos = sculpt.mesh.geometry.attributes.position, r2 = rLocal * rLocal, out = [];
for (let g = 0; g < GR.reps.length; g++) {
const i0 = GR.reps[g].members[0];
_scC.set(pos.getX(i0), pos.getY(i0), pos.getZ(i0));
const d2 = _scC.distanceToSquared(center);
if (d2 > r2) continue;
const p = 1 - Math.sqrt(d2) / rLocal, w = p * p * (3 - 2 * p); // smoothstep falloff
for (const i of GR.reps[g].members) out.push({ i, w });
}
return out;
};
// GRAB snapshots its affected verts + a camera-facing drag plane on press, then reapplies
// from the originals each frame (elastic, one undo step). Mirror side included for X-symmetry.
const sculptGatherSides = (hit) => {
const center = restLocalOfHit(sculpt.mesh, hit), rLocal = sculptRadiusLocal();
const sides = [{ mirror: false, members: sculptGatherMembers(center, rLocal) }];
// X-mirror is a rest-space concept; meaningless in an arbitrary pose, so off in in-pose mode.
if (sculpt.symmetry && sculpt.kind === "figure" && !sculpt.inPose) {
const mc = center.clone(); mc.x = -mc.x;
sides.push({ mirror: true, members: sculptGatherMembers(mc, rLocal) });
}
// In-pose grab needs each vert's inverse skin so a screen-space drag lands correctly once skinned.
for (const s of sides) for (const m of s.members) { sculptRecordOrig(m.i); if (sculpt.inPose) m.ainv = sculptVertInvSkin(sculpt.mesh, m.i); }
return sides;
};
const sculptCommitGeo = () => {
const g = sculpt.mesh.geometry;
g.attributes.position.needsUpdate = true;
g.computeVertexNormals();
g.attributes.normal.needsUpdate = true;
requestRender();
};
// INFLATE pushes each group along its averaged normal; SMOOTH relaxes each group toward its
// neighbours' average (Laplacian). Both record originals for undo. alt = carve (inflate).
const sculptApplyDab = (center, alt) => {
const GR = sculptGroups(sculpt.mesh), pos = sculpt.mesh.geometry.attributes.position, nrm = sculpt.mesh.geometry.attributes.normal;
const rLocal = sculptRadiusLocal(), r2 = rLocal * rLocal, brush = sculpt.stroke.brush;
for (let g = 0; g < GR.reps.length; g++) {
const members = GR.reps[g].members, i0 = members[0];
_scC.set(pos.getX(i0), pos.getY(i0), pos.getZ(i0));
const d2 = _scC.distanceToSquared(center);
if (d2 > r2) continue;
const p = 1 - Math.sqrt(d2) / rLocal, w = p * p * (3 - 2 * p);
if (brush === "inflate") {
_scN.set(0, 0, 0);
for (const i of members) _scN.add(_scTmp.set(nrm.getX(i), nrm.getY(i), nrm.getZ(i)));
if (_scN.lengthSq() < 1e-9) continue;
_scN.normalize();
const amt = w * sculpt.strength * rLocal * 0.2 * (alt ? -1 : 1); // gentle per-dab step (dab spacing controls rate)
for (const i of members) { sculptRecordOrig(i); pos.setXYZ(i, pos.getX(i) + _scN.x * amt, pos.getY(i) + _scN.y * amt, pos.getZ(i) + _scN.z * amt); }
} else if (brush === "revert") {
// Pull each vert toward its UN-sculpted position (pre-stroke pos minus the cumulative
// sculpt delta). The stroke-end fold then shrinks/clears its map entry. Erases any
// brush (grab/inflate/smooth) edit locally; full strength fully restores the base.
const m = Math.min(1, w * sculpt.strength);
for (const i of members) {
sculptRecordOrig(i);
const o = sculpt.stroke.rec.buf.get(i), mb = sculpt.stroke.rec.mapBefore.get(i);
const tx = o[0] - (mb ? mb[0] : 0), ty = o[1] - (mb ? mb[1] : 0), tz = o[2] - (mb ? mb[2] : 0);
pos.setXYZ(i, pos.getX(i) + (tx - pos.getX(i)) * m, pos.getY(i) + (ty - pos.getY(i)) * m, pos.getZ(i) + (tz - pos.getZ(i)) * m);
}
} else { // smooth
const adj = GR.adj[g];
if (!adj.length) continue;
_scAvg.set(0, 0, 0);
for (const ng of adj) { const j0 = GR.reps[ng].members[0]; _scAvg.add(_scTmp.set(pos.getX(j0), pos.getY(j0), pos.getZ(j0))); }
_scAvg.multiplyScalar(1 / adj.length);
const m = Math.min(1, w * sculpt.strength * 0.7);
const nx = _scC.x + (_scAvg.x - _scC.x) * m, ny = _scC.y + (_scAvg.y - _scC.y) * m, nz = _scC.z + (_scAvg.z - _scC.z) * m;
for (const i of members) { sculptRecordOrig(i); pos.setXYZ(i, nx, ny, nz); }
}
}
};
const sculptDabAt = (hit, alt) => {
const c = restLocalOfHit(sculpt.mesh, hit);
sculptApplyDab(c, alt);
if (sculpt.symmetry && sculpt.kind === "figure" && !sculpt.inPose) { const mc = c.clone(); mc.x = -mc.x; sculptApplyDab(mc, alt); }
sculptCommitGeo();
};
const sculptGrabApply = () => {
const Dw = _scD2.copy(_scHit).sub(sculpt.stroke.downWorld);
worldDirToLocal(sculpt.mesh, sculpt.stroke.downWorld, Dw); // fills _scDl
const pos = sculpt.mesh.geometry.attributes.position;
for (const s of sculpt.stroke.sides) {
const sx = s.mirror ? -1 : 1;
for (const m of s.members) {
const o = sculpt.stroke.rec.buf.get(m.i);
let dx = _scDl.x * sx, dy = _scDl.y, dz = _scDl.z;
if (m.ainv) { _scGD.set(dx, dy, dz).applyMatrix3(m.ainv); dx = _scGD.x; dy = _scGD.y; dz = _scGD.z; } // posed drag → rest-buffer delta
pos.setXYZ(m.i, o[0] + dx * m.w, o[1] + dy * m.w, o[2] + dz * m.w);
}
}
sculptCommitGeo();
};
const sculptStrokeStart = (e) => {
setPointer(e);
const hit = raycaster.intersectObject(sculpt.mesh, false)[0];
if (!hit) return;
controls.enabled = false;
sculpt.painting = true;
sculpt.stroke = { rec: { buf: new Map(), mapBefore: new Map() }, brush: e.shiftKey ? "smooth" : sculpt.brush };
if (sculpt.stroke.brush === "grab") {
sculpt.stroke.plane = new THREE.Plane().setFromNormalAndCoplanarPoint(camera.getWorldDirection(new THREE.Vector3()), hit.point.clone());
sculpt.stroke.downWorld = hit.point.clone();
sculpt.stroke.sides = sculptGatherSides(hit);
} else {
sculptDabAt(hit, e.altKey);
sculpt.stroke.lastDab = hit.point.clone(); // dab-spacing anchor (below)
}
sculptUpdateRing(hit);
};
const sculptStrokeMove = (e) => {
if (!sculpt.stroke) return;
setPointer(e);
if (sculpt.stroke.brush === "grab") {
if (raycaster.ray.intersectPlane(sculpt.stroke.plane, _scHit)) sculptGrabApply();
} else {
const hit = raycaster.intersectObject(sculpt.mesh, false)[0];
if (!hit) return;
// Dab spacing: only deposit a dab once the cursor has travelled a fraction of the brush
// radius. Makes stroke strength independent of frame rate / mouse speed — without it,
// inflate balloons (a dab fires on every pointermove, dozens per second).
const spacing = Math.max(0.004, sculpt.radius * 0.35);
if (!sculpt.stroke.lastDab || hit.point.distanceTo(sculpt.stroke.lastDab) >= spacing) {
sculptDabAt(hit, e.altKey);
sculpt.stroke.lastDab = (sculpt.stroke.lastDab || new THREE.Vector3()).copy(hit.point);
}
sculptUpdateRing(hit);
}
};
const sculptStrokeEnd = () => {
if (!sculpt.stroke) { sculpt.painting = false; controls.enabled = true; return; }
const pos = sculpt.mesh.geometry.attributes.position, rec = sculpt.stroke.rec;
// Fold the stroke's net displacement into the cumulative sculpt map (orig = pre-stroke
// buffer, which already includes base + prior sculpt, so this stays a running total).
for (const [i, o] of rec.buf) {
const cx = pos.getX(i) - o[0], cy = pos.getY(i) - o[1], cz = pos.getZ(i) - o[2];
let dv = sculpt.map.get(i);
if (!dv) { dv = [0, 0, 0]; sculpt.map.set(i, dv); }
dv[0] += cx; dv[1] += cy; dv[2] += cz;
if (Math.abs(dv[0]) < 1e-6 && Math.abs(dv[1]) < 1e-6 && Math.abs(dv[2]) < 1e-6) sculpt.map.delete(i);
}
if (!sculpt.isEngine) applySculpt(sculpt.mesh, sculpt.map);
sculpt.undo.push(rec);
if (sculpt.undo.length > 20) sculpt.undo.shift();
sculpt.painting = false; sculpt.stroke = null; controls.enabled = true;
requestRender();
captureAndUpload(node);
};
const sculptUndo = () => {
const rec = sculpt.undo.pop();
if (!rec) return;
const pos = sculpt.mesh.geometry.attributes.position;
for (const [i, o] of rec.buf) pos.setXYZ(i, o[0], o[1], o[2]);
for (const [i, mb] of rec.mapBefore) { if (mb) sculpt.map.set(i, mb.slice()); else sculpt.map.delete(i); }
if (sculpt.isEngine) sculptCommitGeo(); else applySculpt(sculpt.mesh, sculpt.map);
requestRender();
captureAndUpload(node);
};
// Brush footprint ring, laid tangent to the surface at the cursor, sized to the world radius.
sculptRing = new THREE.Mesh(
new THREE.RingGeometry(0.86, 1.0, 40),
new THREE.MeshBasicMaterial({ color: 0x6fd3ff, transparent: true, opacity: 0.85, side: THREE.DoubleSide, depthTest: false })
);
sculptRing.renderOrder = 999; sculptRing.visible = false; sculptRing.raycast = () => {};
scene.add(sculptRing);
const sculptUpdateRing = (hit) => {
if (!hit || !sculpt.mesh) { if (sculptRing.visible) { sculptRing.visible = false; requestRender(); } return; }
_scN.copy(hit.face.normal).applyMatrix3(_scMat3.getNormalMatrix(sculpt.mesh.matrixWorld)).normalize();
sculptRing.position.copy(hit.point).addScaledVector(_scN, 0.004);
sculptRing.quaternion.setFromUnitVectors(_scZ, _scN);
sculptRing.scale.setScalar(Math.max(0.004, sculpt.radius));
sculptRing.material.color.setHex(sculpt.brush === "smooth" ? 0xbcd0ff : sculpt.brush === "inflate" ? 0x8fe08f : sculpt.brush === "revert" ? 0xff8f5c : 0x6fd3ff);
sculptRing.visible = true;
requestRender();
};
const sculptHover = (e) => { if (sculpt.panTool) { sculptUpdateRing(null); return; } setPointer(e); sculptUpdateRing(raycaster.intersectObject(sculpt.mesh, false)[0]); };
// ✋ pan tool: when armed, left-drag pans the view instead of sculpting. More reliable than
// the left+right chord (which the two-button chord still supports as a shortcut).
const sculptSetPanTool = (on) => {
sculpt.panTool = on;
if (on) sculptUpdateRing(null); // no brush footprint while panning
if (sculptPanBtn) { sculptPanBtn.style.background = on ? "#2e5d44" : "#2a2a35"; sculptPanBtn.style.borderColor = on ? "#5fbf8c" : "#4a4a55"; }
requestRender();
};
// Toggle editing on the posed surface vs the rest stance. When a figure session is live,
// re-enter it so the rest-snap (or lack of it) + raycast-slack setup all rerun cleanly.
const sculptSetInPose = (on) => {
if (sculpt.inPose === on) return;
sculpt.inPose = on;
if (sculpt.active && sculpt.kind === "figure" && !sculpt.painting) { const tgt = sculpt.target; exitSculpt(); enterSculpt("figure", tgt); }
sculptSyncPanel();
};
// "Show mesh" wireframe overlay — shares the target's geometry (so it tracks live edits)
// and, for figures, its skeleton (so it follows the pose). x-ray (depthTest off) so the
// whole topology reads through the clay surface. Helps you see vertex density while sculpting.
const sculptSetWire = (on) => {
sculpt.wire = on;
if (on && !sculptWire && sculpt.mesh) {
const m = sculpt.mesh;
const mat = new THREE.MeshBasicMaterial({ color: 0x7fd9ff, wireframe: true, transparent: true, opacity: 0.1, depthTest: false });
if (m.isSkinnedMesh) {
sculptWire = new THREE.SkinnedMesh(m.geometry, mat);
sculptWire.bindMode = m.bindMode;
sculptWire.bind(m.skeleton, m.bindMatrix); // same skeleton+bind → deforms identically
sculptWire.position.copy(m.position); sculptWire.quaternion.copy(m.quaternion); sculptWire.scale.copy(m.scale);
(m.parent || scene).add(sculptWire);
} else {
sculptWire = new THREE.Mesh(m.geometry, mat);
m.add(sculptWire); // identity child → inherits the prop's world transform
}
sculptWire.frustumCulled = false;
sculptWire.raycast = () => {}; // never intercept the brush ray
sculptWire.renderOrder = 998;
}
if (sculptWire) sculptWire.visible = on;
if (sculptWireBtn) { sculptWireBtn.style.background = on ? "#2e5d44" : "#2a2a35"; sculptWireBtn.style.borderColor = on ? "#5fbf8c" : "#4a4a55"; }
requestRender();
};
const sculptDisposeWire = () => {
if (!sculptWire) return;
sculptWire.parent?.remove(sculptWire);
sculptWire.material.dispose(); // geometry is SHARED with the target — never dispose it
sculptWire = null;
};
// Cancel an in-progress stroke without committing (used when a pan chord starts mid-stroke).
const sculptStrokeAbort = () => {
if (sculpt.stroke) {
const pos = sculpt.mesh.geometry.attributes.position;
for (const [i, o] of sculpt.stroke.rec.buf) pos.setXYZ(i, o[0], o[1], o[2]);
sculptCommitGeo();
}
sculpt.painting = false; sculpt.stroke = null;
};
// LEFT+RIGHT held = pan (manual: move camera + orbit target together along the view plane).
const beginSculptPan = (e) => {
sculpt.panning = true; sculpt.panLast = { x: e.clientX, y: e.clientY };
controls.enabled = false; // stop OrbitControls from also orbiting on the right button
canvas.setPointerCapture?.(e.pointerId);
if (sculptRing.visible) { sculptRing.visible = false; requestRender(); }
};
const sculptPanMove = (e) => {
const rect = canvas.getBoundingClientRect();
const dist = camera.position.distanceTo(controls.target);
const worldPerPx = 2 * dist * Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2) / Math.max(1, rect.height);
const dx = (e.clientX - sculpt.panLast.x) * worldPerPx, dy = (e.clientY - sculpt.panLast.y) * worldPerPx;
sculpt.panLast.x = e.clientX; sculpt.panLast.y = e.clientY;
const right = _scA.setFromMatrixColumn(camera.matrix, 0), up = _scB.setFromMatrixColumn(camera.matrix, 1);
const pan = _scDl.copy(right).multiplyScalar(-dx).add(up.multiplyScalar(dy));
camera.position.add(pan); controls.target.add(pan);
controls.update();
requestRender();
};
const endSculptPan = () => {
if (!sculpt.panning) return;
sculpt.panning = false; sculpt.panLast = null;
controls.enabled = true;
captureAndUpload(node);
};
// ── sculpt sub-panel (bottom-left, mirrors the draw panel) ──────────────────
const sculptPanel = document.createElement("div");
sculptPanel.style.cssText = "position:absolute;left:6px;bottom:6px;z-index:4;display:none;flex-direction:column;gap:6px;min-width:184px;" +
"background:rgba(20,20,26,0.9);border:1px solid #4a4a55;border-radius:6px;padding:8px;font:11px system-ui,sans-serif;color:#cdd;";
sculptPanel.addEventListener("pointerdown", (e) => e.stopPropagation());
const sculptBrushRow = document.createElement("div");
sculptBrushRow.style.cssText = "display:flex;gap:3px;";
const sculptBrushBtns = {};
for (const [key, label] of SCULPT_BRUSHES) {
const b = document.createElement("button");
b.textContent = label;
b.style.cssText = "flex:1 0 auto;padding:3px 6px;background:#2a2a35;color:#cdd;border:1px solid #4a4a55;border-radius:4px;cursor:pointer;font:11px system-ui,sans-serif;white-space:nowrap;";
b.addEventListener("click", () => { sculpt.brush = key; sculptSetPanTool(false); sculptSyncPanel(); }); // picking a brush leaves pan mode
sculptBrushBtns[key] = b; sculptBrushRow.appendChild(b);
}
sculptPanel.appendChild(sculptBrushRow);
const scMkSlider = (labelText, min, max, step, val) => {
const wrap = document.createElement("label"); wrap.style.cssText = "display:flex;align-items:center;gap:6px;";
const lab = document.createElement("span"); lab.textContent = labelText; lab.style.cssText = "width:52px;flex:0 0 auto;color:#9aa;";
const sl = document.createElement("input"); sl.type = "range"; sl.min = min; sl.max = max; sl.step = step; sl.value = val;
sl.style.cssText = "flex:1;accent-color:#6fd3ff;cursor:pointer;";
sl.addEventListener("pointerdown", (e) => e.stopPropagation());
wrap.appendChild(lab); wrap.appendChild(sl);
return { wrap, sl };
};
const scRadius = scMkSlider("Size", "0.01", "0.3", "0.005", String(sculpt.radius));
const scStrength = scMkSlider("Strength", "0.05", "1", "0.05", String(sculpt.strength));
scRadius.sl.addEventListener("input", () => { sculpt.radius = parseFloat(scRadius.sl.value); });
scStrength.sl.addEventListener("input", () => { sculpt.strength = parseFloat(scStrength.sl.value); });
sculptPanel.appendChild(scRadius.wrap); sculptPanel.appendChild(scStrength.wrap);
const scSymWrap = document.createElement("label"); scSymWrap.style.cssText = "display:flex;align-items:center;gap:6px;cursor:pointer;";
const scSym = document.createElement("input"); scSym.type = "checkbox"; scSym.checked = sculpt.symmetry;
scSym.addEventListener("pointerdown", (e) => e.stopPropagation());
scSym.addEventListener("change", () => { sculpt.symmetry = scSym.checked; });
const scSymLab = document.createElement("span"); scSymLab.textContent = "⇄ symmetry (X)"; scSymLab.style.color = "#9aa";
scSymWrap.appendChild(scSym); scSymWrap.appendChild(scSymLab);
sculptPanel.appendChild(scSymWrap);
sculptWireBtn = document.createElement("button");
sculptWireBtn.textContent = "▦ show mesh";
sculptWireBtn.title = "Show the mesh wireframe so you can see the vertices you're sculpting";
sculptWireBtn.style.cssText = "padding:3px 6px;background:#2a2a35;color:#cdd;border:1px solid #4a4a55;border-radius:4px;cursor:pointer;font:11px system-ui,sans-serif;";
sculptWireBtn.addEventListener("click", () => sculptSetWire(!sculpt.wire));
sculptPanBtn = document.createElement("button");
sculptPanBtn.textContent = "✋ pan";
sculptPanBtn.title = "Pan tool — while on, left-drag moves the view (right-drag always orbits)";
sculptPanBtn.style.cssText = "padding:3px 6px;background:#2a2a35;color:#cdd;border:1px solid #4a4a55;border-radius:4px;cursor:pointer;font:11px system-ui,sans-serif;";
sculptPanBtn.addEventListener("click", () => sculptSetPanTool(!sculpt.panTool));
// Figures only: sculpt on the current pose instead of the rest stance (e.g. carve a sole groove
// on a lifted foot). The edit is mapped back into the rest buffer so it still rides every pose.
sculptPoseBtn = document.createElement("button");
sculptPoseBtn.textContent = "🧍 in pose";
sculptPoseBtn.title = "Sculpt the figure as it's currently posed instead of snapping to the rest stance. X-symmetry is off while on.";
sculptPoseBtn.style.cssText = "padding:3px 6px;background:#2a2a35;color:#cdd;border:1px solid #4a4a55;border-radius:4px;cursor:pointer;font:11px system-ui,sans-serif;";
sculptPoseBtn.addEventListener("click", () => sculptSetInPose(!sculpt.inPose));
sculptPanel.appendChild(sculptPoseBtn);
const sculptUtilRow = document.createElement("div");
sculptUtilRow.style.cssText = "display:flex;gap:3px;";
sculptUtilRow.append(sculptWireBtn, sculptPanBtn);
sculptWireBtn.style.flex = "1 0 auto"; sculptPanBtn.style.flex = "1 0 auto";
sculptPanel.appendChild(sculptUtilRow);
// Props only: add polygons on demand when a shape is too coarse to shape finely.
const sculptSubdivBtn = document.createElement("button");
sculptSubdivBtn.textContent = "⊞ subdivide";
sculptSubdivBtn.title = "Split every face into four — adds polygons so you can shape finer detail (e.g. on a drawn clothing panel)";
sculptSubdivBtn.style.cssText = "padding:3px 6px;background:#2a2a35;color:#cdd;border:1px solid #4a4a55;border-radius:4px;cursor:pointer;font:11px system-ui,sans-serif;";
sculptSubdivBtn.addEventListener("click", () => sculptSubdivide());
sculptPanel.appendChild(sculptSubdivBtn);
container.appendChild(sculptPanel);
const sculptHint = document.createElement("div");
sculptHint.style.cssText = "position:absolute;top:6px;left:50%;transform:translateX(-50%);z-index:8;display:none;padding:3px 10px;background:#2e4d7d;color:#fff;border:1px solid #6f9fe0;border-radius:5px;font:11px/1.4 system-ui,sans-serif;pointer-events:none;white-space:nowrap;";
sculptHint.textContent = "🗿 drag to sculpt · ✋ pan tool (or left+right) moves the view · right-drag orbits · Shift = smooth · Alt = carve · Ctrl+Z undo · Esc done";
container.appendChild(sculptHint);
const sculptSyncPanel = () => {
for (const [k, b] of Object.entries(sculptBrushBtns)) {
const on = sculpt.brush === k;
b.style.background = on ? "#2e5d44" : "#2a2a35";
b.style.borderColor = on ? "#5fbf8c" : "#4a4a55";
}
scSymWrap.style.display = sculpt.kind === "figure" ? "flex" : "none";
const symInactive = sculpt.kind === "figure" && sculpt.inPose; // mirror is rest-space only
scSymWrap.style.opacity = symInactive ? "0.45" : "1";
scSymWrap.title = symInactive ? "X-symmetry applies in rest mode only (off while editing in pose)" : "";
sculptSubdivBtn.style.display = sculpt.kind === "prop" ? "block" : "none"; // subdividing a skinned/morph body would desync skin+morph attrs
scRadius.sl.value = String(sculpt.radius); scStrength.sl.value = String(sculpt.strength);
if (sculptPanBtn) { sculptPanBtn.style.background = sculpt.panTool ? "#2e5d44" : "#2a2a35"; sculptPanBtn.style.borderColor = sculpt.panTool ? "#5fbf8c" : "#4a4a55"; }
if (sculptPoseBtn) { sculptPoseBtn.style.display = sculpt.kind === "figure" ? "block" : "none"; sculptPoseBtn.style.background = sculpt.inPose ? "#2e5d44" : "#2a2a35"; sculptPoseBtn.style.borderColor = sculpt.inPose ? "#5fbf8c" : "#4a4a55"; }
};
// Frame the camera on the sculpt target: pivot on its center and dolly so it fills the
// view (keeps the current view direction). Box from the rest position buffer — close
// enough for centering without disturbing the raycast-slack boundingSphere.
const sculptFrameCamera = (mesh) => {
const geo = mesh.geometry;
if (!geo.attributes.position) return;
const box = new THREE.Box3().setFromBufferAttribute(geo.attributes.position);
if (box.isEmpty()) return;
const center = box.getCenter(new THREE.Vector3());
mesh.updateWorldMatrix(true, false);
mesh.localToWorld(center);
const ws = mesh.getWorldScale(new THREE.Vector3()), size = box.getSize(new THREE.Vector3());
const r = 0.5 * Math.max(size.x, size.y, size.z) * Math.max(Math.abs(ws.x), Math.abs(ws.y), Math.abs(ws.z)) || 0.5;
const dir = camera.position.clone().sub(controls.target);
if (dir.lengthSq() < 1e-9) dir.set(0, 0, 1);
dir.normalize();
const dist = (r / Math.sin(THREE.MathUtils.degToRad(camera.fov) / 2)) * 1.15;
controls.target.copy(center);
camera.position.copy(center).addScaledVector(dir, dist);
controls.update();
requestRender();
};
const enterSculpt = (kind, target) => {
if (sculpt.active) return;
let mesh = null;
if (kind === "figure") {
if (target.skinBind) { showClipToast("can't sculpt a bound mesh — reshape uses the body sliders", true); return; }
mesh = (target.meshes || []).find((m) => m.isSkinnedMesh) || target.morphMesh;
} else {
mesh = sculptPropMesh(target);
}
if (!mesh) { showClipToast("nothing to sculpt here", true); return; }
sculpt.active = true; sculpt.kind = kind; sculpt.target = target; sculpt.mesh = mesh;
sculpt.isEngine = kind === "figure" && !!target.customEngine;
if (kind === "figure") {
sculpt.map = target.sculptMap || (target.sculptMap = new Map());
} else {
ownPropGeo(target, mesh);
sculpt.map = target.userData.sculptMap || (target.userData.sculptMap = new Map());
}
detachGizmo(); clearBand(); setHover(null);
// Left-drag sculpts, right-drag orbits, LEFT+RIGHT held = pan (handled manually in the
// pointer branches — OrbitControls can't map a two-button chord).
sculpt.savedMouseButtons = { ...controls.mouseButtons };
controls.mouseButtons = { LEFT: null, MIDDLE: controls.mouseButtons.MIDDLE, RIGHT: THREE.MOUSE.ROTATE };
if (kind === "figure" && !sculpt.inPose) { sculpt.poseSnap = sculptSnapPose(target); sculptApplyRest(target); }
if (!sculpt.isEngine) applySculpt(mesh, sculpt.map); // capture a clean base + reapply any existing delta
else if (kind === "figure" && sculpt.inPose && mesh.geometry.computeBoundingSphere) { mesh.geometry.computeBoundingSphere(); if (mesh.geometry.boundingSphere) mesh.geometry.boundingSphere.radius *= 3; } // raycast slack over the posed surface
sculptFrameCamera(mesh); // center the view on what you're about to mold
sculptSetWire(sculpt.wire); // show the mesh wireframe so vertices are visible
sculptSetPanTool(false); // start in brush mode, not pan
sculpt.undo = [];
sculptSyncPanel();
sculptPanel.style.display = "flex"; sculptHint.style.display = "block";
figureSculptBtn.style.background = kind === "figure" ? "#2e5d44" : "#2a2a35";
propSculptBtn.style.background = kind === "prop" ? "#2e5d44" : "#2a2a35";
requestRender();
};
const exitSculpt = () => {
if (!sculpt.active) return;
if (sculpt.painting) sculptStrokeEnd();
sculptDisposeWire();
if (sculpt.savedMouseButtons) { controls.mouseButtons = sculpt.savedMouseButtons; sculpt.savedMouseButtons = null; }
if (sculpt.kind === "figure" && sculpt.poseSnap) { sculptRestorePose(sculpt.target, sculpt.poseSnap); sculpt.poseSnap = null; }
sculptRing.visible = false; sculptPanel.style.display = "none"; sculptHint.style.display = "none";
figureSculptBtn.style.background = "#2a2a35"; propSculptBtn.style.background = "#2a2a35";
controls.enabled = true;
sculpt.active = false; sculpt.painting = false; sculpt.panning = false; sculpt.panLast = null; sculpt.stroke = null; sculpt.undo = [];
sculpt.mesh = null; sculpt.target = null; sculpt.map = null;
requestRender();
captureAndUpload(node);
};
// Mesh editor → "⊞ subdivide": split every triangle of the current PROP mesh into four,
// quadrupling the polygon count so a coarse shape (e.g. a drawn underwear panel) has enough
// vertices to shape finely. The sculpt delta carries forward — originals keep their indices,
// new edge-midpoints take the average of their endpoints' deltas — so the current surface is
// preserved exactly along edges. We subdivide the CLEAN base (not base+delta) and bump a
// recorded level, so subdivideGeometry's determinism lets the saved delta re-map on reload.
const sculptSubdivide = () => {
if (!sculpt.active || sculpt.kind !== "prop" || !sculpt.mesh) return;
const mesh = sculpt.mesh;
if (mesh.geometry.attributes.position.count > 40000) { showClipToast("mesh is already very dense", true); return; }
if (sculpt.painting) sculptStrokeEnd();
const baseArr = mesh.userData._sculptBase || mesh.geometry.attributes.position.array;
const baseGeo = new THREE.BufferGeometry();
baseGeo.setAttribute("position", new THREE.BufferAttribute(baseArr.slice(), 3));
if (mesh.geometry.index) baseGeo.setIndex(Array.from(mesh.geometry.index.array));
const { geometry: newBase, parents } = subdivideGeometry(baseGeo);
baseGeo.dispose();
const oldMap = sculpt.map, newMap = new Map();
for (const [i, d] of oldMap) newMap.set(i, d.slice()); // originals keep their index
for (let i = 0; i < parents.length; i++) {
const par = parents[i]; if (!par) continue;
const da = oldMap.get(par[0]), db = oldMap.get(par[1]);
if (!da && !db) continue;
const a = da || [0, 0, 0], b = db || [0, 0, 0];
newMap.set(i, [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2]);
}
mesh.geometry.dispose();
mesh.geometry = newBase;
delete mesh.userData._sculptBase; // recapture the (clean) base from newBase on next apply
mesh.userData._sculptOwned = true;
sculpt.map = newMap;
sculpt.target.userData.sculptMap = newMap;
sculpt.target.userData.subdivLevel = (sculpt.target.userData.subdivLevel || 0) + 1;
applySculpt(mesh, newMap); // base = newBase + delta → the prior surface, now on a denser mesh
sculpt.undo = []; // indices changed; old per-stroke snapshots are invalid
sculptDisposeWire(); sculptSetWire(sculpt.wire); // the wireframe shares geometry by reference — rebuild it
sculptSyncPanel();
requestRender();
showClipToast(`subdivided → ${newBase.attributes.position.count.toLocaleString()} verts`);
};
const clothingPanel = document.createElement("div");
clothingPanel.style.cssText = "position:absolute;left:6px;bottom:6px;z-index:4;display:none;flex-direction:column;gap:6px;min-width:184px;" +
"background:rgba(20,20,26,0.9);border:1px solid #4a4a55;border-radius:6px;padding:8px;font:11px system-ui,sans-serif;color:#cdd;";
clothingPanel.addEventListener("pointerdown", (e) => e.stopPropagation());
const cpMkRow = () => { const r = document.createElement("div"); r.style.cssText = "display:flex;gap:4px;"; return r; };
const cpMkBtn = (label) => {
const b = document.createElement("button");
b.textContent = label;
b.style.cssText = "flex:1 0 auto;padding:3px 6px;background:#2a2a35;color:#cdd;border:1px solid #4a4a55;border-radius:4px;cursor:pointer;font:11px system-ui,sans-serif;white-space:nowrap;";
return b;
};
const cpModeRow = cpMkRow();
const cpBrushBtn = cpMkBtn("🖌 brush"), cpLassoBtn = cpMkBtn("➰ lasso");
cpModeRow.appendChild(cpBrushBtn); cpModeRow.appendChild(cpLassoBtn);
const cpEditRow = cpMkRow();
const cpAddBtn = cpMkBtn("+ add"), cpEraseBtn = cpMkBtn("- erase");
cpEditRow.appendChild(cpAddBtn); cpEditRow.appendChild(cpEraseBtn);
const cpMkSlider = (label, min, max, step, val) => {
const wrap = document.createElement("label"); wrap.style.cssText = "display:flex;align-items:center;gap:6px;";
const lab = document.createElement("span"); lab.textContent = label; lab.style.cssText = "width:52px;flex:0 0 auto;color:#9aa;";
const sl = document.createElement("input"); sl.type = "range"; sl.min = min; sl.max = max; sl.step = step; sl.value = val;
sl.style.cssText = "flex:1;accent-color:#6f9fe0;cursor:pointer;";
wrap.appendChild(lab); wrap.appendChild(sl); return { wrap, lab, sl };
};
const cpSize = cpMkSlider("brush", 8, 70, 1, clothingPaint.brushPx);
const cpThick = cpMkSlider("thick", 0.003, 0.04, 0.001, clothingPaint.thickness);
const cpImgRow = cpMkRow();
const cpImgBtn = cpMkBtn("🖼 stamp image");
cpImgRow.appendChild(cpImgBtn);
const cpActRow = cpMkRow();
const cpNewBtn = cpMkBtn("+ new piece"), cpDoneBtn = cpMkBtn("✓ done");
cpActRow.appendChild(cpNewBtn); cpActRow.appendChild(cpDoneBtn);
clothingPanel.appendChild(cpModeRow); clothingPanel.appendChild(cpEditRow);
clothingPanel.appendChild(cpSize.wrap); clothingPanel.appendChild(cpThick.wrap);
clothingPanel.appendChild(cpImgRow); clothingPanel.appendChild(cpActRow);
container.appendChild(clothingPanel);
cpImgBtn.addEventListener("click", pickImageForStamp);
// Image-stamp placement bar (shown only while positioning an uploaded image).
const imgBar = document.createElement("div");
imgBar.style.cssText = "position:absolute;top:6px;left:50%;transform:translateX(-50%);z-index:9;display:none;align-items:center;gap:8px;" +
"background:rgba(20,20,26,0.92);border:1px solid #6f9fe0;border-radius:6px;padding:5px 9px;font:11px system-ui,sans-serif;color:#cdd;";
imgBar.addEventListener("pointerdown", (e) => e.stopPropagation());
const imgHint = document.createElement("span"); imgHint.textContent = "drag to place · scale:"; imgHint.style.color = "#9aa";
const imgScale = document.createElement("input"); imgScale.type = "range"; imgScale.min = "0.2"; imgScale.max = "3"; imgScale.step = "0.05"; imgScale.value = "1"; imgScale.style.cssText = "width:90px;accent-color:#6f9fe0;cursor:pointer;";
const imgStampBtn = cpMkBtn("✓ stamp"), imgCancelBtn = cpMkBtn("✕");
imgStampBtn.style.flex = imgCancelBtn.style.flex = "0 0 auto";
imgBar.appendChild(imgHint); imgBar.appendChild(imgScale); imgBar.appendChild(imgStampBtn); imgBar.appendChild(imgCancelBtn);
container.appendChild(imgBar);
imgScale.addEventListener("input", () => { const s = clothingPaint.imgStamp; if (s) { s.scale = s.base * parseFloat(imgScale.value); drawImageOverlay(); } });
imgStampBtn.addEventListener("click", stampImage);
imgCancelBtn.addEventListener("click", cancelImageStamp);
const syncClothingPanel = () => {
const onCol = "#2e4d7d", onBorder = "#6f9fe0", offCol = "#2a2a35", offBorder = "#4a4a55";
const setOn = (b, on) => { b.style.background = on ? onCol : offCol; b.style.borderColor = on ? onBorder : offBorder; };
setOn(cpBrushBtn, clothingPaint.mode === "brush"); setOn(cpLassoBtn, clothingPaint.mode === "lasso");
setOn(cpAddBtn, !clothingPaint.erase); setOn(cpEraseBtn, clothingPaint.erase);
cpSize.wrap.style.display = clothingPaint.mode === "brush" ? "flex" : "none"; // brush radius is meaningless for lasso
};
cpBrushBtn.addEventListener("click", () => { clothingPaint.mode = "brush"; updateClothingHint(); syncClothingPanel(); });
cpLassoBtn.addEventListener("click", () => { clothingPaint.mode = "lasso"; updateClothingHint(); syncClothingPanel(); });
cpAddBtn.addEventListener("click", () => { clothingPaint.erase = false; syncClothingPanel(); });
cpEraseBtn.addEventListener("click", () => { clothingPaint.erase = true; syncClothingPanel(); });
cpSize.sl.addEventListener("input", () => { clothingPaint.brushPx = parseFloat(cpSize.sl.value); });
cpThick.sl.addEventListener("input", () => {
const v = parseFloat(cpThick.sl.value);
const piece = clothingPaint.selected || clothingPaint.brushPiece; // edit the picked piece, else the live brush piece
if (piece) { setGarmentThickness(piece, v); requestRender(); }
if (!clothingPaint.selected) clothingPaint.thickness = v; // also the default for new pieces
});
cpThick.sl.addEventListener("change", () => captureAndUpload(node));
cpNewBtn.addEventListener("click", startNewGarment);
cpDoneBtn.addEventListener("click", exitClothingPaint);
node._pcrPose = { THREE, scene, camera, renderer, controls, transformControls, rig, figures, propsApi, attachApi, ro, container, requestRender, captureBlob, captureRegionMasks, disposeCapture, recordHistory, onKey, onWinPointerDown, onContextMenu, exitFullscreen, enterDock, exitDock, entityNames: liveEntityNames, renameEntityByName,
// Headless-harness probe (cheap, read-only) — lets the puppeteer rig see
// closure state the DOM can't reveal. Not a public API.
_dbgSculptDiag: (cols = 9, rows = 14) => {
const fig = selectedFigure || figures[0];
if (!fig) return { err: "no figure" };
enterSculpt("figure", fig);
const mesh = sculpt.mesh, rLocal = sculptRadiusLocal();
const out = { meshName: mesh.name, skinnedMeshes: (fig.meshes || []).filter((m) => m.isSkinnedMesh).length, verts: mesh.geometry.attributes.position.count, isEngine: sculpt.isEngine, radius: sculpt.radius, worldScale: +mesh.getWorldScale(new THREE.Vector3()).x.toFixed(4), rLocal: +rLocal.toFixed(4), rays: 0, hits: 0, zeroGather: 0, counts: [], deadSamples: [] };
const ndc = new THREE.Vector2();
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
out.rays++;
ndc.set((c + 0.5) / cols * 2 - 1, -((r + 0.5) / rows * 2 - 1));
raycaster.setFromCamera(ndc, camera);
const hit = raycaster.intersectObject(mesh, false)[0];
if (!hit) continue;
out.hits++;
const center = restLocalOfHit(mesh, hit);
const n = sculptGatherMembers(center, rLocal).length;
out.counts.push(n);
if (n === 0) { out.zeroGather++; if (out.deadSamples.length < 6) out.deadSamples.push([+hit.point.x.toFixed(2), +hit.point.y.toFixed(2), +hit.point.z.toFixed(2)]); }
}
const cs = out.counts;
out.min = cs.length ? Math.min(...cs) : null; out.max = cs.length ? Math.max(...cs) : null;
out.avg = cs.length ? Math.round(cs.reduce((a, b) => a + b, 0) / cs.length) : null;
exitSculpt();
return out;
},
_dbgBones: () => ({
selected: !!selectedProp,
selBones: selectedProp?.userData?._bones?.length ?? null,
bonePose: !!bonePoseProp,
handleCount: boneHandlesGroup.children.length,
inProps: bonePoseProp ? propsGroup.children.includes(bonePoseProp) : null,
bonesBtnShown: propBonesBtn.style.display !== "none",
clipsBtnShown: propClipsBtn.style.display !== "none",
}),
_dbgSelectProp: (uid) => {
const m = propsGroup.children.find((p) => p.userData.uid === uid);
if (m) selectProp(m);
return !!m;
},
_dbgSelectFigure: (uid) => {
const f = figures.find((x) => x.uid === uid);
if (f) { setActiveFigure(f); selectFigure(f); }
return !!f;
},
_dbgSetTool: (t) => { setTransformTool(t); return transformTool; },
// Parity-harness probes (_headless/parity.mjs): drive IK / mirror / pins on
// the ACTIVE figure deterministically — these paths are UI-gesture-bound and
// unreachable from synthetic DOM events.
_dbgIk: (tip, x, y, z) => {
const limb = rig && limbFor(rig, tip);
if (!limb) return false;
solveIkTo(limb, new THREE.Vector3(x, y, z));
requestRender();
return true;
},
_dbgMirror: () => { if (!rig) return false; mirror(); return true; },
_dbgTogglePins: () => { toggleFootPins(); return anyFootPinned(); },
_dbgEnforcePins: () => { if (!rig) return false; enforceFootPins(); requestRender(); return true; },
_dbgChainIk: (uid, x, y, z, driveCount, anchorIndex) => { // drive a chain's IK (FABRIK) by uid
const m = propsGroup.children.find((p) => p.userData.uid === uid && p.userData._chain);
if (!m) return false;
if (driveCount == null) solveChainIk(m, new THREE.Vector3(x, y, z));
else solveChainTo(m, new THREE.Vector3(x, y, z), driveCount, anchorIndex || 0);
return true;
},
_dbgSpawnChain: (type) => { spawnProp(type); const m = [...propsGroup.children].reverse().find((p) => p.userData._chain); return m?.userData.uid ?? null; },
// Clothing-shell spike probes (_headless/garment.mjs): build/inspect/clear the
// chest-band garment on a figure (active figure if uid omitted).
_dbgPaintShell: (uid, thickness) => {
const f = uid != null ? figures.find((x) => x.uid === uid) : (rig || figures[0]);
if (!f) return null;
const s = buildGarmentShell(f, thickness != null ? { thickness } : {});
return s ? { faces: s.geometry.index.count / 3, garments: f.garments.length } : null;
},
_dbgGarments: (uid) => {
const f = uid != null ? figures.find((x) => x.uid === uid) : (rig || figures[0]);
return (f?.garments || []).map((g) => ({ faces: g.geometry.index.count / 3, thickness: g.userData._garment.uThickness.value, visible: g.visible }));
},
_dbgClearGarments: (uid) => { const f = uid != null ? figures.find((x) => x.uid === uid) : (rig || figures[0]); clearGarments(f); return true; },
// Paint-mode probes (_headless/garment.mjs): enter paint, dab the brush at a
// WORLD point (nearest body vert = seed), run a lasso polygon (drawCanvas px),
// and read the live garment state — gesture paths the DOM can't drive headlessly.
_dbgEnterPaint: (mode) => { if (mode) clothingPaint.mode = mode; enterClothingPaint(); return clothingPaint.active; },
_dbgExitPaint: () => { exitClothingPaint(); return true; },
_dbgSetErase: (on) => { clothingPaint.erase = !!on; return clothingPaint.erase; },
_dbgBrushWorld: (x, y, z) => {
if (!clothingPaint.active || !clothingPaint.shell) return null;
const body = garmentBodyOf(clothingPaint.fig); if (!body) return null;
const pos = body.geometry.attributes.position, target = body.worldToLocal(new THREE.Vector3(x, y, z));
let best = -1, bestD = Infinity; const v = new THREE.Vector3();
for (let i = 0; i < pos.count; i++) { v.fromBufferAttribute(pos, i); const d = v.distanceToSquared(target); if (d < bestD) { bestD = d; best = i; } }
// Flood from the nearest vertex's ACTUAL position (the probe's world point may
// sit off the surface; brushDabAt uses the true on-surface raycast hit).
brushFloodLocal(new THREE.Vector3().fromBufferAttribute(pos, best), [best]);
return clothingPaint.shell.userData._garment.vertSet.size;
},
_dbgProject: (x, y, z) => {
const cr = canvas.getBoundingClientRect();
const p = new THREE.Vector3(x, y, z).project(camera);
return [(p.x * 0.5 + 0.5) * cr.width, (-p.y * 0.5 + 0.5) * cr.height];
},
// Diagnose the mask brush: project a world point to the cursor, run the real
// paintMaskAt, report whether the body was hit, the hit uv, the computed radius,
// and how many mask pixels are now painted. CPU-only (raycast + canvas) so it
// reproduces the user's machine exactly.
_dbgPaintWorld: (x, y, z) => {
const cr = canvas.getBoundingClientRect();
const p = new THREE.Vector3(x, y, z).project(camera);
const e = { clientX: cr.left + (p.x * 0.5 + 0.5) * cr.width, clientY: cr.top + (-p.y * 0.5 + 0.5) * cr.height };
const body = garmentBodyOf(clothingPaint.fig);
setPointer(e);
const hit = raycaster.intersectObject(body, false)[0];
const before = maskPaintedCount();
paintMaskAt(e);
const after = maskPaintedCount();
return { hit: !!hit, uv: hit?.uv ? [+hit.uv.x.toFixed(3), +hit.uv.y.toFixed(3)] : null, dist: hit ? +hit.distance.toFixed(3) : null,
radiusPx: hit ? +maskRadiusPxAtHit(hit, body, ensureMaskPiece()).toFixed(1) : null, maskBefore: before, maskAfter: after, added: after - before };
},
_dbgMaskStats: () => ({ painted: maskPaintedCount(), pieces: (clothingPaint.fig?.garments || []).filter((g) => g.userData._garment.isMaskShell).length }),
_dbgLasso: (poly) => { applyLasso(poly); return clothingPaint.shell?.userData._garment.vertSet.size ?? null; },
_dbgPaintInfo: () => ({ active: clothingPaint.active, mode: clothingPaint.mode, erase: clothingPaint.erase,
shellVerts: clothingPaint.shell?.userData._garment.vertSet.size ?? null,
shellFaces: clothingPaint.shell?.geometry.index ? clothingPaint.shell.geometry.index.count / 3 : 0,
garments: clothingPaint.fig?.garments?.length ?? 0 }),
_dbgPaintSnapshot: () => { pushPaintUndo(); return clothingPaint.undo.length; },
_dbgPaintUndo: () => { paintUndo(); return clothingPaint.shell?.userData._garment.vertSet.size ?? null; },
_dbgBrushCursorAt: (clientX, clientY) => { updateBrushCursor({ clientX, clientY }); return { visible: brushCursor.visible, scale: +brushCursor.scale.x.toFixed(3) }; },
// Conformed-garment spike: build a smooth own-geometry garment from a circle
// outline (NDC center cx,cy radius r) projected onto the body. Proves the edge is
// smooth (follows the outline) not blocky (body polygons).
_dbgConformedTest: (cx = 0, cy = 0.12, r = 0.2, subdiv = 2) => {
const f = rig || figures[0]; if (!f) return null;
const N = 64, poly = [];
for (let i = 0; i < N; i++) { const t = (i / N) * Math.PI * 2; poly.push([cx + Math.cos(t) * r, cy + Math.sin(t) * r * 1.3]); }
const m = buildConformedGarment(f, poly, { subdiv });
if (m) { requestRender(); captureAndUpload(node); }
return m ? { verts: m.geometry.attributes.position.count, faces: m.geometry.index.count / 3 } : null;
},
// Drive the REAL lasso→conformed-garment path with SCREEN-coord polygon points.
_dbgFinishLasso: (polyPx) => { clothingPaint.lasso = polyPx; finishLasso(); return clothingPaint.fig?.garments?.length ?? 0; },
_dbgSelectPieceByIndex: (i) => { const f = clothingPaint.fig || rig || figures[0]; const g = f?.garments?.[i]; if (g) selectGarmentPiece(g); return g ? g.userData._garment?.uThickness?.value ?? null : null; },
_dbgSetSelThickness: (v) => { if (!clothingPaint.selected) return null; cpThick.sl.value = String(v); cpThick.sl.dispatchEvent(new Event("input")); return clothingPaint.selected.userData._garment.uThickness.value; },
_dbgPickAt: (clientX, clientY) => { const g = pickGarmentPiece({ clientX, clientY }); return g ? (clothingPaint.fig.garments.indexOf(g)) : -1; },
_dbgUndoButton: () => { undo(); return (clothingPaint.fig?.garments || []).filter((g) => g.userData._garment?.conformed && g.visible).length; },
_dbgRects: () => { const r = (e) => { const b = e.getBoundingClientRect(); return [Math.round(b.left), Math.round(b.top), Math.round(b.width), Math.round(b.height)]; };
return { canvas: r(canvas), container: r(container), lasso: r(lassoCanvas), mask: clothingPaint.brushMask ? [clothingPaint.brushMask.width, clothingPaint.brushMask.height] : null, aspect: +camera.aspect.toFixed(3), drawW: renderer.domElement.width, drawH: renderer.domElement.height }; },
_dbgBrushStroke: (pts) => {
pushBrushUndo(); for (const [x, y] of pts) maskDab({ clientX: x, clientY: y }); commitBrushPiece(); captureAndUpload(node);
const g = clothingPaint.brushPiece; if (!g) return { faces: 0 };
const p = g.geometry.attributes.position, v = new THREE.Vector3(); g.updateWorldMatrix(true, false);
let wx = 0, wy = 0, wz = 0; for (let i = 0; i < p.count; i++) { v.fromBufferAttribute(p, i).applyMatrix4(g.matrixWorld); wx += v.x; wy += v.y; wz += v.z; }
const c = new THREE.Vector3(wx / p.count, wy / p.count, wz / p.count).project(camera), cr = canvas.getBoundingClientRect();
return { faces: g.geometry.index.count / 3, cx: Math.round((c.x * 0.5 + 0.5) * cr.width + cr.left), cy: Math.round((-c.y * 0.5 + 0.5) * cr.height + cr.top) };
},
_dbgStampBlobs: (centers, r) => { // simulate the image stamp: N separate filled blobs → N conformed pieces
ensureBrushMask(); clearBrushMask();
const crect = canvas.getBoundingClientRect(), mctx = clothingPaint.brushMaskCtx; mctx.fillStyle = "#fff";
for (const [x, y] of centers) { mctx.beginPath(); mctx.arc(x - crect.left, y - crect.top, r || 18, 0, Math.PI * 2); mctx.fill(); }
const contours = traceAllContours(mctx), made = [];
for (const c of contours) { const g = conformFromContour(clothingPaint.fig, c, { thickness: clothingPaint.thickness, smooth: 1 }); if (g) made.push(g); }
clearBrushMask(); requestRender(); captureAndUpload(node);
return { contours: contours.length, pieces: made.length };
},
_dbgChainPin: (uid, jointIndex, figJoint) => { // combine a chain joint to an active-figure joint
const m = propsGroup.children.find((p) => p.userData.uid === uid && p.userData._chain);
if (!m || !rig?.joints?.[figJoint]) return false;
createChainPin(m, jointIndex, { kind: "joint", fig: rig, joint: figJoint });
requestRender();
return true;
},
_dbgChainJointWorld: (uid, jointIndex) => { // read a chain joint's world position
const m = propsGroup.children.find((p) => p.userData.uid === uid && p.userData._chain);
if (!m) return null;
enforceAttachments();
return chainJointWorld(m, jointIndex, new THREE.Vector3()).toArray();
},
};
// enterDock now exists — tell any already-open 3D Poser panel to (re)dock. On
// workflow load the panel restored open and tried before this assignment.
poseRegistry.signalReady(node);
// Reopen a saved workflow: rebuild the full cast, then each figure's pose + the
// props + camera. Spawns any extra people the save had (figure 0 already exists).
const restoreSavedState = async () => {
const raw = node.widgets?.find((w) => w.name === "pose_state")?.value;
if (!raw) return;
let data; try { data = JSON.parse(raw); } catch { return; }
if (!data) return;
const figData = Array.isArray(data.figures) ? data.figures : [data]; // v2 array | v1 flat
if (!(await reconcileCast(figData))) return;
setActiveFigure(activeFromSnapshot(data, figData));
if (Array.isArray(data.props)) await propsApi.restoreFrom(data.props);
updatePropLabels(); // restored named props get their region labels back
attachApi.restoreFrom(data.attachments); // after props + figures exist (indices/uids resolve)
await syncSkinBinds(); // bound imports (skinBind) rebuild after the cast exists
updatePinBtn();
if (data.camera?.position) camera.position.fromArray(data.camera.position);
if (data.camera?.target) controls.target.fromArray(data.camera.target);
controls.update();
refreshMissingUI({ reset: true }); // proactively warn on load if this workflow's meshes aren't on this machine
};
await restoreSavedState();
if (!node._pcrAlive) return;
// Fresh node (no saved pose): upgrade the default first figure to the 🧬 custom
// slider body — it couldn't be created at the early first-figure spawn (the
// slider engine is defined later in this function and would TDZ). Restores and
// 👤 templates already use custom; this brings a brand-new node in line so the
// starting mannequin has the full slider panel.
{
const savedRaw = node.widgets?.find((w) => w.name === "pose_state")?.value;
if (!savedRaw && figures.length === 1 && figures[0]?.body === DEFAULT_BODY) {
const human = figures[0];
const baseX = human.rootRest?.x ?? human.root.position.x;
const custom = await spawnFigure({ select: true, capture: false, body: "custom" });
if (custom && node._pcrAlive) {
removeFigure(human, { capture: false });
custom.root.position.x = baseX; // recenter (spawnFigure offsets new people sideways)
custom.root.updateMatrixWorld(true);
custom.rootRest = custom.root.position.clone();
}
}
}
syncBodySliders(); // reflect restored body-shape morphs on the sliders
if (rig?.morphMesh) { console.log("[PoseStudio DBG] === LOAD BASELINE ==="); debugMorphReport(); } // rig null = restored a figure-less scene
resize();
captureAndUpload(node); // seed the control map so a fresh node renders something
// If dropped into a complete graph (or on workflow load), adopt the latent res.
requestAnimationFrame(() => syncToLatent(node));
}
function setupPoseStudio(node) {
node._pcrAlive = true;
installPoseStyles();
hideWidget(node, "control_map");
hideWidget(node, "pose_state");
const container = document.createElement("div");
container.className = "pcr-pose-studio";
container.style.cssText =
"position:relative;width:100%;height:100%;min-height:120px;" +
"background:#141418;border-radius:6px;overflow:hidden;touch-action:none;"; // bg shows as the letterbox bars
isolateViewportEvents(container);
// Track this Poser for the PromptChain "3D Poser" panel: register its
// existence (gates the menubar button) and mark it last-interacted so the
// panel docks whichever Poser you last touched.
poseRegistry.add(node);
container.addEventListener("pointerdown", () => poseRegistry.touch(node));
node.addDOMWidget("pose_studio_view", "pose_studio_view", container, { serialize: false });
// ("Match output" now lives in the viewport tool strip as the ⟳ icon; auto-sync below
// still covers mount + connection changes.)
// Re-sync when this node's own connections change (e.g. wired into a graph).
const origOnConnectionsChange = node.onConnectionsChange;
node.onConnectionsChange = function (...args) {
origOnConnectionsChange?.apply(this, args);
requestAnimationFrame(() => syncToLatent(node));
};
// Node body sizing floor (mirrors main.js: distinguish autogrow's current-size
// arg from canvas drag-to-resize so a user-resized node isn't slammed back).
node.computeSize = (out) => {
if (out) {
const cur = node.size || MIN_NODE_SIZE;
return [Math.max(MIN_NODE_SIZE[0], cur[0]), Math.max(MIN_NODE_SIZE[1], cur[1])];
}
return [...MIN_NODE_SIZE];
};
if (!node.size || node.size[0] < MIN_NODE_SIZE[0] || node.size[1] < MIN_NODE_SIZE[1]) {
node.setSize?.(node.computeSize());
}
const origOnRemoved = node.onRemoved;
node.onRemoved = function () {
origOnRemoved?.call(this);
node._pcrAlive = false;
poseRegistry.remove(node); // un-gate panels; open panels re-dock or show empty
viewportContainers.delete(container);
container._pcrDolly = null; // break the stale-closure chain (the window wheel handler holds the Set)
const ps = node._pcrPose;
if (ps) {
try {
ps.exitFullscreen?.(); // restore the container's layout before it's removed
ps.ro.disconnect();
if (ps.onKey) window.removeEventListener("keydown", ps.onKey, true);
if (ps.onWinPointerDown) window.removeEventListener("pointerdown", ps.onWinPointerDown, true);
if (ps.onContextMenu) window.removeEventListener("contextmenu", ps.onContextMenu, true);
node._pcrMenuCleanup?.(); // portaled flyouts + their close listener (mountViewport scope)
node._pcrMenuCleanup = null;
// Not transformControls.dispose(): in three r169 it calls this.traverse and
// throws (it's no longer an Object3D), aborting teardown. The scene.traverse
// below frees the gizmo helper's meshes anyway.
ps.transformControls?.detach();
ps.disposeCapture?.();
ps.controls?.dispose?.();
ps.scene?.traverse((o) => {
o.geometry?.dispose?.();
disposeMaterial(o.material); // material + textures
});
} catch (e) {
console.error("[PoseStudio] teardown error", e);
}
// Free the WebGL context UNCONDITIONALLY — leaking it across rebuilds (graph
// undo, etc.) exhausts the browser's context limit, after which new viewports
// can't compile shaders and render black. Kept in its own try so a failure
// above can never skip it.
try {
ps.renderer?.dispose();
ps.renderer?.forceContextLoss?.();
ps.renderer?.domElement?.remove();
} catch (e) {
console.error("[PoseStudio] renderer dispose error", e);
}
node._pcrPose = null;
}
};
mountViewport(node, container);
}
// ── Detached poser (Re-pose modal) ───────────────────────────────────────────
// Mount a fully standalone poser into an arbitrary element — NOT a LiteGraph
// node, nothing on the user's workflow. The Re-pose feature poses a throwaway
// figure whose control map + masks upload to the input dir; a background recipe
// render then reads those files. Reuses the node's exact viewport + capture
// pipeline via a stub node whose every graph access is guarded (graph === null
// → syncToLatent and downstream tracing no-op). Returns a handle the modal uses
// to read the latest control map, switch output mode (clay vs depth), and tear
// the whole thing down on close.
export async function mountDetachedPoser(parentEl, { width = 832, height = 1216, outputMode = "default", poseState = "" } = {}) {
installPoseStyles();
const container = document.createElement("div");
container.className = "pcr-pose-studio";
container.style.cssText =
"position:relative;width:100%;height:100%;min-height:120px;" +
"background:#141418;border-radius:6px;overflow:hidden;touch-action:none;";
isolateViewportEvents(container);
parentEl.appendChild(container);
const node = {
id: "repose-poser",
comfyClass: NODE_TYPE,
_pcrAlive: true,
graph: null,
// Raise fullscreen + menus above the Re-pose modal backdrop (10020) so the
// poser's ⛶ actually takes over the screen (menus derive to fsZ+9 = 10049).
properties: { pcrOutputMode: outputMode, pcrTrueDepth: outputMode === "depth", pcrFullscreenZ: 10040 },
outputs: [],
widgets: [
{ name: "control_map", value: "" },
// Seed-before-mount: mountViewport→restoreSavedState reads this widget once
// at mount, so a seeded scene boots directly (no fragile post-mount load).
{ name: "pose_state", value: poseState || "" },
{ name: "width", value: width },
{ name: "height", value: height },
],
setDirtyCanvas() {},
setSize() {},
computeSize() { return [width, height]; },
addDOMWidget() {},
};
poseRegistry.add(node);
container.addEventListener("pointerdown", () => poseRegistry.touch(node));
await mountViewport(node, container);
const ps = () => node._pcrPose;
const readWidget = (name) => node.widgets.find((w) => w.name === name)?.value;
// Guarantee an initial capture so getControlMap() returns a map even if the
// user queues without touching the figure (the default mannequin is valid).
requestAnimationFrame(() => captureAndUpload(node));
return {
node,
getControlMap() {
return { filename: readWidget("control_map") || "", poseState: readWidget("pose_state") || "" };
},
// Force a fresh capture in `mode` and AWAIT the upload, then return the control
// map. Called at Run so the recipe always gets the right map (depth for
// RefControl, white for AnyPose) regardless of what mode the live viewport was
// last left in — fixes feeding a clay/white render to the depth LoRA.
async captureNow(mode) {
if (mode) {
node.properties.pcrOutputMode = mode;
node.properties.pcrTrueDepth = mode === "depth";
ps()?.requestRender?.();
}
try { await captureAndUpload(node); } catch (e) { console.error("[Re-pose] captureNow failed", e); }
return { filename: readWidget("control_map") || "", poseState: readWidget("pose_state") || "" };
},
setOutputMode(mode) {
node.properties.pcrOutputMode = mode;
node.properties.pcrTrueDepth = mode === "depth";
ps()?.requestRender?.();
captureAndUpload(node); // re-upload so the control map reflects clay-vs-depth
},
setSize(w, h) {
const ww = node.widgets.find((x) => x.name === "width"); if (ww) ww.value = w;
const hh = node.widgets.find((x) => x.name === "height"); if (hh) hh.value = h;
ps()?.requestRender?.();
captureAndUpload(node);
},
dispose() {
node._pcrAlive = false;
poseRegistry.remove(node);
viewportContainers.delete(container);
container._pcrDolly = null;
const p = node._pcrPose;
if (p) {
try {
p.exitFullscreen?.();
p.ro?.disconnect?.();
if (p.onKey) window.removeEventListener("keydown", p.onKey, true);
if (p.onWinPointerDown) window.removeEventListener("pointerdown", p.onWinPointerDown, true);
if (p.onContextMenu) window.removeEventListener("contextmenu", p.onContextMenu, true);
node._pcrMenuCleanup?.();
node._pcrMenuCleanup = null;
p.transformControls?.detach?.();
p.disposeCapture?.();
p.controls?.dispose?.();
p.scene?.traverse((o) => { o.geometry?.dispose?.(); disposeMaterial(o.material); });
} catch (e) { console.error("[PoseStudio] detached teardown error", e); }
try {
p.renderer?.dispose?.();
p.renderer?.forceContextLoss?.();
p.renderer?.domElement?.remove?.();
} catch (e) { console.error("[PoseStudio] detached renderer dispose error", e); }
node._pcrPose = null;
}
container.remove();
},
};
}
app.registerExtension({
name: "PromptChain.PoseStudio",
setup() {
// Pin-on-enqueue: wrap the queue action once so a busy-queue re-pose can't
// GC a capture set whose render is still pending (execute() pins only when
// the render actually starts). Event-driven — reacts to the queue action,
// never polls — and always delegates to the original.
if (!app._pcrPoseQueueHook && typeof app.queuePrompt === "function") {
app._pcrPoseQueueHook = true;
const origQueue = app.queuePrompt.bind(app);
app.queuePrompt = function (...args) {
try { pinLivePoserSets(); } catch (e) { console.warn("[PoseStudio] pin-on-queue failed", e); }
return origQueue(...args);
};
}
},
async nodeCreated(node) {
if (node.comfyClass !== NODE_TYPE) return;
setupPoseStudio(node);
},
});