From e9d388b1865ecb6777ac0d147dfd9460ebdde0db Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 8 Oct 2026 13:48:16 +0300 Subject: [PATCH 01/92] [fix] quality governor flap lock: auto-quality toggles stop switching back and forth - 40 F12: on a phone Aquarium's water refraction switched off and on by itself. The governor walked 6 -> 5 after 10 s of cheap frames at "post off", level 5 brought the screen-space water pass back and overloaded 3 s later; doubling the recovery hold only slowed the flap (10/20/40/80 s) - a step back UP to the level a recovery just left (within flapWindowMs) now locks that level as the lowest recovery may reach, until clearFlapLock: a new scene (load end), releaseQuality / keepFullQuality, or the auto-quality switch. Every auto toggle (water, fluid, HDRI tier, shadows, resolution, AO, post, particles) derives from the level, so all of them get the rule. A scene that becomes light still walks back - keepFullQuality(): full quality and no automatic step for the rest of the page session (not saved; the Settings auto switch stays on) - unit: qualityFlapLock (10-minute phone simulation, counterfactual: >8 refraction switches without the lock, <=3 with it), the old flap test now ends in the lock Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/qualityGovernor.js | 21 +++++ src/lib/qualityGovernorCore.js | 38 ++++++-- tests/unit/qualityFlapLock.test.js | 135 +++++++++++++++++++++++++++++ tests/unit/qualityGovernor.test.js | 7 +- 4 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 tests/unit/qualityFlapLock.test.js diff --git a/src/lib/qualityGovernor.js b/src/lib/qualityGovernor.js index ca30fed81..132734717 100644 --- a/src/lib/qualityGovernor.js +++ b/src/lib/qualityGovernor.js @@ -187,6 +187,8 @@ export function noteFrameForQuality(ms) { if (wasLoading) { wasLoading = false; governor.settleFor(t); + // 40 F12: a new scene is new evidence — the last scene's flap lock does not carry over + governor.clearFlapLock(); } governor.noteFrame(ms, t); if (phone) notePhoneFrame(ms); @@ -254,10 +256,25 @@ export function pinQuality() { export function releaseQuality() { pinned = false; snoozedUntil = Date.now() + RELEASE_SNOOZE_MS; + governor.clearFlapLock(); governor.setLevel(0, now()); publish(0, 'released'); } +/** 40 F12 — "Keep full quality" (the auto-quality notice): full quality now and NO automatic + * step for the rest of this page session. Not saved — Settings' auto switch stays on, so the + * next visit is protected again; turning that switch off and on ends the hold too. */ +export function keepFullQuality() { + releaseQuality(); + snoozedUntil = Infinity; + publish(0, 'kept full quality'); +} + +/** 40 F12: the level recovery is held at because it flapped there (-1 = none). For the suite. */ +export function qualityFlapLock() { + return governor.flapLock(); +} + /** @param {boolean} on */ export function setAutoQuality(on) { autoQuality.set(!!on); @@ -273,6 +290,9 @@ autoQuality.subscribe((on) => { return; } safeStorage.setItem('autoQuality', on ? 'true' : 'false'); + // 40 F12: either way the switch is a fresh start — no flap lock, no "keep full quality" hold + governor.clearFlapLock(); + snoozedUntil = 0; if (!on) { pinned = false; governor.setLevel(0, now()); @@ -338,6 +358,7 @@ export const governorForTest = { snoozedUntil = 0; drawGapEngaged = false; lastDecideAt = 0; + governor.clearFlapLock(); governor.setLevel(0, -1e9); governor.forget(); ingestDrawGap.set(0); diff --git a/src/lib/qualityGovernorCore.js b/src/lib/qualityGovernorCore.js index 890ce29b6..880909b4f 100644 --- a/src/lib/qualityGovernorCore.js +++ b/src/lib/qualityGovernorCore.js @@ -246,6 +246,17 @@ function percentile(sorted, q) { * each transition itself a visible hitch). A step back up shortly after a walk down * doubles the recovery hold for next time, capped; a step up long after the last walk down * is just a heavier scene, and resets it. + * + * 40 F12 — THE FLAP LOCK. Doubling the hold only slowed the flap down: on a phone, Aquarium's + * water switched its refraction off and on by itself every 10-80 s (level 6 "post off" drops the + * screen-space water pass, so its frames are cheap and recover; level 5 brings the pass back and + * overloads 3 s later). The frames of a level say nothing about the cost of the level above it, + * so a recovery that had to be undone is not retried: stepping back UP to the level just walked + * down FROM (within `flapWindowMs`) makes that level the lowest recovery may reach until + * `clearFlapLock` — a new scene, "Keep full quality", the auto switch. Every automatic toggle + * (water, fluid, HDRI tier, shadows, resolution, AO, post, particles) derives from the level, so + * every one gets the same rule: at most one retry per scene. A scene that becomes LIGHT still + * walks back (that is new evidence, not the same frames again). * @param {{timing?: Partial}} [opts] */ export function createGovernor(opts = {}) { @@ -257,6 +268,9 @@ export function createGovernor(opts = {}) { let level = 0; let changedAt = -Infinity; let lastDownAt = -Infinity; + /** 40 F12: the level the last walk down started from, and the flap lock (-1 = none) */ + let lastDownFrom = -1; + let flapLock = -1; let recoverHoldMs = timing.recoverWindowMs; let settleUntil = -Infinity; /** 31-perf P3: a live XR session's thresholds (xrThresholds), null = the profile's; @@ -287,14 +301,17 @@ export function createGovernor(opts = {}) { } function change(/** @type {number} */ to, /** @type {number} */ now) { - if (to < level) lastDownAt = now; - else if (to > level) + if (to < level) { + lastDownAt = now; + lastDownFrom = level; + } else if (to > level) { // a step up soon after a walk down is a FLAP: make the next recovery wait longer. // A step up long after one is simply a scene that got heavier: forgive the history - recoverHoldMs = - now - lastDownAt < timing.flapWindowMs - ? Math.min(timing.maxRecoverHoldMs, recoverHoldMs * 2) - : timing.recoverWindowMs; + const flap = now - lastDownAt < timing.flapWindowMs; + recoverHoldMs = flap ? Math.min(timing.maxRecoverHoldMs, recoverHoldMs * 2) : timing.recoverWindowMs; + // 40 F12: straight back to the level the recovery left — lock it (see the header) + if (flap && to === lastDownFrom) flapLock = Math.max(flapLock, to); + } level = to; changedAt = now; settleUntil = now + timing.settleMs; @@ -359,6 +376,8 @@ export function createGovernor(opts = {}) { change(level - 1, now); return { level, moved: 'down', reason: 'scene is light', p95 }; } + // 40 F12: a recovery that already had to be undone is not retried + if (level <= flapLock) return { level, moved: null, reason: 'held (flapped)', p95 }; const calm = p95Over(now, recoverHoldMs); if (calm != null && calm < t.underMs && tasks === 0) { change(level - 1, now); @@ -373,6 +392,13 @@ export function createGovernor(opts = {}) { if (next !== level) change(next, now); return level; }, + /** 40 F12: the lowest level recovery may reach because it flapped there (-1 = none) */ + flapLock: () => flapLock, + /** 40 F12: a new scene / "Keep full quality" / the auto switch — the old flap is no evidence */ + clearFlapLock() { + flapLock = -1; + lastDownFrom = -1; + }, /** 31-perf P3: judge against these (an XR session's rate) until cleared with null. * @param {{overMs: number, underMs: number, budgetMs?: number} | null} t */ setThresholds(t) { diff --git a/tests/unit/qualityFlapLock.test.js b/tests/unit/qualityFlapLock.test.js new file mode 100644 index 000000000..f4e96af01 --- /dev/null +++ b/tests/unit/qualityFlapLock.test.js @@ -0,0 +1,135 @@ +// 40 F12 — the user's phone: Aquarium's water switched its refraction OFF and ON by itself. +// Level 6 ("post off") drops the water's screen-space pass, so its frames are cheap and the +// governor recovered to level 5 after the hold — where the pass comes back and overloads 3 s +// later. The old flap rule only doubled the hold (10 / 20 / 40 / 80 s), so the switch kept +// flipping for as long as the scene was open. The flap LOCK: once a recovery had to be undone, +// it is not retried (until a new scene, "Keep full quality", or the auto switch). +import { describe, it, expect } from 'vitest'; +import { createGovernor, phoneThresholds, overridesAt } from '../../src/lib/qualityGovernorCore.js'; + +const ctx = { profile: /** @type {'desktop'} */ ('desktop'), heavy: true, calls: 60 }; + +/** + * A 60 Hz phone whose frame cost depends on the level: at `cheapFrom` and above the frames + * are on time, below it every third frame is missed (the screen-space water pass). Decides + * every 250 ms like the wiring and records every level change. + * @param {ReturnType} g @param {number} from @param {number} dur @param {number} cheapFrom + */ +function phone(g, from, dur, cheapFrom) { + /** @type {{t: number, level: number}[]} */ + const moves = []; + let t = from; + let i = 0; + let next = from + 250; + while (t < from + dur) { + const ms = g.level() >= cheapFrom ? 16.7 : i % 3 === 0 ? 33.3 : 16.7; + t += ms; + i++; + g.noteFrame(ms, t); + if (t >= next) { + next = t + 250; + const d = g.decide(t, ctx); + if (d.moved) moves.push({ t: Math.round(t), level: d.level }); + } + } + return { t, moves }; +} + +/** the water tier the level gives (waterRuntime.resolveTier's auto rule) @param {number} level */ +const waterTier = (level) => { + const o = overridesAt(level); + return o.postOff ? 'quest' : o.aoOff || o.dprScale < 0.8 ? 'medium' : 'high'; +}; + +/** how many times the water's refraction switched (quest <-> screen-space) @param {{level: number}[]} moves @param {number} start */ +function refractionFlips(moves, start) { + let flips = 0; + let was = waterTier(start) === 'quest'; + for (const m of moves) { + const now = waterTier(m.level) === 'quest'; + if (now !== was) flips++; + was = now; + } + return flips; +} + +describe('flap lock (F12)', () => { + it('the phone premise: level 5 refracts, level 6 does not', () => { + expect(waterTier(5)).toBe('medium'); + expect(waterTier(6)).toBe('quest'); + }); + + it('COUNTERFACTUAL: without the lock the refraction keeps switching for the whole session', () => { + const g = createGovernor(); + g.setThresholds(phoneThresholds(60)); + g.setLevel(5, 0); + // the lock disabled: clear it after every decision, which is the pre-F12 rule + const realDecide = g.decide; + g.decide = (t, c) => { + const d = realDecide(t, c); + g.clearFlapLock(); + return d; + }; + const { moves } = phone(g, 1000, 600000, 6); + expect(refractionFlips(moves, 5)).toBeGreaterThan(8); + }); + + it('with the lock: off, one retry, off for good — at most 3 switches in 10 minutes', () => { + const g = createGovernor(); + g.setThresholds(phoneThresholds(60)); + g.setLevel(5, 0); + const { moves } = phone(g, 1000, 600000, 6); + expect(refractionFlips(moves, 5)).toBeLessThanOrEqual(3); + expect(g.level()).toBe(6); + expect(g.flapLock()).toBe(6); + // and the last move happened within the first minute: nothing after that + expect(moves[moves.length - 1].t).toBeLessThan(61000); + }); + + it('a step up LONG after a walk down is a heavier scene, not a flap — no lock', () => { + const g = createGovernor(); + g.setThresholds(phoneThresholds(60)); + g.setLevel(6, 0); + let t = phone(g, 1000, 15000, 0).t; // cheap everywhere: recovers 6 -> 5 + expect(g.level()).toBe(5); + // a long calm, then the scene gets heavy + const calm = phone(g, t, 60000, 0); + t = calm.t; + const heavyCtx = { ...ctx }; + for (let i = 0; i < 400; i++) { + t += 33.3; + g.noteFrame(33.3, t); + } + const lv = g.level(); + const d = g.decide(t, heavyCtx); + expect(d.moved).toBe('up'); + expect(g.level()).toBe(lv + 1); + expect(g.flapLock()).toBe(-1); + }); + + it('a scene that becomes LIGHT still walks back through the lock (new evidence)', () => { + const g = createGovernor(); + g.setThresholds(phoneThresholds(60)); + g.setLevel(5, 0); + const { t } = phone(g, 1000, 120000, 6); + expect(g.flapLock()).toBe(6); + let tt = t; + for (let i = 0; i < 700; i++) { + tt += 16.7; + g.noteFrame(16.7, tt); + } + expect(g.decide(tt, { ...ctx, heavy: false })).toMatchObject({ moved: 'down', reason: 'scene is light' }); + }); + + it('clearFlapLock (a new scene) gives recovery back', () => { + const g = createGovernor(); + g.setThresholds(phoneThresholds(60)); + g.setLevel(5, 0); + const { t } = phone(g, 1000, 120000, 6); + g.clearFlapLock(); + expect(g.flapLock()).toBe(-1); + // cheap at every level now (a lighter scene): recovery walks down again + const after = phone(g, t, 200000, 0); + expect(after.moves.some((m) => m.level < 6)).toBe(true); + }); +}); diff --git a/tests/unit/qualityGovernor.test.js b/tests/unit/qualityGovernor.test.js index f89a14ed1..2a1eac8ed 100644 --- a/tests/unit/qualityGovernor.test.js +++ b/tests/unit/qualityGovernor.test.js @@ -146,7 +146,12 @@ describe('createGovernor', () => { expect(g.recoverHoldMs()).toBe(20000); t = run(g, t, 10500, 16.7); expect(g.decide(t, { heavy: true }).moved).toBe(null); // 10s is no longer enough - t = run(g, t, 10000, 16.7); + // 40 F12: ...and that flap LOCKED level 1 — good frames never walk below it again + t = run(g, t, 30000, 16.7); + expect(g.decide(t, { heavy: true })).toMatchObject({ moved: null, reason: 'held (flapped)' }); + expect(g.flapLock()).toBe(1); + // a new scene clears the lock; the doubled hold is already served, so it walks down + g.clearFlapLock(); expect(g.decide(t, { heavy: true }).moved).toBe('down'); // much later, a heavier scene: not a flap t = run(g, t + 60000, 2100, 50); From 081162b6629437ff0105ccfa534b9bba55b4c79d Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 8 Oct 2026 13:55:11 +0300 Subject: [PATCH 02/92] [feat] general-purpose motion nodes: follow path, wander, orient to velocity, body wave (40 F15) - motion/motionCore.js (pure, unit-tested): centripetal Catmull-Rom paths with arc length, loop/pingpong/once + offset, signed curvature, bank roll, seeded smooth bounded noise with an analytic rate, the body-wave envelope/drive/offset - motion/motionNodes.js (three leaf): Follow Path rides a drawn Spline, a Flow path or its own waypoints in world space, faces along it and banks into turns; Wander drifts inside an area object's box (or a box around its base), seeded per object; Orient to Velocity faces the frame's motion with smoothed turning + bank and holds while still; Body Wave bends any mesh subtree with a vertex-shader travelling wave whose amplitude/frequency follow speed and whose bend follows turn rate (per-mesh material clones keeping the original uuid, put back when the wave stops) - flowRuntime applies the followers after the movers (stable sort, graphs without them unchanged) and prunes waves that stopped - catalog (Animation group), sockets (path/area take an object), cards, doc lines - tests/unit/motionCore.test.js: 23 tests incl. the bank-direction counterfactual Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/editors/Nodes.svelte | 4 + src/lib/flowRuntime.js | 43 ++- src/lib/flowSockets.js | 2 + src/lib/motion/motionCore.js | 383 +++++++++++++++++++++ src/lib/motion/motionNodes.js | 517 ++++++++++++++++++++++++++++ src/lib/nodeCatalog.js | 93 +++++ src/lib/nodeDocs.js | 4 + tests/unit/motionCore.test.js | 324 +++++++++++++++++ 8 files changed, 1369 insertions(+), 1 deletion(-) create mode 100644 src/lib/motion/motionCore.js create mode 100644 src/lib/motion/motionNodes.js create mode 100644 tests/unit/motionCore.test.js diff --git a/src/components/editors/Nodes.svelte b/src/components/editors/Nodes.svelte index ef4dd8340..dd33b741e 100644 --- a/src/components/editors/Nodes.svelte +++ b/src/components/editors/Nodes.svelte @@ -183,6 +183,10 @@ spin: AnimationNode, rotor: AnimationNode, // 36-fb F25 flowfloat: AnimationNode, // 36-fb F24 + followpath: AnimationNode, // 40 F15 + wander: AnimationNode, + orientvelocity: AnimationNode, + bodywave: AnimationNode, bounce: AnimationNode, orbit: AnimationNode, pulse: AnimationNode, diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index acf469ced..e1ed215b8 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -87,6 +87,16 @@ import { registerGameSetting, gameSettingValue } from './gameSettings'; import { setPointGrabEnabled } from './pointGrab'; import { spawnedFromOf } from './transientObjects'; // 31: a copy answers to its template (a leaf) import { applyRotor, applyFlowFloat } from './sim/motionNodes.js'; // 36-fb F25/F24 (a three leaf) +// 40 F15: general-purpose motion (a three leaf + its pure core) +import { + applyFollowPath, + applyWander, + applyOrientVelocity, + applyBodyWave, + pruneBodyWaves, + tickMotionNodes, + MOTION_PHASE +} from './motion/motionNodes.js'; // 37 (R6): variadic math/gate + the Switcher multiplexer (a leaf, no imports) import { rigGoal, rigStep, validVec } from './cameraRig.js'; // 37 (R8): a three leaf import { INPUT_LETTERS, socketCount, opFolds, foldMath, foldGate, switcherItems, switcherHandle, switcherIndexOf, switcherRadioIndex } from './variadicNodes.js'; @@ -3647,6 +3657,23 @@ function applyAnimation(object, base, anim, time, ctx) { applyFlowFloat(object, base, data, time, { root: sceneObjects, key: anim.id + '|' + object.uuid }); return; } + // 40 F15: the general-purpose motion nodes (motion/motionNodes.js, a leaf) + if (anim.type === 'followpath') { + applyFollowPath(object, base, data, time, { root: sceneObjects, key: anim.id + '|' + object.uuid }); + return; + } + if (anim.type === 'wander') { + applyWander(object, base, data, time, { root: sceneObjects }); + return; + } + if (anim.type === 'orientvelocity') { + applyOrientVelocity(object, base, data, time, { key: anim.id + '|' + object.uuid }); + return; + } + if (anim.type === 'bodywave') { + applyBodyWave(object, base, data, time, { key: anim.id + '|' + object.uuid }); + return; + } if (anim.type === 'shake') { const intensity = data.intensity ?? 0.2; const speed = data.speed ?? 10; @@ -3991,6 +4018,8 @@ function runTick(now) { } }); + /** 40 F15: the Body Wave (node, object) keys still driven this tick @type {Set} */ + const liveWaves = new Set(); active.forEach((anims, uuid) => { const object = sceneObjects.getObjectByProperty('uuid', uuid); if (!object) { @@ -4013,8 +4042,20 @@ function runTick(now) { // 21-E3: EFFECTS evaluate on the pause-folded clock, so a paused world holds its // pose and resumes where it froze instead of jumping the gap. Sounds/particles // take the same clock below; value/HUD/game nodes keep the real one. - anims.forEach((/** @type {any} */ anim) => applyAnimation(object, base, anim, effectTime(time), ctx)); + // 40 F15: the motion FOLLOWERS (Orient to Velocity, Body Wave) read what the movers + // wrote this frame, so they apply last whatever order the graph lists them in (a stable + // sort — every graph without them keeps its order byte-for-byte) + const ordered = anims.some((/** @type {any} */ a) => MOTION_PHASE[a.type]) + ? [...anims].sort((/** @type {any} */ a, /** @type {any} */ b) => (MOTION_PHASE[a.type] ?? 0) - (MOTION_PHASE[b.type] ?? 0)) + : anims; + ordered.forEach((/** @type {any} */ anim) => { + if (anim.type === 'bodywave') liveWaves.add(anim.id + '|' + object.uuid); + applyAnimation(object, base, anim, effectTime(time), ctx); + }); }); + // 40 F15: a Body Wave whose node or object went away puts its original materials back + pruneBodyWaves(liveWaves); + tickMotionNodes(); // sound nodes keep their own audio chains (97) — hand over the live pairs // 21-E4: `trigger` is the wired event's STAMP (null when nothing is wired) diff --git a/src/lib/flowSockets.js b/src/lib/flowSockets.js index 15363c4a9..1f17ae610 100644 --- a/src/lib/flowSockets.js +++ b/src/lib/flowSockets.js @@ -164,6 +164,8 @@ const INPUT = { camerarig: { target: 'object', offset: 'vector3' }, // 37 (R8) rotor: { on: 'boolean' }, // 36-fb F25 flowfloat: { path: 'object' }, // 36-fb F24 + followpath: { path: 'object' }, // 40 F15 + wander: { area: 'object' }, // 40 F15 setcolor: { color: 'color' }, visibility: { on: 'boolean' }, setuniform: { value: 'number' }, diff --git a/src/lib/motion/motionCore.js b/src/lib/motion/motionCore.js new file mode 100644 index 000000000..bfbf0960a --- /dev/null +++ b/src/lib/motion/motionCore.js @@ -0,0 +1,383 @@ +// 40 F15 — GENERAL-PURPOSE MOTION, the pure half (no THREE, no DOM, no stores — unit-tested). +// +// Four effect nodes share this file's math; motionNodes.js is the THREE half flowRuntime calls: +// Follow Path (`followpath`) — ride a smooth path (a drawn Spline, a Flow path or clicked +// waypoints) at a speed, facing along it, banking into turns. +// Orient to Velocity (`orientvelocity`) — face wherever the object is moving (after any mover: +// Wander, Orbit, Bounce, a script), turning smoothly, banking. +// Wander (`wander`) — drift around a smooth, bounded, seeded noise target inside an +// area object's box (a water volume, a room) or a box around +// where it was placed. +// Body Wave (`bodywave`) — a travelling bend along the body (a vertex shader): a fish's +// spine, a snake, a flag, a tail; its amplitude and frequency +// follow the object's speed and turn rate. +// The movers (Follow Path, Wander) are pure functions of (data, shared time) — determinism is the +// netcode, exactly like Spin and Path patrol. The followers (Orient, Body Wave) read the motion +// the movers produced, so they keep a little per-peer state; what they write is a look, not a +// pose anyone else needs, and every peer converges on the same one. + +/** @typedef {[number, number, number]} V3 */ + +export const MOTION_VERSION = 1; +/** where the forward axis of a model can point; Path patrol's convention (+Z) is the default */ +export const FORWARD_AXES = ['+z', '-z', '+x', '-x']; +/** samples per control-point span when a path is densified */ +export const SAMPLES_PER_SPAN = 16; +/** the most control points a path keeps (the spline tool's own cap) */ +export const MAX_PATH_POINTS = 200; + +/** @param {any} v @param {number} d @param {number} lo @param {number} hi */ +export function clampNum(v, d, lo, hi) { + const n = Number(v); + if (v === null || v === undefined || v === '' || !Number.isFinite(n)) return d; + return Math.min(hi, Math.max(lo, n)); +} + +/** @param {number[]} a @param {number[]} b */ +const dist = (a, b) => Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]); + +/** finite [x, y, z] points only, capped @param {any} pts @returns {V3[]} */ +export function cleanPoints(pts) { + if (!Array.isArray(pts)) return []; + /** @type {V3[]} */ + const out = []; + for (const p of pts) { + const q = Array.isArray(p) ? p : Array.isArray(p?.pos) ? p.pos : null; + if (!q || q.length < 3) continue; + const v = /** @type {V3} */ ([+q[0], +q[1], +q[2]]); + if (!v.every(Number.isFinite)) continue; + // a doubled click adds nothing but a zero-length span + if (out.length && dist(out[out.length - 1], v) < 1e-6) continue; + out.push(v); + if (out.length >= MAX_PATH_POINTS) break; + } + return out; +} + +/** + * One centripetal Catmull-Rom span (alpha 0.5 — no cusps or self-loops on uneven spacing, the + * spline tool's own curve) between p1 and p2 at u ∈ [0, 1]. + * @param {number[]} p0 @param {number[]} p1 @param {number[]} p2 @param {number[]} p3 @param {number} u + * @returns {V3} + */ +export function catmullRom(p0, p1, p2, p3, u) { + const knot = (/** @type {number[]} */ a, /** @type {number[]} */ b) => Math.max(1e-4, Math.sqrt(dist(a, b))); + const t0 = 0; + const t1 = t0 + knot(p0, p1); + const t2 = t1 + knot(p1, p2); + const t3 = t2 + knot(p2, p3); + const t = t1 + (t2 - t1) * u; + /** @type {V3} */ + const out = [0, 0, 0]; + for (let i = 0; i < 3; i++) { + const a1 = ((t1 - t) / (t1 - t0)) * p0[i] + ((t - t0) / (t1 - t0)) * p1[i]; + const a2 = ((t2 - t) / (t2 - t1)) * p1[i] + ((t - t1) / (t2 - t1)) * p2[i]; + const a3 = ((t3 - t) / (t3 - t2)) * p2[i] + ((t - t2) / (t3 - t2)) * p3[i]; + const b1 = ((t2 - t) / (t2 - t0)) * a1 + ((t - t0) / (t2 - t0)) * a2; + const b2 = ((t3 - t) / (t3 - t1)) * a2 + ((t - t1) / (t3 - t1)) * a3; + out[i] = ((t2 - t) / (t2 - t1)) * b1 + ((t - t1) / (t2 - t1)) * b2; + } + return out; +} + +/** + * A path ready to walk: the control points densified (smooth) or kept as a polyline, with the + * cumulative arc length per sample. A closed path's last sample IS its first. + * @param {any} points @param {{closed?: boolean, smooth?: boolean, samples?: number}} [opts] + * @returns {{pts: V3[], cum: number[], total: number, closed: boolean} | null} + */ +export function buildPath(points, opts = {}) { + const ctrl = cleanPoints(points); + if (ctrl.length < 2) return null; + const closed = !!opts.closed && ctrl.length >= 3; + const smooth = opts.smooth !== false && ctrl.length >= 3; + const per = Math.max(1, Math.min(64, Math.round(opts.samples ?? SAMPLES_PER_SPAN))); + const n = ctrl.length; + /** @type {V3[]} */ + let pts; + if (!smooth) pts = closed ? [...ctrl, ctrl[0]] : ctrl.slice(); + else { + const at = (/** @type {number} */ i) => { + if (closed) return ctrl[((i % n) + n) % n]; + if (i < 0) return mirror(ctrl[0], ctrl[1]); + if (i >= n) return mirror(ctrl[n - 1], ctrl[n - 2]); + return ctrl[i]; + }; + pts = []; + const spans = closed ? n : n - 1; + for (let s = 0; s < spans; s++) { + for (let k = 0; k < per; k++) pts.push(catmullRom(at(s - 1), at(s), at(s + 1), at(s + 2), k / per)); + } + pts.push(closed ? ctrl[0] : ctrl[n - 1]); + } + const cum = [0]; + for (let i = 1; i < pts.length; i++) cum.push(cum[i - 1] + dist(pts[i - 1], pts[i])); + const total = cum[cum.length - 1]; + if (!(total > 1e-6)) return null; + return { pts, cum, total, closed }; +} + +/** the phantom point beyond an open end (keeps the end tangent straight) @param {V3} end @param {V3} prev @returns {V3} */ +function mirror(end, prev) { + return [2 * end[0] - prev[0], 2 * end[1] - prev[1], 2 * end[2] - prev[2]]; +} + +/** + * Position and unit tangent at arc length s (clamped to the path). + * @param {{pts: V3[], cum: number[], total: number}} path @param {number} s + * @returns {{p: V3, t: V3}} + */ +export function samplePath(path, s) { + const { pts, cum, total } = path; + const x = Math.min(total, Math.max(0, s)); + // binary search the span + let lo = 0; + let hi = cum.length - 1; + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if (cum[mid] <= x) lo = mid; + else hi = mid; + } + const a = pts[lo]; + const b = pts[hi]; + const len = cum[hi] - cum[lo]; + const u = len > 0 ? (x - cum[lo]) / len : 0; + /** @type {V3} */ + const p = [a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u, a[2] + (b[2] - a[2]) * u]; + const l = len || 1; + /** @type {V3} */ + const t = [(b[0] - a[0]) / l, (b[1] - a[1]) / l, (b[2] - a[2]) / l]; + return { p, t }; +} + +/** + * Where along the path at `time`: s (arc length) and dir (+1 forward / -1 on a ping-pong's way + * back). `offset` (0..1) shifts the start along the path, so several riders of one path spread + * out; a negative speed rides it backwards. + * @param {number} time @param {number} speed m/s @param {number} total @param {string} mode + * 'loop' | 'pingpong' | 'once' @param {number} [offset] + */ +export function pathDistance(time, speed, total, mode, offset = 0) { + const t = Math.max(0, time); + const start = (((offset % 1) + 1) % 1) * total; + const run = speed * t; + if (mode === 'once') { + const s = Math.min(total, Math.max(0, start + run)); + return { s, dir: speed < 0 ? -1 : 1 }; + } + if (mode === 'pingpong') { + const period = 2 * total; + const c = (((start + run) % period) + period) % period; + const back = c > total; + const s = back ? period - c : c; + const forward = speed >= 0 ? !back : back; + return { s, dir: forward ? 1 : -1 }; + } + const s = (((start + run) % total) + total) % total; + return { s, dir: speed < 0 ? -1 : 1 }; +} + +/** + * Signed horizontal curvature (1/m) at s: positive = the path turns LEFT (counter-clockwise seen + * from above, +Y up). Measured over ±h of arc so a densified polyline's corners do not alias. + * @param {{pts: V3[], cum: number[], total: number, closed: boolean}} path @param {number} s @param {number} [h] + */ +export function curvatureAt(path, s, h = 0.15) { + const span = Math.min(h, path.total / 4); + const wrap = (/** @type {number} */ x) => (path.closed ? ((x % path.total) + path.total) % path.total : x); + const a = samplePath(path, wrap(s - span)).t; + const b = samplePath(path, wrap(s + span)).t; + const ya = Math.atan2(a[0], a[2]); + const yb = Math.atan2(b[0], b[2]); + return wrapAngle(yb - ya) / (2 * span); +} + +/** an angle into (-π, π] @param {number} a */ +export function wrapAngle(a) { + let x = a % (Math.PI * 2); + if (x > Math.PI) x -= Math.PI * 2; + if (x <= -Math.PI) x += Math.PI * 2; + return x; +} + +/** + * The roll (radians) a mover leans INTO a turn: a coordinated turn's lean, shaped so the user + * sets it with two numbers — `bank` (0..1, how much) and `maxBank` (degrees, the ceiling). + * yawRate in rad/s (positive = turning left); the result is positive when the mover's top leans + * to its left. tanh keeps it smooth and bounded: 1 rad/s of turn ≈ 76 % of the ceiling. + * @param {number} yawRate @param {number} bank @param {number} maxBankDeg + */ +export function bankRoll(yawRate, bank, maxBankDeg) { + const b = Math.min(1, Math.max(0, bank)); + const max = (Math.min(85, Math.max(0, maxBankDeg)) * Math.PI) / 180; + return b * max * Math.tanh(yawRate); +} + +// ---- Wander: smooth bounded noise ------------------------------------------------------------- + +/** a 32-bit hash of a string or number → [0, 1) @param {any} seed */ +export function hash01(seed) { + const s = String(seed ?? ''); + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + h ^= h >>> 13; + h = Math.imul(h, 0x5bd1e995); + h ^= h >>> 15; + return (h >>> 0) / 4294967296; +} + +// three octaves at incommensurate rates (golden-ratio steps): smooth (C∞), never periodic in +// practice, bounded by construction — the sum of the weights is the divisor +const OCT = [ + { f: 1, w: 1 }, + { f: 1.618034, w: 0.5 }, + { f: 2.618034, w: 0.25 } +]; +const OCT_SUM = OCT.reduce((a, o) => a + o.w, 0); + +/** + * Smooth noise in [-1, 1] on one channel at time t. `seed` picks the phases (and slightly the + * rates), so two wanderers with different seeds never move in step. + * @param {number} t @param {number} seed01 a [0, 1) seed @param {number} channel 0, 1, 2 + */ +export function smoothNoise(t, seed01, channel) { + let v = 0; + for (let i = 0; i < OCT.length; i++) { + const k = hash01(seed01 * 1000 + channel * 31 + i * 7); + const rate = OCT[i].f * (0.85 + 0.3 * k); + v += OCT[i].w * Math.sin(t * rate + k * Math.PI * 2); + } + return v / OCT_SUM; +} + +/** d/dt of smoothNoise (for an analytic velocity) @param {number} t @param {number} seed01 @param {number} channel */ +export function smoothNoiseRate(t, seed01, channel) { + let v = 0; + for (let i = 0; i < OCT.length; i++) { + const k = hash01(seed01 * 1000 + channel * 31 + i * 7); + const rate = OCT[i].f * (0.85 + 0.3 * k); + v += OCT[i].w * rate * Math.cos(t * rate + k * Math.PI * 2); + } + return v / OCT_SUM; +} + +/** + * The wander target at time t inside a box (centre + half extents, any frame): each axis is its + * own noise channel, scaled to the half extent. `speed` is how fast the noise is walked (1 = the + * base octave turns about once every 6 s); `seed01` makes riders of one node differ. + * @param {number} t @param {V3} center @param {V3} half @param {number} speed @param {number} seed01 + * @returns {{p: V3, v: V3}} position and its time derivative (m/s) + */ +export function wanderAt(t, center, half, speed, seed01) { + const tt = Math.max(0, t) * speed; + /** @type {V3} */ + const p = [0, 0, 0]; + /** @type {V3} */ + const v = [0, 0, 0]; + for (let c = 0; c < 3; c++) { + p[c] = center[c] + half[c] * smoothNoise(tt, seed01, c); + v[c] = half[c] * speed * smoothNoiseRate(tt, seed01, c); + } + return { p, v }; +} + +// ---- Body Wave: the travelling bend ------------------------------------------------------------ + +export const BODY_WAVE_DEFAULTS = Object.freeze({ + forward: '+z', + side: 'x', + amplitude: 0.08, // of the body length, at the tail + wavelength: 1, // body lengths per wave + frequency: 1.5, // Hz at rest + stiffness: 0.3, // the front fraction that does not bend (a fish's head) + falloff: 2, // envelope power (2 = the tail swings most) + speedGain: 1, // extra Hz per m/s (a fast swimmer beats faster) + ampGain: 0.5, // extra amplitude (fraction) per m/s + turnBend: 0.5, // bend into a turn: fraction of body length per rad/s of turn + reverse: false // the wave travels tail → head instead (a flag rippling toward its pole) +}); + +/** + * The envelope at u (0 = the front/head, 1 = the tail): nothing ahead of `stiffness`, rising to + * 1 at the tail as a power curve. Pure, so the shader and the tests agree. + * @param {number} u @param {number} stiffness @param {number} falloff + */ +export function waveEnvelope(u, stiffness, falloff) { + const s = Math.min(0.95, Math.max(0, stiffness)); + const x = Math.min(1, Math.max(0, (u - s) / (1 - s))); + return Math.pow(x, Math.max(0.25, falloff)); +} + +/** + * The live wave numbers for a speed (m/s) and turn rate (rad/s, + = left): frequency and + * amplitude grow with speed (clamped to sane ceilings), the static bend follows the turn. + * @param {any} data node data @param {number} speed @param {number} yawRate + */ +export function waveDrive(data, speed, yawRate) { + const d = BODY_WAVE_DEFAULTS; + const v = Math.max(0, Math.min(10, speed || 0)); + const frequency = clampNum(data.frequency, d.frequency, 0, 10) + clampNum(data.speedGain, d.speedGain, 0, 10) * v; + const amplitude = clampNum(data.amplitude, d.amplitude, 0, 0.5) * (1 + clampNum(data.ampGain, d.ampGain, 0, 5) * v); + const bend = clampNum(data.turnBend, d.turnBend, 0, 2) * Math.max(-2, Math.min(2, yawRate || 0)); + return { frequency: Math.min(20, frequency), amplitude: Math.min(0.6, amplitude), bend: Math.max(-0.6, Math.min(0.6, bend)) }; +} + +/** + * The sideways offset (fraction of body length) at u for an accumulated phase (cycles). The + * shader runs this exact formula; the tests pin it. + * @param {number} u @param {number} phase cycles @param {{amplitude: number, bend: number}} drive + * @param {{wavelength: number, stiffness: number, falloff: number, reverse?: boolean}} shape + */ +export function waveOffset(u, phase, drive, shape) { + const env = waveEnvelope(u, shape.stiffness, shape.falloff); + const k = 1 / Math.max(0.05, shape.wavelength); + const dir = shape.reverse ? -1 : 1; + // head → tail: the crest moves toward larger u as the phase grows + const wave = Math.sin(Math.PI * 2 * (u * k - dir * phase)); + // a turn bends the body like a bow: no lean at the head, most at the tail (u²) + return drive.amplitude * env * wave + drive.bend * u * u; +} + +/** the unit vector of a forward-axis name @param {string} name @returns {V3} */ +export function forwardVector(name) { + switch (name) { + case '-z': + return [0, 0, -1]; + case '+x': + return [1, 0, 0]; + case '-x': + return [-1, 0, 0]; + default: + return [0, 0, 1]; + } +} + +/** + * The Euler Y (radians) that turns a model whose nose is `forward` so it faces +Z — the + * correction every mover applies after building a +Z-forward frame. + * @param {string} forward + */ +export function forwardYaw(forward) { + switch (forward) { + case '-z': + return Math.PI; + case '+x': + return -Math.PI / 2; + case '-x': + return Math.PI / 2; + default: + return 0; + } +} + +/** + * Exponential smoothing factor for a rate (1/s) over dt — frame-rate independent. + * @param {number} rate @param {number} dt + */ +export function damp(rate, dt) { + if (!(rate > 0)) return 1; + return 1 - Math.exp(-rate * Math.max(0, dt)); +} diff --git a/src/lib/motion/motionNodes.js b/src/lib/motion/motionNodes.js new file mode 100644 index 000000000..f5b4193b0 --- /dev/null +++ b/src/lib/motion/motionNodes.js @@ -0,0 +1,517 @@ +// @ts-ignore - no bundled three type declarations (project-wide) +import * as THREE from 'three'; +import { + buildPath, + samplePath, + pathDistance, + curvatureAt, + bankRoll, + wanderAt, + hash01, + waveDrive, + forwardYaw, + wrapAngle, + damp, + clampNum, + BODY_WAVE_DEFAULTS +} from './motionCore.js'; +import { normalizeSpline, isSplineObject } from '../splineTube.js'; +import { normalizeFlowPath } from '../sim/flowPathCore.js'; + +// 40 F15 — GENERAL-PURPOSE MOTION, the THREE half: what flowRuntime.applyAnimation calls for +// `followpath`, `orientvelocity`, `wander` and `bodywave`. A LEAF beside sim/motionNodes.js — it +// imports nothing of the runtime (the history cycle), only three, the pure core and two +// import-free path readers. The maths and the rules live in motionCore.js (unit-tested). +// +// The followers (Orient to Velocity, Body Wave) read the motion the movers wrote THIS frame, so +// flowRuntime applies them after every other effect on the object (MOTION_PHASE below) — the +// node order in a graph then never decides whether a fish faces where it swims. +// +// BODY WAVE draws through the vertex shader, never through the transform (the jiggle rule: the +// transform belongs to the editor, physics and the network). Each waving mesh gets its OWN +// material clone — three re-uploads material uniforms only when the material changes between +// draws, so two fish sharing one material could not wave differently — and the original goes +// back (with any edit made meanwhile) when the wave stops. Serializers need nothing: toJSON and +// GLTF never see onBeforeCompile, the clone keeps the original's uuid and parameters. + +/** the order effects apply in on one object: movers (0), then orienters (1), then deformers (2) */ +export const MOTION_PHASE = /** @type {Record} */ ({ orientvelocity: 1, bodywave: 2 }); + +const UP = new THREE.Vector3(0, 1, 0); +const vA = new THREE.Vector3(); +const vB = new THREE.Vector3(); +const vC = new THREE.Vector3(); +const qA = new THREE.Quaternion(); +const qB = new THREE.Quaternion(); +const mA = new THREE.Matrix4(); + +// ---- shared: what a frame of motion looked like --------------------------------------------- + +/** + * Per (node, object) motion tracking in WORLD space: velocity, speed and turn rate, smoothed so + * a single jittery frame does not snap a fish around. Keyed per node so two nodes reading one + * object do not share a clock. LOCAL per peer (every peer converges on the same movers). + * @type {Map} + */ +const tracks = new Map(); +let frameNo = 0; + +/** call once per runtime tick: forgets tracks nobody read for a while */ +export function tickMotionNodes() { + frameNo++; + if (frameNo % 120) return; + for (const [key, t] of tracks) if (frameNo - t.seen > 240) tracks.delete(key); + for (const [key, w] of waves) if (frameNo - w.seen > 30) detachWave(key); +} + +/** + * Advance the tracker for `key` with the object's world position at `time` and return it. + * @param {string} key @param {any} object @param {number} time + */ +function track(key, object, time) { + object.updateWorldMatrix(true, false); + const p = vA.setFromMatrixPosition(object.matrixWorld); + let t = tracks.get(key); + if (!t) { + t = { pos: p.clone(), t: time, vel: new THREE.Vector3(), speed: 0, yaw: NaN, yawRate: 0, quat: null, phase: 0, seen: frameNo }; + tracks.set(key, t); + return { t, dt: 0 }; + } + t.seen = frameNo; + const dt = time - t.t; + // a pause, a scrub or a reload: re-anchor rather than read a teleport as a speed + if (!(dt > 0) || dt > 0.5) { + t.pos.copy(p); + t.t = time; + return { t, dt: 0 }; + } + const k = damp(8, dt); + vB.copy(p).sub(t.pos).divideScalar(dt); + t.vel.lerp(vB, k); + t.speed = t.vel.length(); + const flat = Math.hypot(t.vel.x, t.vel.z); + if (flat > 0.02) { + const yaw = Math.atan2(t.vel.x, t.vel.z); + if (Number.isFinite(t.yaw)) t.yawRate += (wrapAngle(yaw - t.yaw) / dt - t.yawRate) * damp(4, dt); + t.yaw = yaw; + } else t.yawRate += (0 - t.yawRate) * damp(4, dt); + t.pos.copy(p); + t.t = time; + return { t, dt }; +} + +/** + * The world orientation that points a model's nose along `dir` with its top leaning `roll` + * radians into the turn (+ = leans left). `pitch` false keeps the nose level. + * @param {THREE.Vector3} dir world @param {number} roll @param {boolean} pitch @param {string} forward @param {THREE.Quaternion} out + */ +function headingQuat(dir, roll, pitch, forward, out) { + const z = vB.copy(dir); + if (!pitch) z.y = 0; + if (z.lengthSq() < 1e-10) return false; + z.normalize(); + // a straight-up/down heading has no horizontal: keep the basis finite + const x = vC.crossVectors(UP, z); + if (x.lengthSq() < 1e-8) x.set(1, 0, 0); + x.normalize(); + const y = vA.crossVectors(z, x); + mA.makeBasis(x, y, z); + out.setFromRotationMatrix(mA); + // lean into the turn about the nose (+roll = top to the LEFT, which is the +x side here) + if (roll) out.multiply(qB.setFromAxisAngle(vC.set(0, 0, 1), -roll)); + // the model's own nose axis + const fy = forwardYaw(forward); + if (fy) out.multiply(qB.setFromAxisAngle(UP, fy)); + return true; +} + +/** write a WORLD quaternion onto an object as its local one @param {any} object @param {THREE.Quaternion} world */ +function setWorldQuat(object, world) { + const parent = object.parent; + if (!parent) { + object.quaternion.copy(world); + return; + } + parent.updateWorldMatrix(true, false); + parent.getWorldQuaternion(qA); + object.quaternion.copy(qA.invert().multiply(world)); +} + +/** write a WORLD position onto an object as its local one @param {any} object @param {number[]} p */ +function setWorldPos(object, p) { + const parent = object.parent; + vA.set(p[0], p[1], p[2]); + if (parent) { + parent.updateWorldMatrix(true, false); + parent.worldToLocal(vA); + } + object.position.copy(vA); +} + +/** @param {any} v */ +const on = (v) => v !== false && v !== 0 && v !== 'false'; + +// ---- Follow Path --------------------------------------------------------------------------- + +/** @type {Map}>} */ +const pathCache = new Map(); + +/** + * The world-space control points (and whether they close) a Follow Path node rides: the wired + * Spline or Flow path object, else its own clicked waypoints (already world). + * @param {any} root @param {any} data + * @returns {{points: number[][], closed: boolean, smooth: boolean} | null} + */ +export function pathSourceOf(root, data) { + if (typeof data.path === 'string' && data.path && data.path !== '-None-') { + const o = root?.getObjectByProperty('uuid', data.path); + if (o) { + o.updateWorldMatrix(true, false); + if (isSplineObject(o)) { + const s = normalizeSpline(o.userData.spline); + return { + points: s.points.map((/** @type {any} */ p) => vA.fromArray(p.pos).applyMatrix4(o.matrixWorld).toArray()), + closed: !!s.closed, + smooth: true + }; + } + if (o.userData?.flowPath) { + const f = normalizeFlowPath(o.userData.flowPath); + return { + points: f.points.map((/** @type {number[]} */ p) => vA.fromArray(p).applyMatrix4(o.matrixWorld).toArray()), + closed: false, + smooth: on(data.smooth) + }; + } + } + } + const pts = Array.isArray(data.points) ? data.points : []; + if (pts.length < 2) return null; + return { points: pts, closed: on(data.closed ?? true), smooth: on(data.smooth) }; +} + +/** the built path for a source, cached by its numbers @param {string} key @param {{points: number[][], closed: boolean, smooth: boolean}} src */ +function pathFor(key, src) { + let sig = (src.closed ? 'c' : 'o') + (src.smooth ? 's' : 'p'); + for (const p of src.points) sig += '|' + (+p[0]).toFixed(4) + ',' + (+p[1]).toFixed(4) + ',' + (+p[2]).toFixed(4); + const hit = pathCache.get(key); + if (hit && hit.sig === sig) return hit.path; + const path = buildPath(src.points, { closed: src.closed, smooth: src.smooth }); + pathCache.set(key, { sig, path }); + return path; +} + +/** + * Follow Path: a pure function of (path, data, shared time) — every peer computes the same pose. + * @param {any} object @param {any} base @param {any} data @param {number} time @param {{root: any, key: string}} ctx + * @returns {{s: number, yawRate: number} | null} where it is (for tests and the readout) + */ +export function applyFollowPath(object, base, data, time, ctx) { + const src = pathSourceOf(ctx.root, data); + if (!src) return null; + const path = pathFor(ctx.key, src); + if (!path) return null; + const speed = clampNum(data.speed, 0.5, -20, 20); + const mode = data.mode === 'pingpong' || data.mode === 'once' ? data.mode : 'loop'; + const { s, dir } = pathDistance(time, speed, path.total, mode, clampNum(data.offset, 0, 0, 1)); + const here = samplePath(path, s); + setWorldPos(object, here.p); + const kappa = curvatureAt(path, s); + const moving = mode !== 'once' || (s > 0 && s < path.total); + const yawRate = moving ? kappa * Math.abs(speed) * dir : 0; + if (on(data.align ?? true)) { + const travel = vC.set(here.t[0] * dir, here.t[1] * dir, here.t[2] * dir); + const roll = bankRoll(yawRate, clampNum(data.bank, 0.5, 0, 1), clampNum(data.maxBank, 30, 0, 85)); + const q = new THREE.Quaternion(); + if (headingQuat(travel.clone(), roll, on(data.pitch ?? true), data.forward ?? '+z', q)) setWorldQuat(object, q); + } + return { s, yawRate }; +} + +// ---- Wander --------------------------------------------------------------------------------- + +const box = new THREE.Box3(); + +/** + * The box a wanderer stays in, in WORLD space: the wired area object's bounds shrunk by + * `margin`, else a box of half-extents (rx, ry, rz) around where it was placed. + * @param {any} object @param {any} base @param {any} data @param {any} root + * @returns {{center: [number, number, number], half: [number, number, number]}} + */ +export function wanderBox(object, base, data, root) { + if (typeof data.area === 'string' && data.area && data.area !== '-None-') { + const o = root?.getObjectByProperty('uuid', data.area); + if (o) { + o.updateWorldMatrix(true, true); + box.setFromObject(o); + if (!box.isEmpty()) { + const m = clampNum(data.margin, 0.2, 0, 50); + const c = box.getCenter(vA); + const sz = box.getSize(vB); + return { + center: [c.x, c.y, c.z], + half: [Math.max(0, sz.x / 2 - m), Math.max(0, sz.y / 2 - m), Math.max(0, sz.z / 2 - m)] + }; + } + } + } + const parent = object.parent; + vA.fromArray(base.pos); + if (parent) { + parent.updateWorldMatrix(true, false); + vA.applyMatrix4(parent.matrixWorld); + } + return { + center: [vA.x, vA.y, vA.z], + half: [clampNum(data.rx, 1, 0, 50), clampNum(data.ry, 0.3, 0, 50), clampNum(data.rz, 1, 0, 50)] + }; +} + +/** + * Wander: drift around a smooth, seeded noise target inside a box. Pure of (box, data, time). + * `seed` 0 = derive from the object's uuid, so one Wander node driving several objects (or a + * prefab duplicated five times) never moves them in step. + * @param {any} object @param {any} base @param {any} data @param {number} time @param {{root: any}} ctx + */ +export function applyWander(object, base, data, time, ctx) { + const { center, half } = wanderBox(object, base, data, ctx.root); + const seed = clampNum(data.seed, 0, 0, 100000); + const seed01 = seed ? hash01('seed:' + seed) : hash01(object.uuid); + const { p, v } = wanderAt(time, center, half, clampNum(data.speed, 0.3, 0, 5), seed01); + setWorldPos(object, p); + return { p, v }; +} + +// ---- Orient to Velocity --------------------------------------------------------------------- + +/** + * Orient to Velocity: face the direction the object moved this frame, turning at most + * `turnSpeed` (1/s, exponential) and leaning into turns. Holds its last heading while still. + * @param {any} object @param {any} base @param {any} data @param {number} time @param {{key: string}} ctx + */ +export function applyOrientVelocity(object, base, data, time, ctx) { + const { t, dt } = track(ctx.key, object, time); + const minSpeed = clampNum(data.minSpeed, 0.02, 0, 10); + const pitch = on(data.pitch ?? true); + if (t.speed > minSpeed) { + const roll = bankRoll(t.yawRate, clampNum(data.bank, 0.5, 0, 1), clampNum(data.maxBank, 30, 0, 85)); + const want = new THREE.Quaternion(); + if (headingQuat(t.vel.clone(), roll, pitch, data.forward ?? '+z', want)) { + if (!t.quat) t.quat = want.clone(); + else t.quat.slerp(want, dt > 0 ? damp(clampNum(data.turnSpeed, 4, 0.1, 60), dt) : 1); + } + } + // the base restore put the authored rotation back: re-assert the held heading every frame + if (t.quat) setWorldQuat(object, t.quat); + return t; +} + +// ---- Body Wave ------------------------------------------------------------------------------ + +const WAVE_DECL = /* glsl */ ` +uniform mat4 bwToRoot; +uniform mat3 bwFromRoot; +uniform vec3 bwAxis; +uniform vec3 bwSide; +uniform vec2 bwRange; +uniform vec4 bwShape; +uniform vec3 bwDrive; +`; +// u: 0 at the nose, 1 at the tail; the envelope and the travelling sine are motionCore's +// waveOffset verbatim (k = 1/wavelength in bwShape.x, dir in bwShape.w, drive in body lengths +// already multiplied by the length on the CPU) +const WAVE_VERTEX = /* glsl */ ` +#include +{ + vec3 bwR = (bwToRoot * vec4(transformed, 1.0)).xyz; + float bwU = clamp((bwRange.x - dot(bwR, bwAxis)) / max(bwRange.y, 1e-4), 0.0, 1.0); + float bwX = clamp((bwU - bwShape.y) / max(1.0 - bwShape.y, 1e-3), 0.0, 1.0); + float bwEnv = pow(bwX, bwShape.z); + float bwOff = bwDrive.x * bwEnv * sin(6.2831853 * (bwU * bwShape.x - bwShape.w * bwDrive.z)) + bwDrive.y * bwU * bwU; + transformed += bwFromRoot * (bwSide * bwOff); +} +`; + +/** @typedef {{mesh: any, original: any, clone: any, uniforms: any}} WaveMesh */ +/** @type {Map} */ +const waves = new Map(); + +/** the side (bend) axis for a nose axis: horizontal = up × nose (a fish), vertical = up (a whale) @param {THREE.Vector3} axis @param {string} side */ +function sideAxis(axis, side) { + if (side === 'vertical') { + const v = new THREE.Vector3(0, 1, 0); + return Math.abs(axis.y) > 0.9 ? v.set(1, 0, 0) : v; + } + const v = new THREE.Vector3().crossVectors(UP, axis); + return v.lengthSq() < 1e-8 ? v.set(1, 0, 0) : v.normalize(); +} + +/** meshes under an object (its own subtree), the jiggle traversal @param {any} object */ +function meshesOf(object) { + /** @type {any[]} */ + const out = []; + object.traverse((/** @type {any} */ o) => { + if (o.isMesh && o.geometry && o.material && !Array.isArray(o.material) && o.visible !== false) out.push(o); + }); + return out; +} + +/** @param {any} mesh @param {any} object */ +function attachWaveMesh(mesh, object) { + const original = mesh.material; + const clone = original.clone(); + // the SAME uuid: a save taken mid-wave serializes a material identical to the original + clone.uuid = original.uuid; + const uniforms = { + bwToRoot: { value: new THREE.Matrix4() }, + bwFromRoot: { value: new THREE.Matrix3() }, + bwAxis: { value: new THREE.Vector3(0, 0, 1) }, + bwSide: { value: new THREE.Vector3(1, 0, 0) }, + bwRange: { value: new THREE.Vector2(0, 1) }, + bwShape: { value: new THREE.Vector4(1, 0.3, 2, 1) }, + bwDrive: { value: new THREE.Vector3() } + }; + const previous = original.onBeforeCompile; + clone.onBeforeCompile = (/** @type {any} */ shader, /** @type {any} */ renderer) => { + if (typeof previous === 'function' && previous !== THREE.Material.prototype.onBeforeCompile) previous(shader, renderer); + Object.assign(shader.uniforms, uniforms); + shader.vertexShader = WAVE_DECL + shader.vertexShader.replace('#include ', WAVE_VERTEX); + }; + const baseKey = original.customProgramCacheKey?.() ?? ''; + clone.customProgramCacheKey = () => baseKey + '|bodywave1'; + mesh.material = clone; + return { mesh, original, clone, uniforms }; +} + +/** @param {WaveMesh} m */ +function detachWaveMesh(m) { + if (m.mesh.material !== m.clone) return; // something else replaced it since; leave it + const { onBeforeCompile, customProgramCacheKey, uuid } = m.original; + m.original.copy(m.clone); + m.original.uuid = uuid; + m.original.onBeforeCompile = onBeforeCompile; + m.original.customProgramCacheKey = customProgramCacheKey; + m.original.userData = { ...(m.clone.userData ?? {}) }; + m.original.needsUpdate = true; + m.mesh.material = m.original; + m.clone.dispose(); +} + +/** @param {string} key */ +function detachWave(key) { + const w = waves.get(key); + if (!w) return; + for (const m of w.meshes) detachWaveMesh(m); + waves.delete(key); +} + +/** + * (Re)build a wave's mesh list and body frame when the subtree or the axes changed — a pack + * model arriving in place of its fallback, a LOD swap, a forward-axis edit. + * @param {string} key @param {any} object @param {any} data + */ +function waveFor(key, object, data) { + const meshes = meshesOf(object); + const forward = data.forward ?? BODY_WAVE_DEFAULTS.forward; + const side = data.side === 'vertical' ? 'vertical' : 'horizontal'; + const sig = forward + side + meshes.map((m) => m.uuid + ':' + m.geometry.uuid).join(','); + let w = waves.get(key); + if (w && w.sig === sig && w.object === object) { + // a material swapped under us (an Inspector edit through applyMaterials): re-wrap + for (let i = 0; i < w.meshes.length; i++) { + const m = w.meshes[i]; + if (m.mesh.material !== m.clone && m.mesh.material && !Array.isArray(m.mesh.material)) w.meshes[i] = attachWaveMesh(m.mesh, object); + } + return w; + } + if (w) detachWave(key); + // the body frame: the nose axis in the OBJECT's local frame, the extent along it over every mesh + const axis = new THREE.Vector3(0, 0, 1).applyAxisAngle(UP, -forwardYaw(forward)).normalize(); + const sideV = sideAxis(axis, side); + object.updateWorldMatrix(true, true); + const inv = new THREE.Matrix4().copy(object.matrixWorld).invert(); + let lo = Infinity; + let hi = -Infinity; + for (const m of meshes) { + if (!m.geometry.boundingBox) m.geometry.computeBoundingBox(); + const bb = m.geometry.boundingBox; + mA.copy(inv).multiply(m.matrixWorld); + for (let i = 0; i < 8; i++) { + vA.set(i & 1 ? bb.max.x : bb.min.x, i & 2 ? bb.max.y : bb.min.y, i & 4 ? bb.max.z : bb.min.z).applyMatrix4(mA); + const d = vA.dot(axis); + lo = Math.min(lo, d); + hi = Math.max(hi, d); + } + } + if (!Number.isFinite(lo)) return null; + w = { + object, + meshes: meshes.map((m) => attachWaveMesh(m, object)), + count: meshes.length, + front: hi, + len: Math.max(1e-3, hi - lo), + axis, + side: sideV, + sig, + seen: frameNo + }; + waves.set(key, w); + return w; +} + +/** + * Body Wave: a travelling bend along the body, driven by how fast and how hard the object turns. + * @param {any} object @param {any} base @param {any} data @param {number} time @param {{key: string}} ctx + */ +export function applyBodyWave(object, base, data, time, ctx) { + const w = waveFor(ctx.key, object, data); + if (!w) return null; + w.seen = frameNo; + const { t, dt } = track(ctx.key, object, time); + const drive = waveDrive(data, t.speed, t.yawRate); + if (dt > 0) t.phase = (t.phase + drive.frequency * dt) % 1000; + const d = BODY_WAVE_DEFAULTS; + const k = 1 / Math.max(0.05, clampNum(data.wavelength, d.wavelength, 0.05, 8)); + const stiff = clampNum(data.stiffness, d.stiffness, 0, 0.95); + const fall = clampNum(data.falloff, d.falloff, 0.25, 6); + const dir = data.reverse ? -1 : 1; + object.updateWorldMatrix(true, true); + const inv = mA.copy(object.matrixWorld).invert(); + for (const m of w.meshes) { + const u = m.uniforms; + u.bwToRoot.value.copy(inv).multiply(m.mesh.matrixWorld); + u.bwFromRoot.value.setFromMatrix4(u.bwToRoot.value).invert(); + u.bwAxis.value.copy(w.axis); + u.bwSide.value.copy(w.side); + u.bwRange.value.set(w.front, w.len); + u.bwShape.value.set(k, stiff, fall, dir); + u.bwDrive.value.set(drive.amplitude * w.len, drive.bend * w.len, t.phase); + } + return { drive, phase: t.phase, len: w.len, meshes: w.meshes.length }; +} + +/** the wave nodes no longer driving an object stand down (flowRuntime's restore loop) @param {Set} liveKeys */ +export function pruneBodyWaves(liveKeys) { + for (const key of [...waves.keys()]) if (!liveKeys.has(key)) detachWave(key); +} + +/** put every original material back (a serializer's park, a scene clear) */ +export function resetMotionNodes() { + for (const key of [...waves.keys()]) detachWave(key); + tracks.clear(); + pathCache.clear(); +} + +/** debug: what the motion runtime holds */ +export function motionNodesDebug() { + return { + tracks: tracks.size, + paths: pathCache.size, + waves: [...waves.entries()].map(([key, w]) => ({ + key, + meshes: w.meshes.length, + cloned: w.meshes.every((m) => m.mesh.material === m.clone), + len: w.len + })) + }; +} diff --git a/src/lib/nodeCatalog.js b/src/lib/nodeCatalog.js index 60a4f3f22..68633acde 100644 --- a/src/lib/nodeCatalog.js +++ b/src/lib/nodeCatalog.js @@ -968,6 +968,98 @@ export const nodeCatalog = [ label: 'Path patrol', defaults: { points: [], speed: 1, mode: 'loop' } }, + // 40 F15: GENERAL-PURPOSE motion (motion/motionNodes.js). Movers first (Follow Path, + // Wander are pure functions of the shared clock), then the followers that read the + // motion they produce (Orient to Velocity turns, Body Wave bends) — flowRuntime + // applies them in that order whatever order the graph lists them in. + { + type: 'followpath', + label: 'Follow Path', + defaults: { + points: [], + speed: 0.5, + mode: 'loop', + closed: true, + smooth: true, + offset: 0, + align: true, + pitch: true, + bank: 0.5, + maxBank: 30, + forward: '+z' + }, + inputs: ['path'], + inputLabels: { path: 'path — a Spline or Flow path' }, + params: [ + { key: 'speed', kind: 'range', min: -5, max: 5, step: 0.05 }, + { key: 'offset', kind: 'range', min: 0, max: 1, step: 0.01 }, + { key: 'mode', kind: 'select', options: ['loop', 'pingpong', 'once'] }, + { key: 'align', kind: 'toggle' }, + { key: 'pitch', kind: 'toggle' }, + { key: 'bank', kind: 'range', min: 0, max: 1, step: 0.05 }, + { key: 'maxBank', kind: 'range', min: 0, max: 80, step: 1 }, + { key: 'forward', kind: 'select', options: ['+z', '-z', '+x', '-x'] } + ] + }, + { + type: 'wander', + label: 'Wander', + defaults: { speed: 0.3, seed: 0, margin: 0.2, rx: 1, ry: 0.3, rz: 1 }, + inputs: ['area'], + inputLabels: { area: 'area — stay inside this object' }, + params: [ + { key: 'speed', kind: 'range', min: 0, max: 3, step: 0.05 }, + { key: 'seed', kind: 'range', min: 0, max: 999, step: 1 }, + { key: 'margin', kind: 'range', min: 0, max: 2, step: 0.05 }, + { key: 'rx', kind: 'range', min: 0, max: 10, step: 0.1 }, + { key: 'ry', kind: 'range', min: 0, max: 10, step: 0.1 }, + { key: 'rz', kind: 'range', min: 0, max: 10, step: 0.1 } + ] + }, + { + type: 'orientvelocity', + label: 'Orient to Velocity', + defaults: { turnSpeed: 4, bank: 0.5, maxBank: 30, pitch: true, minSpeed: 0.02, forward: '+z' }, + params: [ + { key: 'turnSpeed', kind: 'range', min: 0.1, max: 30, step: 0.1 }, + { key: 'bank', kind: 'range', min: 0, max: 1, step: 0.05 }, + { key: 'maxBank', kind: 'range', min: 0, max: 80, step: 1 }, + { key: 'minSpeed', kind: 'range', min: 0, max: 2, step: 0.01 }, + { key: 'pitch', kind: 'toggle' }, + { key: 'forward', kind: 'select', options: ['+z', '-z', '+x', '-x'] } + ] + }, + { + type: 'bodywave', + label: 'Body Wave', + defaults: { + forward: '+z', + side: 'horizontal', + amplitude: 0.08, + wavelength: 1, + frequency: 1.5, + stiffness: 0.3, + falloff: 2, + speedGain: 1, + ampGain: 0.5, + turnBend: 0.5, + reverse: false + }, + params: [ + { key: 'amplitude', kind: 'range', min: 0, max: 0.5, step: 0.01 }, + { key: 'wavelength', kind: 'range', min: 0.2, max: 4, step: 0.05 }, + { key: 'frequency', kind: 'range', min: 0, max: 10, step: 0.1 }, + { key: 'stiffness', kind: 'range', min: 0, max: 0.95, step: 0.05 }, + { key: 'falloff', kind: 'range', min: 0.25, max: 6, step: 0.25 }, + { key: 'speedGain', kind: 'range', min: 0, max: 10, step: 0.1 }, + { key: 'ampGain', kind: 'range', min: 0, max: 5, step: 0.1 }, + { key: 'turnBend', kind: 'range', min: 0, max: 2, step: 0.05 }, + { key: 'forward', kind: 'select', options: ['+z', '-z', '+x', '-x'] }, + { key: 'side', kind: 'select', options: ['horizontal', 'vertical'] }, + { key: 'reverse', kind: 'toggle' } + ], + note: 'Bends the look only (a vertex shader): physics and picking see the rest pose.' + }, { // 17-E: the other half of Play Animation — pulses when a clip FINISHES, // so a movement can hand off to whatever comes next (a door that has @@ -1243,6 +1335,7 @@ export const nodeCatalog = [ export const animationTypes = [ 'shake', 'spin', 'bounce', 'orbit', 'pulse', 'blink', 'pathpatrol', 'rotor', 'flowfloat', // 36-fb F25/F24 (sim/motionNodes.js) + 'followpath', 'wander', 'orientvelocity', 'bodywave', // 40 F15 (motion/motionNodes.js) 'lookat', 'setcolor', 'visibility', 'setuniform', 'camerarig', // 37 (R8) 'deviceparam', 'notetrigger' // 23-B3 diff --git a/src/lib/nodeDocs.js b/src/lib/nodeDocs.js index a0902e1fa..684463f26 100644 --- a/src/lib/nodeDocs.js +++ b/src/lib/nodeDocs.js @@ -104,6 +104,10 @@ export const NODE_DOCS = { bounce: "Bounces the connected object up and down off its resting height, like a dribbled ball.", orbit: "Circles the connected object around its resting position on the horizontal plane.", pathpatrol: "Walks the connected object along a series of waypoints you click into the scene, facing along the path.", + followpath: "Moves the connected object along a smooth path - a drawn Spline, a Flow path or its own waypoints - at a speed, looping, ping-ponging or once, facing along it and leaning into the turns. An offset spreads several riders out along one path.", + wander: "Drifts the connected object around a smooth, never-repeating target inside an area object (a water volume, a room) or a box around where it was placed - fish, birds, fireflies, idle drones.", + orientvelocity: "Turns the connected object to face the way it is moving, smoothly, leaning into turns - put it after Wander, Orbit or any mover so it looks where it goes.", + bodywave: "Bends the connected object with a wave travelling along its body - a swimming fish, a snake, a flag, a tail - stronger and faster the faster it moves, curving into turns. A shader effect: physics and picking keep the rest pose.", animfinished: "Fires a pulse when the connected object's animation clip reaches its end.", animmarker: "Fires a pulse when the connected object's animation passes a named marker.", animstate: "Reads the connected object's animation transport as a number - playing, position or progress.", diff --git a/tests/unit/motionCore.test.js b/tests/unit/motionCore.test.js new file mode 100644 index 000000000..c4547af8e --- /dev/null +++ b/tests/unit/motionCore.test.js @@ -0,0 +1,324 @@ +// @ts-nocheck — plain fixtures; the code under test is typed +// 40 F15: the general-purpose motion nodes — the pure core (paths, distance modes, banking, the +// wander noise, the body-wave shape) and the THREE half (Follow Path, Wander, Orient to Velocity, +// Body Wave) driven on real three objects with no renderer. +import { describe, it, expect, beforeEach } from 'vitest'; +import * as THREE from 'three'; +import { + buildPath, + samplePath, + pathDistance, + curvatureAt, + bankRoll, + smoothNoise, + smoothNoiseRate, + wanderAt, + hash01, + waveEnvelope, + waveDrive, + waveOffset, + forwardYaw, + catmullRom, + cleanPoints +} from '../../src/lib/motion/motionCore.js'; +import { + applyFollowPath, + applyWander, + applyOrientVelocity, + applyBodyWave, + pruneBodyWaves, + resetMotionNodes, + motionNodesDebug, + MOTION_PHASE +} from '../../src/lib/motion/motionNodes.js'; + +const base = (o) => ({ pos: o.position.toArray(), rot: [o.rotation.x, o.rotation.y, o.rotation.z], scale: o.scale.toArray() }); +/** a square 4 × 4 loop at y = 1 */ +const SQUARE = [ + [0, 1, 0], + [4, 1, 0], + [4, 1, 4], + [0, 1, 4] +]; +/** a circle of radius r (counter-clockwise seen from above = turning LEFT for a +z nose) */ +const circle = (r, n = 16, y = 0) => + Array.from({ length: n }, (_, i) => { + const a = (i / n) * Math.PI * 2; + return [Math.sin(a) * r, y, Math.cos(a) * r]; + }); + +describe('paths', () => { + it('cleans points: drops non-finite and doubled clicks, accepts spline {pos}', () => { + expect(cleanPoints([[0, 0, 0], [0, 0, 0], [1, NaN, 0], { pos: [1, 2, 3] }, 'x'])).toEqual([ + [0, 0, 0], + [1, 2, 3] + ]); + }); + it('a Catmull-Rom span passes through its two inner points', () => { + const p = catmullRom([0, 0, 0], [1, 0, 0], [2, 1, 0], [3, 1, 0], 0); + expect(p[0]).toBeCloseTo(1, 6); + const q = catmullRom([0, 0, 0], [1, 0, 0], [2, 1, 0], [3, 1, 0], 1); + expect(q[0]).toBeCloseTo(2, 6); + expect(q[1]).toBeCloseTo(1, 6); + }); + it('a polyline loop has the perimeter as its length, and closes on its start', () => { + const path = buildPath(SQUARE, { closed: true, smooth: false }); + expect(path.total).toBeCloseTo(16, 6); + expect(samplePath(path, 16).p).toEqual([0, 1, 0]); + expect(samplePath(path, 2).p).toEqual([2, 1, 0]); + expect(samplePath(path, 2).t).toEqual([1, 0, 0]); + }); + it('a smooth loop passes through every control point and rounds the corners (between the square and its circumcircle)', () => { + // an INTERPOLATING curve through a square's corners bulges outward between them + const path = buildPath(SQUARE, { closed: true }); + expect(path.total).toBeGreaterThan(16); + expect(path.total).toBeLessThan(2 * Math.PI * 2 * Math.SQRT2); + for (const c of SQUARE) { + const hit = path.pts.some((p) => Math.hypot(p[0] - c[0], p[1] - c[1], p[2] - c[2]) < 1e-9); + expect(hit).toBe(true); + } + }); + it('refuses a path with fewer than two usable points', () => { + expect(buildPath([[0, 0, 0]])).toBeNull(); + expect(buildPath([])).toBeNull(); + }); + it('distance: loop wraps, pingpong reflects with a direction, once clamps; offset spreads riders', () => { + expect(pathDistance(3, 2, 10, 'loop').s).toBeCloseTo(6, 9); + expect(pathDistance(7, 2, 10, 'loop').s).toBeCloseTo(4, 9); + const back = pathDistance(7, 2, 10, 'pingpong'); + expect(back.s).toBeCloseTo(6, 9); + expect(back.dir).toBe(-1); + expect(pathDistance(2, 2, 10, 'pingpong').dir).toBe(1); + expect(pathDistance(100, 2, 10, 'once').s).toBe(10); + expect(pathDistance(0, 2, 10, 'loop', 0.25).s).toBeCloseTo(2.5, 9); + expect(pathDistance(1, -2, 10, 'loop').s).toBeCloseTo(8, 9); // backwards wraps the other way + }); + it('curvature: a left (counter-clockwise from above, +z nose) circle reads +1/r', () => { + // increasing angle a moves the point along (cos a, 0, -sin a): x grows, z falls — clockwise + // from above when viewed with +x right and +z down, i.e. the nose turns toward -x… measure it + const path = buildPath(circle(2, 32), { closed: true }); + const k = curvatureAt(path, path.total / 3); + expect(Math.abs(Math.abs(k) - 0.5)).toBeLessThan(0.03); + const rev = buildPath(circle(2, 32).reverse(), { closed: true }); + expect(Math.sign(curvatureAt(rev, rev.total / 3))).toBe(-Math.sign(k)); + }); + it('bank: zero on a straight, bounded by the ceiling, signed with the turn', () => { + expect(bankRoll(0, 1, 45)).toBe(0); + expect(bankRoll(100, 1, 45)).toBeCloseTo(Math.PI / 4, 6); + expect(bankRoll(-100, 1, 45)).toBeCloseTo(-Math.PI / 4, 6); + expect(bankRoll(1, 0, 45)).toBe(0); + expect(bankRoll(1, 0.5, 40)).toBeCloseTo(0.5 * ((40 * Math.PI) / 180) * Math.tanh(1), 9); + }); +}); + +describe('wander noise', () => { + it('stays inside [-1, 1] and inside its box over a long run', () => { + const seed = hash01('fish'); + let lo = Infinity; + let hi = -Infinity; + for (let t = 0; t < 600; t += 0.37) { + const v = smoothNoise(t, seed, 1); + lo = Math.min(lo, v); + hi = Math.max(hi, v); + const { p } = wanderAt(t, [1, 2, 3], [0.5, 0.25, 2], 1, seed); + expect(Math.abs(p[0] - 1)).toBeLessThanOrEqual(0.5 + 1e-9); + expect(Math.abs(p[1] - 2)).toBeLessThanOrEqual(0.25 + 1e-9); + expect(Math.abs(p[2] - 3)).toBeLessThanOrEqual(2 + 1e-9); + } + expect(lo).toBeGreaterThanOrEqual(-1); + expect(hi).toBeLessThanOrEqual(1); + // and it actually uses the box (not stuck near the centre) + expect(hi - lo).toBeGreaterThan(1); + }); + it('is deterministic per seed and differs between seeds', () => { + expect(smoothNoise(12.3, 0.4, 0)).toBe(smoothNoise(12.3, 0.4, 0)); + expect(smoothNoise(12.3, 0.4, 0)).not.toBeCloseTo(smoothNoise(12.3, 0.41, 0), 3); + expect(hash01('a')).not.toBe(hash01('b')); + }); + it('the analytic rate matches a finite difference (smooth, no jumps)', () => { + for (const t of [0.5, 7.1, 40]) { + const h = 1e-4; + const fd = (smoothNoise(t + h, 0.3, 2) - smoothNoise(t - h, 0.3, 2)) / (2 * h); + expect(smoothNoiseRate(t, 0.3, 2)).toBeCloseTo(fd, 4); + } + }); +}); + +describe('body wave shape', () => { + it('the head is still, the tail swings most', () => { + expect(waveEnvelope(0, 0.3, 2)).toBe(0); + expect(waveEnvelope(0.3, 0.3, 2)).toBe(0); + expect(waveEnvelope(1, 0.3, 2)).toBe(1); + expect(waveEnvelope(0.65, 0.3, 2)).toBeCloseTo(0.25, 9); + }); + it('faster swimming beats faster and wider; a turn bends the body', () => { + const rest = waveDrive({}, 0, 0); + const fast = waveDrive({}, 1, 0); + expect(fast.frequency).toBeGreaterThan(rest.frequency); + expect(fast.amplitude).toBeGreaterThan(rest.amplitude); + expect(rest.bend).toBe(0); + expect(waveDrive({}, 0.5, 1).bend).toBeGreaterThan(0); + expect(waveDrive({}, 0.5, -1).bend).toBeLessThan(0); + // clamped: a wild speed does not tear the mesh apart + expect(waveDrive({ amplitude: 0.5, ampGain: 5 }, 10, 0).amplitude).toBeLessThanOrEqual(0.6); + }); + it('the crest TRAVELS head → tail as the phase grows (and the other way when reversed)', () => { + // with the envelope divided out the pattern is a pure sine of (u k - dir·phase): a shift of + // the phase by Δ is the same picture shifted Δ/k toward the TAIL (larger u) + const shape = { wavelength: 1, stiffness: 0, falloff: 1 }; + const drive = { amplitude: 1, bend: 0 }; + const raw = (u, p, s) => waveOffset(u, p, drive, s) / waveEnvelope(u, s.stiffness, s.falloff); + for (const u of [0.3, 0.45, 0.6]) { + expect(raw(u + 0.1, 0.1, shape)).toBeCloseTo(raw(u, 0, shape), 9); + const rev = { ...shape, reverse: true }; + expect(raw(u - 0.1, 0.1, rev)).toBeCloseTo(raw(u, 0, rev), 9); + } + }); + it('forward axis names map to the yaw that brings that nose to +z', () => { + for (const [name, n] of [ + ['+z', [0, 0, 1]], + ['-z', [0, 0, -1]], + ['+x', [1, 0, 0]], + ['-x', [-1, 0, 0]] + ]) { + const v = new THREE.Vector3(...n).applyAxisAngle(new THREE.Vector3(0, 1, 0), forwardYaw(name)); + expect(v.z).toBeCloseTo(1, 9); + } + }); +}); + +describe('the nodes on real objects', () => { + beforeEach(() => resetMotionNodes()); + + it('Follow Path is a pure function of time: two copies agree; the nose points along the path', () => { + const root = new THREE.Group(); + const a = new THREE.Object3D(); + const b = new THREE.Object3D(); + root.add(a, b); + const data = { points: SQUARE, speed: 1, smooth: false, closed: true, bank: 0 }; + applyFollowPath(a, base(a), data, 1.5, { root, key: 'a' }); + applyFollowPath(b, base(b), data, 1.5, { root, key: 'b' }); + expect(a.position.toArray()).toEqual(b.position.toArray()); + expect(a.position.x).toBeCloseTo(1.5, 9); + const nose = new THREE.Vector3(0, 0, 1).applyQuaternion(a.quaternion); + expect(nose.x).toBeCloseTo(1, 6); + }); + it('Follow Path rides a Spline object in WORLD space (the spline moved = the path moved)', () => { + const root = new THREE.Group(); + const spline = new THREE.Mesh(new THREE.BufferGeometry()); + spline.userData.spline = { points: SQUARE.map((p) => ({ pos: p, radius: 0.1 })), closed: true }; + spline.position.set(10, 0, 0); + const fish = new THREE.Object3D(); + root.add(spline, fish); + root.updateMatrixWorld(true); + applyFollowPath(fish, base(fish), { path: spline.uuid, speed: 0 }, 0, { root, key: 'k' }); + expect(fish.position.x).toBeCloseTo(10, 6); + expect(fish.position.y).toBeCloseTo(1, 6); + }); + it('Follow Path banks INTO a left turn (top leans left) and the forward axis turns the model', () => { + const root = new THREE.Group(); + const o = new THREE.Object3D(); + root.add(o); + const pts = circle(1.5, 24); + const out = applyFollowPath(o, base(o), { points: pts, speed: 1, bank: 1, maxBank: 45 }, 2, { root, key: 'k' }); + expect(Math.abs(out.yawRate)).toBeGreaterThan(0.3); + const up = new THREE.Vector3(0, 1, 0).applyQuaternion(o.quaternion); + const left = new THREE.Vector3(1, 0, 0).applyQuaternion(o.quaternion); // +x is the model's left + // leaning into the turn: the top tips toward the side the turn goes + expect(Math.sign(up.dot(new THREE.Vector3(left.x, 0, left.z)))).toBe(Math.sign(out.yawRate)); + // a +x-nosed model: the same pose, its +x now points where +z pointed + const o2 = new THREE.Object3D(); + root.add(o2); + applyFollowPath(o2, base(o2), { points: pts, speed: 1, bank: 1, maxBank: 45, forward: '+x' }, 2, { root, key: 'k2' }); + const nose1 = new THREE.Vector3(0, 0, 1).applyQuaternion(o.quaternion); + const nose2 = new THREE.Vector3(1, 0, 0).applyQuaternion(o2.quaternion); + expect(nose1.distanceTo(nose2)).toBeLessThan(1e-6); + }); + it('Wander stays inside its area object (shrunk by the margin) and differs per object', () => { + const root = new THREE.Group(); + const tank = new THREE.Mesh(new THREE.BoxGeometry(4, 2, 1.6)); + tank.position.set(0, 1.9, 0); + const a = new THREE.Object3D(); + const b = new THREE.Object3D(); + root.add(tank, a, b); + root.updateMatrixWorld(true); + for (let t = 0; t < 120; t += 0.5) { + applyWander(a, base(a), { area: tank.uuid, margin: 0.2, speed: 1 }, t, { root }); + expect(Math.abs(a.position.x)).toBeLessThanOrEqual(1.8 + 1e-6); + expect(Math.abs(a.position.y - 1.9)).toBeLessThanOrEqual(0.8 + 1e-6); + expect(Math.abs(a.position.z)).toBeLessThanOrEqual(0.6 + 1e-6); + } + applyWander(a, base(a), { area: tank.uuid, speed: 1 }, 7, { root }); + applyWander(b, base(b), { area: tank.uuid, speed: 1 }, 7, { root }); + expect(a.position.distanceTo(b.position)).toBeGreaterThan(1e-3); + }); + it('Orient to Velocity turns toward the motion, smoothly, and holds while still', () => { + const o = new THREE.Object3D(); + const root = new THREE.Group(); + root.add(o); + // move along +x for a second at 60 fps + let t = 0; + for (let i = 0; i < 60; i++) { + t += 1 / 60; + o.position.set(t, 0, 0); + o.rotation.set(0, 0, 0); // the base restore + applyOrientVelocity(o, base(o), { turnSpeed: 6, bank: 0 }, t, { key: 'o' }); + } + const nose = new THREE.Vector3(0, 0, 1).applyQuaternion(o.quaternion); + expect(nose.x).toBeGreaterThan(0.95); + // stop: the heading holds through the base restores + for (let i = 0; i < 10; i++) { + t += 1 / 60; + o.rotation.set(0, 0, 0); + applyOrientVelocity(o, base(o), { turnSpeed: 6 }, t, { key: 'o' }); + } + expect(new THREE.Vector3(0, 0, 1).applyQuaternion(o.quaternion).x).toBeGreaterThan(0.95); + }); + it('Body Wave clones each mesh material (same uuid, same look), drives it, and puts it back', () => { + const fish = new THREE.Group(); + const mat = new THREE.MeshStandardMaterial({ color: 0xff8800 }); + const body = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.4), mat); + const tail = new THREE.Mesh(new THREE.ConeGeometry(0.05, 0.1), mat); + tail.position.z = -0.25; + fish.add(body, tail); + const out = applyBodyWave(fish, base(fish), {}, 0, { key: 'w' }); + expect(out.meshes).toBe(2); + expect(body.material).not.toBe(mat); + expect(tail.material).not.toBe(body.material); // per-mesh uniforms need per-mesh clones + expect(body.material.uuid).toBe(mat.uuid); + expect(body.material.color.getHex()).toBe(0xff8800); + // the body length covers both meshes (0.2 nose .. -0.3 tail tip) + expect(out.len).toBeCloseTo(0.5, 6); + // the shader hook patches begin_vertex + const shader = { uniforms: {}, vertexShader: '#include \n', fragmentShader: '' }; + body.material.onBeforeCompile(shader, null); + expect(shader.vertexShader).toContain('bwDrive'); + expect(shader.uniforms.bwDrive).toBeTruthy(); + expect(body.material.customProgramCacheKey()).toContain('bodywave'); + // motion drives the phase + for (let i = 1; i <= 30; i++) { + fish.position.z = i * 0.02; + applyBodyWave(fish, base(fish), {}, i / 30, { key: 'w' }); + } + expect(shader.uniforms.bwDrive.value.z).toBeGreaterThan(0); + expect(motionNodesDebug().waves[0].cloned).toBe(true); + pruneBodyWaves(new Set()); + expect(body.material).toBe(mat); + expect(tail.material).toBe(mat); + expect(motionNodesDebug().waves.length).toBe(0); + }); + it('a counterfactual: without the per-mesh clone the two meshes would share uniforms', () => { + // the reason for cloning: three uploads a material's uniforms once per material switch + const fish = new THREE.Group(); + const mat = new THREE.MeshStandardMaterial(); + fish.add(new THREE.Mesh(new THREE.BoxGeometry(), mat), new THREE.Mesh(new THREE.BoxGeometry(), mat)); + applyBodyWave(fish, base(fish), {}, 0, { key: 'w2' }); + const [m1, m2] = fish.children.map((c) => c.material); + expect(m1).not.toBe(m2); + }); + it('followers run after movers', () => { + expect(MOTION_PHASE.orientvelocity).toBeGreaterThan(0); + expect(MOTION_PHASE.bodywave).toBeGreaterThan(MOTION_PHASE.orientvelocity); + expect(MOTION_PHASE.followpath ?? 0).toBe(0); + }); +}); From 90728692fa9dc9f6d7327c95fcdf245df1f14342 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 8 Oct 2026 13:55:44 +0300 Subject: [PATCH 03/92] [fix] a big image becomes a texture instead of being refused; textures remember their image - setObjectsTexture no longer refuses files over 8 MB: the path already downscales to 1024 px, so the cap only refused photos it was built to shrink (roadmap 40 F13, image-large). The one limit left is a 64 MB decode-safety cap. - material.userData.mapSource = the content hash of the Explorer image a texture was made from (Explorer drop, UV slot drop). It rides the `map` message as an additive `source` field, the `materials` payload as `mapSources`, and a material-type switch. Any write without one (paint, undo, a file from disk, an older peer) drops the link. - texturesFromSource(hash) / linkTextureSource(uuid, slot, hash): the seams the Image editor's Save uses to re-texture every material made from an image. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/commandsHandler.svelte.js | 3 +- src/lib/explorerDrop.js | 2 +- src/lib/materialsHandler.js | 96 ++++++++++++++++++++++++++----- 3 files changed, 86 insertions(+), 15 deletions(-) diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js index 0779a2f9f..b557fd3f0 100644 --- a/src/lib/commandsHandler.svelte.js +++ b/src/lib/commandsHandler.svelte.js @@ -575,7 +575,8 @@ export async function objectParameters(data) { let mesh = sceneObjects.getObjectByProperty('uuid', data.uuid); // UV2: `slot` addresses one material of an ARRAY. Absent (an older peer, // or any single-material object) means slot 0 = today's behaviour. - if (mesh) applyMap(mesh, data.map, data.slot ?? 0); + // 40-image: `source` (additive) = the library image the texture was made from + if (mesh) applyMap(mesh, data.map, data.slot ?? 0, typeof data.source === 'string' ? data.source : null); } else if (data.parameter == 'materialParam') { setMaterialParam(data.uuid, data.key, data.value, false); } else if (data.parameter == 'animation') { diff --git a/src/lib/explorerDrop.js b/src/lib/explorerDrop.js index c9800b56c..956197d7d 100644 --- a/src/lib/explorerDrop.js +++ b/src/lib/explorerDrop.js @@ -132,7 +132,7 @@ export async function applyExplorerImage(uuid, payload, slot = 0) { if (!item || item.kind !== 'image') return false; const blob = await itemBlob(item.id); if (!blob) return false; - await setObjectTexture(uuid, new File([blob], item.name, { type: blob.type || 'image/png' }), slot); + await setObjectTexture(uuid, new File([blob], item.name, { type: blob.type || 'image/png' }), slot, { source: item.hash }); return true; } diff --git a/src/lib/materialsHandler.js b/src/lib/materialsHandler.js index 59c633e1c..1f400cedd 100644 --- a/src/lib/materialsHandler.js +++ b/src/lib/materialsHandler.js @@ -100,6 +100,8 @@ registerHistoryKind('material', (entry, state) => { if (entry.param === 'type') { switchMaterialType(entry.uuid, state.value, true); } else if (entry.param === 'map') { + // 40-image: an undone/redone texture is no longer known to BE a library image, so it + // carries no source — a later image save must not paint over the step it reverted to applyMap(object, state.value, entry.slot ?? 0); broadcast({ type: 'objectParameters', @@ -162,6 +164,9 @@ export function applyMaterials(object, payload, replicate = false) { next.forEach((/** @type {any} */ material, /** @type {number} */ i) => { const url = payload.mapDataUrls?.[i]; if (url) material.userData.mapDataUrl = url; + // 40-image: and which library image it came from (absent on an older peer's payload) + const source = payload.mapSources?.[i]; + if (source) material.userData.mapSource = source; }); object.material = next.length === 1 ? next[0] : next; if (payload.groups && object.geometry) { @@ -217,6 +222,7 @@ export function materialsPayload(object) { ...serialized, // toJSON drops userData.mapDataUrl, and the Inspector/UV sidebar read it mapDataUrls: materials.map((m) => m.userData?.mapDataUrl ?? null), + mapSources: materials.map((m) => m.userData?.mapSource ?? null), groups: (object.geometry?.groups ?? []).map((/** @type {any} */ g) => ({ start: g.start, count: g.count, @@ -392,11 +398,19 @@ export function copyTextureParams(from, to) { * thumbnail. UV2: `slot` addresses a material ARRAY (an imported .obj/.mtl, a * merged mesh); omitted means slot 0, which for a single-material object is * exactly the old behaviour — so older peers' slot-less messages still land. - * @param {any} object @param {string | null} dataURL @param {number} [slot] + * + * 40-image: `source` is the content HASH of the Explorer image the texture was made from, + * kept on `userData.mapSource` so saving that image in the Image editor can find every + * material showing it. Any write WITHOUT one (a paint stroke, an undo, a file picked from + * disk, an older peer) drops the link: the texture is no longer known to be that image, + * and a later save must not paint over it. + * @param {any} object @param {string | null} dataURL @param {number} [slot] @param {string | null} [source] */ -export function applyMap(object, dataURL, slot = 0) { +export function applyMap(object, dataURL, slot = 0, source = null) { const material = materialAt(object, slot); if (!material || !('map' in material)) return; + if (source && dataURL != null) material.userData.mapSource = String(source); + else delete material.userData.mapSource; if (dataURL == null) { material.map?.dispose(); material.map = null; @@ -429,7 +443,7 @@ export function applyMap(object, dataURL, slot = 0) { /** Downscale an image file to maxSize px and encode as a compact dataURL * (exported since 17-D2 — the OBJ/.mtl import path reuses it for its textures) - * @param {File} file @param {number} maxSize */ + * @param {File | Blob} file @param {number} maxSize */ export async function downscaleImage(file, maxSize) { const bitmap = await createImageBitmap(file); const scale = Math.min(1, maxSize / Math.max(bitmap.width, bitmap.height)); @@ -442,6 +456,16 @@ export async function downscaleImage(file, maxSize) { return webp.startsWith('data:image/webp') ? webp : canvas.toDataURL('image/jpeg', 0.85); } +/** + * 40-image (F13, the user's image-large report): the texture is DOWNSCALED to 1024 px + * whatever comes in, so refusing a big file at 8 MB refused photos the path was already + * built to shrink. The only limit left is a decode-safety one — a file this size is not + * an image a phone can decode at all. + */ +export const MAX_TEXTURE_SOURCE_BYTES = 64 * 1024 * 1024; +/** the longest side a texture is stored at (the dataURL rides the wire and every save) */ +export const TEXTURE_MAX_SIDE = 1024; + /** * Set one image file as the texture of MANY objects (17-D1) and replicate. * The file is decoded and downscaled ONCE and the resulting dataURL applied per @@ -451,18 +475,20 @@ export async function downscaleImage(file, maxSize) { * unreliable (the third `createImageBitmap` of one picked file can reject). * UV2: `slot` picks the material slot, so a multi-material mesh is no longer * refused — it used to toast "not supported yet" and skip. - * @param {string[]} uuids @param {File} file @param {number} [slot] + * 40-image: `opts.source` = the content hash of the Explorer image `file` holds (see applyMap). + * @param {string[]} uuids @param {File | Blob} file @param {number} [slot] @param {{source?: string | null}} [opts] * @returns {Promise} how many were textured */ -export async function setObjectsTexture(uuids, file, slot = 0) { - if (file.size > 8 * 1024 * 1024) { - showToast('Image is too large (max 8 MB)'); +export async function setObjectsTexture(uuids, file, slot = 0, opts = {}) { + if (file.size > MAX_TEXTURE_SOURCE_BYTES) { + showToast('That image is too big to read (' + Math.round(file.size / 1048576) + ' MB) — save it smaller first'); return 0; } + const source = opts.source ? String(opts.source) : null; /** @type {string} */ let dataURL; try { - dataURL = await downscaleImage(file, 1024); + dataURL = await downscaleImage(file, TEXTURE_MAX_SIDE); } catch (error) { console.log(error); showToast('Could not read the image file'); @@ -478,13 +504,15 @@ export async function setObjectsTexture(uuids, file, slot = 0) { continue; } recordMaterialChange(uuid, 'map', null, material.userData?.mapDataUrl ?? null, dataURL, slot); - applyMap(object, dataURL, slot); + applyMap(object, dataURL, slot, source); broadcast({ type: 'objectParameters', parameter: 'map', uuid: uuid, map: dataURL, - ...(slot ? { slot } : {}) + ...(slot ? { slot } : {}), + // additive: an older peer ignores it, and a message without it drops the link + ...(source ? { source } : {}) }); applied++; } @@ -493,9 +521,50 @@ export async function setObjectsTexture(uuids, file, slot = 0) { } /** Set an image file as the object's texture and replicate - * @param {string} uuid @param {File} file @param {number} [slot] */ -export async function setObjectTexture(uuid, file, slot = 0) { - await setObjectsTexture([uuid], file, slot); + * @param {string} uuid @param {File | Blob} file @param {number} [slot] @param {{source?: string | null}} [opts] */ +export async function setObjectTexture(uuid, file, slot = 0, opts = {}) { + await setObjectsTexture([uuid], file, slot, opts); +} + +/** + * 40-image: every material slot in the scene whose texture was made from the library image + * with this content hash — what a save in the Image editor re-textures. + * @param {string} hash @returns {{uuid: string, slot: number}[]} + */ +export function texturesFromSource(hash) { + const want = String(hash ?? ''); + /** @type {{uuid: string, slot: number}[]} */ + const out = []; + if (!want) return out; + get(objectsGroup)?.traverse?.((/** @type {any} */ node) => { + materialsOf(node).forEach((/** @type {any} */ m, /** @type {number} */ slot) => { + if (m?.userData?.mapSource === want && m.userData.mapDataUrl) out.push({ uuid: node.uuid, slot }); + }); + }); + return out; +} + +/** + * 40-image: name the library image a texture already IS, without re-encoding it (the Image + * editor imported a texture that had no source). The texture is unchanged, so no history + * entry; the message carries the same pixels plus the link so every peer agrees. + * @param {string} uuid @param {number} slot @param {string} hash + */ +export function linkTextureSource(uuid, slot, hash) { + const object = objectOf(uuid); + const material = materialAt(object, slot); + const url = material?.userData?.mapDataUrl; + if (!material || !url || !hash) return false; + material.userData.mapSource = String(hash); + broadcast({ + type: 'objectParameters', + parameter: 'map', + uuid, + map: url, + ...(slot ? { slot } : {}), + source: String(hash) + }); + return true; } /** @param {string} uuid @param {number} [slot] */ @@ -542,6 +611,7 @@ export function switchMaterialType(uuid, type, replicate = true) { if ('map' in fresh && old.map) { fresh.map = old.map; fresh.userData.mapDataUrl = old.userData?.mapDataUrl; + if (old.userData?.mapSource) fresh.userData.mapSource = old.userData.mapSource; } fresh.transparent = old.transparent; if ('wireframe' in fresh && old.wireframe !== undefined) fresh.wireframe = old.wireframe; From d4cfb20bc24ca9a87d554973dec4d94846b3594f Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 8 Oct 2026 13:59:26 +0300 Subject: [PATCH 04/92] [feat] fish-scale look: thin-film presets and a per-device look tier (40 F16) - material presets carry the thin film (iridescence, its IOR and thickness range) and the transmission volume; three's [min, max] range maps both ways - a new starter "Fish scales": an iridescent physical look over a procedural, tileable scale relief (scales hang like shingles, lower row in front), a light clear coat and a hint of transmission; the swatch shows the film as a hue sweep - materialTiersCore (pure) + materialTiers (runtime): every physical material is drawn at this device's tier, the water rule (headset or post off = low, a phone's start = mid): mid drops transmission (the extra full-scene pass), low also drops the thin film and sheen. LOCAL only; the authored numbers ride as userData.lookTierAuthored, written only when a device draws less, so a desktop save stays byte-identical and a headset's save restores the full look elsewhere; an edit made since is the new look - debug hooks: materialTiers, motionNodes - tests: materialTiers.test.js (10, incl. a save on a low device reloading at full look), the starter list grew by one Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 3 + src/lib/debugHooks.js | 4 + src/lib/materialPresetMaps.js | 48 +++++++++++- src/lib/materialPresets.js | 6 ++ src/lib/materialPresetsCore.js | 79 +++++++++++++++++-- src/lib/materialTiers.js | 116 ++++++++++++++++++++++++++++ src/lib/materialTiersCore.js | 76 ++++++++++++++++++ tests/unit/materialPresets.test.js | 2 +- tests/unit/materialTiers.test.js | 119 +++++++++++++++++++++++++++++ 9 files changed, 444 insertions(+), 9 deletions(-) create mode 100644 src/lib/materialTiers.js create mode 100644 src/lib/materialTiersCore.js create mode 100644 tests/unit/materialTiers.test.js diff --git a/src/App.svelte b/src/App.svelte index 6cd1bc9f3..3edad5aae 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -125,6 +125,7 @@ import { startMusicToolbox } from './lib/musicToolbox' import { startCloudPlugin } from '$lib/cloudPlugin' import { startShaderGraphs } from '$lib/shaderGraph' import { startMaterialSharing } from '$lib/materialSharing' + import { startMaterialTiers } from '$lib/materialTiers.js' import { startShaderSync } from '$lib/shaderSync' import { startHudSync } from '$lib/hudSync' import { startHudImages } from '$lib/hudImages' @@ -298,6 +299,8 @@ import { startMusicToolbox } from './lib/musicToolbox' // (GLTF, a peer's per-object messages, undo) rebuilds materials, and the id survives // where the instance does not startMaterialSharing() + // 40 F16: draw physical materials at this device's look tier (transmission / thin film) + startMaterialTiers() startShaderSync() startHudSync() startHudImages() diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 6160ac29e..16bd8cf0b 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -76,6 +76,10 @@ export const DEBUG_HOOKS = [ ['materialsHandler', () => import('./materialsHandler')], // 37 R5: material presets (materialPresets, peerMaterialPresets, applyMaterialPreset, materialPresetsDebug…) ['materialPresets', () => import('./materialPresets')], + // 40 F16: the look tier (materialTiersDebug, pinMaterialTier, applyMaterialTiers) + ['materialTiers', () => import('./materialTiers.js')], + // 40 F15: the motion nodes (motionNodesDebug, resetMotionNodes) + ['motionNodes', () => import('./motion/motionNodes.js')], ['objectActions', () => import('./objectActions')], ['commandsHandler', () => import('./commandsHandler.svelte')], ['moduleSDK', () => import('./moduleSDK')], diff --git a/src/lib/materialPresetMaps.js b/src/lib/materialPresetMaps.js index d938a5585..23916371d 100644 --- a/src/lib/materialPresetMaps.js +++ b/src/lib/materialPresetMaps.js @@ -61,9 +61,49 @@ function tileFbm(u, v, cu, cv, octaves, seed) { return sum / norm; } +// 40 F16: FISH SCALES — overlapping rounded scales in offset rows, each hanging DOWN from its +// centre like a shingle, the lower row in front. SCALE_ROWS rows × SCALE_COLS columns per tile +// (integers, and odd rows shift half a scale, so an even row count keeps the tile seamless). +const SCALE_ROWS = 10; +const SCALE_COLS = 8; + +/** + * The height of the scale pattern at (u, v) in [0, 1]: a domed scale (highest near its free + * edge's middle, a crisp drop at its rim) over whatever scale lies behind it. + * @param {number} u @param {number} v + */ +export function scaleHeight(u, v) { + const rowH = 1 / SCALE_ROWS; + const colW = 1 / SCALE_COLS; + const R = colW * 0.62; + const row0 = Math.floor(v * SCALE_ROWS); + // front to back: the row whose centres sit just above this pixel hangs over it first + for (let dr = 0; dr <= 2; dr++) { + const row = row0 - dr; + const cy = row * rowH; + const shift = ((row % 2) + 2) % 2 ? 0.5 : 0; + const c0 = Math.floor(u * SCALE_COLS - shift); + for (let dc = -1; dc <= 1; dc++) { + const cx = (c0 + dc + 0.5 + shift) * colW; + let dx = u - cx; + dx -= Math.round(dx); // wrap across the tile edge + let dy = v - cy; + dy -= Math.round(dy); + if (dy <= 0) continue; // a scale hangs below its centre only (strict: v = 0 and v = 1 are one row) + const d = Math.hypot(dx, dy * (colW / rowH) * 0.9) / R; + if (d >= 1) continue; + // a soft dome with a raised rim: the light catches each scale's edge + const dome = Math.sqrt(1 - d * d); + const rim = d > 0.82 ? (d - 0.82) * 2.2 : 0; + return Math.min(1, 0.35 + dome * 0.5 + rim); + } + } + return 0.25; +} + /** * The height field of a kind, size×size in [0, 1], tileable in both directions. - * @param {'wood'|'stone'} kind @param {number} [size] + * @param {'wood'|'stone'|'scales'} kind @param {number} [size] * @returns {Float32Array} */ export function heightField(kind, size = MAP_SIZE) { @@ -84,6 +124,8 @@ export function heightField(kind, size = MAP_SIZE) { // a thin dark seam between planks const seam = Math.abs(((v * 4) % 1) - 0.5) > 0.485 ? 0.35 : 0; h = Math.max(0, h - seam); + } else if (kind === 'scales') { + h = scaleHeight(u, v); } else { // stone: broad blotches, a crisp mid band and grit const broad = tileFbm(u, v, 3, 3, 4, 41); @@ -132,7 +174,7 @@ const cache = new Map(); /** * The encoded maps of a procedural kind (browser only; null elsewhere). Cached per session, * so swatches, applies and the active-swatch compare all see the same strings. - * @param {'wood'|'stone'} kind + * @param {'wood'|'stone'|'scales'} kind * @returns {{map: string, normalMap: string} | null} */ export function proceduralMaps(kind) { @@ -141,7 +183,7 @@ export function proceduralMaps(kind) { if (typeof document === 'undefined') return null; try { const size = MAP_SIZE; - const { color, normal } = mapBytes(heightField(kind, size), size, kind === 'wood' ? 2.5 : 4); + const { color, normal } = mapBytes(heightField(kind, size), size, kind === 'wood' ? 2.5 : kind === 'scales' ? 3 : 4); const encode = (/** @type {Uint8ClampedArray} */ bytes, /** @type {boolean} */ hq) => { const canvas = document.createElement('canvas'); canvas.width = canvas.height = size; diff --git a/src/lib/materialPresets.js b/src/lib/materialPresets.js index a12589bcf..87336a69a 100644 --- a/src/lib/materialPresets.js +++ b/src/lib/materialPresets.js @@ -124,6 +124,12 @@ export function presetWirePayload(preset, current) { const value = preset[key] ?? LOOK_DEFAULTS[key]; if (key in material && typeof value === 'number') material[key] = value; } + // 40 F16: the film thickness is one [min, max] pair on the material + if ('iridescenceThicknessRange' in material) + material.iridescenceThicknessRange = [ + preset.iridescenceThicknessMin ?? LOOK_DEFAULTS.iridescenceThicknessMin, + preset.iridescenceThicknessMax ?? LOOK_DEFAULTS.iridescenceThicknessMax + ]; if (material.color) material.color.set(preset.color ?? LOOK_DEFAULTS.color); if (material.emissive) material.emissive.set(preset.emissive ?? LOOK_DEFAULTS.emissive); material.transparent = !!preset.transparent; diff --git a/src/lib/materialPresetsCore.js b/src/lib/materialPresetsCore.js index dac311e85..cc6a39197 100644 --- a/src/lib/materialPresetsCore.js +++ b/src/lib/materialPresetsCore.js @@ -11,7 +11,12 @@ // roughness, metalness, clearcoat, clearcoatRoughness, transmission, ior, shininess, // emissive: '#rrggbb', emissiveIntensity, opacity, transparent, flatShading, // map: dataURL|null, normalMap: dataURL|null, normalScale, repeat: [u, v], -// procedural: 'wood'|'stone' } ← built-ins only: the maps are generated on the device +// procedural: 'wood'|'stone'|'scales' } ← built-ins only: the maps are generated on the device +// 40 F16 adds the THIN-FILM fields (MeshPhysicalMaterial): iridescence, iridescenceIOR, +// iridescenceThicknessMin/Max (nm — three's iridescenceThicknessRange), thickness (the +// transmission volume). A device may draw fewer of them than the look asks for: the look +// TIER (materialTiers.js) drops transmission on a phone and the whole thin-film set in a +// headset — the preset, the save and the wire always carry the authored numbers. // // Applying one goes through the EXISTING `objectParameters / materials` message (UV4's slot // payload), so the wire gains only the library broadcast (`matpresets`, the `envpresets` @@ -49,7 +54,12 @@ export const NUMERIC_FIELDS = [ 'ior', 'shininess', 'emissiveIntensity', - 'opacity' + 'opacity', + 'iridescence', + 'iridescenceIOR', + 'iridescenceThicknessMin', + 'iridescenceThicknessMax', + 'thickness' ]; /** @@ -126,6 +136,31 @@ export const STARTER_PRESETS = [ roughness: 0.96, metalness: 0 }, + { + // 40 F16: FISH SCALES — the look the aquarium's fish wear, for anyone: a thin-film + // iridescent sheen that shifts with the view (the silver-blue flash of a turning fish), + // a procedural scale relief, a light clear coat and, where a device can afford the pass, + // a hint of transmission so a fin reads thin. Recolour it in the Inspector for any fish, + // snake, dragon or beetle shell. + version: 1, + id: 'fishscale', + label: 'Fish scales', + type: 'MeshPhysicalMaterial', + color: '#c9d6dc', + roughness: 0.32, + metalness: 0.15, + clearcoat: 0.5, + clearcoatRoughness: 0.25, + iridescence: 0.9, + iridescenceIOR: 1.6, + iridescenceThicknessMin: 180, + iridescenceThicknessMax: 560, + transmission: 0.12, + thickness: 0.3, + procedural: 'scales', + normalScale: 0.7, + repeat: [3, 3] + }, { version: 1, id: 'neon', @@ -139,6 +174,9 @@ export const STARTER_PRESETS = [ } ]; +/** the procedural map kinds a built-in may name (materialPresetMaps generates them) */ +export const PROCEDURAL_KINDS = ['wood', 'stone', 'scales']; + /** @param {any} name */ export function cleanName(name) { return String(name ?? '') @@ -171,7 +209,12 @@ const RANGES = { ior: [1, 2.333], shininess: [0, 1000], emissiveIntensity: [0, 20], - opacity: [0, 1] + opacity: [0, 1], + iridescence: [0, 1], + iridescenceIOR: [1, 2.333], + iridescenceThicknessMin: [0, 2000], + iridescenceThicknessMax: [0, 2000], + thickness: [0, 10] }; /** a data:image URL or null — presets never carry a remote URL (a library would fetch it) @param {any} v */ @@ -215,7 +258,7 @@ export function normalizePreset(input) { const v = num(input.repeat[1], 0.01, 100); if (u !== undefined && v !== undefined && (u !== 1 || v !== 1)) out.repeat = [u, v]; } - if (input.procedural === 'wood' || input.procedural === 'stone') out.procedural = input.procedural; + if (PROCEDURAL_KINDS.includes(input.procedural)) out.procedural = input.procedural; if (Number.isFinite(input.savedAt)) out.savedAt = input.savedAt; return out; } @@ -242,6 +285,16 @@ export function snapshotLook(material, label, maps = {}) { for (const key of NUMERIC_FIELDS) { if (typeof material?.[key] === 'number') raw[key] = r4(material[key]); } + // 40 F16: three keeps the film thickness as a [min, max] pair + const range = material?.iridescenceThicknessRange; + if (Array.isArray(range) && range.length === 2) { + raw.iridescenceThicknessMin = r4(range[0]); + raw.iridescenceThicknessMax = r4(range[1]); + } + // …and a device drawing a lower look TIER holds the authored numbers beside the material + const authored = material?.userData?.lookTierAuthored; + if (authored && typeof authored === 'object') + for (const [key, value] of Object.entries(authored)) if (typeof value === 'number' && key in raw) raw[key] = r4(value); // emissiveIntensity without an emissive colour is noise; opacity 1 is the default if (!raw.emissive) delete raw.emissiveIntensity; if (raw.opacity === 1) delete raw.opacity; @@ -308,6 +361,11 @@ export const LOOK_DEFAULTS = { shininess: 30, emissiveIntensity: 1, opacity: 1, + iridescence: 0, + iridescenceIOR: 1.3, + iridescenceThicknessMin: 100, + iridescenceThicknessMax: 400, + thickness: 0, transparent: false, flatShading: false }; @@ -323,7 +381,12 @@ const TYPE_FIELDS = { 'transmission', 'ior', 'emissiveIntensity', - 'opacity' + 'opacity', + 'iridescence', + 'iridescenceIOR', + 'iridescenceThicknessMin', + 'iridescenceThicknessMax', + 'thickness' ], MeshPhongMaterial: ['shininess', 'emissiveIntensity', 'opacity'], MeshLambertMaterial: ['emissiveIntensity', 'opacity'], @@ -394,6 +457,12 @@ export function swatchLayers(p) { `radial-gradient(circle at 50% 50%, transparent 55%, color-mix(in srgb, #000 ${Math.round(25 + metal * 25)}%, transparent) 100%)` ]; if (glow) layers.push(`radial-gradient(circle at 50% 50%, ${glow} 0%, color-mix(in srgb, ${glow} 40%, transparent) 70%)`); + // 40 F16: a thin film reads as a hue sweep across the sphere + const irid = typeof p?.iridescence === 'number' ? p.iridescence : 0; + if (irid > 0) + layers.push( + `conic-gradient(from 200deg at 60% 60%, color-mix(in srgb, #ff6ad5 ${Math.round(irid * 35)}%, transparent), color-mix(in srgb, #5ad1ff ${Math.round(irid * 35)}%, transparent), color-mix(in srgb, #8cff8a ${Math.round(irid * 30)}%, transparent), color-mix(in srgb, #ff6ad5 ${Math.round(irid * 35)}%, transparent))` + ); layers.push( alpha < 1 ? `linear-gradient(color-mix(in srgb, ${base} ${Math.round(alpha * 100)}%, transparent), color-mix(in srgb, ${base} ${Math.round(alpha * 100)}%, transparent))` diff --git a/src/lib/materialTiers.js b/src/lib/materialTiers.js new file mode 100644 index 000000000..b04f79bcf --- /dev/null +++ b/src/lib/materialTiers.js @@ -0,0 +1,116 @@ +// @ts-ignore - no bundled three type declarations (project-wide) +import { get } from 'svelte/store'; +import { objectsGroup, globalRenderer } from '../stores/sceneStore'; +import { qualityOverrides } from './qualityGovernor'; +import { lookTier, planTier, TIERED_FIELDS } from './materialTiersCore.js'; + +// 40 F16 — THE LOOK TIER, the runtime: walks the scene's physical materials and draws each one at +// this device's tier (materialTiersCore.js has the rule and the why). LOCAL: it writes the live +// material only, never a message, an undo entry or a preference. +// +// The AUTHORED numbers ride beside the material as `userData.lookTierAuthored` — written only +// when this device actually draws less than the author asked for, so a desktop save of an +// untouched scene stays byte-identical (a fresh stamp on every load would mark every scene +// dirty: the signature rule). userData travels with toJSON, the GLTF extras, the wire and undo, +// so a headset's save still restores the full look on a desktop, and a preset snapshot reads the +// record (materialPresetsCore.snapshotLook). + +/** the tier pass's own writes, per material, so an edit made since is recognised @type {WeakMap>} */ +const written = new WeakMap(); +/** @type {'high'|'mid'|'low'} */ +let tier = 'high'; +/** a test/debug pin over the measured tier @type {'high'|'mid'|'low'|null} */ +let pin = null; +let overrides = /** @type {any} */ ({}); +let started = false; +/** @type {ReturnType | null} */ +let pending = null; +let passes = 0; +let lowered = 0; + +/** the tier for this device now */ +function measure() { + let xr = false; + try { + xr = !!(/** @type {any} */ (get(globalRenderer))?.xr?.isPresenting); + } catch { + /* a disposed renderer */ + } + return lookTier({ xr, overrides, pin }); +} + +/** + * Draw one material at a tier. Exported for the unit tests (a plain object with the fields works). + * @param {any} material @param {'high'|'mid'|'low'} t @returns {boolean} whether anything is drawn lower than authored + */ +export function applyTierTo(material, t) { + if (!material || !material.isMeshPhysicalMaterial) return false; + /** @type {Record} */ + const current = {}; + for (const f of TIERED_FIELDS) current[f] = typeof material[f] === 'number' ? material[f] : 0; + const record = material.userData?.lookTierAuthored ?? null; + const plan = planTier(current, record, written.get(material) ?? null, t); + // the record exists once this device has drawn the look lower (and is kept current after) + if (plan.lowers || record) { + material.userData = material.userData ?? {}; + material.userData.lookTierAuthored = plan.authored; + } + let program = false; + for (const f of TIERED_FIELDS) { + const to = plan.draw[f]; + if (material[f] === to) continue; + // three compiles transmission/sheen/iridescence in or out at zero: crossing it is a new program + if (material[f] > 0 !== to > 0) program = true; + material[f] = to; + } + if (program) material.needsUpdate = true; + written.set(material, { ...plan.draw }); + return plan.lowers; +} + +/** one pass over the scene */ +export function applyMaterialTiers() { + pending = null; + tier = measure(); + const group = /** @type {any} */ (get(objectsGroup)); + if (!group?.traverse) return; + passes++; + let n = 0; + group.traverse((/** @type {any} */ o) => { + if (!o.material) return; + const list = Array.isArray(o.material) ? o.material : [o.material]; + for (const m of list) if (applyTierTo(m, tier)) n++; + }); + lowered = n; +} + +function schedule() { + if (pending) return; + pending = setTimeout(applyMaterialTiers, 120); +} + +/** boot: follow the scene, the governor and the headset */ +export function startMaterialTiers() { + if (started) return; + started = true; + objectsGroup.subscribe(schedule); + qualityOverrides.subscribe((o) => { + overrides = o ?? {}; + schedule(); + }); + // entering or leaving a headset is a tier change no store announces: look once a second + setInterval(() => { + if (measure() !== tier) schedule(); + }, 1000); +} + +/** pin the tier (tests, a debug toggle); null follows the device again @param {'high'|'mid'|'low'|null} t */ +export function pinMaterialTier(t) { + pin = t === 'high' || t === 'mid' || t === 'low' ? t : null; + applyMaterialTiers(); +} + +/** debug: the tier and what it lowered */ +export function materialTiersDebug() { + return { tier, pin, passes, lowered }; +} diff --git a/src/lib/materialTiersCore.js b/src/lib/materialTiersCore.js new file mode 100644 index 000000000..49ea634e2 --- /dev/null +++ b/src/lib/materialTiersCore.js @@ -0,0 +1,76 @@ +// 40 F16 — THE LOOK TIER, the pure half (no THREE, no DOM, no stores — unit-tested). +// +// Some material features cost a device more than it can pay: TRANSMISSION renders the whole +// opaque scene a second time into a texture (one extra full pass per frame), and the THIN-FILM +// (iridescence) and SHEEN terms add a heavy block to every lit pixel. A desktop pays for both; a +// phone drops the pass; a headset — 2 × 1832² at 72-120 Hz — drops the per-pixel terms too. +// +// The tier is a fact about THIS device right now, decided from the same signals the water tier +// reads (WebXR presenting, the quality governor's overrides) — never a preference written into +// the scene. What a tier changes is applied LOCALLY on the live material; the authored numbers +// travel beside it (materialTiers.js), so a save, a preset snapshot or a peer always sees the +// look the author picked. + +/** the material fields a tier may lower, cheapest-to-keep last */ +export const TIERED_FIELDS = ['transmission', 'sheen', 'iridescence']; + +/** what each tier keeps: `high` everything, `mid` all but the extra pass, `low` none of them */ +const KEEP = { + high: new Set(TIERED_FIELDS), + mid: new Set(['sheen', 'iridescence']), + low: new Set() +}; + +/** + * The tier for this device now. Mirrors water's rule so the two never disagree about what a + * phone is: a headset is `low`; the governor's "post off" step is `low`; a phone's start (AO + * off, resolution under 80 %) is `mid`; otherwise `high`. + * @param {{xr?: boolean, overrides?: {postOff?: boolean, aoOff?: boolean, dprScale?: number} | null, pin?: 'high'|'mid'|'low'|null}} s + * @returns {'high'|'mid'|'low'} + */ +export function lookTier(s) { + if (s?.pin === 'high' || s?.pin === 'mid' || s?.pin === 'low') return s.pin; + if (s?.xr) return 'low'; + const o = s?.overrides ?? {}; + if (o.postOff) return 'low'; + if (o.aoOff || (typeof o.dprScale === 'number' && o.dprScale < 0.8)) return 'mid'; + return 'high'; +} + +/** + * The value a field is DRAWN with on a tier, from its authored value. + * @param {string} field @param {number} authored @param {'high'|'mid'|'low'} tier + */ +export function tieredValue(field, authored, tier) { + if (!TIERED_FIELDS.includes(field)) return authored; + const keep = KEEP[tier] ?? KEEP.high; + return keep.has(field) ? authored : 0; +} + +/** + * Plan one material's tier pass. `current` = what the live material holds, `authored` = the + * record beside it (or null), `written` = what this pass wrote last time (or null). A field + * whose live value is not what we wrote was EDITED since (an Inspector change, a peer's + * material message): that edit is the new authored value. + * @param {Record} current @param {Record | null} authored + * @param {Record | null} written @param {'high'|'mid'|'low'} tier + * @returns {{authored: Record, draw: Record, lowers: boolean}} + */ +export function planTier(current, authored, written, tier) { + /** @type {Record} */ + const next = {}; + /** @type {Record} */ + const draw = {}; + let lowers = false; + for (const f of TIERED_FIELDS) { + const cur = typeof current[f] === 'number' ? current[f] : 0; + let a; + if (written && typeof written[f] === 'number' && cur !== written[f]) a = cur; // edited since + else if (authored && typeof authored[f] === 'number') a = authored[f]; + else a = cur; + next[f] = a; + draw[f] = tieredValue(f, a, tier); + if (draw[f] !== a) lowers = true; + } + return { authored: next, draw, lowers }; +} diff --git a/tests/unit/materialPresets.test.js b/tests/unit/materialPresets.test.js index 8ba2763ca..3f0d1f28b 100644 --- a/tests/unit/materialPresets.test.js +++ b/tests/unit/materialPresets.test.js @@ -36,7 +36,7 @@ function fakeMaterial(fields) { describe('the starter set', () => { it('is the seven the brief names, in order', () => { - expect(STARTER_PRESETS.map((p) => p.id)).toEqual(['wood', 'metal', 'plastic', 'glass', 'stone', 'rubber', 'neon']); + expect(STARTER_PRESETS.map((p) => p.id)).toEqual(['wood', 'metal', 'plastic', 'glass', 'stone', 'rubber', 'fishscale', 'neon']); }); it('every starter survives its own normalisation unchanged in look', () => { diff --git a/tests/unit/materialTiers.test.js b/tests/unit/materialTiers.test.js new file mode 100644 index 000000000..f2ede14c4 --- /dev/null +++ b/tests/unit/materialTiers.test.js @@ -0,0 +1,119 @@ +// @ts-nocheck — plain fixtures; the code under test is typed +// 40 F16: the fish-scale look — the Fish scales starter, the tileable scale relief, and the +// LOOK TIER that draws transmission / thin film only where a device can pay for them while the +// authored look rides beside the material. +import { describe, it, expect, vi } from 'vitest'; +import * as THREE from 'three'; + +vi.mock('../../src/lib/qualityGovernor', async () => { + const { writable } = await import('svelte/store'); + return { qualityOverrides: writable({}) }; +}); + +import { lookTier, tieredValue, planTier, TIERED_FIELDS } from '../../src/lib/materialTiersCore.js'; +import { applyTierTo } from '../../src/lib/materialTiers.js'; +import { STARTER_PRESETS, normalizePreset, snapshotLook, swatchLayers } from '../../src/lib/materialPresetsCore.js'; +import { heightField, scaleHeight, MAP_SIZE } from '../../src/lib/materialPresetMaps.js'; + +describe('the look tier rule', () => { + it('desktop full quality = high; a phone start = mid; post off or a headset = low', () => { + expect(lookTier({ overrides: {} })).toBe('high'); + expect(lookTier({ overrides: { aoOff: true, dprScale: 0.72 } })).toBe('mid'); + expect(lookTier({ overrides: { dprScale: 0.72 } })).toBe('mid'); + expect(lookTier({ overrides: { postOff: true } })).toBe('low'); + expect(lookTier({ xr: true, overrides: {} })).toBe('low'); + expect(lookTier({ xr: true, pin: 'high' })).toBe('high'); + }); + it('mid drops only the extra pass; low drops the per-pixel terms too', () => { + expect(tieredValue('transmission', 0.3, 'high')).toBe(0.3); + expect(tieredValue('transmission', 0.3, 'mid')).toBe(0); + expect(tieredValue('iridescence', 0.9, 'mid')).toBe(0.9); + expect(tieredValue('iridescence', 0.9, 'low')).toBe(0); + expect(tieredValue('roughness', 0.4, 'low')).toBe(0.4); // not a tiered field + }); + it('an edit made since the last pass becomes the authored value', () => { + const first = planTier({ transmission: 0.3, sheen: 0, iridescence: 0.8 }, null, null, 'low'); + expect(first.authored).toEqual({ transmission: 0.3, sheen: 0, iridescence: 0.8 }); + expect(first.draw).toEqual({ transmission: 0, sheen: 0, iridescence: 0 }); + // nobody touched it: the record wins over the drawn zeros + const again = planTier(first.draw, first.authored, first.draw, 'low'); + expect(again.authored.iridescence).toBe(0.8); + // the Inspector set transmission to 0.5 while drawn at 0: that is the new look + const edited = planTier({ ...first.draw, transmission: 0.5 }, first.authored, first.draw, 'high'); + expect(edited.authored.transmission).toBe(0.5); + expect(edited.draw.transmission).toBe(0.5); + }); +}); + +describe('the tier on a real material', () => { + it('lowers, records, restores — and recompiles only when a term crosses zero', () => { + const m = new THREE.MeshPhysicalMaterial({ iridescence: 0.9, transmission: 0.2 }); + const v0 = m.version; + expect(applyTierTo(m, 'high')).toBe(false); + expect(m.userData.lookTierAuthored).toBeUndefined(); // a desktop writes nothing + expect(m.version).toBe(v0); + expect(applyTierTo(m, 'mid')).toBe(true); + expect(m.transmission).toBe(0); + expect(m.iridescence).toBe(0.9); + expect(m.version).toBeGreaterThan(v0); // needsUpdate + applyTierTo(m, 'low'); + expect(m.iridescence).toBe(0); + expect(m.userData.lookTierAuthored).toEqual({ transmission: 0.2, sheen: 0, iridescence: 0.9 }); + // a save on this device: toJSON carries the drawn zeros AND the authored record … + const json = m.toJSON(); + const loaded = new THREE.MaterialLoader().parse(json); + expect(loaded.iridescence).toBe(0); + // … so the desktop that opens it draws the full look again + applyTierTo(loaded, 'high'); + expect(loaded.iridescence).toBeCloseTo(0.9, 9); + expect(loaded.transmission).toBeCloseTo(0.2, 9); + }); + it('a preset snapshot taken on a low device reads the authored look', () => { + const m = new THREE.MeshPhysicalMaterial({ iridescence: 0.7 }); + applyTierTo(m, 'low'); + expect(m.iridescence).toBe(0); + expect(snapshotLook(m, 'Mine').iridescence).toBeCloseTo(0.7, 4); + }); + it('leaves standard materials alone', () => { + const m = new THREE.MeshStandardMaterial(); + expect(applyTierTo(m, 'low')).toBe(false); + expect(m.userData.lookTierAuthored).toBeUndefined(); + }); + it('every tiered field is a real MeshPhysicalMaterial property', () => { + const m = new THREE.MeshPhysicalMaterial(); + for (const f of TIERED_FIELDS) expect(typeof m[f]).toBe('number'); + }); +}); + +describe('the Fish scales preset', () => { + const fish = STARTER_PRESETS.find((p) => p.id === 'fishscale'); + it('is a physical, iridescent, scale-relief look that survives normalisation', () => { + expect(fish.type).toBe('MeshPhysicalMaterial'); + const n = normalizePreset(fish); + expect(n.iridescence).toBeGreaterThan(0.5); + expect(n.iridescenceThicknessMax).toBeGreaterThan(n.iridescenceThicknessMin); + expect(n.procedural).toBe('scales'); + expect(n.transmission).toBeGreaterThan(0); + }); + it('its swatch shows the film', () => { + expect(swatchLayers(fish).some((l) => l.startsWith('conic-gradient'))).toBe(true); + expect(swatchLayers(STARTER_PRESETS.find((p) => p.id === 'metal')).some((l) => l.startsWith('conic'))).toBe(false); + }); + it('the scale relief tiles seamlessly and has real relief', () => { + const n = 64; + const h = heightField('scales', n); + let lo = 1; + let hi = 0; + for (const v of h) { + lo = Math.min(lo, v); + hi = Math.max(hi, v); + } + expect(hi - lo).toBeGreaterThan(0.5); + // the left edge continues the right edge, the top the bottom (a wrap, not a seam) + for (let i = 0; i < 1; i += 0.05) { + expect(Math.abs(scaleHeight(0, i) - scaleHeight(1 - 1e-9, i))).toBeLessThan(0.05); + expect(Math.abs(scaleHeight(i, 0) - scaleHeight(i, 1 - 1e-9))).toBeLessThan(0.05); + } + expect(MAP_SIZE).toBe(256); + }); +}); From 3a3b023a7d0b7282f27275da89c648164642be61 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 8 Oct 2026 14:03:44 +0300 Subject: [PATCH 05/92] =?UTF-8?q?[feat]=20auto-quality=20notice:=20one=20"?= =?UTF-8?q?Lowered=20...=20to=20keep=20it=20smooth=20=C2=B7=20Keep=20full?= =?UTF-8?q?=20quality"=20per=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 40 F12: when the device lowers a quality toggle on its own it says so once per toggle per session, batched per drop: "Lowered water quality and the scene look to keep it smooth" with a "Keep full quality" action (keepFullQuality: full quality for the rest of the page session, the Settings auto switch untouched) - the rule is pure (qualityNoticeCore: which toggles, newly lowered, the words); the AutoQualityNotice component watches qualityOverrides + simplifiedWater, counts only automatic drops (frames / long tasks / the phone's lighter start, never a game preset or the headset floor), holds a drop made in Play or a headset until you are back, and judges in a microtask because publish writes the overrides before the reason - replaces the object list's first-step toast (gone whenever that window was closed) and SimplifiedWaterNotice; the quality chip says "stays this way for this scene" while held - governorForTest: setLevel on a synthetic clock, flapLock / clearFlapLock - e2e auto-quality-notice (phone 390x844 touch + desktop 1440x900: the drop, one retry, held for 10 min, counterfactual 15 switches, scene load clears the lock, Play deferral, tap Keep full quality, shadows on desktop); fb-water-phone part 3 asserts a game preset is not announced; unit qualityNotice Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 4 +- src/components/AutoQualityNotice.svelte | 93 +++++++ src/components/menu/Controls.svelte | 25 +- .../water/SimplifiedWaterNotice.svelte | 102 ------- src/lib/qualityGovernor.js | 21 +- src/lib/qualityNoticeCore.js | 78 ++++++ src/lib/water/simplifiedNotice.js | 4 +- src/lib/water/waterRuntime.js | 2 +- tests/e2e/auto-quality-notice.test.cjs | 248 ++++++++++++++++++ tests/e2e/fb-water-phone.test.cjs | 43 +-- tests/unit/qualityNotice.test.js | 45 ++++ 11 files changed, 499 insertions(+), 166 deletions(-) create mode 100644 src/components/AutoQualityNotice.svelte delete mode 100644 src/components/water/SimplifiedWaterNotice.svelte create mode 100644 src/lib/qualityNoticeCore.js create mode 100644 tests/e2e/auto-quality-notice.test.cjs create mode 100644 tests/unit/qualityNotice.test.js diff --git a/src/App.svelte b/src/App.svelte index 6cd1bc9f3..0085c97a8 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -139,7 +139,7 @@ import { startMusicToolbox } from './lib/musicToolbox' import { startSharedLibrary } from '$lib/sharedLibrary' import { startSceneIdentity } from '$lib/sceneIdentity' import GameChip from './components/hud/GameChip.svelte' - import SimplifiedWaterNotice from './components/water/SimplifiedWaterNotice.svelte' // 36-fb-water F27 + import AutoQualityNotice from './components/AutoQualityNotice.svelte' // 40 F12 (was SimplifiedWaterNotice) import ShortcutSheet from './components/menu/ShortcutSheet.svelte' // 36 I3 import HudLayer from './components/hud/HudLayer.svelte' import PickCycleHint from './components/menu/PickCycleHint.svelte' // 36 F22 / S6 / S12 @@ -452,7 +452,7 @@ import { startMusicToolbox } from './lib/musicToolbox' editor's stand-in for a game's screens, which HudLayer no longer draws outside Play. Editor-only (it hides itself in Play, in VR and in embed mode). --> - + diff --git a/src/components/AutoQualityNotice.svelte b/src/components/AutoQualityNotice.svelte new file mode 100644 index 000000000..2cfec0197 --- /dev/null +++ b/src/components/AutoQualityNotice.svelte @@ -0,0 +1,93 @@ + diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index 9512d31e3..42a5f5ea2 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -25,7 +25,7 @@ import { sendPing } from '$lib/ping'; import { buildObjectMenuItems } from '$lib/objectMenu'; import * as THREE from 'three'; - import { onMount, setContext, tick, untrack } from 'svelte'; + import { onMount, setContext, tick } from 'svelte'; import { createGesture } from '$lib/modalGrab'; import { writable, get } from 'svelte/store'; import { shareObject } from '$lib/objectPermissions'; @@ -599,30 +599,17 @@ const what = q.labels.join(', ').toLowerCase(); return q.pinned ? 'Held at reduced quality (' + what + '). Click to restore full quality.' - : 'This scene is heavy for this device, so drawing was reduced: ' + what + '. It comes back on its own when frames recover. Click to keep it this way.'; + : q.held + ? 'This scene is heavy for this device, so drawing was reduced: ' + what + '. It stays this way for this scene (it came back once and the frames dropped again). Click to keep it this way.' + : 'This scene is heavy for this device, so drawing was reduced: ' + what + '. It comes back on its own when frames recover. Click to keep it this way.'; }); function qualityChipClick(node: HTMLElement) { const click = () => (get(qualityState).pinned ? releaseQuality() : pinQuality()); node.addEventListener('click', click); return { destroy() { node.removeEventListener('click', click); } }; } - // …and once per session, a toast when it FIRST acts, because the chip lives in the object - // list's footer and that window can be closed - let qualityToasted = false; - $effect(() => { - const q = $qualityState; - if (q.level > 0 && !qualityToasted) { - qualityToasted = true; - untrack(() => - showQualityToast('Quality reduced — this scene is heavy for this device (' + q.labels.join(', ').toLowerCase() + '). It comes back on its own.', [ - // 33 integrate: not "Restore …" — on a phone this toast now shows on every load, beside the - // session's own "Restore" (the L1 Restore path), and a tap on the wrong one undid the phone fix - { label: 'Use full quality', action: () => releaseQuality() }, - { label: 'Keep it', action: () => pinQuality() } - ]) - ); - } - }); + // 40 F12: the once-per-session "Lowered … to keep it smooth · Keep full quality" toast is + // AutoQualityNotice (App root) — it used to live here and was gone whenever this window was closed. // bottom status line: totals across the whole tree (N objects · M hidden) let objectCount = $state(0); diff --git a/src/components/water/SimplifiedWaterNotice.svelte b/src/components/water/SimplifiedWaterNotice.svelte deleted file mode 100644 index 2dd593c2f..000000000 --- a/src/components/water/SimplifiedWaterNotice.svelte +++ /dev/null @@ -1,102 +0,0 @@ - - -{#if shown} -
- - Simplified water for this device — - - shows refraction. - - -
-{/if} - - diff --git a/src/lib/qualityGovernor.js b/src/lib/qualityGovernor.js index 132734717..30e27c246 100644 --- a/src/lib/qualityGovernor.js +++ b/src/lib/qualityGovernor.js @@ -59,7 +59,8 @@ export const ingestDrawGap = writable(0); /** * For the chip and the suite. - * @type {import('svelte/store').Writable<{level: number, max: number, pinned: boolean, labels: string[], reason: string, at: number, snoozedUntil: number}>} + * `held` (40 F12): recovery is held at this level because coming back from it flapped. + * @type {import('svelte/store').Writable<{level: number, max: number, pinned: boolean, labels: string[], reason: string, at: number, snoozedUntil: number, held: boolean}>} */ export const qualityState = writable({ level: 0, @@ -68,7 +69,8 @@ export const qualityState = writable({ labels: /** @type {string[]} */ ([]), reason: '', at: 0, - snoozedUntil: 0 + snoozedUntil: 0, + held: false }); /** The opt-out. LOCAL preference, default ON. */ @@ -132,7 +134,8 @@ function publish(level, reason) { labels: stepLabelsAt(level), reason, at, - snoozedUntil + snoozedUntil, + held: level > 0 && governor.flapLock() >= level }); } @@ -189,6 +192,7 @@ export function noteFrameForQuality(ms) { governor.settleFor(t); // 40 F12: a new scene is new evidence — the last scene's flap lock does not carry over governor.clearFlapLock(); + if (get(qualityState).held) qualityState.update((s) => ({ ...s, held: false })); } governor.noteFrame(ms, t); if (phone) notePhoneFrame(ms); @@ -348,9 +352,9 @@ export const governorForTest = { longTask(t) { governor.noteLongTask(t); }, - /** @param {number} level */ - setLevel(level) { - governor.setLevel(level, now()); + /** @param {number} level @param {number} [t] (40 F12: a synthetic clock, so real frames stay inert) */ + setLevel(level, t) { + governor.setLevel(level, t ?? now()); publish(governor.level(), 'test'); }, reset() { @@ -364,7 +368,10 @@ export const governorForTest = { ingestDrawGap.set(0); publish(0, 'reset'); }, - level: () => governor.level() + level: () => governor.level(), + /** 40 F12: the flap lock, and the pre-F12 rule (clear it after every decision) */ + flapLock: () => governor.flapLock(), + clearFlapLock: () => governor.clearFlapLock() }; registerFrameObserver(noteFrameForQuality); diff --git a/src/lib/qualityNoticeCore.js b/src/lib/qualityNoticeCore.js new file mode 100644 index 000000000..c97967c37 --- /dev/null +++ b/src/lib/qualityNoticeCore.js @@ -0,0 +1,78 @@ +// 40 F12 — WHAT THE AUTO-QUALITY NOTICE SAYS. PURE and import-free (vitest drives it). +// +// Every automatic quality toggle — the governor's ladder (shadows, resolution, AO, the scene +// look, particles) and what derives from it (the water losing its refraction, a fluid tank +// dropping to drops) — follows ONE rule: when the device lowers it on its own, the person is +// told once ("Lowered water quality to keep it smooth · Keep full quality"). One toast per +// drop names every toggle that drop newly lowered; a toggle already announced this session is +// not announced again (the chip in the object list still shows the current state). +// +// A snapshot is the state the notice watches: +// {dprScale, shadowsOff, aoOff, postOff, particlesCapped, water, fluid} +// where `water` / `fluid` are simplifiedNotice's flags (set only when QUALITY simplified them — +// never for a headset or the user's own "Low" water pick). + +/** The toggles, in the order a toast names them (the most visible first). */ +export const QUALITY_TOGGLES = [ + { key: 'water', name: 'water quality' }, + { key: 'fluid', name: 'fluid quality' }, + { key: 'shadows', name: 'shadows' }, + { key: 'resolution', name: 'resolution' }, + { key: 'ao', name: 'ambient occlusion' }, + { key: 'post', name: 'the scene look' }, + { key: 'particles', name: 'particles' } +]; + +/** @typedef {{dprScale?: number, shadowsOff?: boolean, aoOff?: boolean, postOff?: boolean, particlesCapped?: boolean, water?: boolean, fluid?: boolean}} QualitySnapshot */ + +/** Is this toggle lowered in `s`? @param {string} key @param {QualitySnapshot | null | undefined} s */ +export function isLowered(key, s) { + if (!s) return false; + switch (key) { + case 'water': + return !!s.water; + case 'fluid': + return !!s.fluid; + case 'shadows': + return !!s.shadowsOff; + case 'resolution': + return Number(s.dprScale ?? 1) < 1; + case 'ao': + return !!s.aoOff; + case 'post': + return !!s.postOff; + case 'particles': + return !!s.particlesCapped; + } + return false; +} + +/** + * The toggles lowered in `next` that were not in `prev` (a further resolution step counts only + * the first time resolution drops below full). @param {QualitySnapshot | null | undefined} prev + * @param {QualitySnapshot} next @returns {string[]} + */ +export function newlyLowered(prev, next) { + return QUALITY_TOGGLES.filter((t) => isLowered(t.key, next) && !isLowered(t.key, prev)).map((t) => t.key); +} + +/** + * What to announce: `keys` minus everything announced before, in QUALITY_TOGGLES order. + * @param {string[]} keys @param {Iterable} announced @returns {string[]} + */ +export function toAnnounce(keys, announced) { + const seen = new Set(announced); + const want = new Set(keys); + return QUALITY_TOGGLES.filter((t) => want.has(t.key) && !seen.has(t.key)).map((t) => t.key); +} + +/** "Lowered water quality and shadows to keep it smooth" @param {string[]} keys */ +export function noticeText(keys) { + const names = QUALITY_TOGGLES.filter((t) => keys.includes(t.key)).map((t) => t.name); + if (!names.length) return ''; + const list = names.length === 1 ? names[0] : names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1]; + return 'Lowered ' + list + ' to keep it smooth'; +} + +/** The action's label (one place, so the suite and the docs agree). */ +export const KEEP_FULL_LABEL = 'Keep full quality'; diff --git a/src/lib/water/simplifiedNotice.js b/src/lib/water/simplifiedNotice.js index 7eae6cd7f..1bcee8f4f 100644 --- a/src/lib/water/simplifiedNotice.js +++ b/src/lib/water/simplifiedNotice.js @@ -1,8 +1,8 @@ // 36-fb-water F27: "the quality level simplified the water / fluid on this device" — a LEAF // (svelte/store only). waterRuntime sets `water` while a visible volume draws the Quest tier // BECAUSE OF QUALITY (not a headset, not the user's own "Low" pick); fluidRuntime sets `fluid` -// while a tank draws the points tier for the same reason. SimplifiedWaterNotice shows ONE small -// dismissible line about it, once per session, never in Play and never in a headset. +// while a tank draws the points tier for the same reason. AutoQualityNotice (40 F12) names it +// as "water quality" / "fluid quality", once per session, never in Play and never in a headset. import { writable, get } from 'svelte/store'; /** @type {import('svelte/store').Writable<{water: boolean, fluid: boolean}>} */ diff --git a/src/lib/water/waterRuntime.js b/src/lib/water/waterRuntime.js index cde6586eb..d3fb70ce0 100644 --- a/src/lib/water/waterRuntime.js +++ b/src/lib/water/waterRuntime.js @@ -1434,7 +1434,7 @@ export function tickWater(_delta) { return; } // 36-fb-water F27: the QUALITY LEVEL (not a headset, not the user's own Low) took the - // refraction away from water on screen — SimplifiedWaterNotice says so, once a session + // refraction away from water on screen — AutoQualityNotice says so, once a session (40 F12) noteSimplified( 'water', tier === 'quest' && qualityPref === 'auto' && !rendererRef?.xr?.isPresenting && [...entries.values()].some((e) => shown(e.object)) diff --git a/tests/e2e/auto-quality-notice.test.cjs b/tests/e2e/auto-quality-notice.test.cjs new file mode 100644 index 000000000..10e3335c0 --- /dev/null +++ b/tests/e2e/auto-quality-notice.test.cjs @@ -0,0 +1,248 @@ +// 40 F12 — "water refractions switch off/on by themselves on the phone" (the user's 1.31 review, +// refractions-off.jpg / refractions--on.jpg). Through the REAL wiring (qualityGovernor + the +// water runtime + AutoQualityNotice) on an emulated phone (390x844, touch) and a desktop +// (1440x900): +// 1. the phone's lighter start is announced once ("Lowered shadows, resolution and ambient +// occlusion to keep it smooth"); +// 2. Aquarium: a drop to "post off" turns the refraction off and says so ONCE ("Lowered water +// quality … to keep it smooth · Keep full quality"); the one retry comes back, overloads, and +// the level is then HELD — no more switching for the rest of a simulated 10 minutes; +// the counterfactual (the lock cleared after every decision = the 1.31 rule) switches > 8x; +// a scene load clears the lock; +// 3. a drop in Play is not announced until you leave Play; "Keep full quality" (a TAP) gives +// full quality and the device stops lowering it; once per session; +// 4. desktop: the same rule for shadows. +// Frames are SYNTHETIC on a clock 1e9 ms ahead (governorForTest), so the page's own frames — +// on a real timeline far behind it — can neither step nor recover the level mid-test. +// OUT= writes the evidence screenshots (phone + desktop, dark + light). +const h = require('./helpers.cjs'); +const fs = require('fs'); +const path = require('path'); + +const OUT = process.env.OUT || ''; +const SC = [process.env.FBW_SCENES, path.resolve(__dirname, '../../../scenes-lane-39-int/examples'), path.resolve(__dirname, '../../../scenes/examples')] + .filter(Boolean) + .find((d) => fs.existsSync(path.join(/** @type {string} */ (d), 'aquarium', 'scene.tpscene'))); +const PHONE = { viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true, userAgent: 'Mozilla/5.0 (Linux; Android 15; PKH110) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36' }; +const DESKTOP = { viewport: { width: 1440, height: 900 } }; + +/** @param {any} page @param {string} slug */ +async function load(page, slug) { + const bytes = Array.from(fs.readFileSync(path.join(/** @type {string} */ (SC), slug, 'scene.tpscene'))); + await page.evaluate(async (arr) => { + const s = /** @type {any} */ (window).__stores; + const payload = await s.sessions.readSessionZip(new Uint8Array(arr).buffer); + await s.sessions.applySession(payload, { backup: false }); + }, bytes); +} + +/** install the synthetic driver and freeze the governor on the synthetic clock @param {any} page @param {number} level */ +async function freeze(page, level) { + await page.evaluate((lv) => { + const w = /** @type {any} */ (window); + w.__f12 = { t: 1e9 }; + w.__stores.qualityGovernor.governorForTest.setLevel(lv, w.__f12.t); + w.__f12.t += 1000; + /** + * frames at `slow` ms on every third frame while the level is below `cheapFrom` (the + * screen-space water pass), on time otherwise; a decision every 250 ms like the wiring. + * @param {number} dur @param {number} cheapFrom @param {{slow?: number, noLock?: boolean}} [o] + */ + w.__f12drive = (dur, cheapFrom, o = {}) => { + const Q = w.__stores.qualityGovernor; + const g = Q.governorForTest; + const slow = o.slow ?? 33.3; + /** @type {number[]} */ const moves = []; + const end = w.__f12.t + dur; + let next = w.__f12.t + 250; + let i = 0; + while (w.__f12.t < end) { + const ms = g.level() >= cheapFrom ? 16.7 : i % 3 === 0 ? slow : 16.7; + w.__f12.t += ms; + i++; + g.frame(ms, w.__f12.t); + if (w.__f12.t >= next) { + next = w.__f12.t + 250; + const d = Q.decideNow(w.__f12.t); + if (o.noLock) g.clearFlapLock(); + if (d.moved) moves.push(d.level); + } + } + return { moves, level: g.level(), lock: g.flapLock() }; + }; + }, level); +} + +/** @param {any} page */ +const state = (page) => + page.evaluate(() => { + const S = /** @type {any} */ (window).__stores; + let q; + S.qualityGovernor.qualityState.subscribe((/** @type {any} */ v) => (q = v))(); + return { + level: q?.level, + held: q?.held, + reason: q?.reason, + lock: S.qualityGovernor.qualityFlapLock(), + water: S.waterRuntime.waterDebug?.()?.tier ?? null, + toasts: [...document.querySelectorAll('.tp-toast-text')].map((e) => e.textContent?.trim() ?? '') + }; + }); + +/** the water tier a level gives on auto (waterRuntime.resolveTier) */ +const tierOf = (/** @type {number} */ lv) => (lv >= 6 ? 'quest' : lv >= 3 ? 'medium' : 'high'); +/** refraction switches along a list of levels @param {number[]} levels @param {number} start */ +function switches(levels, start) { + let n = 0; + let off = tierOf(start) === 'quest'; + for (const lv of levels) { + const now = tierOf(lv) === 'quest'; + if (now !== off) n++; + off = now; + } + return n; +} + +/** @param {any} page @param {string} name */ +async function shots(page, name) { + if (!OUT) return; + for (const t of ['dark', 'light']) { + await page.evaluate((th) => /** @type {any} */ (window).__stores.themes.theme.set(th), t); + await page.waitForTimeout(400); + await page.screenshot({ path: path.join(OUT, `${name}-${t}.png`) }); + } + await page.evaluate(() => /** @type {any} */ (window).__stores.themes.theme.set('dark')); +} + +/** h.eventually, returning the state it accepted (null on timeout) + * @param {() => Promise} fn @param {(v: any) => boolean} pred @param {string} label @param {number} [timeout] */ +async function until(fn, pred, label, timeout = 10000) { + const t0 = Date.now(); + let last; + while (Date.now() - t0 < timeout) { + last = await fn(); + if (pred(last)) { + h.check(true, label); + return last; + } + await new Promise((r) => setTimeout(r, 400)); + } + console.log(' last: ' + JSON.stringify(last)); + h.check(false, label); + return null; +} + +/** @param {any} page @param {RegExp} re */ +const toastLike = (page, re) => page.evaluate((src) => [...document.querySelectorAll('.tp-toast-text')].some((e) => new RegExp(src).test(e.textContent ?? '')), re.source); + +h.run(async () => { + if (OUT) fs.mkdirSync(OUT, { recursive: true }); + h.check(!!SC, `an Aquarium scene to load (${SC})`); + const browser = await h.launch({ args: h.GPU_ARGS }); + + // ── 1. the phone's lighter start, announced once ───────────────────────────────────────────── + const A = await h.setupPage(browser, 'f12-phone', { context: PHONE }); + h.check(await A.page.evaluate(() => /** @type {any} */ (window).__stores.qualityGovernor.phoneQuality()), 'the app judges this device a phone'); + await until(() => state(A.page), (s) => s.toasts.some((t) => /^Lowered shadows, resolution and ambient occlusion to keep it smooth$/.test(t)), '1.1 the lighter start is announced: "Lowered shadows, resolution and ambient occlusion to keep it smooth"', 8000); + h.check(await A.page.evaluate(() => [...document.querySelectorAll('.tp-toast-action')].some((b) => b.textContent?.trim() === 'Keep full quality')), '1.2 …with a "Keep full quality" action'); + h.check((await A.page.$('#simplified-water-notice')) === null, '1.3 the old "Simplified water" strip is gone (one notice for every toggle)'); + await freeze(A.page, 4); + await A.page.evaluate(() => document.querySelectorAll('.tp-toast-x').forEach((b) => /** @type {HTMLElement} */ (b).click())); + + // ── 2. Aquarium: off, one retry, then held ─────────────────────────────────────────────────── + await load(A.page, 'aquarium'); + await A.page.waitForTimeout(2500); + await A.page.evaluate(() => { + const w = /** @type {any} */ (window); + w.__stores.qualityGovernor.governorForTest.setLevel(5, w.__f12.t); + w.__f12.t += 1000; + }); + await h.eventually(() => state(A.page), (s) => s.water === 'medium', '2.0 (premise) level 5: Aquarium refracts on the half-res screen-space tier', 8000); + if (OUT) await A.page.screenshot({ path: path.join(OUT, '20-after-phone-aquarium-L5-refraction-on.png') }); + + const drop = await A.page.evaluate(() => /** @type {any} */ (window).__f12drive(3000, 6)); + h.check(drop.level === 6 && drop.moves.join() === '6', `2.1 the screen-space pass overloads: 5 -> 6 (${drop.moves.join(',')})`); + const off1 = await until(() => state(A.page), (s) => s.water === 'quest' && s.toasts.some((t) => /^Lowered water quality/.test(t)), '2.2 refraction OFF, and the toast says so', 8000); + const waterToast = off1?.toasts.find((/** @type {string} */ t) => /^Lowered water quality/.test(t)); + h.check(waterToast === 'Lowered water quality and the scene look to keep it smooth', `2.3 the words ("${waterToast}")`); + await shots(A.page, '21-after-phone-water-lowered-toast'); + + const retry = await A.page.evaluate(() => /** @type {any} */ (window).__f12drive(10000, 6)); + h.check(retry.moves.join() === '5', `2.4 cheap frames at level 6: ONE retry back to 5 (${retry.moves.join(',')})`); + await h.eventually(() => state(A.page), (s) => s.water === 'medium', '2.5 the retry brings the refraction back', 8000); + const again = await A.page.evaluate(() => /** @type {any} */ (window).__f12drive(4000, 6)); + h.check(again.moves.join() === '6' && again.lock === 6, `2.6 the retry overloads again: 5 -> 6 and the level is LOCKED (lock ${again.lock})`); + const held = await until(() => state(A.page), (s) => s.water === 'quest' && s.held === true, '2.7 refraction off, qualityState.held', 8000); + h.check(!!held && held.toasts.filter((/** @type {string} */ t) => /^Lowered water quality/.test(t)).length <= 1, '2.8 no second water toast (once per session)'); + const ten = await A.page.evaluate(() => /** @type {any} */ (window).__f12drive(600000, 6)); + h.check(ten.moves.length === 0 && ten.level === 6, `2.9 ten more minutes of steady frames: no switch at all (moves ${ten.moves.join(',') || 'none'})`); + const all = [...drop.moves, ...retry.moves, ...again.moves, ...ten.moves]; + h.check(switches(all, 5) === 3, `2.10 refraction switched ${switches(all, 5)} times in ~10.3 min: off, the one retry, off (was every 10-80 s)`); + if (OUT) await A.page.screenshot({ path: path.join(OUT, '22-after-phone-aquarium-held-refraction-off.png') }); + + // counterfactual: the 1.31 rule (no lock) on the same page and the same frames + await A.page.evaluate(() => { + const w = /** @type {any} */ (window); + w.__stores.qualityGovernor.governorForTest.clearFlapLock(); + w.__stores.qualityGovernor.governorForTest.setLevel(5, w.__f12.t); + w.__f12.t += 1000; + }); + const old = await A.page.evaluate(() => /** @type {any} */ (window).__f12drive(600000, 6, { noLock: true })); + h.check(switches(old.moves, 5) > 8, `2.11 COUNTERFACTUAL — without the lock the refraction switches ${switches(old.moves, 5)} times in 10 min`); + + // a new scene clears the lock + await A.page.evaluate(() => { + const w = /** @type {any} */ (window); + w.__stores.qualityGovernor.governorForTest.setLevel(5, w.__f12.t); + w.__f12.t += 1000; + }); + await A.page.evaluate(() => /** @type {any} */ (window).__f12drive(20000, 6)); + h.check((await state(A.page)).lock === 6, '2.12 (premise) locked again'); + await load(A.page, 'aquarium'); + await h.eventually(() => state(A.page), (s) => s.lock === -1 && s.held === false, '2.12 a scene load clears the lock (new scene, new evidence)', 15000); + await A.ctx.close(); + + // ── 3. Play defers the notice; "Keep full quality" (a tap) ─────────────────────────────────── + const B = await h.setupPage(browser, 'f12-phone-keep', { context: PHONE }); + await freeze(B.page, 4); + await load(B.page, 'aquarium'); + await B.page.waitForTimeout(2500); + await B.page.evaluate(() => { + const w = /** @type {any} */ (window); + w.__stores.qualityGovernor.governorForTest.setLevel(5, w.__f12.t); + w.__f12.t += 1000; + w.__stores.isLocked.set(true); + }); + await B.page.evaluate(() => /** @type {any} */ (window).__f12drive(3000, 6)); + await B.page.waitForTimeout(1500); + h.check(!(await toastLike(B.page, /^Lowered water quality/)), '3.1 a drop while playing is not announced in Play'); + await B.page.evaluate(() => /** @type {any} */ (window).__stores.isLocked.set(null)); + await h.eventually(() => toastLike(B.page, /^Lowered water quality/), (v) => v, '3.2 …it is, once you are back in the editor', 8000); + if (OUT) await B.page.screenshot({ path: path.join(OUT, '23-after-phone-toast-before-tap.png') }); + const keep = B.page.locator('.tp-toast', { hasText: 'Lowered water quality' }).locator('.tp-toast-action', { hasText: 'Keep full quality' }); + await keep.tap(); + const kept = await until(() => state(B.page), (s) => s.level === 0 && s.water === 'high', '3.3 tapping "Keep full quality": full quality, the water refracts at full resolution', 8000); + h.check(kept?.reason === 'kept full quality', `3.4 …recorded as kept (${kept?.reason})`); + const after = await B.page.evaluate(() => /** @type {any} */ (window).__f12drive(60000, 99)); + h.check(after.moves.length === 0 && after.level === 0, `3.5 a minute of missed frames afterwards: it stays at full quality (moves ${after.moves.join(',') || 'none'})`); + if (OUT) await B.page.screenshot({ path: path.join(OUT, '24-after-phone-kept-full-quality.png') }); + h.check((await h.pageErrors(B)).length === 0, '3.6 no page errors'); + await B.ctx.close(); + + // ── 4. desktop: the same rule for every toggle (shadows) ───────────────────────────────────── + const D = await h.setupPage(browser, 'f12-desktop', { context: DESKTOP }); + h.check(!(await D.page.evaluate(() => /** @type {any} */ (window).__stores.qualityGovernor.phoneQuality())), '4.0 (premise) a desktop'); + await freeze(D.page, 0); + await load(D.page, 'aquarium'); + await D.page.waitForTimeout(2500); + await D.page.evaluate(() => { + // a heavy scene for the desktop budget (the governor only acts on one) + /** @type {any} */ (window).__stores.sceneBudget.qualityBaseline.set({ objects: 0, triangles: 0, calls: 5000 }); + }); + const d1 = await D.page.evaluate(() => /** @type {any} */ (window).__f12drive(2500, 99, { slow: 60 })); + h.check(d1.moves.join() === '1', `4.1 missed frames on a heavy scene: shadows off (${d1.moves.join(',')})`); + await h.eventually(() => toastLike(D.page, /^Lowered shadows to keep it smooth$/), (v) => v, '4.2 "Lowered shadows to keep it smooth" · Keep full quality', 8000); + await shots(D.page, '25-after-desktop-shadows-lowered-toast'); + h.check((await h.pageErrors(D)).length === 0, '4.3 no page errors'); + await h.finish(browser); +}); diff --git a/tests/e2e/fb-water-phone.test.cjs b/tests/e2e/fb-water-phone.test.cjs index 1df269c6f..4963d371f 100644 --- a/tests/e2e/fb-water-phone.test.cjs +++ b/tests/e2e/fb-water-phone.test.cjs @@ -4,8 +4,8 @@ // left the Quest tier) — 36-int-124's regression probe as a check; // 2. every quality level (pinned through the game-quality seam): a screenshot of both scenes // and a perf row (calls, triangles, p50/p95 at CPU x4) -> PHONE_OUT/perf-phone.md; -// 3. the one-time "Simplified water" notice: shown when quality takes refraction away, dismiss -// works, once per session, never in Play. +// 3. a game's Quality preset that simplifies the water is not announced (40 F12: the automatic +// drops are AutoQualityNotice's, suite auto-quality-notice). const h = require('./helpers.cjs'); const fs = require('fs'); const path = require('path'); @@ -95,42 +95,19 @@ h.run(async () => { ); console.log(rows.join('\n')); - // ── 3. the one-time notice — on a FRESH page: the level sweep above already used this - // session's one notice (correctly), and the component remembers that for the page's life - h.check((await A.page.evaluate(() => sessionStorage.getItem('water:simplifiedNoticeSeen'))) === '1', 'the sweep\'s first simplified level used the session\'s notice'); + // ── 3. 40 F12: the old "Simplified water" strip became AutoQualityNotice (one toast per + // automatic drop, suite auto-quality-notice). What stays here: a game's own Quality preset is + // an explicit choice, so pinning a level that simplifies the water is NOT announced await A.ctx.close(); - const B = await h.setupPage(browser, 'phone-notice', { context: PHONE }); - const A2 = B; + const A2 = await h.setupPage(browser, 'phone-notice', { context: PHONE }); await load(A2.page, 'aquarium'); await A2.page.waitForTimeout(3000); - // in Play first: never shown there - await A2.page.evaluate(() => { - window.__stores.isLocked.set(true); - window.__stores.qualityGovernor.applyGameQuality(6, 'test'); - }); - await A2.page.waitForTimeout(1500); - h.check((await A2.page.$('#simplified-water-notice')) === null, 'no notice while playing'); - await A2.page.evaluate(() => window.__stores.isLocked.set(null)); - const note = await A2.page.waitForSelector('#simplified-water-notice', { timeout: 5000 }).catch(() => null); - h.check(!!note, 'leaving Play, the notice says the water was simplified'); - const text = note ? await note.textContent() : ''; - h.check(/Simplified water for this device/.test(text) && /Water quality/.test(text), `...and where to give refraction back ("${text?.trim()}")`); - if (OUT && note) - for (const t of ['dark', 'light']) { - await A2.page.evaluate((th) => window.__stores.themes.theme.set(th), t); - await A2.page.waitForTimeout(300); - await A2.page.screenshot({ path: path.join(OUT, `72-phone-simplified-notice-${t}.png`) }); - } - await A2.page.evaluate(() => window.__stores.themes.theme.set('dark')); - await A2.page.click('#simplified-water-notice .sw-x').catch(() => {}); - await A2.page.waitForTimeout(400); - h.check((await A2.page.$('#simplified-water-notice')) === null, 'dismiss hides it'); - // once per session: down to full and back to simplified -> no second notice - await A2.page.evaluate(() => window.__stores.qualityGovernor.applyGameQuality(0, 'test')); - await A2.page.waitForTimeout(1000); await A2.page.evaluate(() => window.__stores.qualityGovernor.applyGameQuality(6, 'test')); + await h.eventually(() => state(A2.page), (s) => s.water === 'quest', 'a pinned level 6: the water is simplified', 8000); await A2.page.waitForTimeout(1500); - h.check((await A2.page.$('#simplified-water-notice')) === null, 'once per session: it does not come back'); + h.check((await A2.page.$('#simplified-water-notice')) === null, 'the old strip is gone'); + h.check(!(await A2.page.evaluate(() => [...document.querySelectorAll('.tp-toast-text')].some((e) => /^Lowered water quality/.test(e.textContent ?? '')))), 'a game preset is not announced as an automatic drop'); + if (OUT) await A2.page.screenshot({ path: path.join(OUT, '72-phone-game-preset-no-notice.png') }); await A2.page.evaluate(() => window.__stores.qualityGovernor.applyGameQuality(null)); h.check((await h.pageErrors(A2)).length === 0, 'no page errors'); await h.finish(browser); diff --git a/tests/unit/qualityNotice.test.js b/tests/unit/qualityNotice.test.js new file mode 100644 index 000000000..19a39c92a --- /dev/null +++ b/tests/unit/qualityNotice.test.js @@ -0,0 +1,45 @@ +// 40 F12 — what the auto-quality notice says: one toast per drop, naming every toggle that drop +// newly lowered, each toggle announced once per session ("Lowered water quality to keep it +// smooth · Keep full quality"). +import { describe, it, expect } from 'vitest'; +import { newlyLowered, toAnnounce, noticeText, isLowered, QUALITY_TOGGLES, KEEP_FULL_LABEL } from '../../src/lib/qualityNoticeCore.js'; +import { overridesAt } from '../../src/lib/qualityGovernorCore.js'; + +/** @param {number} level @param {Record} [extra] */ +const snap = (level, extra = {}) => ({ ...overridesAt(level), water: false, fluid: false, ...extra }); + +describe('quality notice (F12)', () => { + it('the spec sentence, verbatim', () => { + expect(noticeText(['water'])).toBe('Lowered water quality to keep it smooth'); + expect(KEEP_FULL_LABEL).toBe('Keep full quality'); + }); + + it('lists several in toggle order with "and"', () => { + expect(noticeText(['ao', 'shadows', 'resolution'])).toBe('Lowered shadows, resolution and ambient occlusion to keep it smooth'); + expect(noticeText(['post', 'water'])).toBe('Lowered water quality and the scene look to keep it smooth'); + expect(noticeText([])).toBe(''); + }); + + it('the phone start (level 0 -> 4) lowers shadows, resolution and AO', () => { + expect(newlyLowered(snap(0), snap(4))).toEqual(['shadows', 'resolution', 'ao']); + }); + + it('level 5 -> 6 with the water simplified names the water first', () => { + expect(newlyLowered(snap(5), snap(6, { water: true }))).toEqual(['water', 'post']); + }); + + it('a further resolution step is not a new drop; a recovery is not a drop', () => { + expect(newlyLowered(snap(2), snap(3))).toEqual([]); // 85 % -> 72 % + expect(newlyLowered(snap(6, { water: true }), snap(5))).toEqual([]); + }); + + it('once per session: an announced toggle is never announced again', () => { + expect(toAnnounce(['water', 'post'], ['post'])).toEqual(['water']); + expect(toAnnounce(['water'], ['water'])).toEqual([]); + }); + + it('every toggle in the list has a lowered rule', () => { + for (const t of QUALITY_TOGGLES) expect(isLowered(t.key, snap(9, { water: true, fluid: true }))).toBe(true); + for (const t of QUALITY_TOGGLES) expect(isLowered(t.key, snap(0))).toBe(false); + }); +}); From 3112c54698784e4b5b0e9d31e8992a2f40d6d29a Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 8 Oct 2026 14:13:33 +0300 Subject: [PATCH 06/92] [fix] 40 F6-F11 dock and window chrome from the 1.31 review - F6 docked tab strip: the bar paints from a non-scrolling wrapper, so every tab (not just the first three) sits on it when scrolled and the edge fade masks only the tabs; one shared scroll position survives a tab pick (each panel mounts its own strip, which used to start at 0) - F7 window header names: one line + ellipsis, full name on hover and touch long-press ($lib/ui/fullName.js, on WindowChrome titles and the docked .tp-dock-sub which had no rule); a phone-narrow Animation moves the name out of the control row - F8 docked windows carry no close or undock button (tabs carry the close, the dock code workspace keeps its unsaved-tabs ask through registerDockCloser); the undock button sits left of "minimize the dock" and undocks the active tab, or every tab as one tabbed group (Settings > Interface > Windows & chrome > Undock button); a floating tab group gets a dock-all button left of "close all tabs" - F9 phone "+" (floating windows off) opens a sheet of every window with show/hide switches; with floating windows on it is the desktop menu - F10 a docked tab dropped onto/into a floating window joins it as a tab (windowTabs.windowAt + queueMerge); dragWindow's size watch and window-resize re-clamp stand down for tab members, which threw a revealed member off its group - F11 the Layouts popover is the kit ModalDialog (it painted with a theme var most themes no longer define); own desktop width because the kit width prop loses to the layered !important frame rule - bottomDock.armDockModes queues several dock-mode arms (the slot is write-once) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/DockTabs.svelte | 191 ++++++++++++++---- src/components/DockViewsSheet.svelte | 92 +++++++++ src/components/Flow.svelte | 7 - src/components/editors/AnimationWindow.svelte | 29 ++- src/components/editors/CodeWorkspace.svelte | 8 +- src/components/editors/Explorer.svelte | 7 - src/components/editors/FlowCode.svelte | 2 - src/components/editors/HudEditor.svelte | 2 - src/components/editors/Profiler.svelte | 10 - src/components/editors/ShaderEditor.svelte | 21 +- src/components/editors/UvEditor.svelte | 2 - src/components/menu/LayoutsMenu.svelte | 87 +++----- src/components/menu/TabStrips.svelte | 17 +- .../menu/settings/InterfaceSettings.svelte | 3 + .../menu/settings/UndockButtonSettings.svelte | 24 +++ src/components/ui/WindowChrome.svelte | 3 +- src/lib/bottomDock.js | 41 ++++ src/lib/debugHooks.js | 1 + src/lib/dockMenu.js | 92 ++++++++- src/lib/dragWindow.js | 9 + src/lib/ui/fullName.js | 121 +++++++++++ src/lib/windowTabs.js | 48 +++++ src/styles/windows.css | 42 ++++ 23 files changed, 706 insertions(+), 153 deletions(-) create mode 100644 src/components/DockViewsSheet.svelte create mode 100644 src/components/menu/settings/UndockButtonSettings.svelte create mode 100644 src/lib/ui/fullName.js diff --git a/src/components/DockTabs.svelte b/src/components/DockTabs.svelte index f3ad525c4..89a84e36d 100644 --- a/src/components/DockTabs.svelte +++ b/src/components/DockTabs.svelte @@ -1,3 +1,8 @@ + + + +
+ +
    + {#each rows as row (row.key)} +
  • + + + {row.title} + {row.tooltip} + + setDockViewShown(row.key, on)} + /> +
  • + {/each} +
+
+
+ + diff --git a/src/components/Flow.svelte b/src/components/Flow.svelte index 0d72d5252..dd5dab6b7 100644 --- a/src/components/Flow.svelte +++ b/src/components/Flow.svelte @@ -201,13 +201,6 @@ onpointerup={endResize} > -
diff --git a/src/components/editors/AnimationWindow.svelte b/src/components/editors/AnimationWindow.svelte index 60aa95d86..f6edbccea 100644 --- a/src/components/editors/AnimationWindow.svelte +++ b/src/components/editors/AnimationWindow.svelte @@ -40,6 +40,7 @@ import DockTabs from '../DockTabs.svelte'; import WindowChrome from '../ui/WindowChrome.svelte'; import ScrollStrip from '../ui/ScrollStrip.svelte'; + import { fullName } from '$lib/ui/fullName.js'; import Segmented from '../ui/Segmented.svelte'; import { createGesture } from '$lib/modalGrab'; // W5: the BINDING for this pane's grab key lives in the shortcut registry (an @@ -2669,14 +2670,22 @@ onpointerup={endResize} >
-
- Animation - {target ? target.name || 'object' : 'no selection'} - {#if compact && target}{@render paneSwitch()}{/if} - - - -
+ + {#if compact && target} +
+ Animation + {target.name || 'object'} +
+
{@render paneSwitch()}
+ {:else} +
+ Animation + {target ? target.name || 'object' : 'no selection'} +
+ {/if}
{@render body()}
@@ -2707,14 +2716,14 @@ > {#snippet heading()} Animation - {target ? target.name || 'object' : 'no selection'} - {#if compact && target}{@render paneSwitch()}{/if} + {target ? target.name || 'object' : 'no selection'} {/snippet} {#snippet actions()} {/snippet} + {#if compact && target}
{@render paneSwitch()}
{/if} {@render body()}
registerDockCloser('code', requestCloseWorkspace)); async function closeSavingAll() { const failed = await saveAllCodeTabs(); if (failed.length) { @@ -762,8 +766,6 @@ {@render openGraph()} {@render sideToggle('right')} - -
{@render main()} diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 27a2467cb..94cc4981f 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -8328,13 +8328,6 @@ {@render logChip()} {@render storageChip()} {@render identityChip()} -
{@render content()} diff --git a/src/components/editors/FlowCode.svelte b/src/components/editors/FlowCode.svelte index 1dfcc8bed..696eb9cff 100644 --- a/src/components/editors/FlowCode.svelte +++ b/src/components/editors/FlowCode.svelte @@ -232,8 +232,6 @@ Flow Code {@render editorActions()} - -
{@render body()} diff --git a/src/components/editors/HudEditor.svelte b/src/components/editors/HudEditor.svelte index 106374edc..58778f22f 100644 --- a/src/components/editors/HudEditor.svelte +++ b/src/components/editors/HudEditor.svelte @@ -1454,8 +1454,6 @@
HUD editor - -
{@render body()} diff --git a/src/components/editors/Profiler.svelte b/src/components/editors/Profiler.svelte index 4eaf64578..2cf74dd49 100644 --- a/src/components/editors/Profiler.svelte +++ b/src/components/editors/Profiler.svelte @@ -827,16 +827,6 @@ {@render controls()} - -
{@render body()} diff --git a/src/components/editors/ShaderEditor.svelte b/src/components/editors/ShaderEditor.svelte index bec319577..0d7f7ebf7 100644 --- a/src/components/editors/ShaderEditor.svelte +++ b/src/components/editors/ShaderEditor.svelte @@ -693,12 +693,15 @@
diff --git a/src/components/editors/UvEditor.svelte b/src/components/editors/UvEditor.svelte index 1d14245c6..41a5b2c18 100644 --- a/src/components/editors/UvEditor.svelte +++ b/src/components/editors/UvEditor.svelte @@ -2021,8 +2021,6 @@
UV editor - -
{@render body()} diff --git a/src/components/menu/LayoutsMenu.svelte b/src/components/menu/LayoutsMenu.svelte index e6613052d..06089eb32 100644 --- a/src/components/menu/LayoutsMenu.svelte +++ b/src/components/menu/LayoutsMenu.svelte @@ -1,71 +1,46 @@ + { - if ($layoutsMenuOpen && e.key === 'Escape') layoutsMenuOpen.set(false); + if ($layoutsMenuOpen && e.key === 'Escape') close(); }} /> -{#if $layoutsMenuOpen} -
-
- Workspace layouts - -
- -
-{/if} + $layoutsMenuOpen, (v) => layoutsMenuOpen.set(v)} + aria-label="Workspace layouts" +> + + diff --git a/src/components/menu/TabStrips.svelte b/src/components/menu/TabStrips.svelte index 8ca4c2e41..9cf5d3ab9 100644 --- a/src/components/menu/TabStrips.svelte +++ b/src/components/menu/TabStrips.svelte @@ -8,7 +8,8 @@ import { focusTick, raiseWindowNode } from '$lib/windowFocus'; import ContextMenu from '../ContextMenu.svelte'; import Icon from '../ui/Icon.svelte'; - import { DOCK_ICONS } from '$lib/bottomDock'; + import { DOCK_ICONS, DOCK_FAMILY } from '$lib/bottomDock'; + import { dockAllOf } from '$lib/dockMenu'; // 38 NOTES-38 #23: the view icon before each tab's name — the dock's icons plus the // windows that only ever float @@ -122,6 +123,20 @@ {/each}
+ + {#if group.members.some((/** @type {string} */ k) => DOCK_FAMILY.includes(k))} + + {/if}
@@ -708,7 +720,7 @@ left: 50%; transform: translateX(-50%); bottom: calc(84px + env(safe-area-inset-bottom, 0px)); - z-index: 41; /* above the sheets (38), a hosted window (40) and the Inspector */ + z-index: 44; /* above every sheet (38-43), a hosted window (40) and the Inspector */ display: flex; align-items: center; gap: 2px; @@ -775,7 +787,7 @@ left: 0; right: 0; bottom: 0; - z-index: 42; /* #32: above the sheets, so the raised Play circle is never covered */ + z-index: 44; /* #32 / 40 F1: above every sheet (the main menu is 43), so the raised Play circle is never covered */ height: calc(76px + env(safe-area-inset-bottom, 0px)); padding: 0 6px calc(8px + env(safe-area-inset-bottom, 0px)); display: grid; @@ -826,6 +838,14 @@ font: 600 10px/17px var(--font-ui-mono, monospace); } + .ps-dockgrip { + position: fixed; + left: 0; + right: 0; + height: 24px; + z-index: 36; /* over its dock panel (--z-bottom, 35), under every sheet */ + background: transparent; + } .ps-scrim { position: fixed; inset: 0 0 calc(76px + env(safe-area-inset-bottom, 0px)) 0; /* #32: the bar stays live */ diff --git a/src/components/ui/ModuleToolboxLayer.svelte b/src/components/ui/ModuleToolboxLayer.svelte index 73315c281..c7cb07cdd 100644 --- a/src/components/ui/ModuleToolboxLayer.svelte +++ b/src/components/ui/ModuleToolboxLayer.svelte @@ -33,6 +33,7 @@ width={box.width ?? 220} minW={box.minW ?? 160} defaultRect={box.defaultRect ?? { right: 12, top: 76 }} + onclose={() => closeModuleToolbox(box.id)} > {#snippet actions()} + + + diff --git a/src/components/ui/ToolboxWindow.svelte b/src/components/ui/ToolboxWindow.svelte index 744db4c09..e15236dd3 100644 --- a/src/components/ui/ToolboxWindow.svelte +++ b/src/components/ui/ToolboxWindow.svelte @@ -42,6 +42,9 @@ // `style:` writes ONE property via setProperty and leaves the rest alone — // the pattern Flow.svelte already uses for its width/height. import Icon from './Icon.svelte'; + import SheetGrip from './SheetGrip.svelte'; + import { saveSheetH } from '$lib/ui/sheetDrag.js'; + import { phoneSheetMaxH } from '$lib/ui/phoneShell.js'; import { dragWindow } from '$lib/dragWindow'; import { focusStack } from '$lib/windowFocus'; import { notesDrawerOpen, inspectorClose } from '../../stores/appStore'; @@ -50,7 +53,8 @@ /** @type {{ id: string, title: string, key: string, * defaultRect?: { left?: number, top?: number, right?: number, bottom?: number }, - * minW?: number, width?: number, tabs?: any, actions?: any, status?: any, children: any }} */ + * minW?: number, width?: number, tabs?: any, actions?: any, status?: any, children: any, + * onclose?: () => void }} */ let { id, title, @@ -61,7 +65,10 @@ tabs = null, actions = null, status = null, - children + children, + // 40 F1: what a swipe-away does on the phone sheet; absent = the sheet only resizes (a + // toolbox that belongs to an edit session closes through its own Done) + onclose = undefined } = $props(); // 18-C3: the exact 640 breakpoint the Inspector and the notes drawer use for @@ -92,37 +99,11 @@ const saved = parseInt(safeStorage.getItem(sheetKey) || ''); sheetH = !saved || Number.isNaN(saved) ? Math.round(window.innerHeight * 0.4) : saved; }); - let sheetResizing = $state(false); - /** @param {PointerEvent} e */ - function startSheetResize(e) { - sheetResizing = true; - /** @type {HTMLElement} */ (e.currentTarget).setPointerCapture?.(e.pointerId); - e.preventDefault(); - e.stopPropagation(); // never start a window DRAG from the grabber - } - /** @param {PointerEvent} e */ - function doSheetResize(e) { - if (!sheetResizing) return; - // bottom:0, so height = viewport height - finger y; the ceiling clears the - // Connect bar + the top-right chrome, exactly as the Inspector sheet does - const cb = - parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--connect-bottom')) || 54; - const maxH = Math.max(180, window.innerHeight - cb - 56); - sheetH = Math.min(Math.max(140, window.innerHeight - e.clientY), maxH); - } - /** @param {PointerEvent} e */ - function endSheetResize(e) { - if (!sheetResizing) return; - sheetResizing = false; - // releasePointerCapture THROWS when the pointer was never captured, and it - // used to run before the write — so a capture quirk silently cost the user - // their height. The persist does not depend on it. - try { - /** @type {HTMLElement} */ (e.currentTarget).releasePointerCapture?.(e.pointerId); - } catch {} - try { - safeStorage.setItem(sheetKey, String(sheetH)); - } catch {} + // 40 F1: the grab bar is ui/SheetGrip (shared gesture; the height keeps its per-toolbox key) + /** @param {number} h @param {boolean} done */ + function sheetResize(h, done) { + sheetH = Math.round(h); + if (done) saveSheetH(sheetKey, sheetH); } @@ -137,14 +118,9 @@ use:focusStack={key} > {#if sheetMode} - -
-
+ +
+ onclose?.()} />
{/if}
@@ -274,17 +250,6 @@ .tbx-sheet-resize { display: none; flex: 0 0 auto; - height: 18px; - align-items: center; - justify-content: center; - cursor: ns-resize; - touch-action: none; - } - .tbx-sheet-grab { - width: 40px; - height: 4px; - border-radius: 9999px; - background: var(--text-faint); } @media (max-width: 640px) { .toolbox.tbx-sheet { @@ -294,7 +259,7 @@ bottom: 0 !important; width: 100vw !important; max-width: 100vw !important; - height: var(--tbx-sheet-h, 40vh) !important; + height: min(var(--tbx-sheet-h, 40vh), var(--ps-sheet-max, 100vh)) !important; /* never rise above the Connect bar + the top-right chrome */ max-height: calc(100vh - var(--connect-bottom, 54px) - 56px) !important; border-radius: 0.75rem 0.75rem 0 0 !important; @@ -306,7 +271,7 @@ padding-bottom: var(--controls-inset, 0px) !important; } .toolbox.tbx-sheet .tbx-sheet-resize { - display: flex; + display: block; } /* a sheet is not draggable, and its width grip is meaningless */ .toolbox.tbx-sheet .toolbox-header { diff --git a/src/lib/ui/phoneShell.js b/src/lib/ui/phoneShell.js index 68b171bc7..6fbe31e37 100644 --- a/src/lib/ui/phoneShell.js +++ b/src/lib/ui/phoneShell.js @@ -91,3 +91,10 @@ export function phoneDetentHeights(viewportH) { * stop publishing a docked bar's height: on the phone shell the bar is a sheet, so nothing * should be pushed down by it. */ export const phoneShellActive = writable(false); + +/** 40 F1: the room every phone sheet may take, published by PhoneShell (px) — between the top + * bar and the bottom bar, less the selection strip while one rides on top (`phoneSheetMax` in + * sheetSnap.js). Also on as `--ps-sheet-max` for the CSS-placed sheets. 0 = no shell. */ +export const phoneSheetMaxH = writable(0); +/** the smallest a resizable phone sheet gets before a release closes it (px) */ +export const PHONE_SHEET_MIN = 180; diff --git a/src/lib/ui/sheetDrag.js b/src/lib/ui/sheetDrag.js new file mode 100644 index 000000000..bff94aaa5 --- /dev/null +++ b/src/lib/ui/sheetDrag.js @@ -0,0 +1,167 @@ +// 40 F1 — THE ONE SHEET GESTURE. Every phone drawer (the shell's framed sheets, ui/Sheet, the +// Inspector / Configure Scene, the main menu, the profile menu, the Add list, the notes sheet, +// the toolboxes, the dock) is dragged by its grab bar through THIS action, so they all resize +// the same way and all close the same way: swipe down to the end (or flick down from the min). +// Where a release rests is the pure rule in sheetSnap.js (`settleSheet` for a free-height +// sheet; a detent sheet passes its own `settle`). +// +// use:sheetDrag={{ +// height: () => number, the sheet's height NOW (read at the press) +// min: () => number, max: () => number, +// dismissible?: boolean, default true +// onmove: (h) => void, live height while the finger drags +// onsettle: (h) => void, the resting height after a release / key +// onclose: () => void, a release that dismisses +// ontap?: () => void, a press that did not travel (the grip's own click) +// settle?: (g) => number|'closed' replaces settleSheet (detent sheets) +// keys?: boolean false = the owner handles ArrowUp/ArrowDown itself +// }} +// +// POINTER events (touch arrives as pointerType "touch"), DIRECT listeners (svelte delegates +// `onpointerdown` to the root and panel chrome swallows it on the way up — the repo rule for a +// gesture inside a panel), pointer capture so the finger may leave the bar. A press that starts +// on a control INSIDE the grab zone (a close button, a title field) belongs to that control. +// The grab bar needs `touch-action: none` or the browser scrolls the page instead. +import { settleSheet } from './sheetSnap.js'; +import { safeStorage } from '../safeStorage'; + +/** px of travel before a press becomes a drag */ +export const DRAG_SLOP = 6; +/** a click that a drag ends with is not a tap (the click fires after pointerup) */ +const CLICK_AFTER_DRAG_MS = 350; +/** keyboard step for ArrowUp / ArrowDown on a free-height sheet */ +const KEY_STEP = 64; + +const INTERACTIVE = 'input, textarea, select, a[href], [contenteditable=""], [contenteditable="true"], button:not([data-sheet-grip])'; + +/** + * @typedef {{height: () => number, min: () => number, max: () => number, dismissible?: boolean, keys?: boolean, + * onmove?: (h: number) => void, onsettle?: (h: number) => void, onclose?: () => void, ontap?: () => void, + * settle?: (g: {height: number, velocity: number, min: number, max: number, dismissible: boolean}) => number|'closed'}} SheetDragOptions + */ + +/** @param {HTMLElement} node @param {SheetDragOptions} options */ +export function sheetDrag(node, options) { + let opts = options; + let startY = 0; + let startH = 0; + let moved = false; + let draggedAt = -Infinity; + let pointerId = -1; + /** recent [time, y] samples for the release velocity @type {[number, number][]} */ + let samples = []; + + const clampLive = (/** @type {number} */ h) => Math.max(0, Math.min(opts.max(), h)); + /** @param {number} height @param {number} velocity */ + function release(height, velocity) { + const g = { height, velocity, min: opts.min(), max: opts.max(), dismissible: opts.dismissible !== false }; + const at = (opts.settle ?? settleSheet)(g); + if (at === 'closed') opts.onclose?.(); + else opts.onsettle?.(at); + } + + /** @param {PointerEvent} e */ + const down = (e) => { + if (e.button !== 0 || pointerId !== -1) return; + const t = /** @type {HTMLElement} */ (e.target); + if (t !== node && t.closest?.(INTERACTIVE) && node.contains(t.closest(INTERACTIVE))) return; + pointerId = e.pointerId; + startY = e.clientY; + startH = opts.height(); + moved = false; + samples = [[e.timeStamp, e.clientY]]; + try { + node.setPointerCapture?.(e.pointerId); + } catch {} + }; + /** @param {PointerEvent} e */ + const move = (e) => { + if (e.pointerId !== pointerId) return; + const dy = e.clientY - startY; + if (!moved && Math.abs(dy) < DRAG_SLOP) return; + moved = true; + samples.push([e.timeStamp, e.clientY]); + if (samples.length > 6) samples.shift(); + opts.onmove?.(clampLive(startH - dy)); + e.preventDefault(); + }; + /** @param {PointerEvent} e */ + const up = (e) => { + if (e.pointerId !== pointerId) return; + pointerId = -1; + try { + node.releasePointerCapture?.(e.pointerId); + } catch {} + if (!moved) return; // a tap: the grip's own click handles it + const first = samples[0]; + samples = []; + const velocity = e.type === 'pointercancel' ? 0 : (e.clientY - first[1]) / Math.max(1, e.timeStamp - first[0]); + draggedAt = performance.now(); + release(clampLive(startH - (e.clientY - startY)), velocity); + }; + /** @param {MouseEvent} e */ + const click = (e) => { + if (performance.now() - draggedAt < CLICK_AFTER_DRAG_MS) { + e.stopPropagation(); + e.preventDefault(); + return; + } + const t = /** @type {HTMLElement} */ (e.target); + if (t !== node && t.closest?.(INTERACTIVE) && node.contains(t.closest(INTERACTIVE)) && !t.closest('[data-sheet-grip]')) return; + opts.ontap?.(); + }; + /** @param {KeyboardEvent} e */ + const key = (e) => { + if (opts.keys === false || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return; + const t = /** @type {HTMLElement} */ (e.target); + if (t !== node && !t.closest?.('[data-sheet-grip]')) return; + e.preventDefault(); + const h = opts.height(); + if (e.key === 'ArrowUp') release(Math.min(opts.max(), h + KEY_STEP), 0); + else if (h <= opts.min() + 1) release(0, 0); // already at the min: down closes + else release(Math.max(opts.min(), h - KEY_STEP), 0); + }; + + node.addEventListener('pointerdown', down); + node.addEventListener('pointermove', move); + node.addEventListener('pointerup', up); + node.addEventListener('pointercancel', up); + node.addEventListener('click', click, true); + node.addEventListener('keydown', key); + return { + /** @param {SheetDragOptions} next */ + update(next) { + opts = next; + }, + destroy() { + node.removeEventListener('pointerdown', down); + node.removeEventListener('pointermove', move); + node.removeEventListener('pointerup', up); + node.removeEventListener('pointercancel', up); + node.removeEventListener('click', click, true); + node.removeEventListener('keydown', key); + } + }; +} + +// ---- remembered heights ------------------------------------------------------------------- +// Each sheet keeps the storage key it always had (inspectorSheetH, notesSheetH, tbxSheetH:, +// flowDockHeight …: the behaviour lock), so this only centralises the read/clamp/write. + +/** @param {string} key @param {number} fallback @returns {number} */ +export function readSheetH(key, fallback) { + try { + const v = parseInt(safeStorage.getItem(key) || ''); + return Number.isFinite(v) && v > 0 ? v : fallback; + } catch { + return fallback; + } +} +/** @param {string} key @param {number} h */ +export function saveSheetH(key, h) { + try { + safeStorage.setItem(key, String(Math.round(h))); + } catch { + // private mode: remembered for this session only + } +} diff --git a/src/lib/ui/sheetSnap.js b/src/lib/ui/sheetSnap.js index addc4a93f..d24fc2039 100644 --- a/src/lib/ui/sheetSnap.js +++ b/src/lib/ui/sheetSnap.js @@ -66,3 +66,50 @@ export function stepDetent(current, dir, detents, heights, dismissible = true) { if (i === 0) return dismissible ? 'closed' : current; return order[i - 1]; } + +// ---- 40 F1: FREE-HEIGHT sheets (the phone's drawers) -------------------------------------- +// Most phone drawers are not three-detent sheets: the Inspector, the main menu, the Add list, +// the dock rest wherever the finger leaves them (the height is remembered), between a MIN and +// a MAX. What every one of them shares is where a release ENDS: +// - dragged down "to the end" (under DISMISS_FRACTION of the min) -> closed; +// - a flick DOWN that starts at or near the min -> closed (the swipe-away gesture); +// a flick down from higher up -> the min; +// - a flick UP -> the max; +// - a slow release -> stays where it is, clamped to [min, max], and SNAPS to min / max +// when it lands within SNAP_PX of either (so "all the way up" is reachable by feel). + +/** px within which a slow release snaps onto the min or the max */ +export const SNAP_PX = 32; + +/** + * Where a released free-height sheet comes to rest. + * @param {{height: number, velocity: number, min: number, max: number, dismissible?: boolean}} g + * height = the sheet's height at release (px); velocity = px/ms, POSITIVE = moving DOWN + * @returns {number|'closed'} the resting height, or 'closed' + */ +export function settleSheet({ height, velocity, min, max, dismissible = true }) { + const lo = Math.max(0, Math.min(min, max)); + const hi = Math.max(lo, max); + if (velocity > FLICK_VELOCITY) { + if (dismissible && height <= lo + SNAP_PX) return 'closed'; + return lo; + } + if (velocity < -FLICK_VELOCITY) return hi; + if (dismissible && height < lo * DISMISS_FRACTION) return 'closed'; + const h = Math.min(hi, Math.max(lo, height)); + if (hi - h <= SNAP_PX) return hi; + if (h - lo <= SNAP_PX) return lo; + return Math.round(h); +} + +/** + * The room a phone sheet may take: everything between the top bar and the bottom bar, less + * the selection strip while one rides on top of the sheet (NOTES-38 #32 b) — so a sheet at its + * max never pushes the strip under the top bar or covers Play (40 F1, cover-play-and-handle). + * @param {number} viewportH window.innerHeight + * @param {{barH?: number, topH?: number, stripH?: number}} [o] bottom bar, top bar (logo / + * chip / avatar row), and the strip's height + gap (0 = nothing selected) + */ +export function phoneSheetMax(viewportH, o = {}) { + return Math.max(160, Math.round(viewportH - (o.barH ?? 76) - (o.topH ?? 64) - (o.stripH ?? 0))); +} diff --git a/src/styles/phone.css b/src/styles/phone.css index 5336962b8..b64d50907 100644 --- a/src/styles/phone.css +++ b/src/styles/phone.css @@ -78,7 +78,11 @@ } :root.phone-shell .top-right-chrome > .hud-tr, :root.phone-shell #peers-popover, -:root.phone-shell #notif-panel { +:root.phone-shell #notif-panel, +:root.phone-shell #avatar-dropdown { + /* 40 F2: the profile menu lives inside the corner wrapper too — without this it inherited + `pointer-events: none` and every tap on Customize character / Profile settings fell + through to the canvas */ pointer-events: auto; } :root.phone-shell #avatar-trigger { @@ -91,46 +95,47 @@ width: 44px !important; height: 44px !important; } -/* the profile menu opens as a sheet from the bottom edge, where the thumb is */ +/* the profile menu opens as a sheet from the bottom edge, where the thumb is — 40 F1: ABOVE the + bottom bar (Play stays visible), resizable and swiped away by its grab bar */ :root.phone-shell #avatar-dropdown { position: fixed !important; top: auto !important; - bottom: 0 !important; + bottom: var(--ps-bar-h) !important; left: 0 !important; right: 0 !important; width: auto !important; max-width: none !important; - max-height: 92vh; + height: var(--profile-sheet-h, auto); + max-height: var(--ps-sheet-max, 92vh); overflow-y: auto; margin: 0 !important; border-radius: 18px 18px 0 0 !important; - padding-bottom: env(safe-area-inset-bottom, 0px); + padding-bottom: 8px; } -/* the main menu is ONE sheet (the logo still toggles it) */ +:root.phone-shell #avatar-dropdown .pm-grip { + margin-bottom: 2px; + border-radius: 18px 18px 0 0; +} +/* the main menu is ONE sheet (the logo still toggles it). 40 F1: it sits ABOVE the bottom bar + (Play stays visible), takes the remembered height inside the shell's sheet room, and its + grab bar (ui/SheetGrip, sticky) resizes it and swipes it away */ :root.phone-shell #sidebar70 { position: fixed !important; top: auto !important; left: 0 !important; right: 0 !important; - bottom: 0 !important; + bottom: var(--ps-bar-h) !important; width: auto !important; min-width: 0 !important; max-width: none !important; - max-height: 92vh !important; - padding: 26px 8px calc(12px + env(safe-area-inset-bottom, 0px)) !important; + height: var(--menu-sheet-h, var(--ps-sheet-max, 70vh)) !important; + max-height: var(--ps-sheet-max, 92vh) !important; + padding: 0 8px 12px !important; border-radius: 18px 18px 0 0 !important; font-size: var(--fs-body); } -:root.phone-shell #sidebar70::before { - content: ''; - position: absolute; - top: 9px; - left: 50%; - width: 40px; - height: 5px; - margin-left: -20px; - border-radius: 3px; - background: var(--border-strong); +:root.phone-shell #sidebar70 .side-grip { + margin: 0 -8px 2px; } :root.phone-shell #sidebar70 .side-row { min-height: 48px; @@ -231,26 +236,21 @@ bottom: 0 !important; width: auto !important; min-width: 0 !important; - height: min(68vh, 600px); - max-height: min(68vh, 600px) !important; + /* 40 F1: the height the grab bar left it at (ContextMenu publishes --ctx-sheet-h), inside + the shell's sheet room — so the Add list never pushes the selection strip under the top + bar (cover-play-and-handle) */ + height: min(var(--ctx-sheet-h, 68vh), var(--ps-sheet-max, 600px)); + max-height: var(--ps-sheet-max, min(68vh, 600px)) !important; box-sizing: border-box; - padding: 18px 6px calc(10px + env(safe-area-inset-bottom, 0px)) !important; + padding: 0 6px calc(10px + env(safe-area-inset-bottom, 0px)) !important; border-radius: 18px 18px 0 0 !important; border-width: 1px 0 0 !important; font-size: 15px !important; box-shadow: 0 -12px 32px color-mix(in srgb, var(--bg-app) 35%, transparent) !important; } -/* the sheet's grabber: a visual cue that this is a sheet (the backdrop closes it) */ -:root.phone-shell .ctx-scroll[role='menu']::before { - content: ''; - position: absolute; - top: 7px; - left: 50%; - width: 36px; - height: 4px; - margin-left: -18px; - border-radius: 2px; - background: var(--text-faint); +/* the sheet's grab bar is ui/SheetGrip (40 F1); it bleeds over the 6px side padding */ +:root.phone-shell .ctx-scroll .ctx-grip-bar { + margin: 0 -6px 4px; } :root.phone-shell .ctx-scroll .ctx-row { display: flex; @@ -269,7 +269,7 @@ gap: 6px; width: 100%; min-height: 44px; - margin: -8px 0 4px; + margin: 10px 0 4px; padding: 0 8px; border: 0; border-bottom: 1px solid var(--border); @@ -347,7 +347,10 @@ /* (the Inspector's phone-shell rule lives in ui.css — its base rule there is a layered !important) */ :root.phone-shell .tp-dock-panel { bottom: var(--ps-bar-h) !important; - max-height: calc(100vh - var(--ps-bar-h) - 64px) !important; + /* 40 F1: the shell's sheet room, less DockTabs' strip riding above the panel */ + max-height: calc(var(--ps-sheet-max, calc(100vh - var(--ps-bar-h) - 64px)) - 28px) !important; + /* room for the dock's grab bar (PhoneShell #ps-dock-grip, drawn over this edge) */ + padding-top: 26px !important; } :root.phone-shell #notes-drawer { bottom: var(--ps-bar-h) !important; @@ -360,3 +363,29 @@ :root.phone-shell #notes-drawer { padding-bottom: 12px !important; } + +/* 40 F1 — NOTHING COVERS PLAY OR THE SELECTION STRIP. The main menu, the profile menu and the + Add list keep their own tiers (over toasts, as before), so while one of them is open the bar + and the strip rise over it: the sheet ends at the bar's top and the raised Play circle is + drawn on top of its edge (cover-play-and-handle). */ +:root.phone-shell:has(#sidebar70) #ps-bar, +:root.phone-shell:has(#sidebar70) #ps-strip, +:root.phone-shell:has(#avatar-dropdown) #ps-bar, +:root.phone-shell:has(#avatar-dropdown) #ps-strip, +:root.phone-shell.ps-add-open #ps-bar, +:root.phone-shell.ps-add-open #ps-strip { + z-index: calc(var(--z-menu) + 2) !important; +} +/* a framed window that lives inside another stacking context (the notification centre is in the + top-right corner chrome, z 997) paints at THAT level, so the bar and the strip rise over it + too — still under the full-screen modals (1100), which cover everything (#32 e) */ +:root.phone-shell:has(.top-right-chrome [data-ps-host]) #ps-bar, +:root.phone-shell:has(.top-right-chrome [data-ps-host]) #ps-strip { + z-index: 999 !important; +} + +/* 40 F1: on the phone the notification centre is a framed sheet, whose own scrim (above the bar) + closes it — the desktop click-catcher would cover the bar, Play and the sheet's grab bar */ +:root.phone-shell .notif-backdrop { + display: none !important; +} diff --git a/src/styles/ui.css b/src/styles/ui.css index dc0d00d85..71596853f 100644 --- a/src/styles/ui.css +++ b/src/styles/ui.css @@ -146,22 +146,10 @@ input.tp-check:disabled { z-index: 1000 !important; } } -/* inspector bottom-sheet drag handle — hidden in the wide side-drawer layout */ +/* inspector bottom-sheet grab bar (40 F1: ui/SheetGrip, sticky) — hidden in the wide + side-drawer layout */ .ins-resize { - display: none; - flex: 0 0 auto; - height: 16px; - margin: -0.25rem 0 0.25rem; - cursor: ns-resize; - touch-action: none; - align-items: center; - justify-content: center; -} -.ins-grabber { - width: 40px; - height: 4px; - border-radius: 9999px; - background: color-mix(in srgb, var(--text-faint) 70%, transparent); + display: none !important; } @media (max-width: 640px) { /* Object/mesh/scene settings become a bottom SHEET (like the scene-notes sheet + the @@ -177,7 +165,7 @@ input.tp-check:disabled { padding-bottom: var(--controls-inset, 0px) !important; width: 100% !important; max-width: 100vw !important; - height: var(--inspector-h, 45vh) !important; + height: min(var(--inspector-h, 45vh), var(--ps-sheet-max, 100vh)) !important; /* never rise above the Connect bar + top-right chrome (like the Flow/Explorer dock) */ max-height: calc(100vh - var(--connect-bottom, 54px) - 56px) !important; border-radius: 0.75rem 0.75rem 0 0 !important; @@ -188,10 +176,15 @@ input.tp-check:disabled { :root.phone-shell #inspector { bottom: var(--ps-bar-h) !important; padding-bottom: 16px !important; - max-height: calc(100vh - var(--ps-bar-h) - 64px) !important; + /* 40 F1: the shell's sheet room — clear of the top bar AND of the selection strip */ + max-height: var(--ps-sheet-max, calc(100vh - var(--ps-bar-h) - 64px)) !important; } .ins-resize { - display: flex; + display: flex !important; + } + /* 40 F1: the Inspector's sticky header sticks UNDER the sticky grab bar, never over it */ + #inspector .ins-head { + top: 24px; } /* the notifications panel is anchored to the bell (top-right); on a narrow screen pin it to the viewport so its 20rem width can't spill off the left/top edge */ diff --git a/tests/e2e/mobile-map.test.cjs b/tests/e2e/mobile-map.test.cjs index e50b7dc9d..b62bbeb7c 100644 --- a/tests/e2e/mobile-map.test.cjs +++ b/tests/e2e/mobile-map.test.cjs @@ -80,7 +80,8 @@ h.run(async () => { await tap('#logo-menu'); row(1, (await visible('#sidebar70')) && (await read(store('closeMenu'))) === false, 'Logo (main menu) opens the main menu'); const menuBox = await read(() => document.querySelector('#sidebar70')?.getBoundingClientRect().toJSON()); - h.check(menuBox && Math.abs(menuBox.bottom - 844) < 2 && menuBox.width > 380, `the main menu is a bottom sheet (${JSON.stringify(menuBox)})`); + // 40 F1: a bottom sheet that ends at the TOP of the bar (844 - 76), so Play stays uncovered + h.check(menuBox && Math.abs(menuBox.bottom - 768) < 2 && menuBox.width > 380, `the main menu is a bottom sheet above the bar (${JSON.stringify(menuBox)})`); const menuBar = await read(() => { const el = document.querySelector('#sidebar70'); if (!el) return -1; @@ -216,7 +217,7 @@ h.run(async () => { row(16, await visible('#avatar-trigger'), 'Profile and peers: the avatar is in the top bar at rest'); await tap('#avatar-trigger'); const prof = await read(() => document.querySelector('#avatar-dropdown')?.getBoundingClientRect().toJSON()); - h.check(prof && prof.height > 0 && Math.abs(prof.bottom - 844) < 2, `the avatar opens the profile menu as a bottom sheet (${JSON.stringify(prof)})`); + h.check(prof && prof.height > 0 && Math.abs(prof.bottom - 768) < 2, `the avatar opens the profile menu as a bottom sheet above the bar (${JSON.stringify(prof)})`); await P.keyboard.press('Escape'); await P.evaluate(() => document.querySelector('#avatar-trigger')?.click()); await rest(); diff --git a/tests/e2e/mobile-ui-fixes.test.cjs b/tests/e2e/mobile-ui-fixes.test.cjs index 239e46f51..1ccfa5b2d 100644 --- a/tests/e2e/mobile-ui-fixes.test.cjs +++ b/tests/e2e/mobile-ui-fixes.test.cjs @@ -222,7 +222,8 @@ h.run(async () => { await A.page.waitForTimeout(600); const anchor = await A.page.evaluate(() => { const t = document.querySelector('#avatar-trigger')?.getBoundingClientRect(); - const p = document.querySelector('[popover]:popover-open')?.getBoundingClientRect(); + // 40 F1: on the phone shell the panel is a plain sheet (no top layer) — find it by id + const p = (document.querySelector('[popover]:popover-open') ?? document.querySelector('#avatar-dropdown'))?.getBoundingClientRect(); if (!t || !p) return null; return { trigger: [Math.round(t.width), Math.round(t.height)], @@ -238,10 +239,11 @@ h.run(async () => { // 38 R9: below 640px the phone shell draws the profile menu as a BOTTOM SHEET (design // map row 16, "opens a sheet") — the seat-on-the-circle rule is the desktop/tablet one const phoneShell = await A.page.evaluate(() => document.documentElement.classList.contains('phone-shell')); - const sheet = phoneShell && (await A.page.evaluate(() => { const p = document.querySelector('[popover]:popover-open')?.getBoundingClientRect(); return p && { left: Math.round(p.left), right: Math.round(p.right), bottom: Math.round(p.bottom), vw: innerWidth, vh: innerHeight }; })); + // 40 F1: the sheet ends at the TOP of the bottom bar (Play is never covered), not the window edge + const sheet = phoneShell && (await A.page.evaluate(() => { const p = document.querySelector('#avatar-dropdown')?.getBoundingClientRect(); const b = document.querySelector('#ps-bar')?.getBoundingClientRect(); return p && b && { left: Math.round(p.left), right: Math.round(p.right), bottom: Math.round(p.bottom), barTop: Math.round(b.top), vw: innerWidth }; })); h.check( phoneShell - ? !!sheet && sheet.left === 0 && sheet.right === sheet.vw && sheet.bottom === sheet.vh + ? !!sheet && sheet.left === 0 && sheet.right === sheet.vw && Math.abs(sheet.bottom - sheet.barTop) <= 1 : !!anchor && Math.abs(anchor.rightGap) <= 2 && Math.abs(anchor.topGap) <= 2 && anchor.inset >= 12, phoneShell ? `on a phone the profile menu is a bottom sheet (${JSON.stringify(sheet)})` diff --git a/tests/e2e/phone-sheets.test.cjs b/tests/e2e/phone-sheets.test.cjs new file mode 100644 index 000000000..34b4c913a --- /dev/null +++ b/tests/e2e/phone-sheets.test.cjs @@ -0,0 +1,235 @@ +// 40 F1 + F2 — EVERY PHONE SHEET IS ONE SHEET. On a 390x844 touch phone, driven by REAL touch +// events (CDP Input.dispatchTouchEvent: the browser turns them into pointer events with +// pointerType "touch", the path a finger takes): +// +// F1 every drawer (main menu, notifications, Configure Scene, Add — also with an object +// selected —, Inspector, Objects, Explorer (the dock), More, profile menu, notes, Customize +// character) RESIZES from its grab bar and CLOSES when swiped down to the end; +// the grab bar is STICKY (scrolling the Inspector never takes it away); +// a sheet's height is REMEMBERED across close/reopen; +// NO sheet covers Play or the selection strip (cover-play-and-handle), and the strip +// never goes under the top bar; +// F2 Customize character and Profile settings open from a TAP in the profile menu (the menu +// inherited `pointer-events: none` from the corner chrome — counterfactual below). +// Then a DESKTOP page (1440x900, mouse) proves no phone grab bar leaks there. +const h = require('./helpers.cjs'); + +const store = (name) => `(() => { let v; window.__stores.${name}.subscribe((x) => (v = x))(); return v; })()`; + +/** @param {import('playwright').Page} page */ +async function touchApi(page) { + const cdp = await page.context().newCDPSession(page); + const drag = async (/** @type {number} */ x, /** @type {number} */ y0, /** @type {number} */ y1, steps = 12, stepMs = 16) => { + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y: y0, id: 1 }] }); + for (let i = 1; i <= steps; i++) { + await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y: y0 + ((y1 - y0) * i) / steps, id: 1 }] }); + await new Promise((r) => setTimeout(r, stepMs)); + } + await new Promise((r) => setTimeout(r, 120)); // a slow release, not a flick + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + }; + const tap = async (/** @type {number} */ x, /** @type {number} */ y) => { + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y, id: 1 }] }); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + }; + return { drag, tap }; +} + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'phone', { + context: { viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, deviceScaleFactor: 2 }, + storage: { toursSeen: '{"editor-touch":true,"editor":true}' } + }); + const P = A.page; + const touch = await touchApi(P); + const read = (expr) => P.evaluate(expr); + const rect = (sel) => P.evaluate((s) => { const e = document.querySelector(s); if (!e) return null; const r = e.getBoundingClientRect(); return r.width || r.height ? { x: r.x, y: r.y, w: r.width, h: r.height, top: r.top, bottom: r.bottom } : null; }, sel); + const rest = async () => { + await P.evaluate(() => { + const s = window.__stores; + s.phoneShell.phoneSheet.set(null); + s.viewportMenu.set(null); + s.objectListClose.set(true); + s.notificationCenterOpen.set(false); + s.notesDrawerOpen.set(false); + s.inspectorClose.set(true); + s.closeMenu.set(true); + s.explorerClose.set(true); + s.characterModalOpen.set(false); + s.profileSettingsOpen.set(false); + s.selectedObjects.set([]); + // a toast (the phone quality notice) sits over the top of a tall sheet: not what is tested + s.toastStore.set([]); + }); + await P.waitForTimeout(500); + }; + await P.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box')); + await P.waitForTimeout(800); + const select = async () => { + await P.evaluate(() => { + const s = window.__stores; + let g; s.objectsGroup.subscribe((v) => (g = v))(); + s.objectActions.applySelectionSet([g.children[0].uuid]); + }); + await P.waitForTimeout(500); + }; + const shell = await read(() => document.documentElement.classList.contains('phone-shell')); + h.check(shell, 'the phone shell is mounted at 390x844'); + + /** what a finger at the top of the raised Play circle hits — never a sheet */ + const playClear = () => + P.evaluate(() => { + const live = document.querySelector('#ps-play .ps-live'); + if (!live) return 'no play'; + const r = live.getBoundingClientRect(); + const at = document.elementFromPoint(r.left + r.width / 2, r.top + 4); + return at && document.getElementById('ps-play')?.contains(at) ? 'ok' : (at?.id || at?.className || 'nothing').toString().slice(0, 60); + }); + /** the selection strip is fully below the top bar and nothing covers its centre */ + const stripClear = () => + P.evaluate(() => { + const s = document.getElementById('ps-strip'); + if (!s) return 'no strip'; + const r = s.getBoundingClientRect(); + if (r.top < 56) return 'under the top bar (top ' + Math.round(r.top) + ')'; + const at = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); + return at && s.contains(at) ? 'ok' : 'covered by ' + (at?.id || at?.className || 'nothing').toString().slice(0, 60); + }); + + // ---- the sheets ------------------------------------------------------------------ + /** [name, open(), sheet selector (height), grip selector, isOpen expr] */ + const SHEETS = [ + ['main menu', async () => touch.tap(34, 34), '#sidebar70', '#sidebar70 [data-sheet-grip]', `!!document.querySelector('#sidebar70')`], + ['notifications', async () => P.evaluate(() => window.__stores.notificationCenterOpen.set(true)), '.ps-sheet', '#ps-sheet-handle', store('notificationCenterOpen')], + ['Configure Scene', async () => P.evaluate(() => window.__stores.showSidebar('scene')), '#inspector', '#inspector [data-sheet-grip]', `!${store('inspectorClose')}`], + ['Add', async () => { const b = await rect('#ps-add'); await touch.tap(b.x + b.w / 2, b.y + b.h / 2); }, '.ctx-scroll[role=menu]', '.ctx-scroll[role=menu] [data-sheet-grip]', `!!document.querySelector('.ctx-scroll[role=menu]')`], + ['Add with an object selected', async () => { await select(); const b = await rect('#ps-add'); await touch.tap(b.x + b.w / 2, b.y + b.h / 2); }, '.ctx-scroll[role=menu]', '.ctx-scroll[role=menu] [data-sheet-grip]', `!!document.querySelector('.ctx-scroll[role=menu]')`, true], + ['Inspector', async () => { await select(); const b = await rect('#ps-inspect'); await touch.tap(b.x + b.w / 2, b.y + b.h / 2); }, '#inspector', '#inspector [data-sheet-grip]', `!${store('inspectorClose')}`, true], + ['Objects', async () => { await select(); await P.evaluate(() => window.__stores.objectListClose.set(false)); }, '.ps-sheet', '#ps-sheet-handle', `!${store('objectListClose')}`, true], + ['Explorer (the dock)', async () => { const b = await rect('#ps-explorer'); await touch.tap(b.x + b.w / 2, b.y + b.h / 2); }, '#explorer-list', '#ps-dock-grip [data-sheet-grip]', `!(${store('bottomDock.dockMinimized')}) && !(${store('explorerClose')})`], + ['More', async () => { const b = await rect('#ps-more'); await touch.tap(b.x + b.w / 2, b.y + b.h / 2); }, '.ps-sheet', '#ps-sheet-handle', `${store('phoneShell.phoneSheet')} === 'more'`], + ['profile menu', async () => P.locator('#avatar-trigger').dispatchEvent('mousedown'), '#avatar-dropdown', '#avatar-dropdown [data-sheet-grip]', `!!document.querySelector('#avatar-dropdown')`], + ['scene notes', async () => P.evaluate(() => window.__stores.notesDrawerOpen.set(true)), '#notes-drawer', '#notes-drawer [data-sheet-grip]', store('notesDrawerOpen')], + ['Customize character', async () => P.evaluate(() => window.__stores.characterModalOpen.set(true)), '.cp', '#character-resize', store('characterModalOpen')] + ]; + + for (const [name, open, sheetSel, gripSel, isOpen, withSel] of SHEETS) { + await rest(); + await open(); + await P.waitForTimeout(900); + h.check(await read(isOpen), `${name}: opens`); + const g = await rect(gripSel); + const s0 = await rect(sheetSel); + h.check(!!g && !!s0, `${name}: has a grab bar (${JSON.stringify(g)})`); + if (!g || !s0) continue; + h.check(Math.abs(g.top - s0.top) < 30, `${name}: the grab bar sits at the sheet's top edge (grip ${Math.round(g.top)}, sheet ${Math.round(s0.top)})`); + h.check((await playClear()) === 'ok', `${name}: Play is not covered (${await playClear()})`); + if (withSel) h.check((await stripClear()) === 'ok', `${name}: the selection strip rides above it, under no top bar and no sheet (${await stripClear()})`); + // RESIZE: drag the bar up 120 px (or down, when it already sits at its max) + const gx = g.x + g.w / 2; + const gy = g.y + g.h / 2; + const atTop = s0.top < 160; + await touch.drag(gx, gy, gy + (atTop ? 140 : -220)); // far enough to reach the next detent of a detent sheet + await P.waitForTimeout(500); + const s1 = await rect(sheetSel); + const grew = s1 ? s1.h - s0.h : 0; + h.check(!!s1 && (atTop ? grew < -40 : grew > 40), `${name}: resizes from its grab bar (${Math.round(s0.h)} -> ${Math.round(s1?.h ?? 0)})`); + if (withSel) h.check((await stripClear()) === 'ok', `${name}: after resizing, the strip is still clear (${await stripClear()})`); + h.check((await playClear()) === 'ok', `${name}: after resizing, Play is still clear`); + // CLOSE: swipe the bar down to the end + const g2 = await rect(gripSel); + if (!g2) { h.check(false, `${name}: grab bar still there after resizing`); continue; } + await touch.drag(g2.x + g2.w / 2, g2.y + g2.h / 2, 838, 16); + await P.waitForTimeout(700); + h.check(!(await read(isOpen)), `${name}: closes when swiped down to the end`); + } + + // ---- the grab bar is STICKY: scrolling the Inspector never takes it away --------------- + await rest(); + await P.evaluate(() => window.__stores.showSidebar('scene')); + await P.waitForTimeout(900); + const sticky = await P.evaluate(async () => { + const ins = document.getElementById('inspector'); + const grip = ins?.querySelector('[data-sheet-grip]'); + if (!ins || !grip) return null; + ins.scrollTop = 600; + await new Promise((r) => setTimeout(r, 200)); + const ir = ins.getBoundingClientRect(); + const gr = grip.getBoundingClientRect(); + const at = document.elementFromPoint(gr.left + gr.width / 2, gr.top + gr.height / 2); + return { scrolled: ins.scrollTop, gripTop: gr.top, insTop: ir.top, hit: !!at && grip.contains(at) }; + }); + h.check(!!sticky && sticky.scrolled > 100, `premise: Configure Scene scrolls (${JSON.stringify(sticky)})`); + h.check(!!sticky && Math.abs(sticky.gripTop - sticky.insTop) < 4 && sticky.hit, `the grab bar stays at the top of a scrolled sheet and takes the finger (${JSON.stringify(sticky)})`); + + // ---- a sheet's height is REMEMBERED -------------------------------------------------- + const g0 = await rect('#inspector [data-sheet-grip]'); + await touch.drag(g0.x + g0.w / 2, g0.y + g0.h / 2, g0.y + g0.h / 2 + 90); + await P.waitForTimeout(500); + const hBefore = (await rect('#inspector')).h; + await rest(); + await P.evaluate(() => window.__stores.showSidebar('scene')); + await P.waitForTimeout(900); + const hAfter = (await rect('#inspector')).h; + h.check(Math.abs(hAfter - hBefore) < 3, `Configure Scene reopens at the height it was left at (${Math.round(hBefore)} -> ${Math.round(hAfter)})`); + const saved = await read(() => localStorage.getItem('inspectorSheetH')); + h.check(Math.abs(Number(saved) - hBefore) < 3, `...under its own storage key (inspectorSheetH=${saved})`); + + // ---- the max leaves the strip clear (the reported cover-play-and-handle) ------------- + await rest(); + await select(); + const add = await rect('#ps-add'); + await touch.tap(add.x + add.w / 2, add.y + add.h / 2); + await P.waitForTimeout(800); + const ga = await rect('.ctx-scroll[role=menu] [data-sheet-grip]'); + await touch.drag(ga.x + ga.w / 2, ga.y + ga.h / 2, 60); // all the way up + await P.waitForTimeout(500); + const top = await rect('.ctx-scroll[role=menu]'); + h.check((await stripClear()) === 'ok', `Add dragged all the way up: the strip stays below the top bar and uncovered (${await stripClear()}; sheet top ${Math.round(top?.top ?? 0)})`); + h.check((await playClear()) === 'ok', 'Add dragged all the way up: Play is not covered'); + + // ---- F2: the profile menu's rows answer a tap ----------------------------------------- + for (const [label, st] of [['Customize character', 'characterModalOpen'], ['Profile settings', 'profileSettingsOpen']]) { + await rest(); + await P.locator('#avatar-trigger').dispatchEvent('mousedown'); + await P.waitForTimeout(600); + const r = await P.locator('#avatar-dropdown').getByText(label).first().boundingBox(); + const hit = await P.evaluate(([x, y]) => !!document.elementFromPoint(x, y)?.closest('#avatar-dropdown'), [r.x + r.width / 2, r.y + r.height / 2]); + h.check(hit, `F2: a finger on "${label}" lands on the profile menu, not the canvas`); + await touch.tap(r.x + r.width / 2, r.y + r.height / 2); + await P.waitForTimeout(900); + h.check(await read(store(st)), `F2: tapping "${label}" opens it`); + } + // counterfactual: the inherited pointer-events the fix overrides IS what ate the taps + await rest(); + await P.locator('#avatar-trigger').dispatchEvent('mousedown'); + await P.waitForTimeout(600); + const cf = await P.evaluate(() => { + const d = document.getElementById('avatar-dropdown'); + if (!d) return null; + d.style.setProperty('pointer-events', 'inherit', 'important'); + const row = [...d.querySelectorAll('button, [role=button]')].find((b) => /Customize/.test(b.textContent || '')); + const r = row?.getBoundingClientRect(); + const at = r ? document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2) : null; + d.style.removeProperty('pointer-events'); + return at ? !d.contains(at) : null; + }); + h.check(cf === true, `counterfactual: with the corner's inherited pointer-events the row is NOT hit (${cf})`); + await rest(); + + // ---- desktop: no phone grab bar leaks ------------------------------------------------- + const D = await h.setupPage(browser, 'desktop', { context: { viewport: { width: 1440, height: 900 } } }); + await D.page.evaluate(() => window.__stores.showSidebar('scene')); + await D.page.waitForTimeout(900); + const desk = await D.page.evaluate(() => ({ + insGrip: (() => { const g = document.querySelector('#inspector .ins-resize'); return g ? getComputedStyle(g).display : 'none'; })(), + shell: document.documentElement.classList.contains('phone-shell') + })); + h.check(!desk.shell && desk.insGrip === 'none', `desktop: no phone shell, the Inspector side drawer shows no grab bar (${JSON.stringify(desk)})`); + await D.page.locator('#logo-menu').click(); + await D.page.waitForTimeout(500); + h.check((await D.page.locator('#sidebar70 [data-sheet-grip]').count()) === 0, 'desktop: the main menu stays a dropdown (no grab bar)'); + + await h.finish(browser); +}); diff --git a/tests/unit/sheetSnap.test.js b/tests/unit/sheetSnap.test.js index 4684f217d..b9b5fe300 100644 --- a/tests/unit/sheetSnap.test.js +++ b/tests/unit/sheetSnap.test.js @@ -51,3 +51,31 @@ describe('sheetSnap', () => { expect(stepDetent('peek', -1, all, heights, false)).toBe('peek'); }); }); + +// 40 F1: a FREE-HEIGHT phone sheet (the Inspector, the menus, the dock …) rests where the finger +// left it, snaps onto its min / max near them, and closes when swiped down to the end. +import { settleSheet, phoneSheetMax, SNAP_PX } from '../../src/lib/ui/sheetSnap.js'; +describe('settleSheet (40 F1)', () => { + const g = { min: 180, max: 640 }; + it('a slow release stays where it is, clamped, and snaps onto the ends', () => { + expect(settleSheet({ ...g, height: 400, velocity: 0 })).toBe(400); + expect(settleSheet({ ...g, height: 640 - SNAP_PX + 4, velocity: 0 })).toBe(640); + expect(settleSheet({ ...g, height: 180 + SNAP_PX - 4, velocity: 0 })).toBe(180); + expect(settleSheet({ ...g, height: 900, velocity: 0 })).toBe(640); + }); + it('swiped down to the end closes; a dismissible:false sheet rests on its min', () => { + expect(settleSheet({ ...g, height: 40, velocity: 0.1 })).toBe('closed'); + expect(settleSheet({ ...g, height: 0, velocity: 0 })).toBe('closed'); + expect(settleSheet({ ...g, height: 40, velocity: 0.1, dismissible: false })).toBe(180); + }); + it('a flick down from the min closes, from higher up it goes to the min; a flick up -> max', () => { + expect(settleSheet({ ...g, height: 190, velocity: FLICK_VELOCITY + 0.4 })).toBe('closed'); + expect(settleSheet({ ...g, height: 500, velocity: FLICK_VELOCITY + 0.4 })).toBe(180); + expect(settleSheet({ ...g, height: 300, velocity: -(FLICK_VELOCITY + 0.4) })).toBe(640); + }); + it('the phone sheet room keeps the top bar and the selection strip clear', () => { + expect(phoneSheetMax(844)).toBe(844 - 76 - 64); + expect(phoneSheetMax(844, { stripH: 60 })).toBe(844 - 76 - 64 - 60); + expect(phoneSheetMax(200)).toBe(160); + }); +}); From 8c7d38e56f050909d9ed3eacaa2679770b9df9c5 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 8 Oct 2026 14:45:04 +0300 Subject: [PATCH 09/92] [feat] image editor: crop, rotate/flip, resize, adjust, save, copy, versions (WIP) - src/lib/image/imageOps.js: pure raster maths (crop/rotate/flip/area+bilinear resize/ brightness-contrast-saturation), vitest imageOps (19 green) - src/lib/image/imageVersions.js: per-image version history in idb (Original always kept, cap 20) - src/lib/image/imageEditor.js: open/close, decode/encode, saveImage (same record, version kept, linked textures re-textured as one undo), restoreImageVersion, saveImageCopy, editMaterialTexture - ImageEditorWindow.svelte: WindowShell canvas + right sidebar (Edit / Versions), zoom/pan/pinch, crop drag, editor-local undo, phone sheet above Play - entry points: Explorer "Edit image...", preview #preview-edit, Inspector #texture-edit - storage breakdown: "Image versions" category - e2e image-editor (WIP: sections 1-8 pass; save is slow on a janky page, being profiled) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 3 + src/components/editors/Explorer.svelte | 13 + .../editors/FilePreviewWindow.svelte | 15 +- .../editors/ImageEditorWindow.svelte | 1056 +++++++++++++++++ src/components/menu/Inspector.svelte | 13 + src/components/ui/Icon.svelte | 4 + src/lib/debugHooks.js | 3 + src/lib/image/imageEditor.js | 226 ++++ src/lib/image/imageOps.js | 296 +++++ src/lib/image/imageVersions.js | 89 ++ src/lib/storageUsage.js | 31 + tests/e2e/image-editor.test.cjs | 432 +++++++ tests/unit/imageOps.test.js | 159 +++ 13 files changed, 2339 insertions(+), 1 deletion(-) create mode 100644 src/components/editors/ImageEditorWindow.svelte create mode 100644 src/lib/image/imageEditor.js create mode 100644 src/lib/image/imageOps.js create mode 100644 src/lib/image/imageVersions.js create mode 100644 tests/e2e/image-editor.test.cjs create mode 100644 tests/unit/imageOps.test.js diff --git a/src/App.svelte b/src/App.svelte index 6cd1bc9f3..6b3fcff87 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -35,6 +35,7 @@ import Explorer from './components/editors/Explorer.svelte' import TextEditorWindow from './components/editors/TextEditorWindow.svelte' import FilePreviewWindow from './components/editors/FilePreviewWindow.svelte' + import ImageEditorWindow from './components/editors/ImageEditorWindow.svelte' import { previewWindows } from './lib/fileWindows' import ModelPreviewWindow from './components/editors/ModelPreviewWindow.svelte' import DungeonMinimap from './components/play/DungeonMinimap.svelte' @@ -387,6 +388,8 @@ import { startMusicToolbox } from './lib/musicToolbox' {#await import('./components/editors/CodeWorkspace.svelte') then m}{/await} {/if} + + {#each $previewWindows as w, i (w.id)} diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 27a2467cb..766a0f521 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -4783,6 +4783,19 @@ } ] : []), + // 40-image (F13): an image in YOUR library opens in the Image editor (crop, rotate, + // resize, adjust, versions). A pack item is a reference to bytes on a CDN, so it + // has nothing to save into — copy it into the Library first. + ...(item.kind === 'image' && !item.packEntry && !item.dataUrl + ? [ + { + label: 'Edit image…', + icon: 'square-pen', + tooltip: 'Crop, rotate, resize and adjust it; saving updates every texture made from it', + action: () => import('$lib/image/imageEditor').then((m) => m.openImageEditor(item.id)) + } + ] + : []), ...(item.kind === 'text' ? [ { diff --git a/src/components/editors/FilePreviewWindow.svelte b/src/components/editors/FilePreviewWindow.svelte index 9fc3eeb1a..c1cdd84bf 100644 --- a/src/components/editors/FilePreviewWindow.svelte +++ b/src/components/editors/FilePreviewWindow.svelte @@ -32,7 +32,7 @@ stepPreview, clampPreviewOpacity } from '$lib/filePreview'; - import { activeFolder, explorerFolders, itemBlob } from '$lib/explorer'; + import { activeFolder, explorerFolders, explorerItems, itemBlob } from '$lib/explorer'; // R22 round 13 P3: the arrows may walk into a MOUNTED project's folder, whose files are // not in the library's blob store — `volumeBlob` reads them from the saved payload. import { volumeOf, volumeFolders, volumeKey, volumeBlob } from '$lib/mountedVolumes'; @@ -209,6 +209,9 @@ * Users popover makes for Watch when a peer is in another scene. */ const opacityApplies = $derived(face === 'image' || face === 'object'); + /** 40-image (F13): an image in YOUR library can be edited — a pack card or a mounted + * project's file has no record to save into */ + const editable = $derived(face === 'image' && !!target?.itemId && $explorerItems.some((i: any) => i.id === target?.itemId)); const walkId = $derived(String(target?.itemId ?? target?.folderId ?? '')); const place = $derived(previewPosition($previewSiblings.entries, walkId)); const canPrev = $derived(!!stepPreview($previewSiblings.entries, walkId, -1)); @@ -626,6 +629,16 @@ {/if} + {#if editable} + + {/if}