Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/components/Outline.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
import { coarsePointer } from '$lib/inputDevice';
import { viewPrefs } from '$lib/viewPrefs';
import { recordingClean } from '$lib/helperLayer';
// 41 G10: the character studio hides the scene, so the selection glare stands down with it
import { studioIsolating } from '$lib/avatars/avatarState';
import { shadowQuality } from '$lib/lightParams';
import { useTask, useThrelte } from '@threlte/core';
import { framesHeld, registerComposerWarm, schedule } from '$lib/sceneLoader';
Expand Down Expand Up @@ -578,7 +580,7 @@
// 30b P1: ...and in INTERACT, which is play's hands without play's lock: a
// player-style view of the scene has no selection glare either
// 36-share: a clean recording is a viewer's picture too
const playing = $isLocked || $editorMode === 'interact' || $recordingClean;
const playing = $isLocked || $editorMode === 'interact' || $recordingClean || $studioIsolating;
if ($objectsGroup && !editing && !playing)
for (const uuid of $selectedObjects) addMeshes(outlineEffectSelected.selection, uuid);
outlineEffectLocked.selection.clear();
Expand Down
3 changes: 2 additions & 1 deletion src/components/PingMarkers.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@
</script>

{#each $pings as ping (ping.id)}
<T.Group position={ping.pos}>
<!-- named: the character studio (41 G10) finds a previewed ping by it -->
<T.Group position={ping.pos} name="ping-marker">
<!-- halo ring pair at the exact hit point -->
<T.Mesh rotation.x={-Math.PI / 2} oncreate={track(ping.id, 'ringA')}>
<T.RingGeometry args={[0.42, 0.5, 40]} />
Expand Down
330 changes: 281 additions & 49 deletions src/components/menu/CharacterPanel.svelte

Large diffs are not rendered by default.

78 changes: 78 additions & 0 deletions src/components/play/CharacterStudio.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<script>
// 41 G10: the customise panel's isolated studio, mounted beside the preview avatar while the
// panel is open on a flat screen (Player.svelte). Each frame, while "Show in scene" is off:
// the studio shows, the camera sees ONLY the studio layer, and the preview avatar joins it
// (its model loads late, so every frame). With "Show in scene" on — or once the panel closes
// — the camera gets back exactly the mask it had. Nothing in the scene is written. See
// $lib/avatars/characterStudio.js for why layers and not `visible`.
import { useTask, useThrelte } from '@threlte/core';
import { onDestroy, onMount } from 'svelte';
import { avatarPreview, studioIsolating, studioPingUntil } from '$lib/avatars/avatarState';
import { buildStudio, joinStudioLayer, STUDIO_LAYER } from '$lib/avatars/characterStudio';
import { feetBelowHead } from '$lib/avatars/catalog';
import { registerDebugHook } from '$lib/debugHooks';

const { scene, camera } = useThrelte();
const studio = buildStudio();
scene.add(studio.group);

/** the camera whose mask we replaced, and the mask it had @type {{cam: any, mask: number} | null} */
let held = null;
/** the preview's two roots: the head group and, a sibling of it, the rigged body @type {any[]} */
let roots = [];
let placedAt = '';

function release() {
if (!held) return;
held.cam.layers.mask = held.mask;
held = null;
}

useTask(() => {
const preview = $avatarPreview;
const isolating = $studioIsolating && !!preview;
studio.group.visible = isolating;
/** @type {any} */
const cam = camera.current;
if (!isolating || !cam) {
release();
return;
}
if (held && held.cam !== cam) release();
if (!held) held = { cam, mask: cam.layers.mask };
cam.layers.set(STUDIO_LAYER);
if (roots.length < 2 || roots.some((r) => !r.parent))
roots = ['avatar-preview', 'avatar-preview-avatar'].map((n) => scene.getObjectByName(n)).filter(Boolean);
for (const r of roots) joinStudioLayer(r);
// the ping Preview beside the character shows inside the studio for its lifetime
if (performance.now() < $studioPingUntil) for (const m of scene.getObjectsByProperty('name', 'ping-marker')) joinStudioLayer(m);
const key = `${preview.position.join(',')}:${preview.yaw}`;
if (key !== placedAt) {
placedAt = key;
const p = preview.position;
studio.place([p[0], p[1] - feetBelowHead(), p[2]], preview.yaw);
}
});

/** @type {MutationObserver | null} */
let themeWatch = null;
onMount(() => {
// the studio wears the theme: recolour when it changes under an open panel
themeWatch = new MutationObserver(() => studio.retheme());
themeWatch.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
registerDebugHook('characterStudio', {
state: () => ({
shown: studio.group.visible,
held: !!held,
cameraMask: /** @type {any} */ (camera.current)?.layers.mask ?? null,
studioLayer: STUDIO_LAYER
})
});
});

onDestroy(() => {
release();
themeWatch?.disconnect();
studio.dispose();
});
</script>
5 changes: 4 additions & 1 deletion src/components/play/Player.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import VRControls from './VRControls.svelte'
import PointerLockControls from './PointerLockControls.svelte'
import AvatarRig from './AvatarRig.svelte'
import CharacterStudio from './CharacterStudio.svelte'
import { playerCam, peerHands, worldRig, peerHandStyle } from '../../stores/sceneStore'
import { userdata, peers, username } from '../../stores/appStore'
// P2b: a peer standing in ANOTHER scene is looking at a different world, so their
Expand All @@ -19,7 +20,7 @@
import { Text } from '@threlte/extras'
// 36-avatars: a side whose hand the rigged body holds with its IK draws no floating box (the
// body's hand IS the marker); the customise panel's preview of YOUR character renders here too
import { avatarIkPeers, avatarPreview } from '$lib/avatars/avatarState'
import { avatarIkPeers, avatarPreview, studioOpen } from '$lib/avatars/avatarState'

// CO5 — A COLOCATED PEER IS RENDERED AS A GHOST, and the whole rule lives in this
// component because it is PRESENTATION. `$colocatedPeers` is a peer id set derived
Expand Down Expand Up @@ -207,4 +208,6 @@
lookId={$peers.peer?.id ?? ''}
preview={{ position: $avatarPreview.position, yaw: $avatarPreview.yaw }}
/>
<!-- 41 G10: the isolated studio around it (flat screens; the panel opens it) -->
{#if $studioOpen}<CharacterStudio />{/if}
{/if}
15 changes: 14 additions & 1 deletion src/lib/avatars/avatarState.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// 36-avatars: the small LOCAL state the rigged avatars share with the rest of the UI. A leaf
// (svelte/store + safeStorage). Nothing here replicates.

import { writable } from 'svelte/store';
import { derived, writable } from 'svelte/store';
import { safeStorage } from '../safeStorage';

/**
Expand All @@ -18,6 +18,19 @@ export const avatarIkPeers = writable({});
*/
export const avatarPreview = writable(null);

/**
* 41 G10: the customise panel's ISOLATED STUDIO (characterStudio.js). `studioOpen` = the panel
* is open on a flat screen; `studioInScene` = the remembered "Show in scene" switch (the
* character previewed in the current scene instead). LOCAL prefs, never replicated.
*/
export const studioOpen = writable(false);
export const studioInScene = writable(safeStorage.getItem('characterStudio:inScene') === '1');
studioInScene.subscribe((v) => safeStorage.setItem('characterStudio:inScene', v ? '1' : '0'));
/** the studio also shows ping markers until this performance.now() (the ping Preview) */
export const studioPingUntil = writable(0);
/** the scene is hidden from the render right now */
export const studioIsolating = derived([studioOpen, studioInScene], ([open, inScene]) => open && !inScene);

/** live RiggedAvatar instances by root name, for the debug hook / e2e @type {Map<string, any>} */
export const avatarInstances = new Map();

Expand Down
116 changes: 116 additions & 0 deletions src/lib/avatars/characterFraming.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// 41 G8: how the customise panel frames YOUR character, as pure arithmetic (the unit suite pins
// it; CharacterPanel applies it). A LEAF: no imports.
//
// The character must stay WHOLE and CENTRED in the part of the viewport the drawer leaves free
// — left of the side drawer on a wide screen, above the bottom sheet on a phone — through every
// layout change (a fold/unfold flips the drawer between those two shapes). Two pieces:
// · a DISTANCE at which the body's envelope fits the free rect in BOTH directions (a folded
// phone's free area can be narrower than it is tall);
// · a projection VIEW OFFSET (three's `camera.setViewOffset`) that puts the orbit target —
// the body's centre — at the free rect's centre. The camera itself looks straight at the
// body, so orbiting turns the character in place like a turntable, which a screen-space
// PAN of the camera (the 38 approach) could not: the pivot sat beside the body.

/** the body's envelope in metres, feet to the top of the name label, arms and a hat included */
export const BODY_HEIGHT = 2.9;
export const BODY_WIDTH = 1.5;
/** the share of the free rect the envelope may fill (the rest is breathing room) */
export const FILL = 0.86;
/** never closer than this (a wide free rect would otherwise put the lens inside the hat) */
export const MIN_DIST = 2.6;

/**
* @typedef {{x: number, y: number, w: number, h: number}} Rect CSS px, relative to the canvas
*/

/**
* The distance at which a `bodyH` x `bodyW` envelope fits `free` on a canvas `viewH` tall
* seen through a vertical field of view of `fovDeg`.
* @param {{viewH: number, free: Rect, fovDeg: number, bodyH?: number, bodyW?: number, fill?: number}} o
*/
export function fitDistance({ viewH, free, fovDeg, bodyH = BODY_HEIGHT, bodyW = BODY_WIDTH, fill = FILL }) {
const H = Math.max(1, viewH);
const t = Math.tan(((fovDeg || 40) * Math.PI) / 360);
// world metres per css px at distance d is 2 d t / H, the same horizontally (square pixels)
const byHeight = ((bodyH / fill) * H) / (2 * Math.max(1, free.h) * t);
const byWidth = ((bodyW / fill) * H) / (2 * Math.max(1, free.w) * t);
return Math.max(MIN_DIST, byHeight, byWidth);
}

/**
* The view offset that shows the camera's centre of view at the free rect's centre: the args
* of `camera.setViewOffset(fullWidth, fullHeight, x, y, width, height)`.
* @param {number} viewW @param {number} viewH @param {Rect} free
* @returns {[number, number, number, number, number, number]}
*/
export function centreOffset(viewW, viewH, free) {
const cx = free.x + free.w / 2;
const cy = free.y + free.h / 2;
return [viewW, viewH, viewW / 2 - cx, viewH / 2 - cy, viewW, viewH];
}

/**
* The free rect: the canvas minus the drawer (its left edge on a wide screen, its top on a
* phone sheet) and a strip of top chrome, clamped so it never collapses.
* @param {{left: number, top: number, width: number, height: number}} canvas the canvas's client rect
* @param {{left: number, top: number, width: number, height: number} | null} panel the drawer's client rect
* @param {boolean} sheet the drawer is a bottom sheet (phone) rather than a side drawer
* @param {number} [topInset] css px of chrome over the top of the canvas
* @returns {Rect}
*/
export function freeRect(canvas, panel, sheet, topInset = 64) {
const W = Math.max(1, canvas.width);
const H = Math.max(1, canvas.height);
let right = W;
let bottom = H;
if (panel && panel.width > 0 && panel.height > 0) {
if (sheet) bottom = Math.min(H, Math.max(0, panel.top - canvas.top));
else right = Math.min(W, Math.max(0, panel.left - canvas.left));
}
const top = Math.min(topInset, H * 0.25);
// a drawer wider than the screen leaves nothing: frame on the whole canvas then
const w = right >= W * 0.25 ? right : W;
const h = bottom - top >= H * 0.2 ? bottom - top : H - top;
return { x: 0, y: top, w, h };
}

/**
* Refine a distance against the body's REAL bounds: project the box corners through a scratch
* copy of the camera (with the centring view offset) and scale the distance until the farthest
* corner sits at `fill` of the free rect's half-size. A box seen 3/4 on, its near corners
* magnified by perspective, projects wider than any envelope guess — the measured box is the
* truth. Duck-typed on three's camera / Vector3, so this file stays import-free.
* @param {{cam: any, corners: any[], target: number[], dir: number[], dist: number, viewW: number, viewH: number, free: Rect, fill?: number}} o
* @returns {number}
*/
export function fitToCorners({ cam, corners, target, dir, dist, viewW, viewH, free, fill = FILL }) {
cam.setViewOffset(...centreOffset(viewW, viewH, free));
cam.aspect = viewW / Math.max(1, viewH);
cam.updateProjectionMatrix();
const cx = free.x + free.w / 2;
const cy = free.y + free.h / 2;
const hw = Math.max(1, (free.w * fill) / 2);
const hh = Math.max(1, (free.h * fill) / 2);
let d = dist;
for (let i = 0; i < 6; i++) {
cam.position.set(target[0] + dir[0] * d, target[1] + dir[1] * d, target[2] + dir[2] * d);
cam.lookAt(target[0], target[1], target[2]);
cam.updateMatrixWorld(true);
let need = 0;
for (const c of corners) {
const v = c.clone().project(cam);
if (v.z > 1) {
need = Math.max(need, 2);
continue;
}
const px = ((v.x + 1) / 2) * viewW;
const py = ((1 - v.y) / 2) * viewH;
need = Math.max(need, Math.abs(px - cx) / hw, Math.abs(py - cy) / hh);
}
if (!(need > 0)) break;
const next = Math.max(MIN_DIST, d * need);
if (Math.abs(next - d) < 0.005) break;
d = next;
}
return d;
}
Loading
Loading