From 47b4eb17944e65e93aa27d415f4637aec81a01c1 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 22 Oct 2026 17:57:56 +0300 Subject: [PATCH 01/65] [feat] 51 scatter set format: params + seed + overrides, a deterministic generator - terrainScatterCore: a set may carry seed + gen {count|density, scale range, rot, spacing} (GENERATED slots, each drawn from its own (seed, k) stream so a slot is a stable id), ov {[slot]: {x,z,yaw,scale,del}} (hand edits a re-roll keeps), sources [{source, w}] (a mixed set; painted records keep their pick in `pick`), tint, sink; every field optional, so a 1.38 set normalizes to the identical object and the block stays v1 until one is used - instancesOf (the ONE list: generated + overrides, then painted, ids g/p), withOverrides, withSeed, generateSlots (place/accept hooks for 51-surface), pack sources - terrainSurface draws per source with tint colours and sink, colliders from the same list (one entry per source of a mixed set), pack items load through packRefs - terrainPaint: writeScatter / editScatterSet / editScatterInstances / rerollScatterSet / addScatterSet / scatterInstancesOf; the brush keeps v2 fields, counts generated instances toward its density and erases them as deleted edits - vitest scatterSetFormat (25): determinism twice + across two wire hops, stable slots, masks, spacing on raw slots, re-roll keeps edits, migration byte-identical, weights Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/terrainPaint.js | 168 ++++++++++-- src/lib/terrainScatterCore.js | 409 +++++++++++++++++++++++++++- src/lib/terrainSurface.js | 208 ++++++++++---- tests/unit/scatterSetFormat.test.js | 236 ++++++++++++++++ 4 files changed, 927 insertions(+), 94 deletions(-) create mode 100644 tests/unit/scatterSetFormat.test.js diff --git a/src/lib/terrainPaint.js b/src/lib/terrainPaint.js index 596842717..4af58d276 100644 --- a/src/lib/terrainPaint.js +++ b/src/lib/terrainPaint.js @@ -9,7 +9,12 @@ import { decodeInstances, encodeInstances, scatterInCircle, - eraseInCircle, + instancesOf, + withOverrides, + withSeed, + newSeed, + pickSource, + blockVersion, sourceKey, MAX_INSTANCES, MAX_SETS @@ -218,7 +223,8 @@ export function clearTerrainPaint(uuid) { // ---- scatter ---------------------------------------------------------------------------- -/** @type {{uuid: string, before: any, key: string, list: any[], set: any, others: any[], changed: boolean, seed: number} | null} */ +/** @type {{uuid: string, before: any, key: string, list: any[], set: any, others: any[], changed: boolean, seed: number, + * generated: any[], erased: Set} | null} */ let scatterStroke = null; let lastScatterPreview = 0; @@ -246,17 +252,28 @@ export function beginScatterStroke(uuid) { showToast('A terrain holds up to ' + MAX_SETS + ' scatter models'); return false; } + /** @type {any[]} */ + let list = []; + if (set?.count) { + // 51: a painted record keeps the source it picked (a mixed set) through every erase + const pick = set.pick ? base64ToBytes(set.pick) : null; + list = decodeInstances(/** @type {Uint8Array} */ (base64ToBytes(set.data)), b).map((inst, j) => ({ ...inst, src: pick?.[j] ?? 0 })); + } scatterStroke = { uuid, before: block && block.sets.length ? block : null, key, - list: set ? decodeInstances(/** @type {Uint8Array} */ (base64ToBytes(set.data)), b) : [], + list, // the align toggle is a property of the SET (heights are derived at render time), so // the brush's current setting re-stamps it set: { ...(set ?? { id: key, source, data: '', count: 0, collider: get(scatterCollider) }), align: get(scatterAlign) }, others: sets.filter((s) => s !== set), changed: false, - seed: (Math.random() * 2 ** 32) >>> 0 + seed: (Math.random() * 2 ** 32) >>> 0, + // 51: the set's GENERATED instances count toward the brush's density (painting over a + // filled patch adds nothing) and an erase deletes them as hand edits + generated: set?.gen ? instancesOf(set, b, base64ToBytes).filter((i) => i.slot != null) : [], + erased: new Set() }; return true; } @@ -264,8 +281,18 @@ export function beginScatterStroke(uuid) { /** The block the stroke has built so far. @param {NonNullable} st @param {any} b */ function strokeBlock(st, b) { const sets = [...st.others]; - if (st.list.length) sets.push({ ...st.set, data: bytesToBase64(encodeInstances(st.list, b)), count: st.list.length }); - return sets.length ? { v: 1, sets } : null; + /** @type {any} */ + let set = { ...st.set, data: st.list.length ? bytesToBase64(encodeInstances(st.list, b)) : '', count: st.list.length }; + if (set.sources && st.list.length) set.pick = bytesToBase64(new Uint8Array(st.list.map((i) => i.src ?? 0))); + else delete set.pick; + if (st.erased.size) { + /** @type {Record} */ + const patch = {}; + for (const id of st.erased) patch[id] = { del: true }; + set = withOverrides(set, patch, { bounds: b, decode: base64ToBytes, encode: bytesToBase64 }); + } + if (set.count || set.gen) sets.push(set); + return sets.length ? { v: blockVersion(sets), sets } : null; } /** A tiny seeded rng per stroke (Math.random would do; a seed makes a suite replayable). */ @@ -282,7 +309,8 @@ function strokeRng() { /** * One scatter application at a LOCAL (x, z), exported for the suite. Paint adds up to the - * density inside the circle; erase removes the active source's instances in it. + * density inside the circle; erase removes the active source's instances in it (a generated + * one becomes a deleted hand edit, so a re-roll does not bring it back). * @param {string} uuid @param {number} x @param {number} z @param {number} radius * @returns {number} instances added (negative = removed) */ @@ -293,12 +321,19 @@ export function scatterAt(uuid, x, z, radius) { if (!st || st.uuid !== uuid || !b) return 0; let delta = 0; if (get(scatterErase)) { - const next = eraseInCircle(st.list, x, z, radius); + const next = st.list.filter((p) => Math.hypot(p.x - x, p.z - z) > radius); delta = next.length - st.list.length; st.list = next; + for (const g of st.generated) + if (!st.erased.has(g.id) && Math.hypot(g.x - x, g.z - z) <= radius) { + st.erased.add(g.id); + delta--; + } } else { - const othersCount = st.others.reduce((sum, s) => sum + s.count, 0); - const added = scatterInCircle(st.list, { + const othersCount = st.others.reduce((sum, s) => sum + s.count + (s.gen?.count ?? 0), 0); + const live = st.generated.filter((g) => !st.erased.has(g.id)); + const rng = strokeRng(); + const added = scatterInCircle([...st.list, ...live], { cx: x, cz: z, radius, @@ -307,9 +342,11 @@ export function scatterAt(uuid, x, z, radius) { scaleMax: get(scatterScaleMax), randomRotation: get(scatterRandomRotation), bounds: b, - rng: strokeRng(), - room: MAX_INSTANCES - othersCount - st.list.length + rng, + room: MAX_INSTANCES - othersCount - st.list.length - live.length }); + // 51 E1: a mixed set's new instance draws its source by weight + for (const inst of added) /** @type {any} */ (inst).src = pickSource(st.set.sources, rng()); st.list.push(...added); delta = added.length; } @@ -350,23 +387,108 @@ registerHistoryKind('terrainScatter', (entry, state) => { }); /** - * Inspector: change one set (align toggle) or remove it (`patch` null). One undo entry. - * @param {string} uuid @param {string} setId @param {{align?: boolean, collider?: string} | null} patch + * THE ONE WRITE PATH for a terrain's scatter block outside a brush stroke: apply, send, ONE undo + * entry. A gesture previews with `{record: false}` (and `{send: false}` while it streams faster + * than the wire wants) and commits with `{before}` on release — one entry per drag. + * @param {string} uuid @param {(block: {v: number, sets: any[]}) => any[] | null} edit returns the new sets (null = no change) + * @param {{before?: any, record?: boolean, send?: boolean}} [opts] */ -export function editScatterSet(uuid, setId, patch) { +export function writeScatter(uuid, edit, opts = {}) { const object = objectOf(uuid); - if (!object || !canEditObject(object)) return false; - const block = scatterBlockOf(object); - if (!block) return false; - const sets = patch ? block.sets.map((s) => (s.id === setId ? { ...s, ...patch } : s)) : block.sets.filter((s) => s.id !== setId); - const next = sets.length ? { v: 1, sets } : null; - applyTerrainScatter(uuid, next); + if (!object) return false; + if (!canEditObject(object)) { + warnViewerReadOnly(); + return false; + } + const block = scatterBlockOf(object) ?? { v: 1, sets: [] }; + const sets = edit(block); + if (!sets) return false; + const before = opts.before !== undefined ? opts.before : block.sets.length ? block : null; + applyTerrainScatter(uuid, sets.length ? { v: blockVersion(sets), sets } : null); const after = object.userData.terrainScatter ?? null; - send({ type: 'objectParameters', parameter: 'terrainScatter', uuid, terrainScatter: after }); - recordEntry({ kind: 'terrainScatter', uuid, before: { block }, after: { block: after } }); + if (opts.send !== false) send({ type: 'objectParameters', parameter: 'terrainScatter', uuid, terrainScatter: after }); + if (opts.record !== false && JSON.stringify(before) !== JSON.stringify(after)) + recordEntry({ kind: 'terrainScatter', uuid, before: { block: before }, after: { block: after } }); return true; } +/** + * Inspector: change one set (align, collider, and since 51 any set field — gen, seed, sources, + * tint, sink; a null value clears it) or remove it (`patch` null). One undo entry. + * @param {string} uuid @param {string} setId @param {Record | null} patch + * @param {{before?: any, record?: boolean, send?: boolean}} [opts] + */ +export function editScatterSet(uuid, setId, patch, opts) { + return writeScatter( + uuid, + (block) => { + if (!block.sets.some((s) => s.id === setId)) return null; + return patch ? block.sets.map((s) => (s.id === setId ? { ...s, ...patch } : s)) : block.sets.filter((s) => s.id !== setId); + }, + opts + ); +} + +/** + * 51: hand edits on single instances — `patch` = {[id]: {x?, z?, yaw?, scale?, del?: true} | null} + * (ids 'g' / 'p' from `scatterInstancesOf`; null clears a generated slot's edit). The + * write path 51-edit's gizmo, box ops and brushes go through. One undo entry (see writeScatter). + * @param {string} uuid @param {string} setId @param {Record} patch + * @param {{before?: any, record?: boolean, send?: boolean}} [opts] + */ +export function editScatterInstances(uuid, setId, patch, opts) { + const b = terrainBounds(objectOf(uuid)); + if (!b) return false; + return writeScatter( + uuid, + (block) => { + const set = block.sets.find((s) => s.id === setId); + if (!set) return null; + const next = withOverrides(set, patch, { bounds: b, decode: base64ToBytes, encode: bytesToBase64 }); + return block.sets.map((s) => (s === set ? next : s)).filter((s) => s.count || s.gen); + }, + opts + ); +} + +/** 51: re-roll a seeded set (a new seed; hand edits kept). @param {string} uuid @param {string} setId @param {number} [seed] */ +export function rerollScatterSet(uuid, setId, seed = newSeed()) { + const set = scatterBlockOf(objectOf(uuid))?.sets.find((s) => s.id === setId); + if (!set?.gen) return false; + return editScatterSet(uuid, setId, { seed: withSeed(set, seed).seed }); +} + +/** + * 51: add a set (a seeded fill, or a source picked from a pack / prefab / the selection). The id + * is made unique on this terrain; returns it (null when refused). + * @param {string} uuid @param {Record} set + */ +export function addScatterSet(uuid, set) { + /** @type {string | null} */ + let id = null; + const ok = writeScatter(uuid, (block) => { + if (block.sets.length >= MAX_SETS) { + showToast('A terrain holds up to ' + MAX_SETS + ' scatter sets'); + return null; + } + const base = String(set.id || sourceKey(set.source) || 'set').slice(0, 34); + id = base; + for (let n = 2; block.sets.some((s) => s.id === id); n++) id = base + '~' + n; + const next = { data: '', count: 0, align: false, ...set, id, ...(set.gen && set.seed == null ? { seed: newSeed() } : {}) }; + return [...block.sets, next]; + }); + return ok ? id : null; +} + +/** 51: every instance a terrain's set draws, with ids — what an edit tool reads. + * @param {string} uuid @param {string} setId */ +export function scatterInstancesOf(uuid, setId) { + const object = objectOf(uuid); + const b = terrainBounds(object); + const set = scatterBlockOf(object)?.sets.find((s) => s.id === setId); + return set && b ? instancesOf(set, b, base64ToBytes) : []; +} + /** True while a paint or scatter stroke is open (terrainSculpt's endStroke asks). */ export function terrainStrokeOpen() { return !!(paintStroke || scatterStroke); diff --git a/src/lib/terrainScatterCore.js b/src/lib/terrainScatterCore.js index 6200bfb6e..e31adbf9b 100644 --- a/src/lib/terrainScatterCore.js +++ b/src/lib/terrainScatterCore.js @@ -137,50 +137,433 @@ export function eraseInCircle(list, cx, cz, radius) { return list.filter((p) => Math.hypot(p.x - cx, p.z - cz) > radius); } -/** @param {any} s */ -function sourceOf(s) { +/** a Library model hash / a pack path: what a source may name */ +const HASH_RE = /^[0-9a-zA-Z_-]{1,128}$/; +const PATH_RE = /^(?:https?:\/\/)?[\w\-./%~:+@]{1,400}$/; + +/** 51 E1: the short note a source row shows (a prefab / an object scattered as its LOOK only) */ +const noteOf = (/** @type {any} */ s) => (typeof s.note === 'string' && s.note ? { note: s.note.slice(0, 160) } : {}); + +/** @param {any} s @returns {any} */ +export function sourceOf(s) { if (!s || typeof s !== 'object') return null; if (s.kind === 'primitive' && SCATTER_PRIMITIVES.includes(s.type)) return { kind: 'primitive', type: s.type }; - if (s.kind === 'library' && typeof s.hash === 'string' && /^[0-9a-zA-Z_-]{1,128}$/.test(s.hash)) - return { kind: 'library', hash: s.hash, name: typeof s.name === 'string' ? s.name.slice(0, 120) : '' }; + if (s.kind === 'library' && typeof s.hash === 'string' && HASH_RE.test(s.hash)) + return { kind: 'library', hash: s.hash, name: typeof s.name === 'string' ? s.name.slice(0, 120) : '', ...noteOf(s) }; + // 51 E1: a pack item, by the same {pack, item, path} a placed kit piece's packRef carries — every + // peer fetches it from the pack CDN, nothing travels but the path + if (s.kind === 'pack' && typeof s.path === 'string' && PATH_RE.test(s.path) && !s.path.includes('..')) + return { + kind: 'pack', + path: s.path, + pack: typeof s.pack === 'string' ? s.pack.slice(0, 80) : '', + item: typeof s.item === 'string' ? s.item.slice(0, 120) : '', + ...noteOf(s) + }; return null; } /** A stable key for "the same source" (the brush paints into the set that matches it). @param {any} s */ export function sourceKey(s) { const n = sourceOf(s); - return !n ? '' : n.kind === 'primitive' ? 'p:' + n.type : 'l:' + n.hash; + return !n ? '' : n.kind === 'primitive' ? 'p:' + n.type : n.kind === 'pack' ? 'k:' + n.path : 'l:' + n.hash; } /** * Validate a stored/wire block. Sets with a bad source or bytes are DROPPED (never * guessed at); empty sets are dropped too, so "erase everything" leaves `sets: []`. + * + * 51 — THE SET FORMAT. A set is PARAMS + SEED + OVERRIDES, plus the painted instances 1.38 + * already stored. Every 51 field is OPTIONAL and written only when it says something, so a + * 1.38 set normalizes to exactly the object it did before (same keys, same order) and a scene + * that uses none of them saves byte-identical: + * data painted instances, the 1.38 8-byte records (the brush writes these) + * seed+gen GENERATED instances: `gen` {count | density, scaleMin, scaleMax, rot, spacing} + * drawn from the seeded generator below — slot k is always the same instance on + * every peer and after a reload; nothing per instance travels + * ov per-slot hand edits {[slot]: {x?, z?, yaw?, scale?, del?: 1}} — a re-roll (a new + * seed) keeps them, keyed by the slot + * sources a MIXED set [{source, w}] (2..8, weights > 0); `source` stays sources[0] + * pick one byte per PAINTED instance in a mixed set: which source it drew + * tint 0..1 colour variation, `sink` metres pushed into the ground (x the instance scale) * @param {any} block @param {(b64: string) => Uint8Array | null} decode - * @returns {{v: number, sets: {id: string, source: any, align: boolean, data: string, count: number, collider?: string}[]} | null} + * @returns {{v: number, sets: ScatterSet[]} | null} */ export function normalizeScatter(block, decode) { if (!block || typeof block !== 'object' || !Array.isArray(block.sets)) return null; + /** @type {ScatterSet[]} */ const sets = []; let total = 0; for (const s of block.sets.slice(0, MAX_SETS)) { if (!s || typeof s !== 'object') continue; - const source = sourceOf(s.source); - const bytes = typeof s.data === 'string' ? decode(s.data) : null; - if (!source || !bytes || bytes.length % 8 !== 0 || !bytes.length) continue; - const count = bytes.length / 8; + const sources = sourcesOf(s); + const source = sources ? sources[0].source : sourceOf(s.source); + if (!source) continue; + const bytes = typeof s.data === 'string' && s.data ? decode(s.data) : null; + if (bytes && bytes.length % 8 !== 0) continue; + const count = bytes ? bytes.length / 8 : 0; + const gen = genOf(s.gen); + if (!count && !gen) continue; if (total + count > MAX_INSTANCES) continue; total += count; const collider = scatterColliderOf(s.collider); - sets.push({ + /** @type {ScatterSet} */ + const set = { id: typeof s.id === 'string' && s.id ? s.id.slice(0, 40) : sourceKey(source), source, align: !!s.align, - data: s.data, + data: count ? s.data : '', count, ...(collider === 'none' ? {} : { collider }) + }; + if (gen) { + if (gen.count != null) { + gen.count = Math.min(gen.count, MAX_INSTANCES - total); + total += gen.count; + } + set.seed = (Number(s.seed) >>> 0) || 0; + set.gen = gen; + const ov = overridesOf(s.ov); + if (ov) set.ov = ov; + } + if (sources) { + set.sources = sources; + const pick = count && typeof s.pick === 'string' ? decode(s.pick) : null; + if (pick && pick.length === count) set.pick = s.pick; + } + const tint = clampNum(s.tint, 0, 1); + if (tint) set.tint = tint; + const sink = clampNum(s.sink, SINK_MIN, SINK_MAX); + if (sink) set.sink = sink; + sets.push(set); + } + return { v: blockVersion(sets), sets }; +} + +/** @typedef {{id: string, source: any, align: boolean, data: string, count: number, collider?: string, + * seed?: number, gen?: Gen, ov?: Record, sources?: {source: any, w: number}[], + * pick?: string, tint?: number, sink?: number}} ScatterSet */ +/** @typedef {{count?: number, density?: number, scaleMin: number, scaleMax: number, rot: boolean, + * spacing?: number}} Gen */ +/** @typedef {{x?: number, z?: number, yaw?: number, scale?: number, del?: 1}} Override */ + +/** 1 while every set is the 1.38 shape (so an untouched scene saves byte-identical), else 2 + * @param {ScatterSet[]} sets */ +export function blockVersion(sets) { + return sets.some((s) => s.gen || s.sources || s.tint || s.sink) ? 2 : SCATTER_VERSION; +} + +export const SINK_MIN = -2; +export const SINK_MAX = 5; +export const SPACING_MAX = 50; + +/** @param {any} v @param {number} lo @param {number} hi */ +function clampNum(v, lo, hi) { + const n = Number(v); + return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : 0; +} + +/** @param {any} g @returns {Gen | null} */ +function genOf(g) { + if (!g || typeof g !== 'object') return null; + /** @type {Gen} */ + const out = { + scaleMin: clampNum(g.scaleMin ?? 1, SCALE_MIN, SCALE_MAX), + scaleMax: clampNum(g.scaleMax ?? 1, SCALE_MIN, SCALE_MAX), + rot: g.rot !== false + }; + if (Number.isFinite(Number(g.density)) && g.count == null) out.density = clampNum(g.density, DENSITY_MIN, DENSITY_MAX); + else out.count = Math.floor(clampNum(g.count, 0, MAX_INSTANCES)); + const spacing = clampNum(g.spacing, 0, SPACING_MAX); + if (spacing) out.spacing = spacing; + // 51-surface's masks and modes ride here as further fields; carried through untouched + for (const k of Object.keys(g)) + if (!(k in out) && !['count', 'density', 'scaleMin', 'scaleMax', 'rot', 'spacing'].includes(k) && isPlain(g[k])) /** @type {any} */ (out)[k] = g[k]; + return out; +} + +/** JSON-safe and small: what a carried-through field may hold @param {any} v */ +function isPlain(v) { + try { + const s = JSON.stringify(v); + return typeof s === 'string' && s.length <= 2000; + } catch { + return false; + } +} + +/** @param {any} ov @returns {Record | null} */ +function overridesOf(ov) { + if (!ov || typeof ov !== 'object') return null; + /** @type {Record} */ + const out = {}; + let n = 0; + for (const key of Object.keys(ov)) { + if (n >= MAX_INSTANCES) break; + if (!/^\d{1,5}$/.test(key) || Number(key) >= MAX_INSTANCES) continue; + const o = overrideOf(ov[key]); + if (o) (out[key] = o), n++; + } + return n ? out : null; +} + +/** @param {any} o @returns {Override | null} */ +function overrideOf(o) { + if (!o || typeof o !== 'object') return null; + if (o.del) return { del: 1 }; + /** @type {Override} */ + const out = {}; + for (const k of /** @type {const} */ (['x', 'z', 'yaw'])) if (Number.isFinite(o[k])) out[k] = o[k]; + if (Number.isFinite(o.scale)) out.scale = Math.min(SCALE_MAX, Math.max(SCALE_MIN, o.scale)); + return Object.keys(out).length ? out : null; +} + +/** @param {any} s @returns {{source: any, w: number}[] | null} 2..8 valid entries, else null */ +function sourcesOf(s) { + if (!Array.isArray(s.sources)) return null; + const out = []; + for (const e of s.sources.slice(0, MAX_MIX)) { + const source = sourceOf(e?.source); + const w = clampNum(e?.w, 0, 1000); + if (source && w > 0) out.push({ source, w }); + } + return out.length >= 2 ? out : null; +} + +/** sources one mixed set may draw (one pick byte keeps the index) */ +export const MAX_MIX = 8; + +// ---- 51: the generator, the overrides, and the ONE instance list -------------------------- +// +// SLOT k of a seeded set is drawn from its OWN stream (seed, k) — never from a stream shared +// with the slots before it — so raising the count adds instances without moving any, a mask +// that rejects slot 3 moves nothing else, and slot 7 is the same rock on every peer and after +// every reload. That is what makes a slot a stable ID that a hand edit can be keyed by. +// Spacing is judged on the RAW slots, before any override: deleting a rock must never make a +// rock it had crowded out appear beside it. +// +// IDS (strings, the JSON key shape): 'g' = generated slot k (edits live in `ov`, a re-roll +// keeps them) · 'p' = painted instance j, the j-th 8-byte record (an edit REWRITES the +// record, a delete drops it — so 'p' ids after j shift down by one; read the list again after +// a structural edit). + +/** @typedef {{id: string, slot?: number, index?: number, x: number, z: number, yaw: number, + * scale: number, src: number, tint: number}} ScatterInstance */ + +/** A 32-bit mix of (seed, k): the seed of slot k's own stream. @param {number} seed @param {number} k */ +export function slotSeed(seed, k) { + let h = ((seed >>> 0) ^ Math.imul(k + 1, 0x9e3779b1)) >>> 0; + h = Math.imul(h ^ (h >>> 16), 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + return (h ^ (h >>> 16)) >>> 0; +} + +/** + * Slot k's six draws, in a FIXED order (a placer for another surface reads u/v the same way): + * u, v (where), yaw, scale, pick (which source), tint. @param {number} seed @param {number} k + */ +export function slotDraws(seed, k) { + const r = rng32(slotSeed(seed, k)); + return { u: r(), v: r(), yaw: r(), scale: r(), pick: r(), tint: r() }; +} + +/** Which source a draw in [0, 1) picks, by weight. @param {{w: number}[] | undefined} sources @param {number} r */ +export function pickSource(sources, r) { + if (!sources || sources.length < 2) return 0; + let sum = 0; + for (const s of sources) sum += s.w; + let at = r * sum; + for (let i = 0; i < sources.length; i++) { + at -= sources[i].w; + if (at < 0) return i; + } + return sources.length - 1; +} + +/** How many slots a seeded set has on these bounds (`room` = what the terrain may still hold). + * @param {ScatterSet} set @param {Bounds} b @param {number} [room] */ +export function slotCount(set, b, room = MAX_INSTANCES) { + const g = set.gen; + if (!g) return 0; + const n = g.count != null ? g.count : Math.floor((g.density ?? 0) * b.sizeX * b.sizeZ); + return Math.max(0, Math.min(n, room, MAX_INSTANCES)); +} + +/** + * The RAW generated instances (no overrides): one per slot that survives `accept` and spacing. + * `place(u, v, k)` maps a slot's draw to local (x, z) — the terrain rectangle by default; another + * surface passes its own (51-surface). `accept(x, z, k)` is a mask (slope, height, layer). + * @param {ScatterSet} set @param {Bounds} b + * @param {{room?: number, place?: (u: number, v: number, k: number) => {x: number, z: number} | null, + * accept?: (x: number, z: number, k: number) => boolean}} [opts] + * @returns {ScatterInstance[]} + */ +export function generateSlots(set, b, opts = {}) { + const g = set.gen; + if (!g) return []; + const n = slotCount(set, b, opts.room); + const lo = Math.min(g.scaleMin, g.scaleMax); + const hi = Math.max(g.scaleMin, g.scaleMax); + const spacing = g.spacing ?? 0; + /** @type {Map} */ + const grid = new Map(); + const cell = (/** @type {number} */ x, /** @type {number} */ z) => Math.floor(x / spacing) + ',' + Math.floor(z / spacing); + /** @type {ScatterInstance[]} */ + const out = []; + for (let k = 0; k < n; k++) { + const d = slotDraws(set.seed ?? 0, k); + const at = opts.place ? opts.place(d.u, d.v, k) : { x: b.minX + d.u * b.sizeX, z: b.minZ + d.v * b.sizeZ }; + if (!at) continue; + if (opts.accept && !opts.accept(at.x, at.z, k)) continue; + if (spacing) { + const cx = Math.floor(at.x / spacing); + const cz = Math.floor(at.z / spacing); + let ok = true; + for (let i = -1; i <= 1 && ok; i++) + for (let j = -1; j <= 1 && ok; j++) + for (const p of grid.get(cx + i + ',' + (cz + j)) ?? []) + if (Math.hypot(p.x - at.x, p.z - at.z) < spacing) { + ok = false; + break; + } + if (!ok) continue; + } + const inst = { + id: 'g' + k, + slot: k, + x: at.x, + z: at.z, + yaw: g.rot ? d.yaw * TAU : 0, + scale: lo + (hi - lo) * d.scale, + src: pickSource(set.sources, d.pick), + tint: d.tint * 2 - 1 + }; + if (spacing) { + const key = cell(at.x, at.z); + const list = grid.get(key); + if (list) list.push(inst); + else grid.set(key, [inst]); + } + out.push(inst); + } + return out; +} + +/** A painted record's tint draw: a hash of its quantized x/z (stable under erase, the same on every peer). + * @param {DataView} view @param {number} o */ +function paintedTint(view, o) { + const h = slotSeed(view.getUint32(o, true), 0x51); + return (h / 4294967296) * 2 - 1; +} + +/** + * EVERY instance a set draws: the generated slots with their hand edits applied (deleted ones + * left out), then the painted records. The ONE list the renderer, the colliders and the edit + * tools read. @param {ScatterSet} set @param {Bounds} b @param {(b64: string) => Uint8Array | null} decode + * @param {{room?: number, place?: any, accept?: any}} [opts] + * @returns {ScatterInstance[]} + */ +export function instancesOf(set, b, decode, opts = {}) { + /** @type {ScatterInstance[]} */ + const out = []; + for (const inst of generateSlots(set, b, opts)) { + const o = set.ov?.[String(inst.slot)]; + if (o?.del) continue; + out.push(o ? { ...inst, ...o } : inst); + } + const bytes = set.count && set.data ? decode(set.data) : null; + if (bytes) { + const pick = set.pick ? decode(set.pick) : null; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + decodeInstances(bytes, b).forEach((inst, j) => { + out.push({ id: 'p' + j, index: j, ...inst, src: pick && set.sources ? Math.min(pick[j] ?? 0, set.sources.length - 1) : 0, tint: paintedTint(view, j * 8) }); }); } - return { v: SCATTER_VERSION, sets }; + return out; +} + +/** @param {string} id @returns {{kind: 'g' | 'p', n: number} | null} */ +export function parseInstanceId(id) { + const m = /^([gp])(\d{1,5})$/.exec(String(id)); + return m ? { kind: /** @type {'g' | 'p'} */ (m[1]), n: Number(m[2]) } : null; +} + +/** + * Apply hand edits. `patch` = {[id]: {x?, z?, yaw?, scale?, del?: true} | null}: a generated id + * writes its override (null clears it, back to the seed's pose); a painted id rewrites its + * record (null does nothing — a painted instance has no seed to fall back to; del drops it). + * Returns a NEW set; the input is untouched. + * @param {ScatterSet} set @param {Record & {del?: any} | null>} patch + * @param {{bounds: Bounds, decode: (b64: string) => Uint8Array | null, encode: (bytes: Uint8Array) => string}} io + * @returns {ScatterSet} + */ +export function withOverrides(set, patch, io) { + /** @type {ScatterSet} */ + const next = { ...set }; + /** @type {Record} */ + const ov = { ...(set.ov ?? {}) }; + /** @type {Map} */ + const painted = new Map(); + for (const [id, edit] of Object.entries(patch ?? {})) { + const p = parseInstanceId(id); + if (!p) continue; + if (p.kind === 'g') { + if (!set.gen || p.n >= MAX_INSTANCES) continue; + const key = String(p.n); + if (edit === null) delete ov[key]; + else { + const o = overrideOf(edit.del ? { del: 1 } : { ...(ov[key]?.del ? {} : ov[key]), ...edit }); + if (o) ov[key] = o; + } + } else if (edit) painted.set(p.n, edit); + } + if (set.gen) { + if (Object.keys(ov).length) next.ov = ov; + else delete next.ov; + } + if (painted.size && set.count) { + const bytes = io.decode(set.data); + if (bytes) { + const list = decodeInstances(bytes, io.bounds); + const pick = set.pick ? io.decode(set.pick) : null; + /** @type {Instance[]} */ + const kept = []; + /** @type {number[]} */ + const keptPick = []; + list.forEach((inst, j) => { + const edit = painted.get(j); + if (edit?.del) return; + kept.push(edit ? { ...inst, ...pickPose(edit) } : inst); + if (pick) keptPick.push(pick[j] ?? 0); + }); + next.count = kept.length; + next.data = kept.length ? io.encode(encodeInstances(kept, io.bounds)) : ''; + if (pick) { + if (kept.length) next.pick = io.encode(new Uint8Array(keptPick)); + else delete next.pick; + } + } + } + return next; +} + +/** @param {any} e */ +function pickPose(e) { + /** @type {any} */ + const out = {}; + for (const k of ['x', 'z', 'yaw', 'scale']) if (Number.isFinite(e[k])) out[k] = e[k]; + if (out.scale != null) out.scale = Math.min(SCALE_MAX, Math.max(SCALE_MIN, out.scale)); + return out; +} + +/** The whole-set RE-ROLL: a new seed, every hand edit kept (keyed by slot). @param {ScatterSet} set @param {number} seed */ +export function withSeed(set, seed) { + return { ...set, seed: seed >>> 0 }; +} + +/** A fresh seed for a new set or a re-roll (Math.random is fine: the SEED is what travels). */ +export function newSeed() { + return (Math.random() * 4294967296) >>> 0; } // ---- heights: the terrain's regular grid, sampled exactly as it is triangulated -------- diff --git a/src/lib/terrainSurface.js b/src/lib/terrainSurface.js index e9577b009..a90dfd69a 100644 --- a/src/lib/terrainSurface.js +++ b/src/lib/terrainSurface.js @@ -3,7 +3,7 @@ import * as THREE from 'three'; import { get } from 'svelte/store'; import { objectsGroup, globalScene, sceneRevision, pokeScene } from '../stores/sceneStore'; import { decodeSplatBlock, encodeSplatBlock, normalizeLayers, baseIsMaterial, base64ToBytes, LAYER_COUNT } from './terrainPaintCore'; -import { normalizeScatter, decodeInstances, buildHeightGrid, sampleHeight } from './terrainScatterCore'; +import { normalizeScatter, instancesOf, blockVersion, buildHeightGrid, sampleHeight, MAX_INSTANCES } from './terrainScatterCore'; import { lodObject, releaseOwner } from './lod'; import { EXPLICIT_MIN_TRIANGLES } from './lodCore'; @@ -364,17 +364,7 @@ function libraryParts(hash) { if (!blob) throw new Error('no bytes'); const ext = String(item.name ?? '').split('.').pop()?.toLowerCase() ?? 'glb'; const root = await explorer.parseObjectFile(await blob.arrayBuffer(), ext); - root.updateMatrixWorld(true); - const inv = new THREE.Matrix4().copy(root.matrixWorld).invert(); - /** @type {{geometry: any, material: any}[]} */ - const parts = []; - root.traverse((/** @type {any} */ m) => { - if (!m.isMesh || !m.geometry?.attributes?.position || m.isSkinnedMesh) return; - const geometry = m.geometry.clone(); - geometry.applyMatrix4(new THREE.Matrix4().multiplyMatrices(inv, m.matrixWorld)); - parts.push({ geometry, material: Array.isArray(m.material) ? m.material[0] : m.material }); - }); - models.set(hash, { parts, state: 'ready' }); + models.set(hash, { parts: bakeParts(root), state: 'ready' }); } catch (error) { console.log('scatter: model ' + hash + ' failed to load', error); models.set(hash, { parts: [], state: 'ready' }); @@ -384,9 +374,53 @@ function libraryParts(hash) { return null; } +/** url -> parts (null while loading) — 51 E1: a pack item, fetched from the pack CDN by every peer + * @type {Map} */ +const packModels = new Map(); + +/** A pack item as instanceable parts (the same bake as a Library model). @param {any} source */ +function packParts(source) { + const key = source.path; + const entry = packModels.get(key); + if (entry) return entry.parts; + packModels.set(key, { parts: null, state: 'loading' }); + void (async () => { + /** @type {{geometry: any, material: any}[]} */ + let parts = []; + try { + const refs = await import('./packRefs'); + const { scene } = await refs.loadPackTemplate(refs.packRefUrl({ pack: source.pack, item: source.item, path: source.path })); + parts = bakeParts(scene); + } catch (error) { + console.log('scatter: pack item ' + key + ' failed to load', error); + } + packModels.set(key, { parts, state: 'ready' }); + scheduleReconcile(true); + })(); + return null; +} + +/** every mesh of a parsed model, its transform relative to the root baked into a geometry clone + * (the model's look; skinned meshes left out) @param {any} root */ +function bakeParts(root) { + root.updateMatrixWorld(true); + const inv = new THREE.Matrix4().copy(root.matrixWorld).invert(); + /** @type {{geometry: any, material: any}[]} */ + const parts = []; + root.traverse((/** @type {any} */ m) => { + if (!m.isMesh || !m.geometry?.attributes?.position || m.isSkinnedMesh) return; + const geometry = m.geometry.clone(); + geometry.applyMatrix4(new THREE.Matrix4().multiplyMatrices(inv, m.matrixWorld)); + parts.push({ geometry, material: Array.isArray(m.material) ? m.material[0] : m.material }); + }); + return parts; +} + /** @param {any} source */ function partsOf(source) { - return source.kind === 'primitive' ? primitiveParts(source.type) : libraryParts(source.hash); + if (source.kind === 'primitive') return primitiveParts(source.type); + if (source.kind === 'pack') return packParts(source); + return libraryParts(source.hash); } const _m = new THREE.Matrix4(); @@ -452,7 +486,7 @@ function reconcileScatter(object) { if (entry.holder.parent !== scene) scene.add(entry.holder); const pos = object.geometry?.attributes?.position; const layoutKey = (pos ? pos.version + ':' + pos.count + ':' + (pos.array?.length ?? 0) : '') + ':' + uuidTag(pos); - const key = JSON.stringify(sets.map((s) => [s.id, s.data, s.align, sourceReady(s.source)])); + const key = JSON.stringify(sets.map((s) => [s.id, s.data, s.align, s.seed, s.gen, s.ov, s.sources, s.pick, s.tint, s.sink, setSources(s).map(sourceReady)])); if (entry.key === key && entry.layoutKey === layoutKey) return; entry.key = key; entry.layoutKey = layoutKey; @@ -473,7 +507,26 @@ function uuidTag(attr) { /** @param {any} source */ function sourceReady(source) { - return source.kind === 'primitive' || models.get(source.hash)?.state === 'ready'; + if (source.kind === 'primitive') return true; + if (source.kind === 'pack') return packModels.get(source.path)?.state === 'ready'; + return models.get(source.hash)?.state === 'ready'; +} + +/** every source a set draws (a mixed set's list, else its one source) @param {any} set @returns {any[]} */ +function setSources(set) { + return set.sources ? set.sources.map((/** @type {any} */ e) => e.source) : [set.source]; +} + +/** Every set's instances, the MAX_INSTANCES cap shared across the block in set order (the + * same arithmetic on every peer). @param {any[]} sets @param {any} b */ +function blockInstances(sets, b) { + let room = MAX_INSTANCES; + for (const set of sets) room -= set.count; + return sets.map((set) => { + const list = instancesOf(set, b, base64ToBytes, { room: Math.max(0, room) }); + room -= list.length - set.count; + return list; + }); } /** @param {any} object @param {any} holder @param {any[]} sets */ @@ -483,52 +536,85 @@ function layoutScatter(object, holder, sets) { const pos = object.geometry?.attributes?.position; const grid = pos ? buildHeightGrid(pos.array) : null; if (!b) return; - for (const set of sets) { - const parts = partsOf(set.source); - if (!parts || !parts.length) continue; - const list = decodeInstances(/** @type {Uint8Array} */ (base64ToBytes(set.data)), b); - const matrices = instanceMatrices(list, set.align, grid); - for (const part of parts) { - // 47-int (46 LOD): a dense model takes levels. One centre for instances spread over a - // whole terrain means nothing, so a LOD'd part is split into cells — each cell picks - // its level by its NEAREST instance (lod.js) and is culled on its own. A light part - // (a primitive, a low-poly rock) stays ONE draw call per set, as the lane built it. - const lodded = trianglesOfGeo(part.geometry) >= EXPLICIT_MIN_TRIANGLES; - for (const ids of lodded ? cellsOf(list, b) : [list.map((_, k) => k)]) { - const mesh = new THREE.InstancedMesh(part.geometry, part.material, ids.length); - mesh.name = 'terrain-scatter:' + set.id; - mesh.castShadow = true; - mesh.receiveShadow = true; - mesh.userData.scatterSet = set.id; - ids.forEach((id, k) => mesh.setMatrixAt(k, matrices[id])); - mesh.instanceMatrix.needsUpdate = true; - mesh.computeBoundingSphere(); - holder.add(mesh); - if (lodded) lodObject(mesh, {}, holder); + const lists = blockInstances(sets, b); + sets.forEach((set, si) => { + const all = lists[si]; + const matrices = instanceMatrices(all, set.align, grid, set.sink ?? 0); + const colors = set.tint ? all.map((inst) => tintColor(inst.tint, set.tint)) : null; + // 51 E1: a MIXED set draws each source from the instances that picked it + setSources(set).forEach((source, src) => { + const parts = partsOf(source); + if (!parts || !parts.length) return; + /** @type {number[]} */ + const mine = []; + all.forEach((inst, k) => { + if (inst.src === src) mine.push(k); + }); + if (!mine.length) return; + for (const part of parts) { + // 47-int (46 LOD): a dense model takes levels. One centre for instances spread over a + // whole terrain means nothing, so a LOD'd part is split into cells — each cell picks + // its level by its NEAREST instance (lod.js) and is culled on its own. A light part + // (a primitive, a low-poly rock) stays ONE draw call per source, as the lane built it. + const lodded = trianglesOfGeo(part.geometry) >= EXPLICIT_MIN_TRIANGLES; + for (const cell of lodded ? cellsOf(mine.map((k) => all[k]), b).map((ids) => ids.map((i) => mine[i])) : [mine]) { + const mesh = new THREE.InstancedMesh(part.geometry, part.material, cell.length); + mesh.name = 'terrain-scatter:' + set.id; + mesh.castShadow = true; + mesh.receiveShadow = true; + mesh.userData.scatterSet = set.id; + // 51: which instance each slot of this mesh draws ('g12' / 'p3') — the edit tools pick by it + mesh.userData.instanceIds = cell.map((k) => all[k].id); + cell.forEach((k, i) => { + mesh.setMatrixAt(i, matrices[k]); + if (colors) mesh.setColorAt(i, colors[k]); + }); + mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; + mesh.computeBoundingSphere(); + holder.add(mesh); + if (lodded) lodObject(mesh, {}, holder); + } } - } - } + }); + }); +} + +/** 51 E1 tint variation: an instance's draw in [-1, 1] times the set's amount, as a multiplier on + * its material colour — lighter/darker with a slight warm/cool lean, never a new hue. + * @param {number} t @param {number} amount */ +function tintColor(t, amount) { + const a = t * amount; + const f = Math.max(0.3, 1 + a * 0.35); + return new THREE.Color(f * (1 + a * 0.06), f, f * (1 - a * 0.06)); } -/** Terrain-LOCAL instance matrices — the drawn pose, and (48 B1) the collider pose. +/** Terrain-LOCAL instance matrices — the drawn pose, and (48 B1) the collider pose. `sink` (51) + * pushes each instance into the ground by sink x its scale. * @param {{x: number, z: number, yaw: number, scale: number}[]} list @param {boolean} align - * @param {ReturnType} grid @returns {any[]} */ -function instanceMatrices(list, align, grid) { + * @param {ReturnType} grid @param {number} [sink] @returns {any[]} */ +function instanceMatrices(list, align, grid, sink = 0) { return list.map((inst) => { const h = grid ? sampleHeight(grid, inst.x, inst.z) : { y: 0, nx: 0, ny: 1, nz: 0 }; _qy.setFromAxisAngle(UP, inst.yaw); if (align) _q.setFromUnitVectors(UP, _n.set(h.nx, h.ny, h.nz)).multiply(_qy); else _q.copy(_qy); - return new THREE.Matrix4().compose(_p.set(inst.x, h.y, inst.z), _q, _s.setScalar(inst.scale)); + const m = new THREE.Matrix4().compose(_p.set(inst.x, h.y, inst.z), _q, _s.setScalar(inst.scale)); + // down the instance's own up axis, so a tilted rock sinks into the slope it leans on + if (sink) m.multiplyMatrices(m, _sinkM.makeTranslation(0, -sink, 0)); + return m; }); } +const _sinkM = new THREE.Matrix4(); /** * 48 B1: what a simulation needs to give a terrain's scatter its colliders — every set that * asks for one (`collider` box / sphere / hull), with the parts it draws and its instances' - * TERRAIN-LOCAL matrices, computed exactly as the render does (same heights, same tilt), so - * a ball rests on the rock it sees. A set whose Library model is still loading is left out + * TERRAIN-LOCAL matrices, computed exactly as the render does (same heights, same tilt, same + * sink), so a ball rests on the rock it sees. A set whose model is still loading is left out * (it is not drawn either); `pending` says so, and the physics side rebuilds when it lands. + * 51: one entry per SOURCE of a mixed set, and EVERY instance — device thinning only ever + * hides far DRAWS, never a collider, so gameplay is the same on a phone and a desktop. * @param {any} object a terrain @returns {{sets: {id: string, collider: string, parts: {geometry: any}[], matrices: any[]}[], pending: boolean}} */ export function scatterPhysicsSets(object) { @@ -536,20 +622,26 @@ export function scatterPhysicsSets(object) { const block = object?.userData?.terrainScatter; const b = block ? terrainBounds(object) : null; if (!b) return out; - const wanted = (normalizeScatter(block, base64ToBytes)?.sets ?? []).filter((s) => s.collider); - if (!wanted.length) return out; + const sets = normalizeScatter(block, base64ToBytes)?.sets ?? []; + if (!sets.some((s) => s.collider)) return out; const pos = object.geometry?.attributes?.position; const grid = pos ? buildHeightGrid(pos.array) : null; - for (const set of wanted) { - const parts = partsOf(set.source); - if (!parts) { - out.pending = true; - continue; - } - if (!parts.length) continue; - const list = decodeInstances(/** @type {Uint8Array} */ (base64ToBytes(set.data)), b); - out.sets.push({ id: set.id, collider: /** @type {string} */ (set.collider), parts, matrices: instanceMatrices(list, set.align, grid) }); - } + const lists = blockInstances(sets, b); + sets.forEach((set, si) => { + if (!set.collider) return; + const all = lists[si]; + const matrices = instanceMatrices(all, set.align, grid, set.sink ?? 0); + setSources(set).forEach((source, src) => { + const parts = partsOf(source); + if (!parts) { + out.pending = true; + return; + } + if (!parts.length) return; + const mine = matrices.filter((_, k) => all[k].src === src); + if (mine.length) out.sets.push({ id: set.id, collider: /** @type {string} */ (set.collider), parts, matrices: mine }); + }); + }); return out; } @@ -582,7 +674,7 @@ export function applyTerrainScatter(uuid, block) { const object = objectOf(uuid); if (!object) return false; const sets = block ? normalizeScatter(block, base64ToBytes)?.sets ?? [] : []; - if (sets.length) object.userData.terrainScatter = { v: 1, sets }; + if (sets.length) object.userData.terrainScatter = { v: blockVersion(sets), sets }; else delete object.userData.terrainScatter; reconcileScatter(object); pokeScene(); diff --git a/tests/unit/scatterSetFormat.test.js b/tests/unit/scatterSetFormat.test.js new file mode 100644 index 000000000..93c5db799 --- /dev/null +++ b/tests/unit/scatterSetFormat.test.js @@ -0,0 +1,236 @@ +// @ts-nocheck — a fixture test: sets are built by hand and asserted by the checks themselves +// 51 — THE SCATTER SET FORMAT (params + seed + overrides). The rules this file holds: +// the same seed + params + overrides give the same instances twice and on two "peers" (two +// independent wire round trips), a slot is a STABLE id (raising the count moves nothing), a +// re-roll keeps the hand edits, a 1.38 set loads byte-identical, and the edit API rewrites +// painted records / generated overrides without touching anything else. +import { describe, test, expect } from 'vitest'; +import { + normalizeScatter, + encodeInstances, + decodeInstances, + instancesOf, + generateSlots, + withOverrides, + withSeed, + slotDraws, + pickSource, + sourceOf, + sourceKey, + blockVersion, + MAX_INSTANCES +} from '../../src/lib/terrainScatterCore.js'; + +const B = { minX: -20, minZ: -20, sizeX: 40, sizeZ: 40 }; +const b64 = (u) => Buffer.from(u).toString('base64'); +const unb64 = (s) => new Uint8Array(Buffer.from(s, 'base64')); +const IO = { bounds: B, decode: unb64, encode: b64 }; +/** a JSON wire hop: what a peer receives (and a reload reads) */ +const wire = (block) => normalizeScatter(JSON.parse(JSON.stringify(block)), unb64); + +const ROCK = { kind: 'primitive', type: 'Dodecahedron' }; +const seeded = (extra = {}) => ({ id: 'rocks', source: ROCK, seed: 12345, gen: { count: 200, scaleMin: 0.5, scaleMax: 2, rot: true }, ...extra }); + +describe('51 set format: migration', () => { + test('a 1.38 set normalizes to exactly the object it did before (no new keys, v1)', () => { + const data = b64(encodeInstances([{ x: 1, z: 2, yaw: 0.5, scale: 1.2 }, { x: -3, z: 4, yaw: 1, scale: 0.8 }], B)); + const old = { v: 1, sets: [{ id: 'p:Cone', source: { kind: 'primitive', type: 'Cone' }, align: true, data, count: 2, collider: 'box' }] }; + const n = normalizeScatter(old, unb64); + expect(n).toEqual(old); + expect(JSON.stringify(n)).toBe(JSON.stringify(old)); + expect(Object.keys(n.sets[0])).toEqual(['id', 'source', 'align', 'data', 'count', 'collider']); + }); + + test('a 1.38 set draws its painted instances with p ids, nothing generated', () => { + const data = b64(encodeInstances([{ x: 1, z: 2, yaw: 0.5, scale: 1.2 }], B)); + const set = normalizeScatter({ sets: [{ source: ROCK, data }] }, unb64).sets[0]; + const list = instancesOf(set, B, unb64); + expect(list.map((i) => i.id)).toEqual(['p0']); + expect(list[0].src).toBe(0); + }); + + test('the block stays v1 until a set uses a 51 field', () => { + expect(blockVersion([{ count: 1 }])).toBe(1); + expect(blockVersion([{ count: 0, gen: { count: 1 } }])).toBe(2); + expect(blockVersion([{ count: 1, tint: 0.3 }])).toBe(2); + }); +}); + +describe('51 set format: determinism', () => { + test('same seed + params + overrides = same instances, twice and across two peers', () => { + const block = { v: 2, sets: [seeded({ ov: { 3: { del: 1 }, 7: { x: 1, z: 1 } }, tint: 0.4, sink: 0.1 })] }; + const peerA = wire(block).sets[0]; + const peerB = wire(JSON.parse(JSON.stringify(block))).sets[0]; + const a1 = instancesOf(peerA, B, unb64); + const a2 = instancesOf(peerA, B, unb64); + const b1 = instancesOf(peerB, B, unb64); + expect(a1.length).toBe(199); + expect(a2).toEqual(a1); + expect(b1).toEqual(a1); + // a reload of what peer A stored reads the same + expect(instancesOf(wire({ v: 2, sets: [peerA] }).sets[0], B, unb64)).toEqual(a1); + }); + + test('a different seed gives a different layout', () => { + const a = instancesOf(seeded(), B, unb64); + const b = instancesOf(seeded({ seed: 54321 }), B, unb64); + expect(a.length).toBe(b.length); + expect(a.filter((p, i) => p.x === b[i].x).length).toBe(0); + }); + + test('slot ids are stable: raising the count moves no existing slot', () => { + const small = generateSlots(seeded({ gen: { count: 50, scaleMin: 1, scaleMax: 1, rot: true } }), B); + const big = generateSlots(seeded({ gen: { count: 400, scaleMin: 1, scaleMax: 1, rot: true } }), B); + expect(big.slice(0, 50)).toEqual(small); + }); + + test('a mask rejecting one slot moves no other slot', () => { + const all = generateSlots(seeded(), B); + const masked = generateSlots(seeded(), B, { accept: (_x, _z, k) => k !== 10 }); + expect(masked.length).toBe(all.length - 1); + expect(masked.find((i) => i.slot === 11)).toEqual(all.find((i) => i.slot === 11)); + }); + + test('spacing: no two generated instances closer than the spacing', () => { + const list = generateSlots(seeded({ gen: { count: 800, scaleMin: 1, scaleMax: 1, rot: true, spacing: 2 } }), B); + expect(list.length).toBeGreaterThan(50); + expect(list.length).toBeLessThan(800); + let min = Infinity; + for (let i = 0; i < list.length; i++) + for (let j = i + 1; j < list.length; j++) min = Math.min(min, Math.hypot(list[i].x - list[j].x, list[i].z - list[j].z)); + expect(min).toBeGreaterThanOrEqual(2); + }); + + test('deleting a crowding slot does not make a crowded-out slot appear (spacing reads raw slots)', () => { + const set = seeded({ gen: { count: 800, scaleMin: 1, scaleMax: 1, rot: true, spacing: 3 } }); + const before = instancesOf(set, B, unb64); + const victim = before[5]; + const after = instancesOf(withOverrides(set, { [victim.id]: { del: true } }, IO), B, unb64); + expect(after.length).toBe(before.length - 1); + expect(after.map((i) => i.id)).toEqual(before.filter((i) => i !== victim).map((i) => i.id)); + }); + + test('density mode counts over the bounds (0.1 / m² on 40x40 = 160 slots)', () => { + const set = normalizeScatter({ sets: [{ source: ROCK, seed: 1, gen: { density: 0.1 } }] }, unb64).sets[0]; + expect(set.gen.density).toBe(0.1); + expect(instancesOf(set, B, unb64).length).toBe(160); + }); + + test('slot draws are a pure function of (seed, k)', () => { + expect(slotDraws(9, 4)).toEqual(slotDraws(9, 4)); + expect(slotDraws(9, 4)).not.toEqual(slotDraws(9, 5)); + }); +}); + +describe('51 set format: overrides and re-roll', () => { + test('move / rotate / scale / delete a generated slot', () => { + const set = seeded(); + const ids = instancesOf(set, B, unb64).map((i) => i.id); + const edited = withOverrides(set, { g0: { x: 5, z: 6 }, g1: { yaw: 1.5 }, g2: { scale: 3 }, g3: { del: true } }, IO); + expect(edited.ov).toEqual({ 0: { x: 5, z: 6 }, 1: { yaw: 1.5 }, 2: { scale: 3 }, 3: { del: 1 } }); + const list = instancesOf(edited, B, unb64); + const byId = Object.fromEntries(list.map((i) => [i.id, i])); + expect([byId.g0.x, byId.g0.z]).toEqual([5, 6]); + expect(byId.g1.yaw).toBe(1.5); + expect(byId.g2.scale).toBe(3); + expect(byId.g3).toBeUndefined(); + expect(list.length).toBe(ids.length - 1); + // null clears an edit: back to the seed's pose + const cleared = withOverrides(edited, { g0: null }, IO); + expect(instancesOf(cleared, B, unb64).find((i) => i.id === 'g0')).toEqual(instancesOf(set, B, unb64)[0]); + expect(set.ov).toBeUndefined(); // input untouched + }); + + test('a re-roll keeps the hand edits (keyed by slot)', () => { + const edited = withOverrides(seeded(), { g4: { x: 1, z: 2, yaw: 0.25 }, g9: { del: true } }, IO); + const rerolled = withSeed(edited, 999); + const before = instancesOf(edited, B, unb64); + const after = instancesOf(rerolled, B, unb64); + expect(after.find((i) => i.id === 'g4')).toMatchObject({ x: 1, z: 2, yaw: 0.25 }); + expect(after.find((i) => i.id === 'g9')).toBeUndefined(); + // the unedited ones moved + expect(after.find((i) => i.id === 'g5').x).not.toBe(before.find((i) => i.id === 'g5').x); + }); + + test('painted records: edit rewrites the record, delete drops it (and its pick byte)', () => { + const data = b64(encodeInstances([{ x: 1, z: 1, yaw: 0, scale: 1 }, { x: 2, z: 2, yaw: 0, scale: 1 }, { x: 3, z: 3, yaw: 0, scale: 1 }], B)); + const set = normalizeScatter( + { sets: [{ source: ROCK, data, sources: [{ source: ROCK, w: 1 }, { source: { kind: 'primitive', type: 'Cone' }, w: 1 }], pick: b64(new Uint8Array([0, 1, 0])) }] }, + unb64 + ).sets[0]; + expect(instancesOf(set, B, unb64).map((i) => i.src)).toEqual([0, 1, 0]); + const next = withOverrides(set, { p0: { del: true }, p2: { x: -5, scale: 2 } }, IO); + expect(next.count).toBe(2); + const list = instancesOf(next, B, unb64); + expect(list.map((i) => i.src)).toEqual([1, 0]); + expect(list[1].x).toBeCloseTo(-5, 2); + expect(list[1].scale).toBeCloseTo(2, 2); + expect(list[0].x).toBeCloseTo(2, 2); + }); +}); + +describe('51 set format: mixed sets, tint, sink, sources, validation', () => { + test('a mixed set picks its sources by weight (50/30/20 within a few percent)', () => { + const sources = [ + { source: ROCK, w: 50 }, + { source: { kind: 'primitive', type: 'Box' }, w: 30 }, + { source: { kind: 'primitive', type: 'Cone' }, w: 20 } + ]; + const set = normalizeScatter({ sets: [seeded({ sources, gen: { count: 5000, scaleMin: 1, scaleMax: 1 } })] }, unb64).sets[0]; + const counts = [0, 0, 0]; + for (const i of instancesOf(set, B, unb64)) counts[i.src]++; + expect(counts[0] / 5000).toBeCloseTo(0.5, 1); + expect(counts[1] / 5000).toBeCloseTo(0.3, 1); + expect(counts[2] / 5000).toBeCloseTo(0.2, 1); + expect(pickSource(sources, 0)).toBe(0); + expect(pickSource(sources, 0.999)).toBe(2); + }); + + test('a one-entry sources list is not a mix (dropped); tint and sink clamp', () => { + const set = normalizeScatter({ sets: [seeded({ sources: [{ source: ROCK, w: 1 }], tint: 7, sink: -40 })] }, unb64).sets[0]; + expect(set.sources).toBeUndefined(); + expect(set.tint).toBe(1); + expect(set.sink).toBe(-2); + }); + + test('tint draws are spread over [-1, 1] and stable', () => { + const list = instancesOf(seeded(), B, unb64); + expect(Math.min(...list.map((i) => i.tint))).toBeLessThan(-0.8); + expect(Math.max(...list.map((i) => i.tint))).toBeGreaterThan(0.8); + }); + + test('pack sources validate, key by path; bad paths are refused', () => { + const s = sourceOf({ kind: 'pack', pack: 'nature-kit', item: 'Rock1', path: 'nature-kit/Rock1.glb', note: 'x' }); + expect(s).toEqual({ kind: 'pack', path: 'nature-kit/Rock1.glb', pack: 'nature-kit', item: 'Rock1', note: 'x' }); + expect(sourceKey(s)).toBe('k:nature-kit/Rock1.glb'); + expect(sourceOf({ kind: 'pack', path: '../etc/passwd' })).toBeNull(); + expect(sourceOf({ kind: 'library', hash: 'abc', name: 'Rock', note: 'Prefab "Gate" — look only' }).note).toBe('Prefab "Gate" — look only'); + }); + + test('bad overrides are dropped, not guessed at', () => { + const set = normalizeScatter({ sets: [seeded({ ov: { 1: { x: 'a' }, nope: { del: 1 }, 99999: { del: 1 }, 2: { scale: 1e6 }, 3: { del: true } } })] }, unb64).sets[0]; + expect(set.ov).toEqual({ 2: { scale: 20 }, 3: { del: 1 } }); + }); + + test('the instance cap holds across painted + generated (count clamps at normalize)', () => { + const data = b64(encodeInstances(Array.from({ length: 100 }, () => ({ x: 0, z: 0, yaw: 0, scale: 1 })), B)); + const n = normalizeScatter({ sets: [{ source: ROCK, data }, seeded({ gen: { count: MAX_INSTANCES } })] }, unb64); + expect(n.sets[1].gen.count).toBe(MAX_INSTANCES - 100); + }); + + test('a generated-only set survives normalize with no data', () => { + const set = normalizeScatter({ sets: [seeded()] }, unb64).sets[0]; + expect(set).toMatchObject({ id: 'rocks', data: '', count: 0, seed: 12345 }); + }); + + test('51-surface fields ride the gen block untouched', () => { + const set = normalizeScatter({ sets: [seeded({ gen: { count: 10, slope: [0, 30], mode: 'fill' } })] }, unb64).sets[0]; + expect(set.gen.slope).toEqual([0, 30]); + expect(set.gen.mode).toBe('fill'); + }); + + test('decode/encode round trip unchanged (the 1.38 records)', () => { + const list = [{ x: 1, z: 2, yaw: 0.5, scale: 1.2 }]; + expect(decodeInstances(encodeInstances(list, B), B)[0].scale).toBeCloseTo(1.2, 3); + }); +}); From 512623e7f8a21436e451156b1c508f05b0881d6a Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 22 Oct 2026 17:58:48 +0300 Subject: [PATCH 02/65] [refactor] 51-surface: the Inspector Scatter section moves into its own file - ScatterSection.svelte = the Scatter
block of TerrainPaintSection, moved verbatim (set list, align, collider, remove, Scatter... button) - TerrainPaintSection renders where the block was, so the terrain Inspector is unchanged; any mesh can carry the section from here on (E2) - svelte-check 299/39 = baseline Co-Authored-By: Claude Opus 5.5 (1M context) --- .../menu/inspector/ScatterSection.svelte | 75 +++++++++++++++++++ .../menu/inspector/TerrainPaintSection.svelte | 63 ++-------------- 2 files changed, 80 insertions(+), 58 deletions(-) create mode 100644 src/components/menu/inspector/ScatterSection.svelte diff --git a/src/components/menu/inspector/ScatterSection.svelte b/src/components/menu/inspector/ScatterSection.svelte new file mode 100644 index 000000000..cbca186a2 --- /dev/null +++ b/src/components/menu/inspector/ScatterSection.svelte @@ -0,0 +1,75 @@ + + +
+ {#if scatter.length} +
+ {#each scatter as s (s.id)} +
+
+ {setName(s)} + {s.count} + + +
+ +
+ Collider + editScatterSet(uuid, s.id, { collider: v })} + /> +
+
+ {/each} +
+

{total} of {MAX_INSTANCES} instances

+ {:else} +

No scattered models yet.

+ {/if} + Scatter… +
diff --git a/src/components/menu/inspector/TerrainPaintSection.svelte b/src/components/menu/inspector/TerrainPaintSection.svelte index ed4d6c95b..62d88b65c 100644 --- a/src/components/menu/inspector/TerrainPaintSection.svelte +++ b/src/components/menu/inspector/TerrainPaintSection.svelte @@ -1,20 +1,18 @@
@@ -136,38 +117,4 @@
-
- {#if scatter.length} -
- {#each scatter as s (s.id)} -
-
- {setName(s)} - {s.count} - - -
- -
- Collider - editScatterSet(uuid, s.id, { collider: v })} - /> -
-
- {/each} -
-

{total} of {MAX_INSTANCES} instances

- {:else} -

No scattered models yet.

- {/if} - Scatter… -
+ From fb2bcfadac898a9181d5d05d1ddbb5826ab715d2 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 22 Oct 2026 18:00:26 +0300 Subject: [PATCH 03/65] [feat] 51 E4: edit scatter instances - select + gizmo, box ops, rotate/scale/relax brushes, per-source erase, per-terrain splat detail - scatterEditCore.js (pure, vitest): box pick by projected point, rotate/scale/delete/re-roll a selection, group transform about a pivot, rotate/scale/relax/erase brushes (rate-based, smoothstep falloff, relax deterministic and clamped to the brush), yaw from a quaternion - scatterEditAdapter.js: the ONE file that knows the set format (today the 1.38 instance bytes; re-pointed at 51-sources' overrides when it lands, QUESTIONS-51-edit Q1) - scatterEdit.js: Scatter brush TOOLS Paint/Erase/Rotate/Scale/Relax/Select + Affects (one model or all = per-source erase); Select = click (Shift/Ctrl adds), box drag, Ctrl+A, Delete, Esc; the shared gizmo on a scene-root proxy (move on the ground, turn about up, uniform size) previews live and commits ONE terrainScatter entry per drag; toolbox Turn / Bigger / Smaller / Re-roll (keeps every other instance's edits) / Delete - every op = applyTerrainScatter + the existing objectParameters terrainScatter message + one terrainScatter undo entry: peers, undo and save need nothing new - per-terrain splat resolution: Inspector > Terrain paint > Detail 64..512, the map resampled (weights re-normalised to 255, coverage kept), one terrainSplat entry + message - terrainSurface: InstancedMeshes carry scatterIds (picking), scatterHolderOf(); terrainSculpt routes the edit brushes; Scene: proxy drag/change + the Select tool's press/move/release - four duotone tool glyphs; debug hooks scatterEdit / scatterEditCore Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/Scene.svelte | 34 +- src/components/menu/SculptToolbar.svelte | 148 +++- .../menu/inspector/TerrainPaintSection.svelte | 20 +- src/components/ui/ToolIcon.svelte | 17 + src/lib/debugHooks.js | 3 + src/lib/scatterEdit.js | 747 ++++++++++++++++++ src/lib/scatterEditAdapter.js | 75 ++ src/lib/scatterEditCore.js | 311 ++++++++ src/lib/terrainPaint.js | 31 +- src/lib/terrainPaintCore.js | 55 ++ src/lib/terrainSculpt.js | 32 +- src/lib/terrainSurface.js | 7 + tests/unit/scatterEditCore.test.js | 212 +++++ 13 files changed, 1665 insertions(+), 27 deletions(-) create mode 100644 src/lib/scatterEdit.js create mode 100644 src/lib/scatterEditAdapter.js create mode 100644 src/lib/scatterEditCore.js create mode 100644 tests/unit/scatterEditCore.test.js diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 9065cea28..510d684c4 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -29,6 +29,7 @@ import { suspendAnimation, resumeAnimation, pumpFlowTick } from '$lib/flowRuntime'; import { holdBody, releaseBody } from '$lib/physics'; import { sculptObject, enterSculpt, beginStroke, strokeMove, endStroke as sculptEndStroke, showCursorAt, hideCursor } from '$lib/terrainSculpt'; + import { scatterSelectArmed, scatterSelectDown, scatterSelectMove, scatterSelectUp, onScatterProxyMoved, onScatterProxyDragChanged } from '$lib/scatterEdit'; import { sceneHits } from '$lib/scenePick'; import { pickStack, chooseInStack, altCycleIndex, primaryIndex } from '$lib/selectThrough'; import { editorPass } from '$lib/pickPass'; // 36 F22 @@ -481,6 +482,11 @@ onSplineProxyDragChanged(event.value); return; } + // 51 E4: scatter instances under the gizmo — ONE terrainScatter entry per drag + if (object.userData?.isScatterProxy) { + onScatterProxyDragChanged(event.value); + return; + } // 33: one LOD level's offset — ONE lod write per drag, never a move if (object.userData?.isLodLevelProxy) { onLodProxyDragChanged(event.value); @@ -867,6 +873,15 @@ } // T-2: sculpt mode — dragging brushes the terrain instead of orbiting if ($sculptObject && !$isLocked && !$isVRMode) { + // 51 E4: the Scatter Select tool — a press picks instances / starts a box; a + // press on a gizmo handle is the gizmo's (it sets `axis` on hover) + if (scatterSelectArmed()) { + if ($TControls?.axis) return; + scatterSelectDown($sculptObject, event); + sculptActive = true; + setOrbitEnabled(false); + return; + } const terrain = $objectsGroup?.getObjectByProperty('uuid', $sculptObject); setRayFromEvent(event); const hit = terrain ? selectionRaycaster.intersectObject(terrain, false)[0] : null; @@ -1013,8 +1028,16 @@ y1: Math.max(marqueeStart[1], event.clientY) }; } + // 51 E4: the Scatter Select tool draws a box, never a brush ring + if ($sculptObject && scatterSelectArmed()) { + hideCursor(); + if (sculptActive) { + scatterSelectMove(event); + return; + } + } // T-2: the brush cursor tracks the terrain; a held button keeps sculpting - if ($sculptObject) { + else if ($sculptObject) { const terrain = $objectsGroup?.getObjectByProperty('uuid', $sculptObject); setRayFromEvent(event); const hit = terrain ? selectionRaycaster.intersectObject(terrain, false)[0] : null; @@ -1138,7 +1161,8 @@ if (sculptActive && event.button === 0) { sculptActive = false; setOrbitEnabled(true); - sculptEndStroke(); // flush the pending preview + ONE undoable snapshot + if ($sculptObject && scatterSelectArmed()) scatterSelectUp($sculptObject, event, camera.current); + else sculptEndStroke(); // flush the pending preview + ONE undoable snapshot return; } if (strokeActive && event.button === 0) { @@ -1697,6 +1721,12 @@ onSplineProxyMoved(); return; } + // 51 E4: the selected scatter instances follow the proxy (preview; release commits) + if ($TControls.object?.userData?.isScatterProxy) { + $TControls.visible = true; + onScatterProxyMoved(); + return; + } // 33: a LOD level's proxy — the level follows locally; the release commits if ($TControls.object?.userData?.isLodLevelProxy) { $TControls.visible = true; diff --git a/src/components/menu/SculptToolbar.svelte b/src/components/menu/SculptToolbar.svelte index 99a560045..7e59cccf7 100644 --- a/src/components/menu/SculptToolbar.svelte +++ b/src/components/menu/SculptToolbar.svelte @@ -33,7 +33,22 @@ scatterErase, layersOf } from '$lib/terrainPaint'; - import { SCATTER_PRIMITIVES, sourceKey } from '$lib/terrainScatterCore'; + import { SCATTER_PRIMITIVES, sourceKey, normalizeScatter } from '$lib/terrainScatterCore'; + import { base64ToBytes } from '$lib/terrainPaintCore'; + // 51 E4: the tools that EDIT what is scattered (brushes + the Select tool) + import { + scatterTool, + scatterAffects, + scatterBrushReverse, + scatterSelectionCount, + scatterGizmoMode, + setScatterTool, + setScatterGizmoMode, + rotateSelectedInstances, + scaleSelectedInstances, + rerollSelectedInstances, + deleteSelectedInstances + } from '$lib/scatterEdit'; import { explorerItems } from '$lib/explorer'; const OPS = [ @@ -80,6 +95,41 @@ } } + // 51 E4: Paint/Erase were two buttons; the edit tools join them in one armed row + const SCATTER_TOOLS = [ + { tool: 'paint', label: 'Paint', icon: 'scatter', desc: 'paint instances of the model above' }, + { tool: 'erase', label: 'Erase', icon: 'scatter-erase', desc: 'erase instances (Affects picks which models)' }, + { tool: 'rotate', label: 'Rotate', icon: 'scatter-rotate', desc: 'turn the instances under the brush' }, + { tool: 'scale', label: 'Scale', icon: 'scatter-scale', desc: 'grow (or shrink) the instances under the brush' }, + { tool: 'relax', label: 'Relax', icon: 'scatter-relax', desc: 'even out the spacing under the brush' }, + { tool: 'select', label: 'Select', icon: 'scatter-select', desc: 'pick instances and move, turn, scale, delete or re-roll them' } + ]; + const GIZMO_MODES = /** @type {const} */ ([ + { mode: 'translate', label: 'Move', desc: 'Slide the selection over the ground (height follows the terrain)' }, + { mode: 'rotate', label: 'Turn', desc: 'Turn the selection about up' }, + { mode: 'scale', label: 'Size', desc: 'Scale the selection' } + ]); + // the armed tool; the 47 erase flag still reads as Erase (a suite or an old caller sets it) + const tool = $derived($scatterErase && $scatterTool === 'paint' ? 'erase' : $scatterTool); + /** @param {string} t */ + function pickTool(t) { + scatterErase.set(false); + setScatterTool(/** @type {any} */ (t)); + } + // Affects: every model on this terrain, or one of them + const affectItems = $derived.by(() => { + void $sceneRevision; + const object = /** @type {any} */ ($objectsGroup)?.getObjectByProperty?.('uuid', $sculptObject); + const sets = normalizeScatter(object?.userData?.terrainScatter, base64ToBytes)?.sets ?? []; + return [ + { value: '', name: 'All models' }, + ...sets.map((/** @type {any} */ s) => ({ + value: s.id, + name: (s.source.kind === 'primitive' ? s.source.type : s.source.name || 'Library model') + ` (${s.count})` + })) + ]; + }); + /** @param {KeyboardEvent} event */ function onKeydown(event) { if (!viewportHasKeys(event)) return; // 36 U11: keys in another pane are that pane's @@ -133,7 +183,7 @@ {$sculptRadius}m - {#if $sculptOp !== 'scatter'} + {#if $sculptOp !== 'scatter' || tool === 'rotate' || tool === 'scale' || tool === 'relax'}
{/if} + {#if !isLight && !multiCount && $selectedObject?.isMesh && !$selectedObject.userData?.terrain} + + + {/if} + {#if devPrimary}
Scatter
diff --git a/src/components/menu/inspector/ScatterFillRows.svelte b/src/components/menu/inspector/ScatterFillRows.svelte new file mode 100644 index 000000000..d0f80afbf --- /dev/null +++ b/src/components/menu/inspector/ScatterFillRows.svelte @@ -0,0 +1,341 @@ + + +{#if !gen} + Fill the whole surface +{:else} +
+
+ Fill + setMode(v === 'count' ? 'count' : 'density')} + /> + +
+ {#if byCount} + { + lastCount = Math.round(v); + write({ count: lastCount, density: undefined }); + }} + onscrubend={() => scrubEnd(() => ({ count: lastCount, density: undefined }))} + /> + {:else} + { + lastDensity = v; + write({ density: v, count: undefined }); + }} + onscrubend={() => scrubEnd(() => ({ density: lastDensity, count: undefined }))} + /> + {/if} + { + lastSpacing = v; + write({ spacing: v > 0 ? v : undefined }); + }} + onscrubend={() => scrubEnd(() => ({ spacing: lastSpacing > 0 ? lastSpacing : undefined }))} + /> +
+ write({ slope: v ? [0, 30] : undefined })} + /> + Slope + {#if gen.slope} + 0° flat · 90° wall + {/if} +
+ {#if gen.slope} +
+ { + lastSlope = [v, lastSlope[1]]; + write({ slope: lastSlope }); + }} + onscrubend={() => scrubEnd(() => ({ slope: lastSlope }))} + /> + { + lastSlope = [lastSlope[0], v]; + write({ slope: lastSlope }); + }} + onscrubend={() => scrubEnd(() => ({ slope: lastSlope }))} + /> +
+ {/if} +
+ write({ height: v ? [yRange.lo, yRange.hi] : undefined })} + /> + Height + {#if gen.height} + in the object's own frame + {/if} +
+ {#if gen.height} +
+ { + lastHeight = [v, lastHeight[1]]; + write({ height: lastHeight }); + }} + onscrubend={() => scrubEnd(() => ({ height: lastHeight }))} + /> + { + lastHeight = [lastHeight[0], v]; + write({ height: lastHeight }); + }} + onscrubend={() => scrubEnd(() => ({ height: lastHeight }))} + /> +
+ {/if} + {#if terrain} + +
+ On paint + + write({ layer: v === 'none' ? undefined : { i: Number(v), min: lastLayerMin } })} + /> +
+ {#if gen.layer} + { + lastLayerMin = v; + write({ layer: { i: gen.layer.i, min: v } }); + }} + onscrubend={() => scrubEnd(() => ({ layer: { i: gen.layer.i, min: lastLayerMin } }))} + /> + {/if} + {/if} + +
+{/if} diff --git a/src/components/menu/inspector/ScatterSection.svelte b/src/components/menu/inspector/ScatterSection.svelte index cbca186a2..4551a9b4b 100644 --- a/src/components/menu/inspector/ScatterSection.svelte +++ b/src/components/menu/inspector/ScatterSection.svelte @@ -8,10 +8,12 @@ import UiButton from '../../ui/Button.svelte'; import Checkbox from '../../ui/Checkbox.svelte'; import { sceneRevision } from '../../../stores/sceneStore'; - import { editScatterSet } from '$lib/terrainPaint'; + import { editScatterSet, addScatterSet, scatterSource } from '$lib/terrainPaint'; import { base64ToBytes } from '$lib/terrainPaintCore'; - import { normalizeScatter, scatterColliderOf, MAX_INSTANCES } from '$lib/terrainScatterCore'; + import { normalizeScatter, scatterColliderOf, sourceOf, MAX_INSTANCES } from '$lib/terrainScatterCore'; + import { scatterCounts } from '$lib/terrainSurface'; import ThemedSelect from '../../ui/ThemedSelect.svelte'; + import ScatterFillRows from './ScatterFillRows.svelte'; // 51-surface E3 /** @type {{ object: any }} */ let { object } = $props(); @@ -22,11 +24,24 @@ void $sceneRevision; return normalizeScatter(object?.userData?.terrainScatter, base64ToBytes)?.sets ?? []; }); - const total = $derived(scatter.reduce((sum, s) => sum + s.count, 0)); + // 51-surface: what each set DRAWS (painted + its fill after masks/spacing), from the last layout + const drawn = $derived.by(() => { + void $sceneRevision; + return scatterCounts(object); + }); + const countOf = (/** @type {any} */ s) => drawn[s.id] ?? s.count; + const total = $derived(scatter.reduce((sum, s) => sum + countOf(s), 0)); + const terrain = $derived(!!object?.userData?.terrain); async function openScatter() { const ts = await import('$lib/terrainSculpt'); - if (ts.enterSculpt(uuid)) ts.sculptOp.set('scatter'); + // 51-surface: on a plain mesh a scatter-only session (the geometry is left untouched) + if (ts.enterSculpt(uuid, { scatter: !terrain })) ts.sculptOp.set('scatter'); + } + /** 51-surface: a new FILL set — the brush's current source over the whole surface */ + function addFill() { + const source = sourceOf($scatterSource) ?? { kind: 'primitive', type: 'Cone' }; + addScatterSet(uuid, { source, gen: { count: 200, scaleMin: 0.8, scaleMax: 1.2, rot: true } }); } const COLLIDER_ITEMS = [ { value: 'none', name: 'None' }, @@ -45,9 +60,9 @@
{setName(s)} - {s.count} + {countOf(s)} @@ -64,12 +79,20 @@ onchange={(/** @type {string} */ v) => editScatterSet(uuid, s.id, { collider: v })} />
+
{/each}

{total} of {MAX_INSTANCES} instances

{:else} -

No scattered models yet.

+

+ {terrain ? 'No scattered models yet.' : 'Scatter models over this object’s surface — they move, turn and scale with it.'} +

{/if} - Scatter… +
+ + {scatter.length ? 'Add fill' : 'Add scatter'} + {terrain ? 'Scatter…' : 'Paint scatter…'} +
diff --git a/src/lib/scatterSurfaceCore.js b/src/lib/scatterSurfaceCore.js index 00d4b6f0e..9c6e1f008 100644 --- a/src/lib/scatterSurfaceCore.js +++ b/src/lib/scatterSurfaceCore.js @@ -1,4 +1,5 @@ -// 51-surface (E2 + E3) — SCATTER ON ANY SURFACE, the pure half (imports NOTHING; vitest-covered). +// 51-surface (E2 + E3) — SCATTER ON ANY SURFACE, the pure half (imports only the pure set format, +// terrainScatterCore, for its per-slot draws; vitest-covered). // // A terrain's scatter is (x, z) over its rectangle with the height read off its grid. A rock, // a sphere or a house has neither, so a mesh host samples its own TRIANGLES: @@ -24,6 +25,8 @@ // an instance already accepted is rejected. Slot order is fixed by the draws, so the result is the // same on every peer and after every reload. +import { slotDraws, slotSeed, pickSource, ATTEMPTS_PER_SLOT } from './terrainScatterCore.js'; + const TAU = Math.PI * 2; const Q = 65535; const DEG = 180 / Math.PI; @@ -31,7 +34,7 @@ const DEG = 180 / Math.PI; /** caps the core enforces whatever the caller asks (the terrain core's numbers) */ export const SURFACE_MAX_INSTANCES = 8000; /** COUNT mode keeps drawing slots until `count` are placed, up to this many attempts per instance */ -export const ATTEMPTS_PER_INSTANCE = 30; +export const ATTEMPTS_PER_INSTANCE = ATTEMPTS_PER_SLOT; export const SLOPE_MAX = 180; export const SPACING_MAX = 50; /** bytes per painted instance on a mesh host */ @@ -645,3 +648,68 @@ export function decodeSurfaceRecords(bytes, box) { } return out; } + +// ---- a mesh host's SET: its one instance list, its record codec ------------------------------ + +/** The 16-byte record codec a `host: 'mesh'` set's painted instances use (the `io.records` that + * terrainScatterCore.withOverrides takes). */ +export const surfaceRecords = { + /** @param {Uint8Array} bytes @param {any} set */ + decode(bytes, set) { + const box = boxFrom(set?.box); + return box ? decodeSurfaceRecords(bytes, box) : []; + }, + /** @param {SurfaceInstance[]} list */ + encode(list) { + const box = boxOf(list); + return { bytes: encodeSurfaceRecords(list, box), box }; + } +}; + +/** A painted record's tint draw: a hash of its quantized position (stable under erase, the same on + * every peer). @param {DataView} view @param {number} o */ +function recordTint(view, o) { + const h = slotSeed(view.getUint32(o, true) ^ view.getUint16(o + 4, true), 0x51); + return (h / 4294967296) * 2 - 1; +} + +/** + * EVERY instance a `host: 'mesh'` set draws — the surface twin of terrainScatterCore.instancesOf, + * and the ONE list the renderer, the colliders, the brush and the edit tools read on a mesh: the + * generated slots (the set's own seeded per-slot stream, so slot ids mean what they mean on a + * terrain) with their hand edits applied, then the painted 16-byte records. + * @param {any} set a normalized set @param {Surface | null} S the host's surface (null = no fill) + * @param {(b64: string) => Uint8Array | null} decode + * @param {{room?: number, accept?: ((p: SurfacePoint, k: number) => boolean) | null}} [opts] + * @returns {(SurfaceInstance & {id: string, src: number, tint: number, index?: number})[]} + */ +export function surfaceInstancesOf(set, S, decode, opts = {}) { + /** @type {any[]} */ + const out = []; + if (set?.gen && S) { + const seed = set.seed ?? 0; + for (const inst of generateSurfaceSlots(set.gen, S, { draws: (k) => slotDraws(seed, k), room: opts.room, accept: opts.accept ?? null })) { + const o = set.ov?.[String(inst.slot)]; + if (o?.del) continue; + const { pick, ...rest } = /** @type {any} */ (inst); + const base = { ...rest, src: pickSource(set.sources, pick ?? 0), tint: inst.tint ?? 0 }; + out.push(o ? { ...base, ...o } : base); + } + } + const bytes = set?.count && set.data ? decode(set.data) : null; + const box = boxFrom(set?.box); + if (bytes && box) { + const pick = set.pick ? decode(set.pick) : null; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + decodeSurfaceRecords(bytes, box).forEach((inst, j) => { + out.push({ + id: 'p' + j, + index: j, + ...inst, + src: pick && set.sources ? Math.min(pick[j] ?? 0, set.sources.length - 1) : 0, + tint: recordTint(view, j * SURFACE_RECORD) + }); + }); + } + return out; +} diff --git a/src/lib/terrainPaint.js b/src/lib/terrainPaint.js index 4af58d276..fb77249f5 100644 --- a/src/lib/terrainPaint.js +++ b/src/lib/terrainPaint.js @@ -9,7 +9,6 @@ import { decodeInstances, encodeInstances, scatterInCircle, - instancesOf, withOverrides, withSeed, newSeed, @@ -25,8 +24,11 @@ import { applyTerrainSplat, applyTerrainLayers, applyTerrainScatter, - terrainBounds + terrainBounds, + surfaceOf, + scatterSetInstances } from './terrainSurface'; +import { surfaceDab, eraseInSphere, triangleIndex, surfaceRecords } from './scatterSurfaceCore'; // 47 T1 + T2 — THE WRITE SIDE of terrain paint and scatter: the brushes, their strokes, // the undo kinds and the wire. The sculpt stroke pattern verbatim (terrainSculpt.js): @@ -78,10 +80,10 @@ function sendSplat(uuid, bytes, res) { send({ type: 'objectParameters', parameter: 'terrainSplat', uuid, res, bytes: bytes ? bytes.slice().buffer : null }); } -/** @param {any} object */ -function writable_(object) { - if (!object?.userData?.terrain) { - showToast('Paint and scatter work on a Terrain'); +/** @param {any} object @param {boolean} [anyMesh] 51-surface: scatter works on ANY mesh, paint stays terrain-only */ +function writable_(object, anyMesh = false) { + if (!object?.userData?.terrain && !(anyMesh && isMeshHost(object))) { + showToast(anyMesh ? 'Scatter works on a mesh object' : 'Paint and scatter work on a Terrain'); return false; } if (!canEditObject(object)) { @@ -91,6 +93,34 @@ function writable_(object) { return true; } +/** 51-surface: a non-terrain mesh that can carry scatter (its own triangles are the surface) @param {any} object */ +export function isMeshHost(object) { + return !!object?.isMesh && !object.userData?.terrain && !!object.geometry?.attributes?.position; +} + +/** the host's largest world scale — the brush ring is in world metres, the instances live in the + * host's LOCAL frame @param {any} object */ +function hostScale(object) { + const e = object?.matrixWorld?.elements; + if (!e) return 1; + const s = Math.max(Math.hypot(e[0], e[1], e[2]), Math.hypot(e[4], e[5], e[6]), Math.hypot(e[8], e[9], e[10])); + return s > 1e-6 ? s : 1; +} + +/** object -> the brush's triangle grid over its surface (rebuilt with the surface) @type {WeakMap} */ +const brushIndex = new WeakMap(); +/** @param {any} object */ +function brushIndexOf(object) { + const surface = surfaceOf(object); + if (!surface) return null; + let entry = brushIndex.get(object); + if (!entry || entry.surface !== surface) { + entry = { surface, index: triangleIndex(surface) }; + brushIndex.set(object, entry); + } + return entry; +} + // ---- paint ----------------------------------------------------------------------------- /** @type {{uuid: string, before: Uint8Array | null, res: number, changed: boolean} | null} */ @@ -224,7 +254,7 @@ export function clearTerrainPaint(uuid) { // ---- scatter ---------------------------------------------------------------------------- /** @type {{uuid: string, before: any, key: string, list: any[], set: any, others: any[], changed: boolean, seed: number, - * generated: any[], erased: Set} | null} */ + * generated: any[], erased: Set, mesh?: boolean} | null} */ let scatterStroke = null; let lastScatterPreview = 0; @@ -236,7 +266,8 @@ function scatterBlockOf(object) { /** @param {string} uuid */ export function beginScatterStroke(uuid) { const object = objectOf(uuid); - if (!writable_(object)) return false; + if (!writable_(object, true)) return false; + const mesh = isMeshHost(object); const b = terrainBounds(object); if (!b) return false; const block = scatterBlockOf(object); @@ -247,7 +278,7 @@ export function beginScatterStroke(uuid) { return false; } const sets = block?.sets ?? []; - const set = sets.find((s) => sourceKey(s.source) === key) ?? null; + const set = sets.find((s) => sourceKey(s.source) === key && !s.host === !mesh) ?? null; if (!set && sets.length >= MAX_SETS && !get(scatterErase)) { showToast('A terrain holds up to ' + MAX_SETS + ' scatter models'); return false; @@ -257,7 +288,9 @@ export function beginScatterStroke(uuid) { if (set?.count) { // 51: a painted record keeps the source it picked (a mixed set) through every erase const pick = set.pick ? base64ToBytes(set.pick) : null; - list = decodeInstances(/** @type {Uint8Array} */ (base64ToBytes(set.data)), b).map((inst, j) => ({ ...inst, src: pick?.[j] ?? 0 })); + const bytes = /** @type {Uint8Array} */ (base64ToBytes(set.data)); + // 51-surface: a mesh host's records are 16 bytes (xyz + normal) over the set's stored box + list = (mesh ? surfaceRecords.decode(bytes, set) : decodeInstances(bytes, b)).map((inst, j) => ({ ...inst, src: pick?.[j] ?? 0 })); } scatterStroke = { uuid, @@ -266,14 +299,16 @@ export function beginScatterStroke(uuid) { list, // the align toggle is a property of the SET (heights are derived at render time), so // the brush's current setting re-stamps it - set: { ...(set ?? { id: key, source, data: '', count: 0, collider: get(scatterCollider) }), align: get(scatterAlign) }, + set: { ...(set ?? { id: key, source, data: '', count: 0, collider: get(scatterCollider), ...(mesh ? { host: 'mesh' } : {}) }), align: get(scatterAlign) }, others: sets.filter((s) => s !== set), changed: false, seed: (Math.random() * 2 ** 32) >>> 0, // 51: the set's GENERATED instances count toward the brush's density (painting over a // filled patch adds nothing) and an erase deletes them as hand edits - generated: set?.gen ? instancesOf(set, b, base64ToBytes).filter((i) => i.slot != null) : [], - erased: new Set() + // (51-surface: read exactly as drawn — the fill's mask and the block's shared cap included) + generated: set?.gen ? scatterSetInstances(object, set.id).filter((i) => i.slot != null) : [], + erased: new Set(), + mesh }; return true; } @@ -282,14 +317,19 @@ export function beginScatterStroke(uuid) { function strokeBlock(st, b) { const sets = [...st.others]; /** @type {any} */ - let set = { ...st.set, data: st.list.length ? bytesToBase64(encodeInstances(st.list, b)) : '', count: st.list.length }; + let set = { ...st.set, data: '', count: st.list.length }; + if (st.mesh) { + const enc = surfaceRecords.encode(st.list); + if (st.list.length) (set.data = bytesToBase64(enc.bytes)), (set.box = enc.box); + else delete set.box; + } else if (st.list.length) set.data = bytesToBase64(encodeInstances(st.list, b)); if (set.sources && st.list.length) set.pick = bytesToBase64(new Uint8Array(st.list.map((i) => i.src ?? 0))); else delete set.pick; if (st.erased.size) { /** @type {Record} */ const patch = {}; for (const id of st.erased) patch[id] = { del: true }; - set = withOverrides(set, patch, { bounds: b, decode: base64ToBytes, encode: bytesToBase64 }); + set = withOverrides(set, patch, { bounds: b, decode: base64ToBytes, encode: bytesToBase64, records: surfaceRecords }); } if (set.count || set.gen) sets.push(set); return sets.length ? { v: blockVersion(sets), sets } : null; @@ -311,14 +351,17 @@ function strokeRng() { * One scatter application at a LOCAL (x, z), exported for the suite. Paint adds up to the * density inside the circle; erase removes the active source's instances in it (a generated * one becomes a deleted hand edit, so a re-roll does not bring it back). - * @param {string} uuid @param {number} x @param {number} z @param {number} radius + * 51-surface: on a MESH host the point is a local (x, y, z) on its surface and the brush is a sphere + * (meshScatterAt); `y` is ignored on a terrain. + * @param {string} uuid @param {number} x @param {number} z @param {number} radius @param {number} [y] * @returns {number} instances added (negative = removed) */ -export function scatterAt(uuid, x, z, radius) { +export function scatterAt(uuid, x, z, radius, y = 0) { const st = scatterStroke; const object = objectOf(uuid); const b = object ? terrainBounds(object) : null; if (!st || st.uuid !== uuid || !b) return 0; + if (st.mesh) return meshScatterAt(st, object, b, x, y, z, radius); let delta = 0; if (get(scatterErase)) { const next = st.list.filter((p) => Math.hypot(p.x - x, p.z - z) > radius); @@ -357,9 +400,60 @@ export function scatterAt(uuid, x, z, radius) { return delta; } -/** @param {string} uuid @param {number} x @param {number} z @param {number} radius */ -export function scatterStrokeMove(uuid, x, z, radius) { - if (!scatterAt(uuid, x, z, radius)) return; +/** + * 51-surface: the brush on a mesh — a SPHERE around the surface point (local), its radius the ring's + * world radius over the host's scale; paint fills the patch up to the density (what is already there, + * painted or generated, counts), erase removes inside the sphere (a generated one = a deleted edit). + * @param {NonNullable} st @param {any} object @param {any} b + * @param {number} x @param {number} y @param {number} z @param {number} worldRadius + */ +function meshScatterAt(st, object, b, x, y, z, worldRadius) { + const radius = worldRadius / hostScale(object); + const r2 = radius * radius; + const inside = (/** @type {any} */ p) => (p.x - x) ** 2 + (p.y - y) ** 2 + (p.z - z) ** 2 <= r2; + let delta = 0; + if (get(scatterErase)) { + const next = eraseInSphere(st.list, x, y, z, radius); + delta = next.length - st.list.length; + st.list = next; + for (const g of st.generated) + if (!st.erased.has(g.id) && inside(g)) { + st.erased.add(g.id); + delta--; + } + } else { + const brush = brushIndexOf(object); + if (!brush) return 0; + const othersCount = st.others.reduce((sum, s) => sum + s.count + (s.gen?.count ?? 0), 0); + const live = st.generated.filter((g) => !st.erased.has(g.id)); + const rng = strokeRng(); + const added = surfaceDab([...st.list, ...live], brush.surface, brush.index, { + cx: x, + cy: y, + cz: z, + radius, + density: get(scatterDensity), + scaleMin: get(scatterScaleMin), + scaleMax: get(scatterScaleMax), + randomRotation: get(scatterRandomRotation), + rng, + room: MAX_INSTANCES - othersCount - st.list.length - live.length, + spacing: st.set.gen?.spacing + }); + for (const inst of added) /** @type {any} */ (inst).src = pickSource(st.set.sources, rng()); + st.list.push(...added); + delta = added.length; + } + if (delta) { + st.changed = true; + applyTerrainScatter(st.uuid, strokeBlock(st, b)); + } + return delta; +} + +/** @param {string} uuid @param {number} x @param {number} z @param {number} radius @param {number} [y] the mesh host's local y */ +export function scatterStrokeMove(uuid, x, z, radius, y = 0) { + if (!scatterAt(uuid, x, z, radius, y)) return; const now = performance.now(); if (now - lastScatterPreview > PREVIEW_MS) { lastScatterPreview = now; @@ -444,7 +538,7 @@ export function editScatterInstances(uuid, setId, patch, opts) { (block) => { const set = block.sets.find((s) => s.id === setId); if (!set) return null; - const next = withOverrides(set, patch, { bounds: b, decode: base64ToBytes, encode: bytesToBase64 }); + const next = withOverrides(set, patch, { bounds: b, decode: base64ToBytes, encode: bytesToBase64, records: surfaceRecords }); return block.sets.map((s) => (s === set ? next : s)).filter((s) => s.count || s.gen); }, opts @@ -466,15 +560,30 @@ export function rerollScatterSet(uuid, setId, seed = newSeed()) { export function addScatterSet(uuid, set) { /** @type {string | null} */ let id = null; + // 51-surface: a set added to a non-terrain mesh lives on its triangles (and stands on their + // normals unless asked otherwise — upright rocks on a sphere's side look wrong) + const meshHost = isMeshHost(objectOf(uuid)); + if (!meshHost && !objectOf(uuid)?.userData?.terrain) { + showToast('Scatter works on a mesh object'); + return null; + } const ok = writeScatter(uuid, (block) => { if (block.sets.length >= MAX_SETS) { - showToast('A terrain holds up to ' + MAX_SETS + ' scatter sets'); + showToast((meshHost ? 'An object' : 'A terrain') + ' holds up to ' + MAX_SETS + ' scatter sets'); return null; } const base = String(set.id || sourceKey(set.source) || 'set').slice(0, 34); id = base; for (let n = 2; block.sets.some((s) => s.id === id); n++) id = base + '~' + n; - const next = { data: '', count: 0, align: false, ...set, id, ...(set.gen && set.seed == null ? { seed: newSeed() } : {}) }; + const next = { + data: '', + count: 0, + align: meshHost, + ...set, + id, + ...(meshHost ? { host: 'mesh' } : {}), + ...(set.gen && set.seed == null ? { seed: newSeed() } : {}) + }; return [...block.sets, next]; }); return ok ? id : null; @@ -485,8 +594,8 @@ export function addScatterSet(uuid, set) { export function scatterInstancesOf(uuid, setId) { const object = objectOf(uuid); const b = terrainBounds(object); - const set = scatterBlockOf(object)?.sets.find((s) => s.id === setId); - return set && b ? instancesOf(set, b, base64ToBytes) : []; + // 51-surface: the drawn list (a fill's mask, a mesh host's surface, the block's shared cap) + return b ? scatterSetInstances(object, setId) : []; } /** True while a paint or scatter stroke is open (terrainSculpt's endStroke asks). */ diff --git a/src/lib/terrainScatterCore.js b/src/lib/terrainScatterCore.js index e31adbf9b..411d28094 100644 --- a/src/lib/terrainScatterCore.js +++ b/src/lib/terrainScatterCore.js @@ -186,6 +186,10 @@ export function sourceKey(s) { * sources a MIXED set [{source, w}] (2..8, weights > 0); `source` stays sources[0] * pick one byte per PAINTED instance in a mixed set: which source it drew * tint 0..1 colour variation, `sink` metres pushed into the ground (x the instance scale) + * host+box 51-surface: `host: 'mesh'` = the set lives on a NON-terrain mesh (any rock, sphere, + * house): its painted records are 16 bytes (scatterSurfaceCore — xyz over the stored + * `box` [minX, minY, minZ, sizeX, sizeY, sizeZ], a normal, yaw, scale) and its slots + * are placed on the mesh's own triangles. Absent on every terrain set. * @param {any} block @param {(b64: string) => Uint8Array | null} decode * @returns {{v: number, sets: ScatterSet[]} | null} */ @@ -200,8 +204,13 @@ export function normalizeScatter(block, decode) { const source = sources ? sources[0].source : sourceOf(s.source); if (!source) continue; const bytes = typeof s.data === 'string' && s.data ? decode(s.data) : null; - if (bytes && bytes.length % 8 !== 0) continue; - const count = bytes ? bytes.length / 8 : 0; + // 51-surface: a mesh host's painted record is 16 bytes, and it cannot be read without its box + const mesh = s.host === 'mesh'; + const rec = mesh ? 16 : 8; + if (bytes && bytes.length % rec !== 0) continue; + const count = bytes ? bytes.length / rec : 0; + const box = mesh && count ? boxOf(s.box) : null; + if (mesh && count && !box) continue; const gen = genOf(s.gen); if (!count && !gen) continue; if (total + count > MAX_INSTANCES) continue; @@ -214,7 +223,9 @@ export function normalizeScatter(block, decode) { align: !!s.align, data: count ? s.data : '', count, - ...(collider === 'none' ? {} : { collider }) + ...(collider === 'none' ? {} : { collider }), + ...(mesh ? { host: 'mesh' } : {}), + ...(box ? { box } : {}) }; if (gen) { if (gen.count != null) { @@ -242,15 +253,23 @@ export function normalizeScatter(block, decode) { /** @typedef {{id: string, source: any, align: boolean, data: string, count: number, collider?: string, * seed?: number, gen?: Gen, ov?: Record, sources?: {source: any, w: number}[], - * pick?: string, tint?: number, sink?: number}} ScatterSet */ + * pick?: string, tint?: number, sink?: number, host?: 'mesh', box?: number[]}} ScatterSet */ + +/** 51-surface: a stored record box [minX, minY, minZ, sizeX, sizeY, sizeZ] (sizes > 0), else null + * @param {any} box @returns {number[] | null} */ +function boxOf(box) { + if (!Array.isArray(box) || box.length !== 6) return null; + const out = box.map(Number); + return out.every((n) => Number.isFinite(n)) && out[3] > 0 && out[4] > 0 && out[5] > 0 ? out : null; +} /** @typedef {{count?: number, density?: number, scaleMin: number, scaleMax: number, rot: boolean, * spacing?: number}} Gen */ -/** @typedef {{x?: number, z?: number, yaw?: number, scale?: number, del?: 1}} Override */ +/** @typedef {{x?: number, y?: number, z?: number, yaw?: number, scale?: number, del?: 1}} Override */ /** 1 while every set is the 1.38 shape (so an untouched scene saves byte-identical), else 2 * @param {ScatterSet[]} sets */ export function blockVersion(sets) { - return sets.some((s) => s.gen || s.sources || s.tint || s.sink) ? 2 : SCATTER_VERSION; + return sets.some((s) => s.gen || s.sources || s.tint || s.sink || s.host) ? 2 : SCATTER_VERSION; } export const SINK_MIN = -2; @@ -313,7 +332,8 @@ function overrideOf(o) { if (o.del) return { del: 1 }; /** @type {Override} */ const out = {}; - for (const k of /** @type {const} */ (['x', 'z', 'yaw'])) if (Number.isFinite(o[k])) out[k] = o[k]; + // `y`: 51-surface — a slot on a mesh host has a height of its own (a terrain slot's comes from the grid) + for (const k of /** @type {const} */ (['x', 'y', 'z', 'yaw'])) if (Number.isFinite(o[k])) out[k] = o[k]; if (Number.isFinite(o.scale)) out.scale = Math.min(SCALE_MAX, Math.max(SCALE_MIN, o.scale)); return Object.keys(out).length ? out : null; } @@ -380,6 +400,9 @@ export function pickSource(sources, r) { return sources.length - 1; } +/** 51-surface: COUNT mode's attempt budget per wanted instance (scatterSurfaceCore keeps the same number) */ +export const ATTEMPTS_PER_SLOT = 30; + /** How many slots a seeded set has on these bounds (`room` = what the terrain may still hold). * @param {ScatterSet} set @param {Bounds} b @param {number} [room] */ export function slotCount(set, b, room = MAX_INSTANCES) { @@ -410,7 +433,13 @@ export function generateSlots(set, b, opts = {}) { const cell = (/** @type {number} */ x, /** @type {number} */ z) => Math.floor(x / spacing) + ',' + Math.floor(z / spacing); /** @type {ScatterInstance[]} */ const out = []; - for (let k = 0; k < n; k++) { + // 51-surface: COUNT mode means count PLACED — while a mask or the spacing rejects slots, keep + // drawing (k is still the slot id, so raising the count still adds without moving) up to + // ATTEMPTS_PER_SLOT x count; DENSITY mode stays density x area draws, so a mask keeps the + // density right where it passes + const placing = g.count != null && (!!opts.accept || !!opts.place || spacing > 0); + const attempts = placing ? n * ATTEMPTS_PER_SLOT : n; + for (let k = 0; k < attempts && out.length < n; k++) { const d = slotDraws(set.seed ?? 0, k); const at = opts.place ? opts.place(d.u, d.v, k) : { x: b.minX + d.u * b.sizeX, z: b.minZ + d.v * b.sizeZ }; if (!at) continue; @@ -494,7 +523,10 @@ export function parseInstanceId(id) { * record (null does nothing — a painted instance has no seed to fall back to; del drops it). * Returns a NEW set; the input is untouched. * @param {ScatterSet} set @param {Record & {del?: any} | null>} patch - * @param {{bounds: Bounds, decode: (b64: string) => Uint8Array | null, encode: (bytes: Uint8Array) => string}} io + * 51-surface: a `host: 'mesh'` set's painted records are 16 bytes — `io.records` is their codec + * (scatterSurfaceCore.surfaceRecords), and the re-encode writes the set's new `box` with them. + * @param {{bounds: Bounds, decode: (b64: string) => Uint8Array | null, encode: (bytes: Uint8Array) => string, + * records?: {decode: (bytes: Uint8Array, set: any) => any[], encode: (list: any[]) => {bytes: Uint8Array, box: number[]}}}} io * @returns {ScatterSet} */ export function withOverrides(set, patch, io) { @@ -523,8 +555,9 @@ export function withOverrides(set, patch, io) { } if (painted.size && set.count) { const bytes = io.decode(set.data); - if (bytes) { - const list = decodeInstances(bytes, io.bounds); + const codec = set.host === 'mesh' ? io.records : null; + if (bytes && (codec || set.host !== 'mesh')) { + const list = codec ? codec.decode(bytes, set) : decodeInstances(bytes, io.bounds); const pick = set.pick ? io.decode(set.pick) : null; /** @type {Instance[]} */ const kept = []; @@ -537,7 +570,12 @@ export function withOverrides(set, patch, io) { if (pick) keptPick.push(pick[j] ?? 0); }); next.count = kept.length; - next.data = kept.length ? io.encode(encodeInstances(kept, io.bounds)) : ''; + if (codec) { + const enc = codec.encode(kept); + next.data = kept.length ? io.encode(enc.bytes) : ''; + if (kept.length) next.box = enc.box; + else delete next.box; + } else next.data = kept.length ? io.encode(encodeInstances(kept, io.bounds)) : ''; if (pick) { if (kept.length) next.pick = io.encode(new Uint8Array(keptPick)); else delete next.pick; @@ -551,7 +589,7 @@ export function withOverrides(set, patch, io) { function pickPose(e) { /** @type {any} */ const out = {}; - for (const k of ['x', 'z', 'yaw', 'scale']) if (Number.isFinite(e[k])) out[k] = e[k]; + for (const k of ['x', 'y', 'z', 'yaw', 'scale']) if (Number.isFinite(e[k])) out[k] = e[k]; if (out.scale != null) out.scale = Math.min(SCALE_MAX, Math.max(SCALE_MIN, out.scale)); return out; } diff --git a/src/lib/terrainSculpt.js b/src/lib/terrainSculpt.js index ec34df680..65a035b6f 100644 --- a/src/lib/terrainSculpt.js +++ b/src/lib/terrainSculpt.js @@ -38,6 +38,10 @@ export const sculptMode = writable('terrain'); * strokes terrainPaint.js runs; the session, lock, radius and strength are shared. * @type {import('svelte/store').Writable<'raise'|'lower'|'smooth'|'flatten'|'paint'|'scatter'>} */ export const sculptOp = writable('raise'); +/** 51-surface: true while a SCATTER session runs on a plain mesh that was entered for scatter only — + * its geometry was left exactly as it is (no soup conversion, no size cap), so the shaping brushes + * are not offered (Sculpt from the object menu gives them). */ +export const sculptScatterOnly = writable(false); export const sculptRadius = writable(3); export const sculptStrength = writable(0.5); @@ -258,11 +262,15 @@ export function rebuildSculptCaches(object) { const MESH_SCULPT_MAX_FLOATS = MAX_SNAPSHOT; /** Enter sculpt mode: Terrain gets the column brush, any other mesh the - * normal brush (selects = locks it either way). @param {string} uuid */ -export function enterSculpt(uuid) { + * normal brush (selects = locks it either way). 51-surface: `{scatter: true}` on a plain mesh opens + * a SCATTER-ONLY session — the scatter brush paints on its surface and the geometry is never touched + * (so a 100k-triangle import is fine, and nothing is converted or sent). + * @param {string} uuid @param {{scatter?: boolean}} [opts] */ +export function enterSculpt(uuid, opts = {}) { const object = objectOf(uuid); if (!object) return false; const isTerrain = !!object.userData?.terrain; + if (!isTerrain && opts.scatter) return enterMeshScatter(uuid, object); if (!isTerrain && (!object.isMesh || !object.geometry?.attributes?.position)) { showToast('Sculpting works on meshes (Terrain gets the height brush)'); return false; @@ -306,14 +314,38 @@ export function enterSculpt(uuid) { if (isTerrain) rebuildWeldMap(object); else rebuildMeshWeldMap(object); sculptMode.set(isTerrain ? 'terrain' : 'mesh'); - // paint / scatter have no meaning on a plain mesh - if (!isTerrain && (get(sculptOp) === 'paint' || get(sculptOp) === 'scatter')) sculptOp.set('raise'); + sculptScatterOnly.set(false); + // paint has no meaning on a plain mesh (no splat map); scatter does since 51-surface + if (!isTerrain && get(sculptOp) === 'paint') sculptOp.set('raise'); + sculptObject.set(uuid); + return true; +} + +/** 51-surface: the scatter-only session on a plain mesh (see enterSculpt). @param {string} uuid @param {any} object */ +function enterMeshScatter(uuid, object) { + if (!object.isMesh || !object.geometry?.attributes?.position) { + showToast('Scatter works on a mesh object'); + return false; + } + const lock = get(lockedObjects).find((entry) => entry[1] === uuid); + if (lock) { + showToast('Locked by ' + nameOf(lock[0])); + return false; + } + gizmoSuppressed.set(true); + selectObject(uuid); + weld = null; + meshWeld = null; + sculptMode.set('mesh'); + sculptScatterOnly.set(true); + sculptOp.set('scatter'); sculptObject.set(uuid); return true; } export function exitSculpt() { if (strokeBefore || terrainStrokeOpen()) endStroke(); // commit a stroke in flight + sculptScatterOnly.set(false); sculptObject.set(null); weld = null; meshWeld = null; @@ -404,10 +436,11 @@ export function applyBrushAt(uuid, x, z, op, radius, strength, dt = 0.016) { return touched; } -/** 47: is the current op a terrainPaint stroke (and on a terrain)? */ +/** 47: is the current op a terrainPaint stroke? Paint = a terrain only; scatter = any mesh (51-surface). */ function paintOp() { const op = get(sculptOp); - return get(sculptMode) === 'terrain' && (op === 'paint' || op === 'scatter') ? op : null; + if (op === 'scatter') return op; + return get(sculptMode) === 'terrain' && op === 'paint' ? op : null; } /** Stroke begin: snapshot for the ONE undo entry per stroke. @param {string} uuid */ @@ -426,7 +459,7 @@ export function beginStroke(uuid) { export function strokeMove(uuid, x, z, dt = 0.016, y = 0) { const paint = paintOp(); if (paint === 'paint') return paintStrokeMove(uuid, x, z, dt, get(sculptRadius), get(sculptStrength)); - if (paint === 'scatter') return scatterStrokeMove(uuid, x, z, get(sculptRadius)); + if (paint === 'scatter') return scatterStrokeMove(uuid, x, z, get(sculptRadius), y); if (!strokeBefore) return; const op = /** @type {'raise'|'lower'|'smooth'|'flatten'} */ (get(sculptOp)); const changed = diff --git a/src/lib/terrainSurface.js b/src/lib/terrainSurface.js index a90dfd69a..951643aca 100644 --- a/src/lib/terrainSurface.js +++ b/src/lib/terrainSurface.js @@ -4,6 +4,7 @@ import { get } from 'svelte/store'; import { objectsGroup, globalScene, sceneRevision, pokeScene } from '../stores/sceneStore'; import { decodeSplatBlock, encodeSplatBlock, normalizeLayers, baseIsMaterial, base64ToBytes, LAYER_COUNT } from './terrainPaintCore'; import { normalizeScatter, instancesOf, blockVersion, buildHeightGrid, sampleHeight, MAX_INSTANCES } from './terrainScatterCore'; +import { buildSurface, surfaceInstancesOf, maskOf, maskIsEmpty, passesMask, splatWeightAt } from './scatterSurfaceCore'; import { lodObject, releaseOwner } from './lod'; import { EXPLICIT_MIN_TRIANGLES } from './lodCore'; @@ -40,7 +41,7 @@ const splats = new WeakMap(); /** @type {WeakMap} */ const patched = new WeakMap(); /** object -> {holder, key, gridKey} */ -/** @type {Map} */ +/** @type {Map}>} */ const scatters = new Map(); /** a 1×1 white texture: the identity for colour × texture while an image is missing */ @@ -464,7 +465,8 @@ function clearHolder(holder) { } } -/** Make an object's scatter holder match its `terrainScatter` block. @param {any} object */ +/** Make an object's scatter holder match its `terrainScatter` block. @param {any} object + * @returns {boolean} true when it re-laid the instances (51-surface: the Inspector's counts follow) */ function reconcileScatter(object) { const block = object.userData?.terrainScatter; let entry = scatters.get(object); @@ -474,11 +476,12 @@ function reconcileScatter(object) { clearHolder(entry.holder); entry.holder.parent?.remove(entry.holder); scatters.delete(object); + return true; } - return; + return false; } const scene = /** @type {any} */ (get(globalScene)); - if (!scene) return; + if (!scene) return false; if (!entry) { entry = { holder: makeHolder(object), key: '', layoutKey: '', terrain: object }; scatters.set(object, entry); @@ -486,11 +489,23 @@ function reconcileScatter(object) { if (entry.holder.parent !== scene) scene.add(entry.holder); const pos = object.geometry?.attributes?.position; const layoutKey = (pos ? pos.version + ':' + pos.count + ':' + (pos.array?.length ?? 0) : '') + ':' + uuidTag(pos); - const key = JSON.stringify(sets.map((s) => [s.id, s.data, s.align, s.seed, s.gen, s.ov, s.sources, s.pick, s.tint, s.sink, setSources(s).map(sourceReady)])); - if (entry.key === key && entry.layoutKey === layoutKey) return; + const key = JSON.stringify(sets.map((s) => [s.id, s.data, s.align, s.seed, s.gen, s.ov, s.sources, s.pick, s.tint, s.sink, s.host, s.box, setSources(s).map(sourceReady)])); + // 51-surface: a layer-masked fill follows the PAINT, so a new splat re-lays it (compared by + // reference — the block's data string is replaced on every commit, never mutated) + const splatRef = sets.some((s) => /** @type {any} */ (s.gen)?.layer) ? object.userData.terrainSplat?.data ?? '' : ''; + if (entry.key === key && entry.layoutKey === layoutKey && entry.splatRef === splatRef) return false; + entry.splatRef = splatRef; entry.key = key; entry.layoutKey = layoutKey; - layoutScatter(object, entry.holder, sets); + entry.counts = layoutScatter(object, entry.holder, sets); + return true; +} + +/** 51-surface: how many instances each set DRAWS (painted + generated, after masks, spacing and the + * shared cap) — the Inspector's numbers, read from the last layout. @param {any} object + * @returns {Record} */ +export function scatterCounts(object) { + return scatters.get(object)?.counts ?? {}; } /** attribute identity (a meshgeo swap replaces it with a fresh one at version 0) */ @@ -518,25 +533,109 @@ function setSources(set) { } /** Every set's instances, the MAX_INSTANCES cap shared across the block in set order (the - * same arithmetic on every peer). @param {any[]} sets @param {any} b */ -function blockInstances(sets, b) { + * same arithmetic on every peer). @param {any} object @param {any[]} sets @param {any} b */ +function blockInstances(object, sets, b) { let room = MAX_INSTANCES; for (const set of sets) room -= set.count; return sets.map((set) => { - const list = instancesOf(set, b, base64ToBytes, { room: Math.max(0, room) }); + const list = setInstances(object, set, b, Math.max(0, room)); room -= list.length - set.count; return list; }); } -/** @param {any} object @param {any} holder @param {any[]} sets */ +// ---- 51-surface: the one instance list per set, on a terrain OR any mesh ------------------ + +/** object -> its cached surface (a mesh host's triangles + area CDF), keyed by geometry identity + * @type {WeakMap} */ +const surfaces = new WeakMap(); + +/** The host's local surface for fills and the mesh brush, rebuilt only when its geometry + * changes (a 100k-triangle mesh builds in ~20 ms). @param {any} object */ +export function surfaceOf(object) { + const geo = object?.geometry; + const pos = geo?.attributes?.position; + if (!pos) return null; + const nrm = geo.attributes.normal ?? null; + const key = uuidTag(pos) + ':' + pos.version + ':' + (geo.index ? uuidTag(geo.index) + ':' + geo.index.version : '-') + ':' + (nrm ? uuidTag(nrm) + ':' + nrm.version : '-'); + let entry = surfaces.get(object); + if (!entry || entry.key !== key) { + entry = { key, surface: buildSurface(pos.array, geo.index?.array ?? null, nrm?.array ?? null), index: null }; + surfaces.set(object, entry); + } + return entry.surface; +} + +/** object -> its terrain height grid, cached the same way @type {WeakMap} */ +const grids = new WeakMap(); +/** @param {any} object */ +function heightGridOf(object) { + const pos = object?.geometry?.attributes?.position; + if (!pos) return null; + const key = uuidTag(pos) + ':' + pos.version; + let entry = grids.get(object); + if (!entry || entry.key !== key) { + entry = { key, grid: buildHeightGrid(pos.array) }; + grids.set(object, entry); + } + return entry.grid; +} + +/** + * A terrain set's fill mask as the generator's `accept(x, z)`: slope + height from the terrain's + * own grid (the drawn surface), the paint layer from its splat map — so painting layer 2 moves the + * "only on layer 2" rocks with the paint, on every peer. undefined = no mask. + * @param {any} object @param {any} set @param {any} b + */ +function terrainAccept(object, set, b) { + const mask = maskOf(set.gen); + if (maskIsEmpty(mask)) return undefined; + const grid = heightGridOf(object); + const splat = mask.layer ? liveSplat(object) : null; + return (/** @type {number} */ x, /** @type {number} */ z) => { + const h = grid ? sampleHeight(grid, x, z) : { y: 0, ny: 1 }; + return passesMask(mask, h, mask.layer ? (i) => (splat ? splatWeightAt(splat.bytes, splat.res, b, x, z, i) : 0) : null); + }; +} + +/** + * EVERY instance one set draws on its host — a terrain's (instancesOf + its mask) or a mesh's + * (surfaceInstancesOf on the host's own triangles). The renderer, the colliders, the brush and the + * edit tools all read this one function, so they cannot disagree about where a rock is. + * @param {any} object @param {any} set @param {any} b @param {number} [room] + */ +function setInstances(object, set, b, room = MAX_INSTANCES) { + if (set.host === 'mesh') { + const S = set.gen ? surfaceOf(object) : null; + // a mesh's paint layer is not a thing (no splat map): a layer mask rejects everything there + const mask = set.gen ? maskOf(set.gen) : {}; + return surfaceInstancesOf(set, S, base64ToBytes, { room, accept: mask.layer ? () => false : null }); + } + return instancesOf(set, b, base64ToBytes, { room, accept: set.gen ? terrainAccept(object, set, b) : undefined }); +} + +/** + * The instances of one set on a host, exactly as they are drawn (the block's shared cap + * included) — what the brush and the edit tools read. @param {any} object @param {string} setId + */ +export function scatterSetInstances(object, setId) { + const b = terrainBounds(object); + const sets = normalizeScatter(object?.userData?.terrainScatter, base64ToBytes)?.sets ?? []; + const at = sets.findIndex((s) => s.id === setId); + if (!b || at < 0) return []; + return blockInstances(object, sets, b)[at]; +} + +/** @param {any} object @param {any} holder @param {any[]} sets @returns {Record} drawn per set */ function layoutScatter(object, holder, sets) { clearHolder(holder); const b = terrainBounds(object); - const pos = object.geometry?.attributes?.position; - const grid = pos ? buildHeightGrid(pos.array) : null; - if (!b) return; - const lists = blockInstances(sets, b); + const grid = object.userData?.terrain ? heightGridOf(object) : null; + /** @type {Record} */ + const counts = {}; + if (!b) return counts; + const lists = blockInstances(object, sets, b); + sets.forEach((set, si) => (counts[set.id] = lists[si].length)); sets.forEach((set, si) => { const all = lists[si]; const matrices = instanceMatrices(all, set.align, grid, set.sink ?? 0); @@ -578,6 +677,7 @@ function layoutScatter(object, holder, sets) { } }); }); + return counts; } /** 51 E1 tint variation: an instance's draw in [-1, 1] times the set's amount, as a multiplier on @@ -590,12 +690,18 @@ function tintColor(t, amount) { } /** Terrain-LOCAL instance matrices — the drawn pose, and (48 B1) the collider pose. `sink` (51) - * pushes each instance into the ground by sink x its scale. - * @param {{x: number, z: number, yaw: number, scale: number}[]} list @param {boolean} align + * pushes each instance into the ground by sink x its scale. 51-surface: an instance that carries its + * own `y` (a mesh host's) stands where it says, on its own normal; a terrain's reads the grid. + * @param {{x: number, y?: number, z: number, nx?: number, ny?: number, nz?: number, yaw: number, scale: number}[]} list @param {boolean} align * @param {ReturnType} grid @param {number} [sink] @returns {any[]} */ function instanceMatrices(list, align, grid, sink = 0) { return list.map((inst) => { - const h = grid ? sampleHeight(grid, inst.x, inst.z) : { y: 0, nx: 0, ny: 1, nz: 0 }; + const h = + inst.y != null + ? { y: inst.y, nx: inst.nx ?? 0, ny: inst.ny ?? 1, nz: inst.nz ?? 0 } + : grid + ? sampleHeight(grid, inst.x, inst.z) + : { y: 0, nx: 0, ny: 1, nz: 0 }; _qy.setFromAxisAngle(UP, inst.yaw); if (align) _q.setFromUnitVectors(UP, _n.set(h.nx, h.ny, h.nz)).multiply(_qy); else _q.copy(_qy); @@ -624,9 +730,8 @@ export function scatterPhysicsSets(object) { if (!b) return out; const sets = normalizeScatter(block, base64ToBytes)?.sets ?? []; if (!sets.some((s) => s.collider)) return out; - const pos = object.geometry?.attributes?.position; - const grid = pos ? buildHeightGrid(pos.array) : null; - const lists = blockInstances(sets, b); + const grid = object.userData?.terrain ? heightGridOf(object) : null; + const lists = blockInstances(object, sets, b); sets.forEach((set, si) => { if (!set.collider) return; const all = lists[si]; @@ -716,15 +821,19 @@ function scheduleReconcile(force = false) { export function reconcileAll() { const group = /** @type {any} */ (get(objectsGroup)); const live = new Set(); + let laid = false; group?.traverse((/** @type {any} */ o) => { const ud = o.userData; if (!ud) return; if (ud.terrainSplat || splats.get(o)) reconcileSplat(o); if (ud.terrainScatter || scatters.has(o)) { - reconcileScatter(o); + if (reconcileScatter(o)) laid = true; live.add(o); } }); + // a layout that a load / a joiner's sync / a model arriving made: tell the Inspector (the next + // reconcile finds every key equal, so this settles in one round) + if (laid) pokeScene(); for (const [object, entry] of [...scatters]) { if (live.has(object)) continue; clearHolder(entry.holder); diff --git a/tests/e2e/scatter-surface.test.cjs b/tests/e2e/scatter-surface.test.cjs new file mode 100644 index 000000000..0fc5d969a --- /dev/null +++ b/tests/e2e/scatter-surface.test.cjs @@ -0,0 +1,278 @@ +// 51-surface — E2 scatter on ANY mesh + E3 fill mode and masks, two peers. +// E2: the "Add scatter" row sits in the Inspector right after the Water section (Add bubble / pour +// emitter) on a plain mesh; it adds a FILL that lives in the object's own frame — move, turn +// and scale the object and the instances follow, on both peers; the brush paints ON the mesh's +// surface (a scatter-only session that never touches the geometry); one undo per stroke. +// E3: count vs density, minimum spacing (no two closer), slope and height masks, the painted +// terrain layer mask; the same seed gives the same instances on both peers and after a +// re-apply (a reload's path). +const h = require('./helpers.cjs'); + +const SHOTS = process.env.SHOTS_DIR || ''; + +/** every drawn instance of one host, in WORLD space (holder matrixWorld x instance matrix), with the + * local position and up axis too @param {any} page @param {string} uuid */ +const drawn = (page, uuid) => + page.evaluate((u) => { + let scene; + window.__stores.globalScene.subscribe((v) => (scene = v))(); + const holder = scene?.children.find((c) => c.name === 'terrain-scatter' && c.userData.terrainScatterOf === u); + if (!holder) return []; + holder.updateMatrixWorld(true); + const THREE = window.__stores.THREE ?? null; + const out = []; + for (const mesh of holder.children) { + const ids = mesh.userData.instanceIds ?? []; + const e = new Array(16); + for (let i = 0; i < mesh.count; i++) { + const m = mesh.instanceMatrix.array.slice(i * 16, i * 16 + 16); + const w = holder.matrixWorld.elements; + // world = holder.matrixWorld * local (column-major) + for (let r = 0; r < 4; r++) + for (let c = 0; c < 4; c++) { + let s = 0; + for (let k = 0; k < 4; k++) s += w[k * 4 + r] * m[c * 4 + k]; + e[c * 4 + r] = s; + } + const up = [m[4], m[5], m[6]]; + const len = Math.hypot(up[0], up[1], up[2]) || 1; + out.push({ id: ids[i], set: mesh.userData.scatterSet, local: [m[12], m[13], m[14]], up: up.map((v) => v / len), world: [e[12], e[13], e[14]] }); + } + void THREE; + } + out.sort((a, b) => String(a.set + a.id).localeCompare(String(b.set + b.id))); + return out; + }, uuid); +/** @param {any} page @param {string} uuid */ +const blockOf = (page, uuid) => + page.evaluate( + (u) => + new Promise((resolve) => { + window.__stores.objectsGroup.subscribe((g) => { + const o = g?.getObjectByProperty('uuid', u); + resolve(o ? JSON.stringify(o.userData.terrainScatter ?? null) : 'missing'); + })(); + }), + uuid + ); +/** @param {any} a @param {any} b */ +const samePlaces = (a, b, eps = 1e-4) => + a.length === b.length && a.every((p, i) => p.id === b[i].id && p.world.every((v, k) => Math.abs(v - b[i].world[k]) < eps)); +/** @param {any} page */ +const undoDepth = (page) => page.evaluate(() => new Promise((r) => window.__stores.history.undoStack.subscribe((s) => r(s.length))())); +/** @param {any} page @param {string} uuid @param {string} setId @param {any} patch */ +const editSet = (page, uuid, setId, patch) => + page.evaluate(([u, id, p]) => window.__stores.terrainPaint.editScatterSet(u, id, p), [uuid, setId, patch]); +/** the one set on a host @param {any} page @param {string} uuid */ +const firstSet = (page, uuid) => + page.evaluate((u) => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + return g.getObjectByProperty('uuid', u)?.userData?.terrainScatter?.sets?.[0] ?? null; + }, uuid); +/** move / turn / scale the host the replicated way @param {any} page @param {string} uuid */ +const moveHost = (page, uuid, pos, rot, scale) => + page.evaluate( + ([u, p, r, s]) => { + let g, peer; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + window.__stores.peers.subscribe((v) => (peer = v))(); + const o = g.getObjectByProperty('uuid', u); + o.position.set(p[0], p[1], p[2]); + o.rotation.set(r[0], r[1], r[2]); + o.scale.set(s[0], s[1], s[2]); + o.updateMatrixWorld(true); + peer?.send({ type: 'move', uuid: u, pos: p, rot: r, scale: s }); + }, + [uuid, pos, rot, scale] + ); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + + // ---- a plain mesh host: a sphere ------------------------------------------------------- + const uuid = await A.page.evaluate(async () => { + const s = window.__stores; + s.commandsHandler.sceneCommand('/create sphere'); + await new Promise((r) => setTimeout(r, 300)); + const group = await new Promise((r) => s.objectsGroup.subscribe(r)()); + const o = group.children[group.children.length - 1]; + // park it where nothing else is, at rest (a /create stamps a dynamic body; no sim runs here) + return o.uuid; + }); + await h.eventually(() => blockOf(B.page, uuid), (v) => v !== 'missing', 'B has the sphere', 20000); + await moveHost(A.page, uuid, [0, 1, 0], [0, 0, 0], [1, 1, 1]); + const geoBefore = await A.page.evaluate((u) => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + const geo = g.getObjectByProperty('uuid', u).geometry; + window.__scatterGeoRef = geo.attributes.position; + return { indexed: !!geo.index, verts: geo.attributes.position.count }; + }, uuid); + + // ---- E2: the "Add scatter" row, right after the Water section ------------------------------- + await A.page.evaluate((u) => window.__stores.objectActions.selectObject(u, true), uuid); + const add = A.page.locator('#scatter-add'); + await add.waitFor({ state: 'attached', timeout: 15000 }); + if (!(await add.isVisible().catch(() => false))) { + const head = A.page.locator('button', { hasText: /^Scatter$/ }).first(); + if (await head.isVisible().catch(() => false)) await head.click(); + } + const order = await A.page.evaluate(() => { + const pour = document.querySelector('#pour-add'); + const bubbles = document.querySelector('#bubbles-add'); + const scatter = document.querySelector('#scatter-add'); + const after = (a, b) => !!a && !!b && !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); + // nothing but section chrome between the Water section's last row and the Scatter section + return { pour: after(pour, scatter), bubbles: after(bubbles, scatter), label: scatter?.textContent?.trim() }; + }); + h.check(order.pour && order.bubbles, 'the Add scatter row comes after Add bubble emitter / Add pour emitter'); + h.check(order.label === 'Add scatter', `the row reads "Add scatter" (${order.label})`); + await add.scrollIntoViewIfNeeded(); + const undo0 = await undoDepth(A.page); + await add.click(); + await h.eventually(() => firstSet(A.page, uuid), (s) => !!s?.gen, 'Add scatter makes a FILL set on the sphere', 8000); + const set = await firstSet(A.page, uuid); + h.check(set.host === 'mesh' && set.gen.count === 200 && set.seed > 0, `the set lives on the mesh (host ${set.host}, count ${set.gen.count}, seeded)`); + h.check((await undoDepth(A.page)) === undo0 + 1, 'adding the scatter = ONE undo entry'); + let aDrawn = await drawn(A.page, uuid); + h.check(aDrawn.length === 200, `A draws 200 instances (${aDrawn.length})`); + const onSurface = aDrawn.every((p) => Math.abs(Math.hypot(...p.local) - 1) < 0.03); + h.check(onSurface, 'every instance sits ON the sphere surface (local |p| = radius)'); + h.check( + aDrawn.every((p) => p.up[0] * p.local[0] + p.up[1] * p.local[1] + p.up[2] * p.local[2] > 0.95), + 'aligned to the surface: each instance stands on its normal' + ); + const shown = await A.page.locator('[data-scatter-count]').first().getAttribute('data-scatter-count'); + h.check(shown === '200', `the Inspector shows the drawn count (${shown})`); + if (SHOTS) await A.page.screenshot({ path: SHOTS + '/01-add-scatter-sphere.png' }); + + // ---- replication + determinism ----------------------------------------------------------------- + const aBlock = await blockOf(A.page, uuid); + h.check(aBlock.length < 400, `the wire carries params + seed, not instances (${aBlock.length} chars)`); + await h.eventually(() => blockOf(B.page, uuid), (v) => v === aBlock, 'the block is equal on B', 15000); + await h.eventually(() => drawn(B.page, uuid), (d) => samePlaces(d, aDrawn), 'B draws the SAME 200 instances (same seed, same places)', 15000); + const reapplied = await A.page.evaluate((u) => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + const block = JSON.parse(JSON.stringify(g.getObjectByProperty('uuid', u).userData.terrainScatter)); + window.__stores.terrainSurface.applyTerrainScatter(u, null); + window.__stores.terrainSurface.applyTerrainScatter(u, block); + return true; + }, uuid); + h.check(reapplied && samePlaces(await drawn(A.page, uuid), aDrawn), 'a re-applied block (the reload path) lays out identically'); + + // ---- relative to the host: move / turn / scale, on both peers ------------------------------------ + await moveHost(A.page, uuid, [3, 2, -1], [0, Math.PI / 2, 0], [2, 2, 2]); + await A.page.waitForTimeout(200); + const moved = await drawn(A.page, uuid); + const expectWorld = (p) => [3 + 2 * p.local[2], 2 + 2 * p.local[1], -1 - 2 * p.local[0]]; // Ry(90°)·2·local + pos + h.check( + moved.length === 200 && moved.every((p) => expectWorld(p).every((v, k) => Math.abs(v - p.world[k]) < 1e-3)), + 'A: the instances follow the host (moved, turned 90°, scaled 2x)' + ); + h.check(moved.every((p, i) => p.local.every((v, k) => Math.abs(v - aDrawn[i].local[k]) < 1e-6)), 'their LOCAL layout did not change (no reshuffle)'); + await h.eventually(() => drawn(B.page, uuid), (d) => samePlaces(d, moved, 2e-3), 'B: the instances followed the move too', 15000); + + // ---- E3 fill: density, spacing, masks ------------------------------------------------------------ + await editSet(A.page, uuid, set.id, { gen: { ...set.gen, count: undefined, density: 20 } }); + const dens = await drawn(A.page, uuid); + // sphere area ~ 4π (12.5 m² local): 20 / m² ~ 250 attempts, all placed (no mask, no spacing) + h.check(dens.length > 230 && dens.length < 260, `DENSITY 20/m² over the whole surface (${dens.length})`); + await editSet(A.page, uuid, set.id, { gen: { ...set.gen, count: 150, spacing: 0.25 } }); + const spaced = await drawn(A.page, uuid); + let minD = Infinity; + for (let i = 0; i < spaced.length; i++) + for (let j = i + 1; j < spaced.length; j++) + minD = Math.min(minD, Math.hypot(...spaced[i].local.map((v, k) => v - spaced[j].local[k]))); + h.check(spaced.length === 150, `COUNT with spacing still places the count (${spaced.length})`); + h.check(minD >= 0.25 - 1e-3, `minimum spacing: no two instances closer than 0.25 m (min ${minD.toFixed(3)})`); + await editSet(A.page, uuid, set.id, { gen: { ...set.gen, count: 120, slope: [0, 40] } }); + const top = await drawn(A.page, uuid); + h.check(top.length === 120 && top.every((p) => p.local[1] > Math.cos((42 * Math.PI) / 180) * 0.97), `SLOPE 0..40°: only the cap of the sphere (${top.length})`); + await editSet(A.page, uuid, set.id, { gen: { ...set.gen, count: 120, height: [-0.3, 0.3] } }); + const band = await drawn(A.page, uuid); + h.check(band.length === 120 && band.every((p) => Math.abs(p.local[1]) <= 0.3 + 1e-3), `HEIGHT -0.3..0.3 (local): a band round the middle (${band.length})`); + const bandBlock = await blockOf(A.page, uuid); + await h.eventually(() => blockOf(B.page, uuid), (v) => v === bandBlock, 'the mask edits replicate', 15000); + await h.eventually(() => drawn(B.page, uuid), (d) => d.length === 120 && d.every((p, i) => p.id === band[i].id), 'B draws the same masked fill', 15000); + + // the Fill rows in the Inspector: Density switch through the real Segmented control + await A.page.locator('[id$="-mode-density"]').first().click(); + await h.eventually(() => firstSet(A.page, uuid), (s) => s?.gen?.density != null && s.gen.count == null, 'the Fill row switches the set to DENSITY', 8000); + await A.page.locator('[id$="-mode-count"]').first().click(); + await h.eventually(() => firstSet(A.page, uuid), (s) => s?.gen?.count != null, '...and back to COUNT', 8000); + if (SHOTS) + for (const t of ['dark', 'light']) { + await A.page.evaluate((th) => window.__stores.themes.theme.set(th), t); + await A.page.waitForTimeout(400); + await A.page.locator('[data-scatter-fill]').first().scrollIntoViewIfNeeded(); + await A.page.screenshot({ path: `${SHOTS}/02-fill-rows-${t}.png` }); + } + await A.page.evaluate(() => window.__stores.themes.theme.set('dark')); + + // ---- the brush ON the mesh: a scatter-only session, geometry untouched -------------------------- + await moveHost(A.page, uuid, [0, 1, 0], [0, 0, 0], [1, 1, 1]); + const paint = await A.page.evaluate(async (u) => { + const s = window.__stores; + const ts = s.terrainSculpt; + const ok = ts.enterSculpt(u, { scatter: true }); + let only, op, mode; + ts.sculptScatterOnly.subscribe((v) => (only = v))(); + ts.sculptOp.subscribe((v) => (op = v))(); + ts.sculptMode.subscribe((v) => (mode = v))(); + ts.sculptRadius.set(0.6); + s.terrainPaint.scatterSource.set({ kind: 'primitive', type: 'Box' }); + s.terrainPaint.scatterDensity.set(4); + s.terrainPaint.scatterErase.set(false); + ts.beginStroke(u); + // along the equator (local), +x side: points ON the sphere + for (let i = 0; i < 8; i++) { + const a = (i / 8) * 0.8; + ts.strokeMove(u, Math.cos(a), Math.sin(a), 0.05, 0); + } + ts.endStroke(); + await new Promise((r) => setTimeout(r, 100)); + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const o = g.getObjectByProperty('uuid', u); + const sets = o.userData.terrainScatter.sets; + return { ok, only, op, mode, sets: sets.length, painted: sets.find((x) => x.source.type === 'Box') ?? null, sameGeo: o.geometry.attributes.position === window.__scatterGeoRef, indexed: !!o.geometry.index }; + }, uuid); + h.check(paint.ok && paint.only && paint.op === 'scatter' && paint.mode === 'mesh', 'Paint scatter… opens a scatter-only session on the mesh'); + h.check(paint.sameGeo && paint.indexed === geoBefore.indexed, 'the mesh geometry is untouched (no soup conversion, nothing sent)'); + h.check(!!paint.painted && paint.painted.host === 'mesh' && paint.painted.count > 4 && Array.isArray(paint.painted.box), `a stroke paints 16-byte records on the surface (${paint.painted?.count})`); + const boxes = (await drawn(A.page, uuid)).filter((p) => p.set === paint.painted?.id); + h.check(boxes.length === paint.painted?.count && boxes.every((p) => Math.abs(Math.hypot(...p.local) - 1) < 0.03 && p.local[0] > 0.3), 'painted instances sit ON the sphere where the brush went'); + const pBlock = await blockOf(A.page, uuid); + await h.eventually(() => blockOf(B.page, uuid), (v) => v === pBlock, 'the stroke replicates', 15000); + await A.page.evaluate(() => window.__stores.history.undo()); + await h.eventually(() => firstSet(A.page, uuid).then(() => blockOf(A.page, uuid)), (v) => !v.includes('"Box"'), 'ONE undo removes the stroke', 8000); + await h.eventually(() => blockOf(B.page, uuid), (v) => !v.includes('"Box"'), '...on B too', 15000); + await A.page.evaluate(() => window.__stores.terrainSculpt.exitSculpt()); + + // ---- a terrain fill on a painted layer ------------------------------------------------------------ + const terrain = await A.page.evaluate(async () => { + const s = window.__stores; + s.commandsHandler.sceneCommand('/create Terrain 20 20'); + await new Promise((r) => setTimeout(r, 300)); + const group = await new Promise((r) => s.objectsGroup.subscribe(r)()); + const t = group.children[group.children.length - 1]; + // layer 3 on the +x half + const res = 64; + const bytes = new Uint8Array(res * res * 4); + for (let z = 0; z < res; z++) for (let x = 0; x < res; x++) bytes[(z * res + x) * 4 + (x >= res / 2 ? 2 : 0)] = 255; + s.terrainSurface.applyTerrainSplat(t.uuid, bytes, res); + const id = s.terrainPaint.addScatterSet(t.uuid, { source: { kind: 'primitive', type: 'Cone' }, gen: { count: 120, scaleMin: 1, scaleMax: 1, rot: true, layer: { i: 2, min: 0.9 } } }); + return { uuid: t.uuid, id }; + }); + const onLayer = await drawn(A.page, terrain.uuid); + h.check(onLayer.length === 120 && onLayer.every((p) => p.local[0] > -0.2), `LAYER mask: the fill sits on painted layer 3 only (${onLayer.length})`); + const tSet = await firstSet(A.page, terrain.uuid); + h.check(!tSet.host && tSet.gen.layer?.i === 2, 'a terrain set carries no host field (byte-identical shape) and keeps its mask'); + + await h.finish(browser); +}); diff --git a/tests/unit/scatterSetFormat.test.js b/tests/unit/scatterSetFormat.test.js index 93c5db799..f4517f664 100644 --- a/tests/unit/scatterSetFormat.test.js +++ b/tests/unit/scatterSetFormat.test.js @@ -87,7 +87,10 @@ describe('51 set format: determinism', () => { test('a mask rejecting one slot moves no other slot', () => { const all = generateSlots(seeded(), B); const masked = generateSlots(seeded(), B, { accept: (_x, _z, k) => k !== 10 }); - expect(masked.length).toBe(all.length - 1); + // 51-surface: COUNT mode = count PLACED, so the rejected slot's place goes to the next draw + expect(masked.length).toBe(all.length); + expect(masked.find((i) => i.slot === 10)).toBeUndefined(); + expect(masked.at(-1)?.slot).toBe(all.length); expect(masked.find((i) => i.slot === 11)).toEqual(all.find((i) => i.slot === 11)); }); diff --git a/tests/unit/scatterSurfaceCore.test.js b/tests/unit/scatterSurfaceCore.test.js index 8fae95d59..b58b226a0 100644 --- a/tests/unit/scatterSurfaceCore.test.js +++ b/tests/unit/scatterSurfaceCore.test.js @@ -412,3 +412,56 @@ describe('perf: 5,000 instances on a 100k-triangle mesh', () => { expect(t3 - t1).toBeLessThan(1500); }); }); + +// ---- a mesh host's SET in the 51 format --------------------------------------------------------- + +import { surfaceInstancesOf, surfaceRecords } from '../../src/lib/scatterSurfaceCore.js'; +import { normalizeScatter, withOverrides } from '../../src/lib/terrainScatterCore.js'; + +const b64 = (bytes) => Buffer.from(bytes).toString('base64'); +const unb64 = (s) => new Uint8Array(Buffer.from(s, 'base64')); + +describe('host: mesh sets (format + instances + edits)', () => { + const sp = sphere(2, 32, 24); + const S = buildSurface(sp.pos, sp.index, sp.nrm); + const CONE = { kind: 'primitive', type: 'Cone' }; + + it('normalize keeps a 16-byte painted set with its box, and refuses one without a box', () => { + const list = generateSurfaceSlots({ ...GEN, count: 12 }, S, { draws: drawsFor(1) }); + const enc = surfaceRecords.encode(list); + const ok = normalizeScatter({ sets: [{ source: CONE, host: 'mesh', data: b64(enc.bytes), box: enc.box }] }, unb64); + expect(ok.v).toBe(2); + expect(ok.sets[0]).toMatchObject({ host: 'mesh', count: 12, box: enc.box }); + const noBox = normalizeScatter({ sets: [{ source: CONE, host: 'mesh', data: b64(enc.bytes) }] }, unb64); + expect(noBox.sets).toHaveLength(0); + // a terrain set never grows a host field (a 1.38 scene stays byte-identical) + const t = normalizeScatter({ sets: [{ source: CONE, data: b64(new Uint8Array(8)) }] }, unb64); + expect('host' in t.sets[0]).toBe(false); + expect(t.v).toBe(1); + }); + + it('generated + painted instances, with stable ids, on two peers', () => { + const painted = surfaceRecords.encode(generateSurfaceSlots({ ...GEN, count: 5 }, S, { draws: drawsFor(77) })); + const raw = { sets: [{ id: 'm', source: CONE, host: 'mesh', seed: 99, gen: { ...GEN, count: 40, spacing: 0.2 }, data: b64(painted.bytes), box: painted.box }] }; + const set = normalizeScatter(raw, unb64).sets[0]; + const a = surfaceInstancesOf(set, S, unb64); + const b = surfaceInstancesOf(normalizeScatter(JSON.parse(JSON.stringify(raw)), unb64).sets[0], buildSurface(new Float32Array([...sp.pos]), new Uint32Array([...sp.index]), new Float32Array([...sp.nrm])), unb64); + expect(a).toHaveLength(45); + expect(b).toEqual(a); + expect(a.slice(0, 40).every((p) => p.id.startsWith('g'))).toBe(true); + expect(a.slice(40).map((p) => p.id)).toEqual(['p0', 'p1', 'p2', 'p3', 'p4']); + for (const p of a) expect(Math.hypot(p.x, p.y, p.z)).toBeGreaterThan(1.95); + }); + + it('hand edits: a generated slot moves (with y) or goes; a painted record is rewritten through the 16-byte codec', () => { + const painted = surfaceRecords.encode(generateSurfaceSlots({ ...GEN, count: 3 }, S, { draws: drawsFor(5) })); + const set = normalizeScatter({ sets: [{ id: 'm', source: CONE, host: 'mesh', seed: 3, gen: { ...GEN, count: 10 }, data: b64(painted.bytes), box: painted.box }] }, unb64).sets[0]; + const io = { bounds: { minX: -2, minZ: -2, sizeX: 4, sizeZ: 4 }, decode: unb64, encode: b64, records: surfaceRecords }; + const next = withOverrides(set, { g2: { x: 0, y: 2, z: 0 }, g3: { del: true }, p1: { del: true } }, io); + const list = surfaceInstancesOf(normalizeScatter({ sets: [next] }, unb64).sets[0], S, unb64); + expect(list.find((p) => p.id === 'g2')).toMatchObject({ x: 0, y: 2, z: 0 }); + expect(list.find((p) => p.id === 'g3')).toBeUndefined(); + expect(next.count).toBe(2); + expect(list.filter((p) => p.id.startsWith('p'))).toHaveLength(2); + }); +}); From 39de9147ca591910e06a3c580760489728213760 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 22 Oct 2026 18:31:51 +0300 Subject: [PATCH 08/65] [feat] 51-surface phase 3: a group of meshes (a placed model) is a scatter host too - terrainSurface.groupSurfaceOf: every child mesh merged into ONE surface in the group's own frame (relative transforms + normal matrices baked), keyed by each child's geometry and pose so a refilled kit piece or an edited child re-lays; terrainBounds falls back to it for a group - isMeshHost: a mesh, or a non-light group with meshes under it; the Inspector's Scatter section and the scatter-only brush session take it - Scene: the sculpt/scatter raycast is recursive for a group host, the brush ring takes the hit child's normal - e2e scatter-surface gains a group-host section (two boxes grouped, a slope-masked fill on both top faces, identical on B) - svelte-check 299/39 = baseline Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/Scene.svelte | 6 +- src/components/menu/Inspector.svelte | 6 +- src/lib/terrainPaint.js | 12 +++- src/lib/terrainSculpt.js | 3 +- src/lib/terrainSurface.js | 93 +++++++++++++++++++++++++++- tests/e2e/scatter-surface.test.cjs | 24 +++++++ 6 files changed, 134 insertions(+), 10 deletions(-) diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 9065cea28..dbd4e6001 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -869,7 +869,7 @@ if ($sculptObject && !$isLocked && !$isVRMode) { const terrain = $objectsGroup?.getObjectByProperty('uuid', $sculptObject); setRayFromEvent(event); - const hit = terrain ? selectionRaycaster.intersectObject(terrain, false)[0] : null; + const hit = terrain ? selectionRaycaster.intersectObject(terrain, !terrain.isMesh)[0] : null; // 51-surface: a group host's children if (hit) { sculptActive = true; lastSculptAt = performance.now(); @@ -1017,10 +1017,10 @@ if ($sculptObject) { const terrain = $objectsGroup?.getObjectByProperty('uuid', $sculptObject); setRayFromEvent(event); - const hit = terrain ? selectionRaycaster.intersectObject(terrain, false)[0] : null; + const hit = terrain ? selectionRaycaster.intersectObject(terrain, !terrain.isMesh)[0] : null; // 51-surface: a group host's children if (hit) { // mesh sculpt: orient the brush ring to the surface - const worldNormal = hit.face ? hit.face.normal.clone().transformDirection(terrain.matrixWorld) : null; + const worldNormal = hit.face ? hit.face.normal.clone().transformDirection(hit.object.matrixWorld) : null; showCursorAt(hit.point, worldNormal); if (sculptActive) { const now = performance.now(); diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index c70b29df6..5461bf46c 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -29,6 +29,10 @@ import ArrangeSection from './inspector/ArrangeSection.svelte'; // 47 M2 import TerrainPaintSection from './inspector/TerrainPaintSection.svelte'; // 47 T1/T2 import ScatterSection from './inspector/ScatterSection.svelte'; // 51-surface E2: scatter on any mesh + import { isMeshHost } from '$lib/terrainPaint'; + /** 51-surface: a mesh or a group of meshes takes scatter (the tick re-asks when children load) + * @param {any} o @param {any} _tick */ + const scatterHostOk = (o, _tick) => isMeshHost(o); import MeasureSection from './inspector/MeasureSection.svelte'; // 48 B2 import HdriSection from './scene/HdriSection.svelte'; // 37-hdri import PhysicsFloats from '../sim/PhysicsFloats.svelte'; // 36-sim I1 @@ -3620,7 +3624,7 @@ {/if} - {#if !isLight && !multiCount && $selectedObject?.isMesh && !$selectedObject.userData?.terrain} + {#if !isLight && !multiCount && scatterHostOk($selectedObject, $objectsGroup)} diff --git a/src/lib/terrainPaint.js b/src/lib/terrainPaint.js index fb77249f5..77301bc59 100644 --- a/src/lib/terrainPaint.js +++ b/src/lib/terrainPaint.js @@ -93,9 +93,17 @@ function writable_(object, anyMesh = false) { return true; } -/** 51-surface: a non-terrain mesh that can carry scatter (its own triangles are the surface) @param {any} object */ +/** 51-surface: a non-terrain object that can carry scatter — a mesh (its own triangles are the + * surface) or a GROUP with meshes under it (a placed model: its children's surface, in its frame). + * @param {any} object */ export function isMeshHost(object) { - return !!object?.isMesh && !object.userData?.terrain && !!object.geometry?.attributes?.position; + if (!object || object.userData?.terrain || object.isLight || object.isCamera) return false; + if (object.isMesh) return !!object.geometry?.attributes?.position; + let found = false; + object.traverse?.((/** @type {any} */ m) => { + if (!found && m !== object && m.isMesh && !m.isInstancedMesh && m.geometry?.attributes?.position) found = true; + }); + return found; } /** the host's largest world scale — the brush ring is in world metres, the instances live in the diff --git a/src/lib/terrainSculpt.js b/src/lib/terrainSculpt.js index 65a035b6f..a42778685 100644 --- a/src/lib/terrainSculpt.js +++ b/src/lib/terrainSculpt.js @@ -13,6 +13,7 @@ import { endPaintStroke, beginScatterStroke, scatterStrokeMove, + isMeshHost, endScatterStroke, terrainStrokeOpen } from './terrainPaint'; @@ -323,7 +324,7 @@ export function enterSculpt(uuid, opts = {}) { /** 51-surface: the scatter-only session on a plain mesh (see enterSculpt). @param {string} uuid @param {any} object */ function enterMeshScatter(uuid, object) { - if (!object.isMesh || !object.geometry?.attributes?.position) { + if (!isMeshHost(object)) { showToast('Scatter works on a mesh object'); return false; } diff --git a/src/lib/terrainSurface.js b/src/lib/terrainSurface.js index 951643aca..9dcb6a13b 100644 --- a/src/lib/terrainSurface.js +++ b/src/lib/terrainSurface.js @@ -61,7 +61,13 @@ function objectOf(uuid) { /** Local XZ bounds of a terrain's geometry (the splat + scatter frame). @param {any} object */ export function terrainBounds(object) { const geo = object?.geometry; - if (!geo?.attributes?.position) return null; + if (!geo?.attributes?.position) { + // 51-surface: a GROUP host (a placed model) has no geometry of its own — its children's + // surface, in its frame, is the scatter frame + const S = object && !object.isMesh ? surfaceOf(object) : null; + if (!S) return null; + return { minX: S.min[0], minZ: S.min[2], sizeX: Math.max(S.max[0] - S.min[0], 1e-3), sizeZ: Math.max(S.max[2] - S.min[2], 1e-3) }; + } if (!geo.boundingBox) geo.computeBoundingBox(); const b = geo.boundingBox; const sizeX = b.max.x - b.min.x; @@ -488,7 +494,10 @@ function reconcileScatter(object) { } if (entry.holder.parent !== scene) scene.add(entry.holder); const pos = object.geometry?.attributes?.position; - const layoutKey = (pos ? pos.version + ':' + pos.count + ':' + (pos.array?.length ?? 0) : '') + ':' + uuidTag(pos); + // 51-surface: a group host re-lays when any child's geometry or pose changes (its surface key) + const layoutKey = pos + ? pos.version + ':' + pos.count + ':' + (pos.array?.length ?? 0) + ':' + uuidTag(pos) + : 'g:' + (surfaceOf(object) ? surfaces.get(object)?.key ?? '' : ''); const key = JSON.stringify(sets.map((s) => [s.id, s.data, s.align, s.seed, s.gen, s.ov, s.sources, s.pick, s.tint, s.sink, s.host, s.box, setSources(s).map(sourceReady)])); // 51-surface: a layer-masked fill follows the PAINT, so a new splat re-lays it (compared by // reference — the block's data string is replaced on every commit, never mutated) @@ -555,7 +564,7 @@ const surfaces = new WeakMap(); export function surfaceOf(object) { const geo = object?.geometry; const pos = geo?.attributes?.position; - if (!pos) return null; + if (!pos) return object ? groupSurfaceOf(object) : null; const nrm = geo.attributes.normal ?? null; const key = uuidTag(pos) + ':' + pos.version + ':' + (geo.index ? uuidTag(geo.index) + ':' + geo.index.version : '-') + ':' + (nrm ? uuidTag(nrm) + ':' + nrm.version : '-'); let entry = surfaces.get(object); @@ -566,6 +575,84 @@ export function surfaceOf(object) { return entry.surface; } +/** The meshes a GROUP host's surface is made of: every plain, visible-or-not descendant mesh in tree + * order (the order a model's file gives, the same on every peer) — never an InstancedMesh, a skinned + * mesh, or anything scatter/LOD made. @param {any} object */ +function surfaceMeshes(object) { + /** @type {any[]} */ + const out = []; + object.traverse((/** @type {any} */ m) => { + if (m === object || !m.isMesh || m.isInstancedMesh || m.isSkinnedMesh) return; + if (!m.geometry?.attributes?.position) return; + out.push(m); + }); + return out; +} + +const _rel = new THREE.Matrix4(); +const _inv = new THREE.Matrix4(); +const _nm = new THREE.Matrix3(); +const _v = new THREE.Vector3(); + +/** + * 51-surface: a GROUP host — a placed Library model, a kit piece — merges its child meshes into + * ONE surface in the group's own frame (each child's transform relative to the group baked in), so + * a fill covers the whole model and moving the group carries it. The key names every child's + * geometry and its relative pose, so a refilled kit piece or an edited child re-lays. + * @param {any} object + */ +function groupSurfaceOf(object) { + const meshes = surfaceMeshes(object); + if (!meshes.length) return null; + object.updateWorldMatrix(true, true); + _inv.copy(object.matrixWorld).invert(); + /** @type {string[]} */ + const parts = []; + /** @type {any[]} */ + const rels = []; + for (const m of meshes) { + const g = m.geometry; + const rel = new THREE.Matrix4().multiplyMatrices(_inv, m.matrixWorld); + rels.push(rel); + parts.push(uuidTag(g.attributes.position) + '.' + g.attributes.position.version + '.' + (g.index ? uuidTag(g.index) : '-') + '.' + rel.elements.map((e) => Math.round(e * 1e5)).join(',')); + } + const key = 'g|' + parts.join('|'); + let entry = surfaces.get(object); + if (entry && entry.key === key) return entry.surface; + let tris = 0; + for (const m of meshes) tris += (m.geometry.index ? m.geometry.index.count : m.geometry.attributes.position.count) / 3; + const pos = new Float32Array(Math.floor(tris) * 9); + const nrm = new Float32Array(Math.floor(tris) * 9); + let o = 0; + meshes.forEach((m, k) => { + const g = m.geometry; + const p = g.attributes.position; + const n = g.attributes.normal ?? null; + const idx = g.index; + _rel.copy(rels[k]); + _nm.getNormalMatrix(_rel); + const count = idx ? idx.count : p.count; + for (let i = 0; i < count - (count % 3); i++) { + const vi = idx ? idx.getX(i) : i; + _v.fromBufferAttribute(p, vi).applyMatrix4(_rel); + pos[o] = _v.x; + pos[o + 1] = _v.y; + pos[o + 2] = _v.z; + if (n) _v.fromBufferAttribute(n, vi).applyMatrix3(_nm).normalize(); + else _v.set(0, 0, 0); + nrm[o] = _v.x; + nrm[o + 1] = _v.y; + nrm[o + 2] = _v.z; + o += 3; + } + }); + // a child without normals: face normals for all (a zero normal would interpolate to nothing) + const anyMissing = meshes.some((m) => !m.geometry.attributes.normal); + entry = { key, surface: buildSurface(pos.subarray(0, o), null, anyMissing ? null : nrm.subarray(0, o)), index: null }; + surfaces.set(object, entry); + return entry.surface; +} + /** object -> its terrain height grid, cached the same way @type {WeakMap} */ const grids = new WeakMap(); /** @param {any} object */ diff --git a/tests/e2e/scatter-surface.test.cjs b/tests/e2e/scatter-surface.test.cjs index 0fc5d969a..48733909d 100644 --- a/tests/e2e/scatter-surface.test.cjs +++ b/tests/e2e/scatter-surface.test.cjs @@ -274,5 +274,29 @@ h.run(async () => { const tSet = await firstSet(A.page, terrain.uuid); h.check(!tSet.host && tSet.gen.layer?.i === 2, 'a terrain set carries no host field (byte-identical shape) and keeps its mask'); + // ---- a GROUP host (a placed model is one): the fill covers its children, in its frame ------------ + const grp = await A.page.evaluate(async () => { + const w = window.__stores; + for (let i = 0; i < 2; i++) w.commandsHandler.sceneCommand('/create Box 1 1 1'); + await new Promise((r) => setTimeout(r, 300)); + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const kids = g.children.slice(-2); + kids.forEach((o, i) => o.position.set(8 + i * 3, 0.5, 8)); + w.objectActions.applySelectionSet(kids.map((o) => o.uuid)); + const uuid = w.objectActions.groupSelection(); + await new Promise((r) => setTimeout(r, 300)); + const host = w.terrainPaint.isMeshHost(g.getObjectByProperty('uuid', uuid)); + const id = w.terrainPaint.addScatterSet(uuid, { source: { kind: 'primitive', type: 'Sphere' }, gen: { count: 60, scaleMin: 0.2, scaleMax: 0.2, rot: true, slope: [0, 10] } }); + return { uuid, id, host }; + }); + h.check(grp.host && !!grp.id, 'a group of meshes takes scatter (isMeshHost + a set added)'); + const gDrawn = await drawn(A.page, grp.uuid); + // slope 0..10 = the two box TOPS (y = 1 in world); world x near 8 or 11 + h.check( + gDrawn.length === 60 && gDrawn.every((p) => Math.abs(p.world[1] - 1) < 0.02 && (Math.abs(p.world[0] - 8) <= 0.51 || Math.abs(p.world[0] - 11) <= 0.51)), + `the fill lies on both children's top faces (${gDrawn.length})` + ); + await h.eventually(() => drawn(B.page, grp.uuid), (d) => samePlaces(d, gDrawn, 2e-3), 'B draws the group fill identically', 15000); + await h.finish(browser); }); From 561bf51a165195403928dfe161280d8d19231354 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 22 Oct 2026 20:30:13 +0300 Subject: [PATCH 09/65] [fix] 51 E4: a selection survives its own revision; e2e for the edit tools - setScatterSelection records the scatter block it was taken against, so the onRevision check no longer reads our own (or an undone) block as a foreign change and drops a real click's selection or the re-selection after an undo - e2e scatter-edit: rotate / scale / relax brushes, the Affects-filtered erase, one undo entry per stroke, the second peer equal, per-terrain splat detail - e2e scatter-select: a REAL click and a REAL box drag, the gizmo move / turn / size (one entry per drag), Turn / Bigger / Re-roll (keeps every other instance) / Delete, Delete and Escape owned by the selection, the toolbox holding still; toolbox shots in dark / light / contrast. Split in two for the runner's 480 s cap Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/scatterEdit.js | 3 + tests/e2e/scatter-edit.test.cjs | 217 +++++++++++++++++++++++ tests/e2e/scatter-select.test.cjs | 280 ++++++++++++++++++++++++++++++ 3 files changed, 500 insertions(+) create mode 100644 tests/e2e/scatter-edit.test.cjs create mode 100644 tests/e2e/scatter-select.test.cjs diff --git a/src/lib/scatterEdit.js b/src/lib/scatterEdit.js index dc8af116c..6fedd8ca9 100644 --- a/src/lib/scatterEdit.js +++ b/src/lib/scatterEdit.js @@ -269,6 +269,9 @@ export function scatterSelection() { * @param {Map>} picks @param {boolean} [add] */ export function setScatterSelection(uuid, picks, add = false) { if (!sel || sel.uuid !== uuid || !add) sel = { uuid, picks: new Map() }; + // a selection is taken against the scatter AS IT IS NOW (after an undo or a peer's edit + // too), so that block is the one a later change is measured against + lastWritten = objectOf(uuid)?.userData?.terrainScatter ?? null; for (const [id, ids] of picks) { const cur = sel.picks.get(id) ?? new Set(); for (const i of ids) { diff --git a/tests/e2e/scatter-edit.test.cjs b/tests/e2e/scatter-edit.test.cjs new file mode 100644 index 000000000..6280aa800 --- /dev/null +++ b/tests/e2e/scatter-edit.test.cjs @@ -0,0 +1,217 @@ +// 51 E4 — EDITING scatter instances with the BRUSHES, two peers (the Select tool: scatter-select; +// E5 conversions: scatter-convert). The rotate / scale / relax brushes and the Affects-filtered +// (per-source) erase, each ONE undo entry, equal on the second peer; per-terrain splat detail. +const h = require('./helpers.cjs'); + +const SHOTS = process.env.SHOTS_DIR || ''; + +/** @param {any} page @param {string} uuid */ +const scatterOf = (page, uuid) => + page.evaluate( + (u) => + new Promise((resolve) => { + window.__stores.objectsGroup.subscribe((g) => { + const o = g?.getObjectByProperty('uuid', u); + resolve(o ? JSON.stringify(o.userData.terrainScatter ?? null) : 'missing'); + })(); + }), + uuid + ); +/** The decoded lists, per set id. @param {any} page @param {string} uuid */ +const listsOf = (page, uuid) => + page.evaluate((u) => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const t = g.getObjectByProperty('uuid', u); + const b = s.terrainSurface.terrainBounds(t); + const block = s.terrainScatterCore.normalizeScatter(t.userData.terrainScatter, s.terrainPaintCore.base64ToBytes); + const out = {}; + for (const set of block?.sets ?? []) out[set.id] = s.terrainScatterCore.decodeInstances(s.terrainPaintCore.base64ToBytes(set.data), b); + return out; + }, uuid); +const undoDepth = (page) => page.evaluate(() => new Promise((r) => window.__stores.history.undoStack.subscribe((s) => r(s.length))())); +const state = (page) => page.evaluate(() => window.__stores.scatterEdit.scatterEditState()); +/** @param {any} page */ +const objectCount = (page) => page.evaluate(() => new Promise((r) => window.__stores.objectsGroup.subscribe((g) => r(g.children.length))())); + +/** @param {any} page @param {number[]} pos @param {number[]} target */ +async function look(page, pos, target) { + await page.evaluate( + ([p, t]) => { + let cam, orbit; + window.__stores.editorCam.subscribe((v) => (cam = v))(); + window.__stores.orbitControls.subscribe((v) => (orbit = v))(); + cam.position.set(p[0], p[1], p[2]); + orbit?.target?.set(t[0], t[1], t[2]); + orbit?.update?.(); + cam.lookAt(t[0], t[1], t[2]); + }, + [pos, target] + ); +} + +/** One programmatic stroke of the armed tool at local (x, z). @param {any} page @param {string} uuid */ +async function stroke(page, uuid, x, z, steps = 10, dt = 0.05) { + await page.evaluate( + async ([u, x, z, steps, dt]) => { + const ts = window.__stores.terrainSculpt; + ts.beginStroke(u); + for (let i = 0; i < steps; i++) { + ts.strokeMove(u, x, z, dt); + await new Promise((r) => setTimeout(r, 10)); + } + ts.endStroke(); + }, + [uuid, x, z, steps, dt] + ); +} + +/** Screen point of an instance's base (and whether the canvas is what is there). + * @param {any} page @param {string} uuid @param {{x: number, z: number}} inst */ +async function screenOf(page, uuid, inst) { + const world = await page.evaluate( + ([u, x, z]) => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const t = g.getObjectByProperty('uuid', u); + const grid = s.terrainScatterCore.buildHeightGrid(t.geometry.attributes.position.array); + const y = s.terrainScatterCore.sampleHeight(grid, x, z).y; + t.updateWorldMatrix(true, false); + const e = t.matrixWorld.elements; + // the cone's middle, so the ray from above lands on the model (y + 0.5 of a unit cone) + const ly = y + 0.4; + return [e[0] * x + e[4] * ly + e[8] * z + e[12], e[1] * x + e[5] * ly + e[9] * z + e[13], e[2] * x + e[6] * ly + e[10] * z + e[14]]; + }, + [uuid, inst.x, inst.z] + ); + const p = await h.projectPoint(page, world); + const canvas = await page.evaluate(([x, y]) => document.elementFromPoint(x, y)?.tagName === 'CANVAS', [p.x, p.y]); + return { ...p, canvas }; +} + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + + const uuid = await A.page.evaluate(async () => { + window.__stores.commandsHandler.sceneCommand('/create Terrain 24 48'); + const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + return group.children[group.children.length - 1].uuid; + }); + await h.eventually(() => scatterOf(B.page, uuid), (v) => v !== 'missing', 'B has the terrain', 20000); + await look(A.page, [0.5, 22, 3], [0, 0, 0]); + await look(B.page, [-6, 16, -8], [0, 0, 0]); + + // ---- paint two models --------------------------------------------------------------------- + const entered = await A.page.evaluate((u) => { + const s = window.__stores; + const ok = s.terrainSculpt.enterSculpt(u); + s.terrainSculpt.sculptOp.set('scatter'); + s.terrainSculpt.sculptRadius.set(3); + s.terrainPaint.scatterDensity.set(1); + s.terrainPaint.scatterSource.set({ kind: 'primitive', type: 'Cone' }); + s.scatterEdit.setScatterTool('paint'); + return ok; + }, uuid); + h.check(entered, 'A enters the sculpt session, Scatter brush, Paint tool'); + await stroke(A.page, uuid, 0, 0, 6); + await stroke(A.page, uuid, -6, -6, 6); + await A.page.evaluate(() => window.__stores.terrainPaint.scatterSource.set({ kind: 'primitive', type: 'Box' })); + await stroke(A.page, uuid, 0, 0, 6); // boxes fill the gaps between the cones in the same circle + let lists = await listsOf(A.page, uuid); + h.check(lists['p:Cone']?.length > 20 && lists['p:Box']?.length > 0, `two models scattered (${lists['p:Cone']?.length} cones, ${lists['p:Box']?.length} boxes)`); + await h.eventually(async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B holds the same scatter', 15000); + + // ---- E4 brushes --------------------------------------------------------------------------- + const near = (l, x, z, r) => l.filter((p) => Math.hypot(p.x - x, p.z - z) < r); + let d0 = await undoDepth(A.page); + await A.page.evaluate(() => { + const s = window.__stores; + s.scatterEdit.setScatterTool('rotate'); + s.scatterEdit.scatterAffects.set(''); + s.terrainSculpt.sculptStrength.set(1); + }); + const yawBefore = near(lists['p:Cone'], 0, 0, 2).map((p) => p.yaw); + await stroke(A.page, uuid, 0, 0, 8); + lists = await listsOf(A.page, uuid); + const yawAfter = near(lists['p:Cone'], 0, 0, 2).map((p) => p.yaw); + h.check(yawAfter.some((y, i) => Math.abs(y - yawBefore[i]) > 0.05), 'the Rotate brush turns the instances under it'); + h.check((await undoDepth(A.page)) === d0 + 1, 'one rotate stroke = ONE undo entry'); + await h.eventually(async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B sees the rotate', 10000); + + await A.page.evaluate(() => window.__stores.scatterEdit.setScatterTool('scale')); + const scaleMean = (l) => l.reduce((a, p) => a + p.scale, 0) / Math.max(1, l.length); + const sBefore = scaleMean(near(lists['p:Cone'], -6, -6, 1.5)); + await stroke(A.page, uuid, -6, -6, 8); + lists = await listsOf(A.page, uuid); + const sGrown = scaleMean(near(lists['p:Cone'], -6, -6, 1.5)); + h.check(sGrown > sBefore * 1.05, `the Scale brush grows them (${sBefore.toFixed(2)} -> ${sGrown.toFixed(2)})`); + await A.page.evaluate(() => window.__stores.scatterEdit.scatterBrushReverse.set(true)); + await stroke(A.page, uuid, -6, -6, 8); + lists = await listsOf(A.page, uuid); + h.check(scaleMean(near(lists['p:Cone'], -6, -6, 1.5)) < sGrown * 0.97, 'with Shrink ticked it shrinks them back'); + await A.page.evaluate(() => window.__stores.scatterEdit.scatterBrushReverse.set(false)); + + // relax: a clump of six on one spot spreads + await A.page.evaluate((u) => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const t = g.getObjectByProperty('uuid', u); + const b = s.terrainSurface.terrainBounds(t); + const block = JSON.parse(JSON.stringify(t.userData.terrainScatter)); + const clump = Array.from({ length: 6 }, (_, i) => ({ x: 7 + (i % 3) * 0.05, z: 7 + Math.floor(i / 3) * 0.05, yaw: 0, scale: 1 })); + block.sets.push({ id: 'p:Sphere', source: { kind: 'primitive', type: 'Sphere' }, align: false, data: s.terrainPaintCore.bytesToBase64(s.terrainScatterCore.encodeInstances(clump, b)), count: 6 }); + s.terrainSurface.applyTerrainScatter(u, block); + }, uuid); + const gapOf = (l) => { + let m = Infinity; + for (let i = 0; i < l.length; i++) for (let j = i + 1; j < l.length; j++) m = Math.min(m, Math.hypot(l[i].x - l[j].x, l[i].z - l[j].z)); + return m; + }; + lists = await listsOf(A.page, uuid); + const gap0 = gapOf(lists['p:Sphere']); + await A.page.evaluate(() => { + window.__stores.scatterEdit.setScatterTool('relax'); + window.__stores.scatterEdit.scatterAffects.set('p:Sphere'); + }); + await stroke(A.page, uuid, 7, 7, 20); + lists = await listsOf(A.page, uuid); + h.check(gapOf(lists['p:Sphere']) > gap0 * 3, `Relax evens a clump out (closest gap ${gap0.toFixed(3)} -> ${gapOf(lists['p:Sphere']).toFixed(3)} m)`); + h.check(lists['p:Sphere'].every((p) => Math.hypot(p.x - 7, p.z - 7) <= 3.01), 'and keeps every instance inside the brush'); + + // per-source erase: Affects = Box erases boxes only + const cones0 = lists['p:Cone'].length; + const boxes0 = lists['p:Box'].length; + await A.page.evaluate(() => { + window.__stores.scatterEdit.setScatterTool('erase'); + window.__stores.scatterEdit.scatterAffects.set('p:Box'); + }); + d0 = await undoDepth(A.page); + await stroke(A.page, uuid, 0, 0, 2); + lists = await listsOf(A.page, uuid); + h.check((lists['p:Box']?.length ?? 0) < boxes0 && lists['p:Cone'].length === cones0, `Erase with Affects = Box removes boxes only (${boxes0} -> ${lists['p:Box']?.length ?? 0} boxes, cones ${cones0} kept)`); + h.check((await undoDepth(A.page)) === d0 + 1, 'one erase stroke = ONE undo entry'); + await A.page.evaluate(() => window.__stores.history.undo()); + lists = await listsOf(A.page, uuid); + h.check(lists['p:Box'].length === boxes0, 'one undo brings the boxes back'); + await h.eventually(async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B agrees after the undo', 10000); + + // ---- per-terrain splat detail --------------------------------------------------------------- + const res = await A.page.evaluate((u) => { + const p = window.__stores.terrainPaint; + const r0 = p.splatResolutionOf(u); + const ok = p.setSplatResolution(u, 256); + return { r0, ok, r1: p.splatResolutionOf(u) }; + }, uuid); + h.check(res.r0 === 128 && res.ok && res.r1 === 256, `Detail sets this terrain's splat to 256 (${res.r0} -> ${res.r1})`); + await h.eventually(() => B.page.evaluate((u) => window.__stores.terrainPaint.splatResolutionOf(u), uuid), (r) => r === 256, 'B paints at 256 too', 10000); + await A.page.evaluate(() => window.__stores.history.undo()); + h.check((await A.page.evaluate((u) => window.__stores.terrainPaint.splatResolutionOf(u), uuid)) === 128, 'one undo puts it back'); + + await h.finish(browser); +}); diff --git a/tests/e2e/scatter-select.test.cjs b/tests/e2e/scatter-select.test.cjs new file mode 100644 index 000000000..9f4f26441 --- /dev/null +++ b/tests/e2e/scatter-select.test.cjs @@ -0,0 +1,280 @@ +// 51 E4 — the scatter SELECT tool, two peers (brushes: scatter-edit). A REAL click and a REAL box +// drag select instances, the gizmo moves / turns / scales the selection (one entry per drag), the +// toolbox Turn / Bigger / Re-roll (keeps every other instance) / Delete, Delete and Escape owned by +// the selection (never the terrain). +const h = require('./helpers.cjs'); + +const SHOTS = process.env.SHOTS_DIR || ''; + +/** @param {any} page @param {string} uuid */ +const scatterOf = (page, uuid) => + page.evaluate( + (u) => + new Promise((resolve) => { + window.__stores.objectsGroup.subscribe((g) => { + const o = g?.getObjectByProperty('uuid', u); + resolve(o ? JSON.stringify(o.userData.terrainScatter ?? null) : 'missing'); + })(); + }), + uuid + ); +/** The decoded lists, per set id. @param {any} page @param {string} uuid */ +const listsOf = (page, uuid) => + page.evaluate((u) => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const t = g.getObjectByProperty('uuid', u); + const b = s.terrainSurface.terrainBounds(t); + const block = s.terrainScatterCore.normalizeScatter(t.userData.terrainScatter, s.terrainPaintCore.base64ToBytes); + const out = {}; + for (const set of block?.sets ?? []) out[set.id] = s.terrainScatterCore.decodeInstances(s.terrainPaintCore.base64ToBytes(set.data), b); + return out; + }, uuid); +const undoDepth = (page) => page.evaluate(() => new Promise((r) => window.__stores.history.undoStack.subscribe((s) => r(s.length))())); +const state = (page) => page.evaluate(() => window.__stores.scatterEdit.scatterEditState()); +/** @param {any} page */ +const objectCount = (page) => page.evaluate(() => new Promise((r) => window.__stores.objectsGroup.subscribe((g) => r(g.children.length))())); + +/** @param {any} page @param {number[]} pos @param {number[]} target */ +async function look(page, pos, target) { + await page.evaluate( + ([p, t]) => { + let cam, orbit; + window.__stores.editorCam.subscribe((v) => (cam = v))(); + window.__stores.orbitControls.subscribe((v) => (orbit = v))(); + cam.position.set(p[0], p[1], p[2]); + orbit?.target?.set(t[0], t[1], t[2]); + orbit?.update?.(); + cam.lookAt(t[0], t[1], t[2]); + }, + [pos, target] + ); +} + +/** One programmatic stroke of the armed tool at local (x, z). @param {any} page @param {string} uuid */ +async function stroke(page, uuid, x, z, steps = 10, dt = 0.05) { + await page.evaluate( + async ([u, x, z, steps, dt]) => { + const ts = window.__stores.terrainSculpt; + ts.beginStroke(u); + for (let i = 0; i < steps; i++) { + ts.strokeMove(u, x, z, dt); + await new Promise((r) => setTimeout(r, 10)); + } + ts.endStroke(); + }, + [uuid, x, z, steps, dt] + ); +} + +/** Screen point of an instance's base (and whether the canvas is what is there). + * @param {any} page @param {string} uuid @param {{x: number, z: number}} inst */ +async function screenOf(page, uuid, inst) { + const world = await page.evaluate( + ([u, x, z]) => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const t = g.getObjectByProperty('uuid', u); + const grid = s.terrainScatterCore.buildHeightGrid(t.geometry.attributes.position.array); + const y = s.terrainScatterCore.sampleHeight(grid, x, z).y; + t.updateWorldMatrix(true, false); + const e = t.matrixWorld.elements; + // the cone's middle, so the ray from above lands on the model (y + 0.5 of a unit cone) + const ly = y + 0.4; + return [e[0] * x + e[4] * ly + e[8] * z + e[12], e[1] * x + e[5] * ly + e[9] * z + e[13], e[2] * x + e[6] * ly + e[10] * z + e[14]]; + }, + [uuid, inst.x, inst.z] + ); + const p = await h.projectPoint(page, world); + const canvas = await page.evaluate(([x, y]) => document.elementFromPoint(x, y)?.tagName === 'CANVAS', [p.x, p.y]); + return { ...p, canvas }; +} + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + + const uuid = await A.page.evaluate(async () => { + window.__stores.commandsHandler.sceneCommand('/create Terrain 24 48'); + const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + return group.children[group.children.length - 1].uuid; + }); + await h.eventually(() => scatterOf(B.page, uuid), (v) => v !== 'missing', 'B has the terrain', 20000); + await look(A.page, [0.5, 22, 3], [0, 0, 0]); + await look(B.page, [-6, 16, -8], [0, 0, 0]); + + // ---- paint two models --------------------------------------------------------------------- + const entered = await A.page.evaluate((u) => { + const s = window.__stores; + const ok = s.terrainSculpt.enterSculpt(u); + s.terrainSculpt.sculptOp.set('scatter'); + s.terrainSculpt.sculptRadius.set(3); + s.terrainPaint.scatterDensity.set(1); + s.terrainPaint.scatterSource.set({ kind: 'primitive', type: 'Cone' }); + s.scatterEdit.setScatterTool('paint'); + return ok; + }, uuid); + h.check(entered, 'A enters the sculpt session, Scatter brush, Paint tool'); + await stroke(A.page, uuid, 0, 0, 6); + await stroke(A.page, uuid, -6, -6, 6); + await A.page.evaluate(() => window.__stores.terrainPaint.scatterSource.set({ kind: 'primitive', type: 'Box' })); + await stroke(A.page, uuid, 0, 0, 6); // boxes fill the gaps between the cones in the same circle + let lists = await listsOf(A.page, uuid); + h.check(lists['p:Cone']?.length > 20 && lists['p:Box']?.length > 0, `two models scattered (${lists['p:Cone']?.length} cones, ${lists['p:Box']?.length} boxes)`); + await h.eventually(async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B holds the same scatter', 15000); + + let d0 = 0; + // ---- E4 select: real click, real box, gizmo ------------------------------------------------- + await A.page.evaluate(() => { + window.__stores.scatterEdit.scatterAffects.set('p:Cone'); + window.__stores.scatterEdit.setScatterTool('select'); + }); + if (SHOTS) await A.page.screenshot({ path: `${SHOTS}/01-select-tool.png` }); + lists = await listsOf(A.page, uuid); + // an instance whose pixel is the canvas + let pick = null; + for (const [i, inst] of lists['p:Cone'].entries()) { + if (Math.hypot(inst.x, inst.z) > 2.5) continue; + const p = await screenOf(A.page, uuid, inst); + if (p.canvas) { + pick = { i, p }; + break; + } + } + h.check(!!pick, 'a cone near the middle is on the canvas (premise)'); + if (pick) { + await A.page.mouse.click(pick.p.x, pick.p.y); + await A.page.waitForTimeout(200); + let st = await state(A.page); + h.check(st.count === 1 && st.gizmo, `a REAL click selects one instance and seats the gizmo (${st.count}, gizmo ${st.gizmo})`); + // a real box over the middle (from an empty selection: the gizmo would own a press on it) + await A.page.evaluate(() => window.__stores.scatterEdit.clearScatterSelection()); + const c = await h.projectPoint(A.page, [0, 0, 0]); + await A.page.mouse.move(c.x - 70, c.y - 70); + await A.page.mouse.down(); + await A.page.mouse.move(c.x, c.y, { steps: 4 }); + await A.page.mouse.move(c.x + 70, c.y + 70, { steps: 4 }); + await A.page.mouse.up(); + await A.page.waitForTimeout(200); + st = await state(A.page); + const expect = await A.page.evaluate( + ([u, r]) => { + let cam; + window.__stores.editorCam.subscribe((v) => (cam = v))(); + const m = window.__stores.scatterEdit.instancesInScreenRect(u, r, cam); + let n = 0; + for (const ids of m.values()) n += ids.size; + return n; + }, + [uuid, { x0: c.x - 70, y0: c.y - 70, x1: c.x + 70, y1: c.y + 70 }] + ); + h.check(st.count > 3 && st.count === expect, `a REAL box drag selects the cones inside it (${st.count} of ${expect})`); + h.check(Object.keys(st.selection).every((k) => k === 'p:Cone'), 'Affects holds the box to the cones'); + // the toolbox holds still while instances are picked (a selection must not reflow it every frame) + const boxes = await A.page.evaluate(async () => { + const out = []; + for (let i = 0; i < 12; i++) { + const r = document.getElementById('sculpt-toolbar')?.getBoundingClientRect(); + out.push(r ? [r.x, r.y, r.width, r.height].map((v) => Math.round(v * 10) / 10).join(',') : 'none'); + await new Promise((res) => setTimeout(res, 50)); + } + return out; + }); + h.check(new Set(boxes).size === 1 && boxes[0] !== 'none', `the toolbox holds still with a selection (${[...new Set(boxes)].join(' | ')})`); + if (SHOTS) { + await A.page.screenshot({ path: `${SHOTS}/02-box-selected.png` }); + // the new toolbox UI in three themes (53: tokens only, every theme) + for (const t of ['dark', 'light', 'contrast']) { + await A.page.evaluate((id) => window.__stores.themes.theme.set(id), t); + await A.page.waitForTimeout(250); + const r = await A.page.evaluate(() => { + const b = document.getElementById('sculpt-toolbar')?.getBoundingClientRect(); + return b && { x: b.x, y: b.y, width: b.width, height: b.height }; + }); + if (r) await A.page.screenshot({ path: `${SHOTS}/03-toolbox-select-${t}.png`, clip: r }); + } + await A.page.evaluate(() => window.__stores.themes.theme.set('dark')); + } + + // gizmo: move, turn, scale — each drag ONE entry, absolute from the drag start + const sel = st.selection['p:Cone']; + const pre = (await listsOf(A.page, uuid))['p:Cone']; + const other = pre.findIndex((_, k) => !sel.includes(k)); + d0 = await undoDepth(A.page); + const drag = (fn) => + A.page.evaluate((src) => { + const s = window.__stores; + let c; + s.TControls.subscribe((v) => (c = v))(); + const proxy = c.object; + s.scatterEdit.onScatterProxyDragChanged(true); + new Function('proxy', src)(proxy); + proxy.updateMatrixWorld(true); + s.scatterEdit.onScatterProxyMoved(); + s.scatterEdit.onScatterProxyDragChanged(false); + return !!proxy?.userData?.isScatterProxy; + }, fn); + h.check(await drag('proxy.position.x += 1.5;'), 'the gizmo sits on the scatter proxy'); + let post = (await listsOf(A.page, uuid))['p:Cone']; + h.check(sel.every((k) => Math.abs(post[k].x - pre[k].x - 1.5) < 0.02 && Math.abs(post[k].z - pre[k].z) < 0.02), 'Move slides every selected instance 1.5 m'); + h.check(post[other].x === pre[other].x && post[other].z === pre[other].z, 'an unselected instance stays put'); + await drag('proxy.rotateY(0.5);'); + await drag('proxy.scale.set(2, 2, 2);'); + post = (await listsOf(A.page, uuid))['p:Cone']; + const turned = sel.every((k) => { + const d = (post[k].yaw - pre[k].yaw + Math.PI * 4) % (Math.PI * 2); + return Math.abs(d - 0.5) < 0.03; + }); + h.check(turned, 'Turn adds 0.5 rad to each selected yaw'); + h.check(sel.every((k) => Math.abs(post[k].scale / pre[k].scale - 2) < 0.02), 'Size doubles each selected instance'); + h.check((await undoDepth(A.page)) === d0 + 3, 'three gizmo drags = three undo entries'); + await h.eventually(async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B sees the gizmo edits', 10000); + await A.page.evaluate(() => window.__stores.history.undo()); + post = (await listsOf(A.page, uuid))['p:Cone']; + h.check(sel.every((k) => Math.abs(post[k].scale - pre[k].scale) < 0.01), 'undo takes the last drag (the size) back'); + + // toolbox ops by their buttons (re-select: an undo drops the selection, by design) + await A.page.evaluate( + ([u, ids]) => window.__stores.scatterEdit.setScatterSelection(u, new Map([['p:Cone', new Set(ids)]])), + [uuid, sel] + ); + const before = (await listsOf(A.page, uuid))['p:Cone']; + await A.page.click('#sculpt-scatter-turn-left'); + await A.page.click('#sculpt-scatter-bigger'); + await A.page.click('#sculpt-scatter-reroll'); + post = (await listsOf(A.page, uuid))['p:Cone']; + h.check(sel.every((k) => post[k].x === before[k].x && post[k].z === before[k].z), 'Turn / Bigger / Re-roll keep the selected positions'); + h.check(sel.some((k) => post[k].scale !== before[k].scale || post[k].yaw !== before[k].yaw), 'and change their look'); + h.check(post[other].yaw === before[other].yaw && post[other].scale === before[other].scale, 'Re-roll keeps every OTHER instance as it was'); + + // Delete owns the key while instances are picked — the terrain survives + const coneN = post.length; + const objs = await objectCount(A.page); + await A.page.mouse.move(pick.p.x, pick.p.y); + await A.page.keyboard.press('Delete'); + await A.page.waitForTimeout(200); + post = (await listsOf(A.page, uuid))['p:Cone']; + h.check(post.length === coneN - sel.length, `Delete removes the selected instances (${coneN} -> ${post.length})`); + h.check((await objectCount(A.page)) === objs, 'and NOT the terrain'); + await h.eventually(async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B sees the delete', 10000); + await A.page.evaluate(() => window.__stores.history.undo()); + h.check((await listsOf(A.page, uuid))['p:Cone'].length === coneN, 'one undo brings them back'); + + // Escape: first drops the selection, then leaves the session + await A.page.evaluate((u) => window.__stores.scatterEdit.selectAllInstances(u), uuid); + await A.page.keyboard.press('Escape'); + const afterEsc = await A.page.evaluate(() => { + let o; + window.__stores.terrainSculpt.sculptObject.subscribe((v) => (o = v))(); + return { count: window.__stores.scatterEdit.scatterEditState().count, sculpt: o }; + }); + h.check(afterEsc.count === 0 && afterEsc.sculpt === uuid, 'the first Escape drops the selection and stays in the session'); + } + + + await h.finish(browser); +}); From f4af84a264ae045aed7db7caac552d2002ae860e Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 22 Oct 2026 20:30:13 +0300 Subject: [PATCH 10/65] [feat] 51 E5: convert scatter instances to objects and copies to a scatter set - scatterConvert.instancesToObjects: the selection or a whole set becomes real objects at each instance's exact pose (ground, turn, slope tilt, size); one template per source (primitive / Library model / prefab - a logic prefab comes with its logic), duplicated per instance; asks first above 500; ONE history batch, so one undo turns them back - objectsToScatter: several copies of ONE object (a primitive or one Library model) on a terrain become instances of that source (position, turn, size kept) and the objects go, in one batch - entry points: Select tool "To objects", Properties > Scatter row "To objects", object menu "Make scatter" for a multi-selection - terrainSurface.scatterPrimitiveSpec (the create command, offset, colour, roughness a primitive instance is drawn with); debug hook scatterConvert - e2e scatter-convert: both directions, the 600-instance confirm, peers, undo, save/reload Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/menu/SculptToolbar.svelte | 6 + .../menu/inspector/TerrainPaintSection.svelte | 6 + src/lib/debugHooks.js | 1 + src/lib/objectMenu.js | 7 + src/lib/scatterConvert.js | 332 ++++++++++++++++++ src/lib/terrainSurface.js | 32 ++ tests/e2e/scatter-convert.test.cjs | 211 +++++++++++ 7 files changed, 595 insertions(+) create mode 100644 src/lib/scatterConvert.js create mode 100644 tests/e2e/scatter-convert.test.cjs diff --git a/src/components/menu/SculptToolbar.svelte b/src/components/menu/SculptToolbar.svelte index 7e59cccf7..3290adad6 100644 --- a/src/components/menu/SculptToolbar.svelte +++ b/src/components/menu/SculptToolbar.svelte @@ -301,6 +301,12 @@ onclick={() => rerollSelectedInstances()}>Re-roll + {/if} {/if} diff --git a/src/components/menu/inspector/TerrainPaintSection.svelte b/src/components/menu/inspector/TerrainPaintSection.svelte index 46a7e6035..67acd8e08 100644 --- a/src/components/menu/inspector/TerrainPaintSection.svelte +++ b/src/components/menu/inspector/TerrainPaintSection.svelte @@ -164,6 +164,12 @@ editScatterSet(uuid, s.id, { align: !!v })} /> Align + diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 08070804e..4c89a80e1 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -114,6 +114,7 @@ export const DEBUG_HOOKS = [ // 51 E4/E5: editing scatter instances (tools, selection, gizmo) and its pure half ['scatterEdit', () => import('./scatterEdit')], ['scatterEditCore', () => import('./scatterEditCore')], + ['scatterConvert', () => import('./scatterConvert')], ['scatterColliders', () => import('./scatterColliders')], ['terrainScatterCore', () => import('./terrainScatterCore')], ['userModules', () => import('./userModules')], diff --git a/src/lib/objectMenu.js b/src/lib/objectMenu.js index b365d1ae4..f53b2363d 100644 --- a/src/lib/objectMenu.js +++ b/src/lib/objectMenu.js @@ -289,6 +289,13 @@ export function buildObjectMenuItems(uuid, opts = {}) { icon: 'group', tooltip: 'Move the selected objects into one new group', action: () => groupSelection() + }, + // 51 E5: many copies of one object on a terrain become instances of its scatter + { + label: 'Make scatter' + suffix, + icon: 'tool:scatter', + tooltip: 'Turn copies of one object standing on a Terrain into scatter instances (one undo turns them back)', + action: () => import('./scatterConvert').then((m) => m.objectsToScatter(selection)) } ] : []), diff --git a/src/lib/scatterConvert.js b/src/lib/scatterConvert.js new file mode 100644 index 000000000..f29c5134e --- /dev/null +++ b/src/lib/scatterConvert.js @@ -0,0 +1,332 @@ +// @ts-ignore - no bundled three type declarations (project-wide) +import * as THREE from 'three'; +import { get } from 'svelte/store'; +import { objectsGroup } from '../stores/sceneStore'; +import { peers, showToast } from '../stores/appStore'; +import { beginHistoryBatch, endHistoryBatch, historyBatchOpen, recordEntry, recordObjectPresence } from './history'; +import { canEditObject, warnViewerReadOnly } from './objectPermissions'; +import { applyTerrainScatter, scatterPrimitiveSpec } from './terrainSurface'; +import { buildHeightGrid, sampleHeight, SCATTER_PRIMITIVES } from './terrainScatterCore'; +import { readScatter, blockOf, newSet, sourceKey, totalOf, MAX_INSTANCES, MAX_SETS } from './scatterEditAdapter'; +import { deleteSelected, yawOfQuaternion, commonSource, CONVERT_CONFIRM_ABOVE } from './scatterEditCore'; +import { selectedInstanceGroups, clearScatterSelection } from './scatterEdit'; +import { showConfirm } from './confirmDialog'; + +// 51 E5 — CONVERT BOTH WAYS. +// +// INSTANCES -> OBJECTS: each picked instance (the Select tool's selection, or a whole set) +// becomes a real object at exactly the pose it was drawn with — same base on the ground, same +// turn, same tilt when the set aligns to slope, same size. The way every object is made is +// one the session already replicates: ONE template (a `/create` of the primitive, an import +// of the Library model, or — a logic prefab source — `instantiatePrefab`, which brings the +// prefab WITH its logic), then `duplicateObject` per instance (the small `duplicate` +// message + a `move`), then the template goes. The instances leave the set. All of it is ONE +// history batch, so one undo puts the instances back and takes the objects away on every +// peer. More than CONVERT_CONFIRM_ABOVE asks first. +// +// COPIES -> A SCATTER SET: several copies of ONE object (a primitive type, or one Library +// model) standing on a terrain become instances of that terrain's set for that source (made +// if absent), each at its object's ground position, turn and size; the objects are deleted. +// ONE batch again. With 51-sources' overrides each position is an override; today it is an +// explicit instance (scatterEditAdapter). + +const UP = new THREE.Vector3(0, 1, 0); + +/** @param {string} uuid */ +function objectOf(uuid) { + return /** @type {any} */ (get(objectsGroup))?.getObjectByProperty('uuid', uuid) ?? null; +} + +/** @param {any} message */ +function send(message) { + /** @type {any} */ + const peer = get(peers); + peer?.send?.(message); +} + +/** Commit a terrain's scatter inside the open batch. @param {string} uuid @param {any} before @param {any[]} sets @param {any} bounds */ +function commitScatter(uuid, before, sets, bounds) { + applyTerrainScatter(uuid, blockOf(sets, bounds)); + const after = objectOf(uuid)?.userData?.terrainScatter ?? null; + send({ type: 'objectParameters', parameter: 'terrainScatter', uuid, terrainScatter: after }); + recordEntry({ kind: 'terrainScatter', uuid, before: { block: before }, after: { block: after ? JSON.parse(JSON.stringify(after)) : null } }); +} + +/** The world matrix an instance is drawn with (terrainSurface.instanceMatrices, in world). + * @param {any} terrain @param {any} grid @param {{x: number, z: number, yaw: number, scale: number}} inst @param {boolean} align */ +function instanceWorldMatrix(terrain, grid, inst, align) { + const h = grid ? sampleHeight(grid, inst.x, inst.z) : { y: 0, nx: 0, ny: 1, nz: 0 }; + const qy = new THREE.Quaternion().setFromAxisAngle(UP, inst.yaw); + const q = align ? new THREE.Quaternion().setFromUnitVectors(UP, new THREE.Vector3(h.nx, h.ny, h.nz)).multiply(qy) : qy; + const local = new THREE.Matrix4().compose(new THREE.Vector3(inst.x, h.y, inst.z), q, new THREE.Vector3(inst.scale, inst.scale, inst.scale)); + terrain.updateWorldMatrix(true, false); + return new THREE.Matrix4().multiplyMatrices(terrain.matrixWorld, local); +} + +/** Pose an object (a child of objectsGroup) so it shows at `world` and tell peers. + * @param {any} object @param {any} world */ +function poseAt(object, world) { + const parent = object.parent ?? get(objectsGroup); + parent.updateWorldMatrix(true, false); + const local = new THREE.Matrix4().copy(parent.matrixWorld).invert().multiply(world); + local.decompose(object.position, object.quaternion, object.scale); + object.updateMatrix(); + send({ type: 'move', uuid: object.uuid, pos: object.position.toArray(), rot: object.rotation.toArray(), scale: object.scale.toArray() }); +} + +/** Make the ONE template object for a source. Resolves {uuid, offset} (offset = how far the + * object's own origin sits above the instance base), or null. @param {any} source */ +async function makeTemplate(source) { + const commands = await import('./commandsHandler.svelte'); + const group = /** @type {any} */ (get(objectsGroup)); + const before = new Set((group?.children ?? []).map((/** @type {any} */ c) => c.uuid)); + const fresh = () => (get(objectsGroup)?.children ?? []).find((/** @type {any} */ c) => !before.has(c.uuid)) ?? null; + if (source.kind === 'primitive') { + const spec = scatterPrimitiveSpec(source.type); + commands.sceneCommand(spec.command); + const object = fresh(); + if (!object) return null; + const materials = await import('./materialsHandler'); + materials.setObjectColor(object.uuid, spec.color); + materials.setMaterialParam(object.uuid, 'roughness', spec.roughness); + return { uuid: object.uuid, offset: spec.offset }; + } + if (source.kind === 'library') { + const explorer = await import('./explorer'); + const item = explorer.itemByHash(source.hash); + const blob = item ? await explorer.itemBlob(item.id) : null; + if (!item || !blob) return null; + const { importFile } = await import('./fileHandler.svelte'); + const uuid = await importFile(new File([blob], item.name), String(item.name).replace(/\.\w+$/, ''), undefined, [0, 0, 0]); + return uuid ? { uuid, offset: 0 } : null; + } + if (source.kind === 'prefab') { + // a logic prefab scatters its LOOK only (51-sources); a real object is the FULL prefab + const { prefabs, instantiatePrefab } = await import('./prefabs'); + const prefab = /** @type {any[]} */ (get(prefabs)).find((p) => p.id === source.id); + const object = prefab ? instantiatePrefab(prefab) : null; + return object ? { uuid: object.uuid, offset: 0 } : null; + } + return null; +} + +/** + * Instances → real objects. `scope` 'selection' = the Select tool's picks; 'set' = every + * instance of `setId` on terrain `uuid`. Resolves the new objects' uuids ([] when nothing + * was made or the person cancelled). + * @param {{scope: 'selection'} | {scope: 'set', uuid: string, setId: string}} what + * @param {{confirm?: boolean}} [opts] confirm:false skips the large-count ask (a suite) + */ +export async function instancesToObjects(what, opts = {}) { + /** @type {{uuid: string, data: any, items: {set: any, ids: Set}[]} | null} */ + let picked = null; + if (what.scope === 'selection') picked = selectedInstanceGroups(); + else { + const terrain = objectOf(what.uuid); + const data = terrain ? readScatter(terrain) : null; + const set = data?.sets.find((s) => s.id === what.setId); + if (data && set) picked = { uuid: what.uuid, data, items: [{ set, ids: new Set(set.list.map((/** @type {any} */ _, /** @type {number} */ i) => i)) }] }; + } + const terrain = picked ? objectOf(picked.uuid) : null; + if (!picked || !terrain || !picked.items.length) { + showToast('Select scattered instances first (Scatter ▸ Select)'); + return []; + } + if (!canEditObject(terrain)) { + warnViewerReadOnly(); + return []; + } + const count = picked.items.reduce((n, p) => n + p.ids.size, 0); + if (count > CONVERT_CONFIRM_ABOVE && opts.confirm !== false) { + const ok = await showConfirm({ + title: 'Make ' + count + ' objects?', + message: + count + + ' instances become ' + + count + + ' separate objects. Every one is synced, saved and drawn on its own — a scatter draws them together. One undo turns them back.', + confirmLabel: 'Make ' + count + ' objects' + }); + if (!ok) return []; + } + const terrainUuid = picked.uuid; + const before = terrain.userData.terrainScatter ? JSON.parse(JSON.stringify(terrain.userData.terrainScatter)) : null; + const grid = buildHeightGrid(terrain.geometry.attributes.position.array); + const own = !historyBatchOpen(); + if (own) beginHistoryBatch(); + /** @type {string[]} */ + const made = []; + try { + const { duplicateObject, deleteObjectsByUuid } = await import('./objectActions'); + for (const { set, ids } of picked.items) { + const template = await makeTemplate(set.source); + if (!template) { + showToast('Could not make ' + (set.source.name || set.source.type || 'the model') + ' — it is not in your Library'); + continue; + } + const lift = new THREE.Matrix4().makeTranslation(0, template.offset, 0); + for (const i of ids) { + const inst = set.list[i]; + if (!inst) continue; + const clone = duplicateObject(template.uuid, { select: false, history: false, carryCables: false }); + if (!clone) continue; + clone.name = objectOf(template.uuid)?.name ?? clone.name; + poseAt(clone, instanceWorldMatrix(terrain, grid, inst, set.align).multiply(lift)); + recordObjectPresence('create', clone); + made.push(clone.uuid); + } + deleteObjectsByUuid([template.uuid]); + } + // the converted instances leave their sets + const sets = picked.data.sets.map((/** @type {any} */ set) => { + const p = picked?.items.find((x) => x.set.id === set.id); + return p ? { ...set, list: deleteSelected(set.list, p.ids).list } : set; + }); + clearScatterSelection(); + commitScatter(terrainUuid, before, sets, picked.data.bounds); + } finally { + if (own) endHistoryBatch('Instances to objects'); + } + if (made.length) { + const { exitSculpt } = await import('./terrainSculpt'); + exitSculpt(); + const { applySelectionSet } = await import('./objectActions'); + applySelectionSet(made); + showToast(made.length === 1 ? 'Made 1 object' : 'Made ' + made.length + ' objects'); + } + return made; +} + +// ---- copies -> a scatter set -------------------------------------------------------------- + +const GEOMETRY_TYPE = /** @type {Record} */ ({ + BoxGeometry: 'Box', + SphereGeometry: 'Sphere', + ConeGeometry: 'Cone', + CylinderGeometry: 'Cylinder', + CapsuleGeometry: 'Capsule', + DodecahedronGeometry: 'Dodecahedron' +}); + +/** A Library model by the object's name (an import is named after its file; a duplicate + * adds " copy"). @param {any} object @param {any[]} items */ +function libraryItemOf(object, items) { + const base = String(object.name ?? '').replace(/( copy)+$/, ''); + const hits = items.filter((it) => it?.kind === 'object' && it.hash && String(it.name).replace(/\.\w+$/, '') === base); + return hits.length === 1 ? hits[0] : null; +} + +/** The scatter source an object would be, or null. @param {any} object @param {any[]} items */ +export function sourceOfObject(object, items) { + if (!object || object.userData?.terrain) return null; + if (object.isMesh && !Array.isArray(object.material)) { + const type = object.userData?.geometryParams?.gtype ?? GEOMETRY_TYPE[object.geometry?.type] ?? ''; + if (SCATTER_PRIMITIVES.includes(type)) return { kind: 'primitive', type }; + } + const item = libraryItemOf(object, items); + return item ? { kind: 'library', hash: item.hash, name: item.name } : null; +} + +/** The terrain the objects stand on: one in the list, else the one whose ground holds their + * centre. @param {any[]} objects */ +function terrainUnder(objects) { + const group = /** @type {any} */ (get(objectsGroup)); + const centre = new THREE.Vector3(); + for (const o of objects) centre.add(o.getWorldPosition(new THREE.Vector3())); + centre.divideScalar(Math.max(1, objects.length)); + /** @type {any} */ + let found = null; + group?.traverse((/** @type {any} */ t) => { + if (found || !t.userData?.terrain || !t.geometry) return; + const data = readScatter(t); + if (!data) return; + const p = t.worldToLocal(centre.clone()); + const b = data.bounds; + if (p.x >= b.minX && p.x <= b.minX + b.sizeX && p.z >= b.minZ && p.z <= b.minZ + b.sizeZ) found = t; + }); + return found; +} + +/** Can these objects become scatter? (the menu row asks; a reason when not) + * @param {string[]} uuids @param {any[]} items explorer items */ +export function scatterableSelection(uuids, items) { + const all = uuids.map(objectOf).filter(Boolean); + const terrains = all.filter((o) => o.userData?.terrain); + const objects = all.filter((o) => !o.userData?.terrain); + const verdict = commonSource(objects, (o) => { + const s = sourceOfObject(o, items); + return s ? sourceKey(s) : ''; + }); + if (!verdict.ok) return { ok: false, reason: verdict.reason }; + const terrain = terrains.length === 1 ? terrains[0] : terrainUnder(objects); + if (!terrain) return { ok: false, reason: 'The copies must stand on a Terrain' }; + return { ok: true, objects, terrain, source: /** @type {any} */ (sourceOfObject(objects[0], items)) }; +} + +/** Copies of one object → instances of that source's set on the terrain under them. + * Resolves the number of instances made. @param {string[]} uuids */ +export async function objectsToScatter(uuids) { + const explorer = await import('./explorer'); + const items = /** @type {any[]} */ (get(explorer.explorerItems)) ?? []; + const v = scatterableSelection(uuids, items); + if (!v.ok) { + showToast(/** @type {string} */ (v.reason)); + return 0; + } + const { objects, terrain, source } = /** @type {any} */ (v); + if (!canEditObject(terrain) || objects.some((/** @type {any} */ o) => !canEditObject(o))) { + warnViewerReadOnly(); + return 0; + } + const data = readScatter(terrain); + if (!data) return 0; + const key = sourceKey(source); + let set = data.sets.find((s) => sourceKey(s.source) === key) ?? null; + if (!set && data.sets.length >= MAX_SETS) { + showToast('A terrain holds up to ' + MAX_SETS + ' scatter models'); + return 0; + } + if (totalOf(data.sets) + objects.length > MAX_INSTANCES) { + showToast('That would pass ' + MAX_INSTANCES + ' instances on this terrain'); + return 0; + } + const before = terrain.userData.terrainScatter ? JSON.parse(JSON.stringify(terrain.userData.terrainScatter)) : null; + // a primitive's size relative to the scatter's unit primitive (an object made with + // /create Cone defaults is twice the scatter cone's radius): measure height in local units + const unit = source.kind === 'primitive' ? unitHeight(source.type) : 0; + terrain.updateWorldMatrix(true, false); + const tq = terrain.getWorldQuaternion(new THREE.Quaternion()).invert(); + const ts = terrain.getWorldScale(new THREE.Vector3()); + const list = objects.map((/** @type {any} */ o) => { + o.updateWorldMatrix(true, false); + const local = terrain.worldToLocal(o.getWorldPosition(new THREE.Vector3())); + const q = tq.clone().multiply(o.getWorldQuaternion(new THREE.Quaternion())); + const ws = o.getWorldScale(new THREE.Vector3()); + let scale = (ws.x + ws.y + ws.z) / 3 / ((ts.x + ts.y + ts.z) / 3); + if (unit && o.geometry) { + if (!o.geometry.boundingBox) o.geometry.computeBoundingBox(); + const bb = o.geometry.boundingBox; + scale *= (bb.max.y - bb.min.y) / unit; + } + return { x: local.x, z: local.z, yaw: yawOfQuaternion(q), scale }; + }); + const sets = set + ? data.sets.map((s) => (s === set ? { ...s, list: [...s.list, ...list] } : s)) + : [...data.sets, newSet(source, list)]; + const own = !historyBatchOpen(); + if (own) beginHistoryBatch(); + try { + commitScatter(terrain.uuid, before, sets, data.bounds); + const { deleteObjectsByUuid } = await import('./objectActions'); + deleteObjectsByUuid(objects.map((/** @type {any} */ o) => o.uuid)); + } finally { + if (own) endHistoryBatch('Objects to scatter'); + } + showToast(list.length + ' copies are now scatter on ' + (terrain.name || 'the terrain')); + return list.length; +} + +/** @param {string} type */ +function unitHeight(type) { + return 2 * scatterPrimitiveSpec(type).offset; +} diff --git a/src/lib/terrainSurface.js b/src/lib/terrainSurface.js index 8ce4e8dcb..4d53cc072 100644 --- a/src/lib/terrainSurface.js +++ b/src/lib/terrainSurface.js @@ -330,6 +330,38 @@ function primitiveParts(type) { return [{ geometry: geo, material: mat }]; } +/** 51 E5: what an instance of a primitive becomes as a REAL object — the `/create` command + * that builds the SAME geometry as primitiveParts (centred, the way /create builds it), how + * far its centre sits above the instance's base, and the scatter look (colour, roughness). + * @param {string} type @returns {{command: string, offset: number, color: string, roughness: number}} */ +export function scatterPrimitiveSpec(type) { + const args = /** @type {Record} */ ({ + Box: '1 1 1', + Sphere: '0.5 12 8', + Cone: '0.5 1 10', + Cylinder: '0.5 0.5 1 12', + Capsule: '0.3 0.4 4 10', + Dodecahedron: '0.5' + }); + const t = args[type] ? type : 'Dodecahedron'; + const geo = + t === 'Box' + ? new THREE.BoxGeometry(1, 1, 1) + : t === 'Sphere' + ? new THREE.SphereGeometry(0.5, 12, 8) + : t === 'Cone' + ? new THREE.ConeGeometry(0.5, 1, 10) + : t === 'Cylinder' + ? new THREE.CylinderGeometry(0.5, 0.5, 1, 12) + : t === 'Capsule' + ? new THREE.CapsuleGeometry(0.3, 0.4, 4, 10) + : new THREE.DodecahedronGeometry(0.5); + geo.computeBoundingBox(); + const offset = -(geo.boundingBox?.min.y ?? -0.5); + geo.dispose(); + return { command: `/create ${t} ${args[t]}`, offset, color: '#' + new THREE.Color(PRIM_COLOR[t] ?? 0x8a8a84).getHexString(), roughness: 0.9 }; +} + /** hash -> parts (null while loading / missing) */ /** @type {Map} */ const models = new Map(); diff --git a/tests/e2e/scatter-convert.test.cjs b/tests/e2e/scatter-convert.test.cjs new file mode 100644 index 000000000..36f79b017 --- /dev/null +++ b/tests/e2e/scatter-convert.test.cjs @@ -0,0 +1,211 @@ +// 51 E5 — CONVERT BOTH WAYS, two peers: picked instances -> real objects at their exact pose +// (one undo puts them back, on both peers), the confirm above 500 (Cancel makes nothing), +// copies of one object on a terrain -> instances of its scatter (one undo), and a save/reload +// that keeps the edited scatter. (The E4 editing tools: scatter-edit.) +const h = require('./helpers.cjs'); + +const SHOTS = process.env.SHOTS_DIR || ''; + +/** @param {any} page @param {string} uuid */ +const scatterOf = (page, uuid) => + page.evaluate( + (u) => + new Promise((resolve) => { + window.__stores.objectsGroup.subscribe((g) => { + const o = g?.getObjectByProperty('uuid', u); + resolve(o ? JSON.stringify(o.userData.terrainScatter ?? null) : 'missing'); + })(); + }), + uuid + ); +/** The decoded lists, per set id. @param {any} page @param {string} uuid */ +const listsOf = (page, uuid) => + page.evaluate((u) => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const t = g.getObjectByProperty('uuid', u); + const b = s.terrainSurface.terrainBounds(t); + const block = s.terrainScatterCore.normalizeScatter(t.userData.terrainScatter, s.terrainPaintCore.base64ToBytes); + const out = {}; + for (const set of block?.sets ?? []) out[set.id] = s.terrainScatterCore.decodeInstances(s.terrainPaintCore.base64ToBytes(set.data), b); + return out; + }, uuid); +const undoDepth = (page) => page.evaluate(() => new Promise((r) => window.__stores.history.undoStack.subscribe((s) => r(s.length))())); +const state = (page) => page.evaluate(() => window.__stores.scatterEdit.scatterEditState()); +/** @param {any} page */ +const objectCount = (page) => page.evaluate(() => new Promise((r) => window.__stores.objectsGroup.subscribe((g) => r(g.children.length))())); + +/** @param {any} page @param {number[]} pos @param {number[]} target */ +async function look(page, pos, target) { + await page.evaluate( + ([p, t]) => { + let cam, orbit; + window.__stores.editorCam.subscribe((v) => (cam = v))(); + window.__stores.orbitControls.subscribe((v) => (orbit = v))(); + cam.position.set(p[0], p[1], p[2]); + orbit?.target?.set(t[0], t[1], t[2]); + orbit?.update?.(); + cam.lookAt(t[0], t[1], t[2]); + }, + [pos, target] + ); +} + +/** One programmatic stroke of the armed tool at local (x, z). @param {any} page @param {string} uuid */ +async function stroke(page, uuid, x, z, steps = 10, dt = 0.05) { + await page.evaluate( + async ([u, x, z, steps, dt]) => { + const ts = window.__stores.terrainSculpt; + ts.beginStroke(u); + for (let i = 0; i < steps; i++) { + ts.strokeMove(u, x, z, dt); + await new Promise((r) => setTimeout(r, 10)); + } + ts.endStroke(); + }, + [uuid, x, z, steps, dt] + ); +} + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + + const uuid = await A.page.evaluate(async () => { + window.__stores.commandsHandler.sceneCommand('/create Terrain 24 48'); + const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + return group.children[group.children.length - 1].uuid; + }); + await h.eventually(() => scatterOf(B.page, uuid), (v) => v !== 'missing', 'B has the terrain', 20000); + await look(A.page, [0.5, 22, 3], [0, 0, 0]); + const entered = await A.page.evaluate((u) => { + const s = window.__stores; + const ok = s.terrainSculpt.enterSculpt(u); + s.terrainSculpt.sculptOp.set('scatter'); + s.terrainSculpt.sculptRadius.set(3); + s.terrainPaint.scatterDensity.set(1); + s.terrainPaint.scatterSource.set({ kind: 'primitive', type: 'Cone' }); + s.scatterEdit.setScatterTool('paint'); + return ok; + }, uuid); + h.check(entered, 'A enters the sculpt session, Scatter brush'); + await stroke(A.page, uuid, 0, 0, 6); + /** @type {any} */ + let lists = await listsOf(A.page, uuid); + h.check(lists['p:Cone']?.length > 10, `cones scattered (${lists['p:Cone']?.length})`); + let d0 = 0; + + // ---- E5 instances -> objects ------------------------------------------------------------------ + lists = await listsOf(A.page, uuid); + const five = lists['p:Cone'].slice(0, 5).map((_, i) => i); + const objsA = await objectCount(A.page); + d0 = await undoDepth(A.page); + const made = await A.page.evaluate(async ([u, ids]) => { + const s = window.__stores; + s.scatterEdit.setScatterTool('select'); + s.scatterEdit.setScatterSelection(u, new Map([['p:Cone', new Set(ids)]])); + const uuids = await s.scatterConvert.instancesToObjects({ scope: 'selection' }); + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const t = g.getObjectByProperty('uuid', u); + const grid = s.terrainScatterCore.buildHeightGrid(t.geometry.attributes.position.array); + return uuids.map((id) => { + const o = g.getObjectByProperty('uuid', id); + const p = o.position; + return { name: o.name, x: p.x, y: p.y, z: p.z, ground: s.terrainScatterCore.sampleHeight(grid, p.x, p.z).y, type: o.geometry?.type }; + }); + }, [uuid, five]); + h.check(made.length === 5 && made.every((m) => m.type === 'ConeGeometry'), `five instances became five Cone objects (${made.length})`); + const firstFive = lists['p:Cone'].slice(0, 5); + h.check( + made.every((m, k) => Math.abs(m.x - firstFive[k].x) < 0.02 && Math.abs(m.z - firstFive[k].z) < 0.02), + 'each object stands where its instance stood' + ); + h.check(made.every((m, k) => Math.abs(m.y - (m.ground + 0.5 * firstFive[k].scale)) < 0.03), 'its base on the ground (centre half a scaled cone up)'); + h.check((await objectCount(A.page)) === objsA + 5, 'A has five more objects (the template went)'); + h.check((await listsOf(A.page, uuid))['p:Cone'].length === lists['p:Cone'].length - 5, 'and five fewer instances'); + h.check((await undoDepth(A.page)) === d0 + 1, 'the whole conversion is ONE undo entry'); + await h.eventually(() => objectCount(B.page), (n) => n === objsA + 5, 'B has the five objects', 15000); + await A.page.evaluate(() => window.__stores.history.undo()); + h.check((await objectCount(A.page)) === objsA && (await listsOf(A.page, uuid))['p:Cone'].length === lists['p:Cone'].length, 'one undo: objects gone, instances back'); + await h.eventually(() => objectCount(B.page), (n) => n === objsA, 'on B too', 15000); + + // the confirm above 500 + const asked = await A.page.evaluate(async (u) => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const t = g.getObjectByProperty('uuid', u); + const b = s.terrainSurface.terrainBounds(t); + const block = JSON.parse(JSON.stringify(t.userData.terrainScatter)); + const many = Array.from({ length: 600 }, (_, i) => ({ x: -11 + (i % 30) * 0.7, z: -11 + Math.floor(i / 30) * 0.5, yaw: 0, scale: 1 })); + block.sets.push({ id: 'p:Capsule', source: { kind: 'primitive', type: 'Capsule' }, align: false, data: s.terrainPaintCore.bytesToBase64(s.terrainScatterCore.encodeInstances(many, b)), count: 600 }); + s.terrainSurface.applyTerrainScatter(u, block); + const pending = s.scatterConvert.instancesToObjects({ scope: 'set', uuid: u, setId: 'p:Capsule' }); + await new Promise((r) => setTimeout(r, 200)); + let dialog; + s.confirmDialog.confirmDialog.subscribe((v) => (dialog = v))(); + const title = dialog?.title ?? null; + s.confirmDialog.resolveConfirm(false); + const made = await pending; + return { title, made: made.length }; + }, uuid); + h.check(/600 objects/.test(asked.title ?? '') && asked.made === 0, `600 instances ask first ("${asked.title}") and Cancel makes nothing`); + + // ---- E5 copies -> a scatter set --------------------------------------------------------------- + const copies = await A.page.evaluate(async () => { + const s = window.__stores; + const out = []; + for (const [x, z] of [ + [3, -8], + [4, -8], + [5, -8.5], + [6, -8] + ]) { + s.commandsHandler.sceneCommand('/create Cone 0.5 1 10'); + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const o = g.children[g.children.length - 1]; + o.position.set(x, 0.5, z); + o.rotation.y = 0.3; + o.updateMatrixWorld(true); + out.push(o.uuid); + } + return out; + }); + const cBefore = (await listsOf(A.page, uuid))['p:Cone'].length; + const objsB4 = await objectCount(A.page); + d0 = await undoDepth(A.page); + const n = await A.page.evaluate((ids) => window.__stores.scatterConvert.objectsToScatter(ids), copies); + lists = await listsOf(A.page, uuid); + h.check(n === 4 && lists['p:Cone'].length === cBefore + 4, `four Cone copies became four instances (${cBefore} -> ${lists['p:Cone'].length})`); + const added = lists['p:Cone'].slice(-4); + h.check(Math.abs(added[0].x - 3) < 0.02 && Math.abs(added[0].z + 8) < 0.02 && Math.abs(added[0].yaw - 0.3) < 0.01 && Math.abs(added[0].scale - 1) < 0.01, 'at their ground position, turn and size'); + h.check((await objectCount(A.page)) === objsB4 - 4, 'the objects are gone'); + h.check((await undoDepth(A.page)) === d0 + 1, 'ONE undo entry'); + await h.eventually(async () => (await scatterOf(B.page, uuid)) === (await scatterOf(A.page, uuid)), (v) => v, 'B holds the new instances', 10000); + await h.eventually(() => objectCount(B.page), (k) => k === objsB4 - 4, 'and lost the objects', 10000); + const mixed = await A.page.evaluate(async ([a, b]) => window.__stores.scatterConvert.scatterableSelection([a, b], []), [uuid, uuid]); + h.check(mixed.ok === false, 'one object (or none) is refused with a reason'); + + // ---- save / reload keeps the edits -------------------------------------------------------------- + await A.page.evaluate(() => window.__stores.terrainSculpt.exitSculpt()); + const saved = await scatterOf(A.page, uuid); + const zip = await A.page.evaluate(async () => { + const s = window.__stores.sessions; + const out = await s.exportSessionZip(s.buildSessionPayload('Edited scatter')); + return Array.from(new Uint8Array(out instanceof Blob ? await out.arrayBuffer() : out)); + }); + await A.page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/clear all')); + await A.page.waitForTimeout(800); + await A.page.evaluate(async (arr) => { + const s = window.__stores.sessions; + await s.applySession(await s.readSessionZip(new Uint8Array(arr).buffer), { backup: false }); + }, zip); + await h.eventually(() => scatterOf(A.page, uuid), (v) => v === saved, 'the edited scatter survives a save/load round-trip', 15000); + + await h.finish(browser); +}); From 9199cbf7f0bf69d97dd9cfb84a4a57faf1fa1afc Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 22 Oct 2026 20:31:25 +0300 Subject: [PATCH 11/65] [feat] 51 device thinning: far scatter fades out to stay inside the device line - scatterThinCore.js (pure, vitest): thinPlan walks instances nearest first and stops where the triangle budget runs out; NEAR_KEEP 30 m is never thinned; the last 20 % to the cutoff shrinks smoothly (smoothstep), so the edge reads as a fade; scatterBudget = half the target's line; thinProfileOf picks quest / phone / desktop from the XR session and a coarse pointer. - scatterThin.js (runtime): every scatter InstancedMesh registers its full matrices, colours and ids; one plan per frame at most every 150 ms or after a 0.75 m move; kept instances are compacted to the front and `count` lowered; userData.instanceIds is rewritten in DRAW order so a pick maps instanceId right. LOCAL only: nothing is saved or sent, and colliders never read it (scatterPhysicsSets builds every instance from the set). - Settings > Scene > Performance "Distant scatter" (Auto / Headset / Phone / Desktop / All), safeStorage key scatterDetail. - budgetMeterCore: a thinned InstancedMesh counts userData.instanceTotal, so the meter shows the content, not what this device happens to draw. - e2e scatter-sources: desktop + N6 folded peers; params + seed in one message (317 bytes for 6000 instances); the N6 draws 1488 of 6000 inside its 250k line with every instance within 30 m drawn; a ball dropped on a rock the N6 does not draw rests on it there and at the same height on the desktop; hand edits + re-roll; a weighted mix through the real select; tint + sink; a prefab as a look-only Library source; a 1.38 block stored byte-identical. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../menu/settings/ScatterDetailSetting.svelte | 39 ++ .../menu/settings/SceneSettings.svelte | 2 + src/lib/budgetMeterCore.js | 3 +- src/lib/debugHooks.js | 1 + src/lib/scatterThin.js | 232 ++++++++++ src/lib/scatterThinCore.js | 89 ++++ src/lib/settingsIndex.js | 1 + src/lib/terrainSurface.js | 4 + tests/e2e/scatter-sources.test.cjs | 395 ++++++++++++++++++ tests/unit/scatterThin.test.js | 68 +++ 10 files changed, 833 insertions(+), 1 deletion(-) create mode 100644 src/components/menu/settings/ScatterDetailSetting.svelte create mode 100644 src/lib/scatterThin.js create mode 100644 src/lib/scatterThinCore.js create mode 100644 tests/e2e/scatter-sources.test.cjs create mode 100644 tests/unit/scatterThin.test.js diff --git a/src/components/menu/settings/ScatterDetailSetting.svelte b/src/components/menu/settings/ScatterDetailSetting.svelte new file mode 100644 index 000000000..b173c153f --- /dev/null +++ b/src/components/menu/settings/ScatterDetailSetting.svelte @@ -0,0 +1,39 @@ + + + + +
+ + scatterDetail.set(String(v))} /> + +
+ + diff --git a/src/components/menu/settings/SceneSettings.svelte b/src/components/menu/settings/SceneSettings.svelte index 087c8e504..e2b6d141e 100644 --- a/src/components/menu/settings/SceneSettings.svelte +++ b/src/components/menu/settings/SceneSettings.svelte @@ -49,6 +49,7 @@ import LoadingSettings from './LoadingSettings.svelte'; import PlacementSettings from './PlacementSettings.svelte'; // 39 P3 import WaterSettings from '../../water/WaterSettings.svelte'; + import ScatterDetailSetting from './ScatterDetailSetting.svelte'; // 51 import GpuFluidSetting from '../../sim/GpuFluidSetting.svelte'; // 49-fluid W2 import HdriSettings from '../../hdri/HdriSettings.svelte'; // 37-hdri import { showSimControls, duplicateCarriesAnimation, duplicateCarriesFlow, duplicateCarriesShader, showToast } from '../../../stores/appStore.js'; @@ -153,6 +154,7 @@ + diff --git a/src/lib/budgetMeterCore.js b/src/lib/budgetMeterCore.js index 0feca4cec..6af2031ed 100644 --- a/src/lib/budgetMeterCore.js +++ b/src/lib/budgetMeterCore.js @@ -283,7 +283,8 @@ export function measureScene(input) { if (node.castShadow) row.shadowLights++; } if (node.isMesh || node.isPoints || node.isLine || node.isSprite) { - const tris = node.isMesh ? trianglesOf(node.geometry) * (node.isInstancedMesh ? Math.max(0, node.count ?? 0) : 1) : 0; + // 51: a thinned scatter mesh counts every instance (the content, not this device's draw) + const tris = node.isMesh ? trianglesOf(node.geometry) * (node.isInstancedMesh ? Math.max(0, node.userData?.instanceTotal ?? node.count ?? 0) : 1) : 0; row.triangles += tris; let calls = callsOf(node); if (kitInstancing && node.isMesh && !node.isInstancedMesh) { diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index f6ace97d9..3169cd803 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -115,6 +115,7 @@ export const DEBUG_HOOKS = [ ['terrainScatterCore', () => import('./terrainScatterCore')], // 51 E1: the scatter source catalog (pack items, prefabs + objects as their look) ['scatterSources', () => import('./scatterSources')], + ['scatterThin', () => import('./scatterThin')], ['userModules', () => import('./userModules')], ['environment', () => import('./environment')], // 37-hdri: the HDRI layer (hdriDebug, installHdriLayer) + its leaf stores (skyEnv, envToneMapping, hdriStatus) + the pure core diff --git a/src/lib/scatterThin.js b/src/lib/scatterThin.js new file mode 100644 index 000000000..fab87a05f --- /dev/null +++ b/src/lib/scatterThin.js @@ -0,0 +1,232 @@ +import { writable, get } from 'svelte/store'; +import { globalCamera } from '../stores/sceneStore'; +import { safeStorage } from './safeStorage'; +import { defaults, targets } from '../../perf/budgets.json'; +import { resolveTargets } from './budgetMeterCore'; +import { thinPlan, fadeAt, scatterBudget, thinProfileOf, NEAR_KEEP } from './scatterThinCore'; + +// 51 — DEVICE THINNING, the runtime: every scatter InstancedMesh registers its full instance +// list here (terrainSurface.layoutScatter), and once per frame — throttled to a few re-plans a +// second, and only when the viewer moved — the planner (scatterThinCore) picks the distance past +// which this device stops drawing, and each mesh writes the instances it keeps (fading the edge) +// to the front of its buffer and lowers its `count`. +// +// LOCAL, all of it: what this device draws is a fact about this device. Nothing here is saved or +// sent, the layout is untouched, and a collider never reads it — scatterPhysicsSets builds every +// instance from the set itself, so a ball hits the same rock on a phone as on a desktop. +// `userData.instanceIds` is rewritten in DRAW order, so a pick maps `instanceId` to the right +// instance whatever was thinned. + +const KEY = 'scatterDetail'; +// declared ABOVE the subscribe below: it runs synchronously at module eval (the TDZ rule) +let dirty = true; +/** 'auto' (read the device) | 'quest' | 'phone' | 'desktop' | 'off' (draw everything). LOCAL. */ +export const scatterDetail = writable(readChoice()); +scatterDetail.subscribe((v) => { + try { + safeStorage.setItem(KEY, v); + } catch { + /* private mode: the pick lasts the session */ + } + dirty = true; +}); + +function readChoice() { + try { + const v = safeStorage.getItem(KEY); + return v && ['auto', 'quest', 'phone', 'desktop', 'off'].includes(v) ? v : 'auto'; + } catch { + return 'auto'; + } +} + +const TARGETS = resolveTargets({ defaults, targets }); + +/** @typedef {{base: Float32Array, colors: Float32Array | null, ids: string[], pos: Float32Array, tris: number, thinned: boolean}} ThinData */ + +/** every scatter mesh that can be thinned @type {Set} */ +const meshes = new Set(); +let lastFrame = -1; +let lastPlanAt = 0; +const lastCam = { x: NaN, y: NaN, z: NaN }; +/** @type {{profile: string | null, budget: number, cutoff: number, fadeStart: number, total: number, drawnTris: number, drawn: number, instances: number}} */ +let last = { profile: null, budget: Infinity, cutoff: Infinity, fadeStart: Infinity, total: 0, drawnTris: 0, drawn: 0, instances: 0 }; + +const REPLAN_MS = 150; +const MOVE_M = 0.75; + +/** + * Register a freshly laid-out mesh: its full matrices, colours and ids are kept beside it, and the + * per-frame hook drives the plan. @param {any} mesh @param {number} tris triangles per instance + */ +export function registerThinMesh(mesh, tris) { + const n = mesh.count; + const base = new Float32Array(mesh.instanceMatrix.array.subarray(0, n * 16)); + const pos = new Float32Array(n * 3); + for (let i = 0; i < n; i++) { + pos[i * 3] = base[i * 16 + 12]; + pos[i * 3 + 1] = base[i * 16 + 13]; + pos[i * 3 + 2] = base[i * 16 + 14]; + } + /** @type {ThinData} */ + const data = { + base, + colors: mesh.instanceColor ? new Float32Array(mesh.instanceColor.array.subarray(0, n * 3)) : null, + ids: [...(mesh.userData.instanceIds ?? [])], + pos, + tris, + thinned: false + }; + mesh.userData.thin = data; + // the meter's content estimate counts every instance, not what this device draws + mesh.userData.instanceTotal = n; + mesh.onBeforeRender = (/** @type {any} */ renderer) => thinFrame(renderer); + meshes.add(mesh); + dirty = true; +} + +/** @param {any} mesh */ +export function unregisterThinMesh(mesh) { + meshes.delete(mesh); + dirty = true; +} + +/** this device: in a headset? a touch-first screen? @param {any} renderer */ +function deviceOf(renderer) { + let coarse = false; + try { + coarse = typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches; + } catch { + /* no media queries */ + } + return { xr: !!renderer?.xr?.isPresenting, coarse }; +} + +const _cam = { x: 0, y: 0, z: 0 }; + +/** the viewer's world position: the XR camera in a headset, else the main camera + * @param {any} renderer @returns {boolean} */ +function viewer(renderer) { + const cam = renderer?.xr?.isPresenting ? renderer.xr.getCamera() : /** @type {any} */ (get(globalCamera)); + const e = cam?.matrixWorld?.elements; + if (!e) return false; + _cam.x = e[12]; + _cam.y = e[13]; + _cam.z = e[14]; + return true; +} + +/** Once per frame (the first scatter mesh drawn calls it; the shadow pass and the other meshes + * of the frame find it done). @param {any} renderer */ +export function thinFrame(renderer) { + const frame = renderer?.info?.render?.frame ?? -1; + if (frame === lastFrame) return; + lastFrame = frame; + const now = performance.now(); + if (!dirty && now - lastPlanAt < REPLAN_MS) return; + if (!viewer(renderer)) return; + const moved = Math.hypot(_cam.x - lastCam.x, _cam.y - lastCam.y, _cam.z - lastCam.z); + if (!dirty && !(moved > MOVE_M)) return; + lastPlanAt = now; + lastCam.x = _cam.x; + lastCam.y = _cam.y; + lastCam.z = _cam.z; + dirty = false; + replan(thinProfileOf(get(scatterDetail), deviceOf(renderer))); +} + +/** @type {Float32Array} */ let dists = new Float32Array(0); +/** @type {Float32Array} */ let tris = new Float32Array(0); + +/** @param {string | null} profile */ +function replan(profile) { + const list = [...meshes].filter((m) => m.parent && m.userData.thin); + let n = 0; + for (const m of list) n += m.userData.thin.ids.length; + if (dists.length < n) { + dists = new Float32Array(n); + tris = new Float32Array(n); + } + let k = 0; + for (const m of list) { + const t = /** @type {ThinData} */ (m.userData.thin); + const w = m.parent.matrixWorld.elements; + for (let i = 0; i < t.ids.length; i++) { + const x = t.pos[i * 3], y = t.pos[i * 3 + 1], z = t.pos[i * 3 + 2]; + const wx = w[0] * x + w[4] * y + w[8] * z + w[12]; + const wy = w[1] * x + w[5] * y + w[9] * z + w[13]; + const wz = w[2] * x + w[6] * y + w[10] * z + w[14]; + dists[k] = Math.hypot(wx - _cam.x, wy - _cam.y, wz - _cam.z); + tris[k] = t.tris; + k++; + } + } + const target = profile ? /** @type {any} */ (TARGETS)[profile] : null; + const budget = target ? scatterBudget(target.triangles) : Infinity; + const plan = thinPlan(dists.subarray(0, n), tris.subarray(0, n), budget, NEAR_KEEP); + let drawn = 0; + k = 0; + for (const m of list) { + const t = /** @type {ThinData} */ (m.userData.thin); + drawn += apply(m, t, plan, k); + k += t.ids.length; + } + last = { profile, budget, cutoff: plan.cutoff, fadeStart: plan.fadeStart, total: plan.total, drawnTris: plan.drawnTris, drawn, instances: n }; +} + +/** Write the kept instances of one mesh (dists from `offset`). @param {any} mesh @param {ThinData} t + * @param {{cutoff: number, fadeStart: number}} plan @param {number} offset @returns {number} drawn */ +function apply(mesh, t, plan, offset) { + const n = t.ids.length; + if (plan.cutoff === Infinity) { + if (!t.thinned) return n; + mesh.instanceMatrix.array.set(t.base); + if (t.colors && mesh.instanceColor) mesh.instanceColor.array.set(t.colors); + mesh.count = n; + mesh.userData.instanceIds = [...t.ids]; + mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; + t.thinned = false; + return n; + } + const out = mesh.instanceMatrix.array; + const cols = t.colors && mesh.instanceColor ? mesh.instanceColor.array : null; + /** @type {string[]} */ + const ids = []; + let j = 0; + for (let i = 0; i < n; i++) { + const f = fadeAt(dists[offset + i], plan); + if (f <= 0) continue; + const b = i * 16; + const o = j * 16; + // scale the 3x3 part (about the instance's own origin, which stands on the ground) + for (let c = 0; c < 12; c++) out[o + c] = c % 4 === 3 ? t.base[b + c] : t.base[b + c] * f; + out[o + 12] = t.base[b + 12]; + out[o + 13] = t.base[b + 13]; + out[o + 14] = t.base[b + 14]; + out[o + 15] = t.base[b + 15]; + if (cols && t.colors) { + cols[j * 3] = t.colors[i * 3]; + cols[j * 3 + 1] = t.colors[i * 3 + 1]; + cols[j * 3 + 2] = t.colors[i * 3 + 2]; + } + ids.push(t.ids[i]); + j++; + } + mesh.count = j; + mesh.userData.instanceIds = ids; + mesh.instanceMatrix.needsUpdate = true; + if (cols) mesh.instanceColor.needsUpdate = true; + t.thinned = true; + return j; +} + +/** Debug / suite: the current plan and what is drawn. */ +export function scatterThinStats() { + return { ...last, meshes: meshes.size }; +} + +/** Suite: re-plan on the next frame whatever moved. */ +export function thinNow() { + dirty = true; +} diff --git a/src/lib/scatterThinCore.js b/src/lib/scatterThinCore.js new file mode 100644 index 000000000..bcdc740b3 --- /dev/null +++ b/src/lib/scatterThinCore.js @@ -0,0 +1,89 @@ +// 51 — DEVICE THINNING, the pure half (imports NOTHING; unit-tested in tests/unit/scatterThin.test.js). +// +// The user's ruling (2026-10-21): the SAME layout everywhere; a weaker device draws fewer FAR +// instances, faded out by distance, so the scatter stays inside the budget meter's line for that +// device — and the NEAR instances and every COLLIDER stay identical, so gameplay matches. +// +// THE PLAN is one distance: walk the instances nearest first, adding each one's triangles, and +// stop where the budget runs out — that distance is the CUTOFF. Everything inside NEAR_KEEP is +// drawn whatever it costs (what you can walk into is never thinned). The last FADE_BAND share of +// the way to the cutoff shrinks an instance smoothly to nothing, so the edge reads as a fade +// rather than a line of rocks popping as you walk. + +/** metres around the viewer that are never thinned */ +export const NEAR_KEEP = 30; +/** the share of the distance to the cutoff over which an instance shrinks away */ +export const FADE_BAND = 0.2; +/** the share of a device's triangle line scatter may take (the rest is the scene's own) */ +export const SCATTER_SHARE = 0.5; + +/** @typedef {'quest' | 'phone' | 'desktop'} ThinProfile */ + +/** + * The triangle budget scatter gets on a device: its share of the target's line, raised to what + * is left once the rest of the scene is counted when that is known (`others`, the scene's own + * triangles) — never below a quarter of the line, never above the whole of it. + * @param {number} line the target's triangle budget (budgets.json) @param {number} [others] + */ +export function scatterBudget(line, others) { + if (!Number.isFinite(line) || line <= 0) return Infinity; + if (!Number.isFinite(others) || /** @type {number} */ (others) < 0) return line * SCATTER_SHARE; + return Math.min(line, Math.max(line * 0.25, line - /** @type {number} */ (others))); +} + +/** + * Where the drawn scatter ends. `dists[i]` = instance i's distance to the viewer (metres), and + * `tris[i]` its triangles (every part it draws). Infinity = nothing to thin. + * @param {ArrayLike} dists @param {ArrayLike} tris @param {number} budget + * @param {number} [near] @returns {{cutoff: number, fadeStart: number, drawnTris: number, total: number}} + */ +export function thinPlan(dists, tris, budget, near = NEAR_KEEP) { + const n = dists.length; + let total = 0; + for (let i = 0; i < n; i++) total += tris[i]; + if (!(total > budget)) return { cutoff: Infinity, fadeStart: Infinity, drawnTris: total, total }; + const order = new Uint32Array(n); + for (let i = 0; i < n; i++) order[i] = i; + order.sort((a, b) => dists[a] - dists[b]); + let sum = 0; + let cutoff = near; + for (let k = 0; k < n; k++) { + const i = order[k]; + const d = dists[i]; + if (d > near && sum + tris[i] > budget) { + // the cutoff sits half way to the first instance that does not fit, so it is drawn + // by nobody and the last one that fits is inside + const prev = k ? dists[order[k - 1]] : 0; + cutoff = Math.max(near, (prev + d) / 2); + break; + } + sum += tris[i]; + cutoff = d + 1e-6; + if (k === n - 1) cutoff = Infinity; + } + // a fade shrinks what it draws, so the band costs no more than the plan already counted + const fadeStart = cutoff === Infinity ? Infinity : Math.max(near, cutoff * (1 - FADE_BAND)); + return { cutoff, fadeStart, drawnTris: sum, total }; +} + +/** An instance's scale at distance d under a plan: 1 inside the fade, 0 past the cutoff, a + * smoothstep in between. @param {number} d @param {{cutoff: number, fadeStart: number}} plan */ +export function fadeAt(d, plan) { + if (d <= plan.fadeStart) return 1; + if (d >= plan.cutoff) return 0; + const t = (plan.cutoff - d) / (plan.cutoff - plan.fadeStart); + return t * t * (3 - 2 * t); +} + +/** + * Which device profile a viewer is: a headset in an immersive session, a touch-first screen (the + * N6, folded or unfolded), else a desktop. `choice` is the LOCAL setting ('auto' reads the device; + * 'off' draws everything). + * @param {string} choice @param {{xr: boolean, coarse: boolean}} device + * @returns {ThinProfile | null} null = no thinning + */ +export function thinProfileOf(choice, device) { + if (choice === 'off') return null; + if (choice === 'quest' || choice === 'phone' || choice === 'desktop') return choice; + return device.xr ? 'quest' : device.coarse ? 'phone' : 'desktop'; +} diff --git a/src/lib/settingsIndex.js b/src/lib/settingsIndex.js index 92809dffe..3a42d0304 100644 --- a/src/lib/settingsIndex.js +++ b/src/lib/settingsIndex.js @@ -89,6 +89,7 @@ export const SETTINGS_ROWS = [ ["Scene", "Placement preview", "What an item dragged in from the Explorer shows before you drop it: the full model, the model within a triangle budget, or its box"], ["Scene", "Preview triangle budget", "Above this a loaded model previews as its box while you drag it in (50k on a desktop, 15k on a phone or headset)"], ["Scene", "Show dimensions", "Width × depth × height in metres, and the triangles and download size, beside the pointer while you drag an item in"], + ["Scene", "Distant scatter", "Far scattered models fade out to keep this device inside its budget; near ones and their colliders never change"], ["Scene", "Water quality", "High draws refraction, caustics, shoreline foam and planar mirrors at full resolution; Medium at half; Low…"], ["Scene", "Simulation controls", "Show the physics transport (play/pause/stop/reset) at bottom-right."], ["Scene", "Sync animations", "Node animations use wall-clock time so all peers see the same phase"], diff --git a/src/lib/terrainSurface.js b/src/lib/terrainSurface.js index a90dfd69a..71b590d54 100644 --- a/src/lib/terrainSurface.js +++ b/src/lib/terrainSurface.js @@ -6,6 +6,7 @@ import { decodeSplatBlock, encodeSplatBlock, normalizeLayers, baseIsMaterial, ba import { normalizeScatter, instancesOf, blockVersion, buildHeightGrid, sampleHeight, MAX_INSTANCES } from './terrainScatterCore'; import { lodObject, releaseOwner } from './lod'; import { EXPLICIT_MIN_TRIANGLES } from './lodCore'; +import { registerThinMesh, unregisterThinMesh } from './scatterThin'; // 47 T1 + T2 — HOW A PAINTED / SCATTERED TERRAIN LOOKS, on every peer. A LEAF: three, the // scene store and the two pure cores; no history, no peers (terrainPaint.js is the write @@ -459,6 +460,7 @@ function makeHolder(terrain) { function clearHolder(holder) { releaseOwner(holder); // 47-int: the cells' LOD entries (lod.js holds them until their owner lets go) for (const child of [...holder.children]) { + unregisterThinMesh(child); holder.remove(child); child.dispose?.(); } @@ -574,6 +576,8 @@ function layoutScatter(object, holder, sets) { mesh.computeBoundingSphere(); holder.add(mesh); if (lodded) lodObject(mesh, {}, holder); + // 51: far instances thin to this device's budget (draws only — colliders read the set) + registerThinMesh(mesh, trianglesOfGeo(part.geometry)); } } }); diff --git a/tests/e2e/scatter-sources.test.cjs b/tests/e2e/scatter-sources.test.cjs new file mode 100644 index 000000000..dbf970103 --- /dev/null +++ b/tests/e2e/scatter-sources.test.cjs @@ -0,0 +1,395 @@ +// 51-sources — THE SCATTER SET FORMAT, E1 SOURCES AND DEVICE THINNING, two peers: A a desktop, B an +// emulated OPPO Find N6 (folded, touch, mobile viewport — "Auto" thins as a phone there). +// §1 a seeded set travels as params + seed (a few hundred bytes) and B rebuilds the SAME instances +// §2 thinning: the desktop draws every instance; the N6 draws fewer, the FAR ones, inside its budget, +// every instance within NEAR_KEEP of its camera still drawn, the edge fading; "All" draws everything +// §3 colliders are identical: a ball dropped on a rock the N6 does NOT draw rests on it there, and at +// the same height on the desktop (counterfactual: the collider count = every instance, not the drawn) +// §4 hand edits + re-roll: a moved slot stays where it was put on both peers, the rest re-roll +// §5 a mixed set through the real "Add a model to the mix" select; weights replicate +// §6 tint + sink replicate and land in the drawn pose / colours; one undo per change +// §7 a prefab with logic scatters as its LOOK only (a Library GLB + the note); B pulls the bytes +// §8 "Use selected object" → the brush source + the look-only note; the object menu offers it +// §9 a 1.38 block loads byte-identical +// §10 a pack item as a source (skips, never fails, when the pack CDN is unreachable) +const h = require('./helpers.cjs'); + +const N6_FOLDED = { viewport: { width: 390, height: 896 }, deviceScaleFactor: 2.9, hasTouch: true, isMobile: true }; + +/** every instance a terrain's set draws (ids + rounded poses) @param {any} page */ +const instancesOf = (page, uuid, setId) => + page.evaluate(([u, s]) => window.__stores.terrainPaint.scatterInstancesOf(u, s).map((i) => [i.id, +i.x.toFixed(3), +i.z.toFixed(3), +i.yaw.toFixed(3), +i.scale.toFixed(3), i.src]), [uuid, setId]); + +/** the drawn scatter of a terrain: per set, the instance ids in draw order, per source geometry type */ +const drawnOf = (page, uuid) => + page.evaluate((u) => { + let scene = null; + window.__stores.globalScene.subscribe((v) => (scene = v))(); + /** @type {Record} */ + const out = {}; + for (const holder of scene?.children ?? []) { + if (holder.userData?.terrainScatterOf !== u) continue; + for (const m of holder.children) { + const set = m.userData.scatterSet; + const e = (out[set] ??= { ids: [], meshes: 0, types: [], colors: false }); + e.meshes++; + e.ids.push(...(m.userData.instanceIds ?? []).slice(0, m.count)); + e.types.push(m.geometry.type); + if (m.instanceColor) e.colors = true; + } + } + return out; + }, uuid); + +const blockOf = (page, uuid) => + page.evaluate((u) => { + let g = null; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + const o = g?.getObjectByProperty('uuid', u); + return o ? (o.userData.terrainScatter ?? null) : 'missing'; + }, uuid); + +const stats = (page) => + page.evaluate(async () => { + window.__stores.scatterThin.thinNow(); + await new Promise((r) => setTimeout(r, 600)); + return window.__stores.scatterThin.scatterThinStats(); + }); + +const posOf = (page, uuid) => + page.evaluate((u) => { + let g = null; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + return g?.getObjectByProperty('uuid', u)?.position.toArray() ?? null; + }, uuid); + +/** run a sim on `peer` until the ball rests; returns its height + the scatter collider debug */ +async function drop(peer, ball, at) { + await peer.page.evaluate( + ([u, p]) => { + let g = null; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + const o = g.getObjectByProperty('uuid', u); + o.position.set(p[0], p[1], p[2]); + o.rotation.set(0, 0, 0); + o.userData.physics = { mode: 'dynamic', mass: 1 }; + window.__stores.objectsGroup.update((v) => v); + window.__stores.objectActions.deselectObject(); + }, + [ball, at] + ); + await peer.page.evaluate(() => window.__stores.physics.warmup().catch(() => {})); + const started = await peer.page.evaluate(async () => { + const ph = window.__stores.physics; + await ph.toggleSimulation(); + let sim = null; + let remote = null; + ph.simulating.subscribe((v) => (sim = v))(); + ph.remoteSimulating?.subscribe?.((v) => (remote = v))(); + return { sim, remote }; + }); + console.log(` ${peer.id} toggleSimulation -> ${JSON.stringify(started)}`); + // rapier loads lazily on each page: wait for the run to really start (and its scatter colliders) + await h.eventually( + () => peer.page.evaluate(() => (window.__stores.physics.physicsWorldDebug()?.scatter ?? []).length), + (n) => n > 0, + peer.id + ' started a simulation with the scatter colliders', + 30000 + ); + await peer.page.waitForTimeout(800); + let last = null; + let still = 0; + const deadline = Date.now() + 30000; + while (Date.now() < deadline && still < 3) { + await peer.page.waitForTimeout(500); + const y = (await posOf(peer.page, ball))?.[1] ?? null; + still = last !== null && y !== null && Math.abs(y - last) < 0.004 ? still + 1 : 0; + last = y; + } + const dbg = await peer.page.evaluate(() => window.__stores.physics.physicsWorldDebug().scatter); + await peer.page.evaluate(() => window.__stores.physics.stopSimulation()); + await peer.page.waitForTimeout(500); + return { y: last, dbg, rested: still >= 3 }; +} + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + { + const warm = await h.setupPage(browser, 'warm'); + await warm.page.evaluate(() => window.__stores.physics.warmup().catch(() => {})); + await warm.page.waitForTimeout(3000); + await warm.ctx.close(); + } + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B', { context: N6_FOLDED }); + // A dials: the N6 layout keeps the dial box behind the phone bar + await h.connect(A, B); + + // ---- §1 a seeded set: params + seed travel, B rebuilds the same instances ---------------- + const terrain = await A.page.evaluate(async () => { + const s = window.__stores; + s.commandsHandler.sceneCommand('/create Terrain 200 48'); + const group = await new Promise((r) => s.objectsGroup.subscribe(r)()); + const t = group.children[group.children.length - 1]; + t.userData.physics = { collider: 'trimesh' }; + s.objectsGroup.update((v) => v); + s.objectActions.deselectObject(); + return t.uuid; + }); + await h.eventually(() => blockOf(B.page, terrain), (v) => v !== 'missing', 'B has the terrain', 20000); + /** what A sends for the set */ + const sent = []; + await A.page.evaluate(() => { + window.__sent51 = []; + window.__stores.peers.subscribe((p) => { + const orig = p.send.bind(p); + p.send = (m) => { + if (m?.parameter === 'terrainScatter') window.__sent51.push(JSON.stringify(m).length); + return orig(m); + }; + })(); + }); + const setId = await A.page.evaluate( + (u) => + window.__stores.terrainPaint.addScatterSet(u, { + id: 'rocks', + source: { kind: 'primitive', type: 'Sphere' }, + seed: 777, + gen: { count: 6000, scaleMin: 1, scaleMax: 1, rot: true }, + collider: 'box' + }), + terrain + ); + sent.push(...(await A.page.evaluate(() => window.__sent51))); + h.check(setId === 'rocks', `addScatterSet made the set (${setId})`); + h.check(sent.length === 1 && sent[0] < 600, `ONE message, params + seed only (${sent.join(',')} bytes for 6000 instances)`); + await h.eventually(() => blockOf(B.page, terrain), (v) => v?.sets?.[0]?.seed === 777, 'B has the set', 15000); + const onA = await instancesOf(A.page, terrain, 'rocks'); + const onB = await instancesOf(B.page, terrain, 'rocks'); + h.check(onA.length === 6000, `A generates 6000 instances (${onA.length})`); + h.check(JSON.stringify(onA) === JSON.stringify(onB), `B rebuilds the SAME instances from the seed (ids + poses, ${onB.length})`); + h.check(onA[0][0] === 'g0' && onA[5999][0] === 'g5999', `ids are the slots (${onA[0][0]} … ${onA[5999][0]})`); + + // ---- §2 thinning: desktop draws all, the N6 the near ones --------------------------------- + const sA = await stats(A.page); + const sB = await stats(B.page); + h.check(sA.profile === 'desktop' && sA.drawn === sA.instances, `desktop: profile ${sA.profile}, draws all (${sA.drawn}/${sA.instances})`); + const dA = await drawnOf(A.page, terrain); + h.check(dA.rocks?.ids.length === 6000, `desktop meshes draw every instance (${dA.rocks?.ids.length})`); + h.check(sB.profile === 'phone', `the N6 (Auto) thins as a phone (${sB.profile})`); + h.check(sB.drawn < sB.instances && sB.drawn > 100, `the N6 draws fewer (${sB.drawn} of ${sB.instances})`); + h.check(sB.drawnTris <= sB.budget, `…inside its budget (${sB.drawnTris} of ${sB.budget} triangles; all ${sB.total})`); + const near = await B.page.evaluate( + ([u, s]) => { + let cam = null; + window.__stores.globalCamera.subscribe((v) => (cam = v))(); + const e0 = cam.matrixWorld.elements; + const p = { x: e0[12], y: e0[13], z: e0[14] }; + let scene = null; + window.__stores.globalScene.subscribe((v) => (scene = v))(); + const drawn = new Set(); + let fading = 0; + for (const holder of scene.children) + if (holder.userData?.terrainScatterOf === u) + for (const m of holder.children) { + const ids = m.userData.instanceIds.slice(0, m.count); + ids.forEach((id) => drawn.add(id)); + const e = m.instanceMatrix.array; + for (let i = 0; i < m.count; i++) if (Math.hypot(e[i * 16], e[i * 16 + 1], e[i * 16 + 2]) < 0.98) fading++; + } + const all = window.__stores.terrainPaint.scatterInstancesOf(u, s); + const nearIds = all.filter((i) => Math.hypot(i.x - p.x, p.y, i.z - p.z) < 30).map((i) => i.id); + const far = all.map((i) => [i.id, Math.hypot(i.x - p.x, p.y, i.z - p.z)]).sort((a, b) => b[1] - a[1]); + return { near: nearIds.length, missing: nearIds.filter((id) => !drawn.has(id)).length, fading, farthestDrawn: drawn.has(far[0][0]), farId: far[0][0] }; + }, + [terrain, 'rocks'] + ); + h.check(near.near > 0 && near.missing === 0, `every instance within 30 m of the N6 camera is drawn (${near.near}, missing ${near.missing})`); + h.check(!near.farthestDrawn, `the farthest instance (${near.farId}) is not drawn on the N6`); + h.check(near.fading > 0, `the edge fades (${near.fading} instances drawn shrunk)`); + await B.page.evaluate(() => window.__stores.scatterThin.scatterDetail.set('off')); + const sOff = await stats(B.page); + h.check(sOff.drawn === sOff.instances && sOff.profile === null, `"All" on the N6 draws every instance (${sOff.drawn})`); + await B.page.evaluate(() => window.__stores.scatterThin.scatterDetail.set('auto')); + const sBack = await stats(B.page); + h.check(sBack.drawn === sB.drawn, `back to Auto thins again (${sBack.drawn})`); + const meter = await B.page.evaluate(() => window.__stores.budgetMeter.sampleBudget()?.totals?.triangles ?? null); + h.check(meter !== null && meter >= sB.total, `the budget meter still counts every instance (${meter} ≥ ${sB.total})`); + + // ---- §3 colliders identical: a ball on a rock the N6 does not draw ------------------------- + const far = await B.page.evaluate(([u, id]) => window.__stores.terrainPaint.scatterInstancesOf(u, 'rocks').find((i) => i.id === id), [terrain, near.farId]); + const ball = await A.page.evaluate(async () => { + const s = window.__stores; + s.commandsHandler.sceneCommand('/create sphere'); + await new Promise((r) => setTimeout(r, 1200)); + const group = await new Promise((r) => s.objectsGroup.subscribe(r)()); + const b = group.children[group.children.length - 1]; + b.scale.set(0.4, 0.4, 0.4); + s.objectsGroup.update((v) => v); + return b.uuid; + }); + await h.eventually(() => posOf(B.page, ball), (v) => !!v, 'B has the ball', 15000); + // A made (and so selected) the ball: a peer's selection is a LOCK, and a locked object is + // left out of the simulation — let it go and wait for B to see it unlocked + await A.page.evaluate(() => window.__stores.objectActions.deselectObject()); + await h.eventually( + () => B.page.evaluate((u) => { + let l = []; + window.__stores.lockedObjects.subscribe((v) => (l = v))(); + return !l.some((e) => e[1] === u); + }, ball), + (v) => v === true, + 'B sees the ball unlocked', + 10000 + ); + const over = [far.x, 4, far.z]; + const onPhone = await drop(B, ball, over); + h.check(onPhone.dbg?.[0]?.colliders === 6000, `the N6's run builds a collider for EVERY instance, drawn or not (${JSON.stringify(onPhone.dbg)})`); + h.check(onPhone.rested && onPhone.y > 0.9 && onPhone.y < 3.5, `N6: the ball rests ON the undrawn far rock (y ${onPhone.y?.toFixed(2)}; the ground would be ~0.2)`); + await A.page.waitForTimeout(1500); + const onDesk = await drop(A, ball, over); + h.check(onDesk.dbg?.[0]?.colliders === 6000, `the desktop's run builds the same colliders (${JSON.stringify(onDesk.dbg)})`); + h.check(onDesk.rested && Math.abs(onDesk.y - onPhone.y) < 0.05, `desktop: the same hit, the same height (${onDesk.y?.toFixed(3)} vs ${onPhone.y?.toFixed(3)})`); + + // ---- §4 hand edits + re-roll keep the edits -------------------------------------------------- + await A.page.evaluate((u) => window.__stores.terrainPaint.editScatterInstances(u, 'rocks', { g5: { x: 1.5, z: -2.5, yaw: 0.5 }, g6: { del: true } }), terrain); + await h.eventually(() => blockOf(B.page, terrain), (v) => !!v?.sets?.[0]?.ov?.['5'], 'B has the edits', 10000); + const before = await instancesOf(B.page, terrain, 'rocks'); + await A.page.evaluate((u) => window.__stores.terrainPaint.rerollScatterSet(u, 'rocks', 4242), terrain); + await h.eventually(() => blockOf(B.page, terrain), (v) => v?.sets?.[0]?.seed === 4242, 'B has the re-roll', 10000); + const after = await instancesOf(B.page, terrain, 'rocks'); + const g5 = after.find((i) => i[0] === 'g5'); + h.check(g5 && g5[1] === 1.5 && g5[2] === -2.5 && g5[3] === 0.5, `the moved slot stays where it was put after the re-roll (${JSON.stringify(g5)})`); + h.check(!after.some((i) => i[0] === 'g6') && after.length === 5999, `the deleted slot stays deleted (${after.length})`); + h.check(after.find((i) => i[0] === 'g7')?.[1] !== before.find((i) => i[0] === 'g7')?.[1], 'the unedited slots re-rolled'); + h.check(JSON.stringify(after) === JSON.stringify(await instancesOf(A.page, terrain, 'rocks')), 'A and B agree after edits + re-roll'); + // one undo puts the seed back + await A.page.evaluate(() => window.__stores.history.undo()); + await h.eventually(() => blockOf(B.page, terrain), (v) => v?.sets?.[0]?.seed === 777, 'one undo restores the seed on B', 10000); + + // ---- §5 a mixed set through the real select ------------------------------------------------ + await A.page.evaluate((u) => window.__stores.objectActions.selectObject(u, true), terrain); + const add = A.page.locator('[id="terrain-scatter-add-rocks"]'); + if (!(await add.isVisible().catch(() => false))) { + const head = A.page.locator('button', { hasText: /^Scatter$/ }).first(); + if (await head.isVisible().catch(() => false)) await head.click(); + } + await add.scrollIntoViewIfNeeded(); + await add.click(); + await A.page.locator('.ts-list [role="option"]', { hasText: /^\s*Cone\s*$/ }).click(); + await h.eventually(() => blockOf(B.page, terrain), (v) => v?.sets?.[0]?.sources?.length === 2, 'B has the mix', 10000); + const mixB = await blockOf(B.page, terrain); + h.check(mixB.sets[0].sources[1].source.type === 'Cone', `the select added a Cone to the mix (${JSON.stringify(mixB.sets[0].sources.map((e) => e.source.type))})`); + h.check(await A.page.locator('[data-scatter-sources="rocks"] [data-scatter-source]').count() === 2, 'Properties lists both models'); + await A.page.evaluate((u) => window.__stores.terrainPaint.setScatterSourceWeight(u, 'rocks', 1, 3), terrain); + await h.eventually(() => blockOf(B.page, terrain), (v) => v?.sets?.[0]?.sources?.[1]?.w === 3, 'the weight replicates', 10000); + const mixed = await instancesOf(B.page, terrain, 'rocks'); + const cones = mixed.filter((i) => i[5] === 1).length; + h.check(Math.abs(cones / mixed.length - 0.75) < 0.03, `weights 1:3 → ${(100 * cones / mixed.length).toFixed(1)} % cones`); + const dMix = await drawnOf(A.page, terrain); + h.check(dMix.rocks.types.includes('ConeGeometry') && dMix.rocks.types.includes('SphereGeometry'), `both models drawn (${[...new Set(dMix.rocks.types)].join(', ')})`); + + // ---- §6 tint + sink ----------------------------------------------------------------------- + await A.page.evaluate((u) => window.__stores.terrainPaint.editScatterSet(u, 'rocks', { tint: 0.6, sink: 0.3 }), terrain); + await h.eventually(() => blockOf(B.page, terrain), (v) => v?.sets?.[0]?.tint === 0.6 && v?.sets?.[0]?.sink === 0.3, 'tint + sink replicate', 10000); + const pose = await A.page.evaluate((u) => { + let scene = null; + window.__stores.globalScene.subscribe((v) => (scene = v))(); + for (const holder of scene.children) + if (holder.userData?.terrainScatterOf === u) + for (const m of holder.children) { + const i = m.userData.instanceIds.indexOf('g0'); + if (i < 0) continue; + return { y: m.instanceMatrix.array[i * 16 + 13], colors: !!m.instanceColor, c: m.instanceColor ? Array.from(m.instanceColor.array.slice(i * 3, i * 3 + 3)) : null }; + } + return null; + }, terrain); + h.check(pose && Math.abs(pose.y + 0.3) < 0.02, `g0 sits 0.3 m into the flat ground (y ${pose?.y?.toFixed(3)})`); + h.check(pose?.colors && pose.c.some((v) => Math.abs(v - 1) > 0.01), `tinted instance colour (${pose?.c?.map((v) => v.toFixed(2))})`); + h.check((await drawnOf(B.page, terrain)).rocks?.colors, 'B draws the tint too'); + await A.page.evaluate(() => window.__stores.history.undo()); + await h.eventually(() => blockOf(B.page, terrain), (v) => !v?.sets?.[0]?.tint && !v?.sets?.[0]?.sink, 'one undo clears tint + sink on B', 10000); + + // ---- §7 a prefab with logic scatters as its look only -------------------------------------- + const prefab = await A.page.evaluate(async () => { + const s = window.__stores; + s.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 1200)); + const group = await new Promise((r) => s.objectsGroup.subscribe(r)()); + const box = group.children[group.children.length - 1]; + box.userData.physics = { mode: 'dynamic', mass: 2 }; + const entry = await s.prefabs.savePrefab(box.uuid, 'Crate'); + const source = await s.scatterSources.resolveSourceChoice('f:' + entry.id); + return { id: entry.id, source }; + }); + h.check(prefab.source?.kind === 'library' && /look only/i.test(prefab.source?.note ?? ''), `the prefab became a Library look with a note (${JSON.stringify(prefab.source)})`); + await A.page.evaluate(([u, src]) => window.__stores.terrainPaint.addScatterSet(u, { id: 'crates', source: src, seed: 3, gen: { count: 40, scaleMin: 1, scaleMax: 1 } }), [terrain, prefab.source]); + await h.eventually(() => drawnOf(A.page, terrain), (v) => v.crates?.ids.length === 40, 'A draws the crates', 20000); + await h.eventually(() => drawnOf(B.page, terrain), (v) => (v.crates?.ids.length ?? 0) > 0, 'B pulled the look and draws the crates', 30000); + const glb = await A.page.evaluate((hash) => { + let items = []; + window.__stores.explorer.explorerItems.subscribe((v) => (items = v))(); + return items.find((i) => i.hash === hash)?.name ?? null; + }, prefab.source.hash); + h.check(/scatter look\)\.glb$/.test(glb ?? ''), `the look is a Library GLB (${glb})`); + await A.page.evaluate((u) => window.__stores.objectActions.selectObject(u, true), terrain); + await A.page.waitForTimeout(400); + h.check(/look only/i.test((await A.page.locator('[data-scatter-sources="crates"] [data-scatter-note]').first().innerText().catch(() => '')) ?? ''), 'the source row shows the look-only note'); + + // ---- §8 "Use selected object" ---------------------------------------------------------------- + const used = await A.page.evaluate(async () => { + const s = window.__stores; + s.commandsHandler.sceneCommand('/create cone'); + await new Promise((r) => setTimeout(r, 1200)); + const group = await new Promise((r) => s.objectsGroup.subscribe(r)()); + const cone = group.children[group.children.length - 1]; + cone.userData.physics = { mode: 'dynamic', mass: 1 }; + s.objectActions.selectObject(cone.uuid, true); + const menu = s.objectMenu?.buildObjectMenuItems ? JSON.stringify(s.objectMenu.buildObjectMenuItems(cone.uuid).map((i) => i?.label)) : null; + const src = await s.scatterSources.useAsScatterSource(); + let brush = null; + s.terrainPaint.scatterSource.subscribe((v) => (brush = v))(); + return { src, brush, menu }; + }); + h.check(used.brush?.kind === 'library' && used.brush?.hash === used.src?.hash, `the brush source is the selected object's look (${used.brush?.name})`); + h.check(/logic is not scattered/.test(used.brush?.note ?? ''), `an object with physics says so (${used.brush?.note})`); + if (used.menu) h.check(used.menu.includes('Use as scatter source'), 'the object menu offers "Use as scatter source"'); + + // ---- §9 a 1.38 block loads byte-identical ---------------------------------------------------- + const legacy = await A.page.evaluate((u) => { + const s = window.__stores; + const core = s.terrainScatterCore; + const B0 = { minX: -100, minZ: -100, sizeX: 200, sizeZ: 200 }; + const data = s.terrainPaintCore.bytesToBase64(core.encodeInstances([{ x: 1, z: 2, yaw: 0.5, scale: 1.2 }, { x: -3, z: 4, yaw: 1, scale: 0.8 }], B0)); + const block = { v: 1, sets: [{ id: 'p:Cone', source: { kind: 'primitive', type: 'Cone' }, align: true, data, count: 2, collider: 'box' }] }; + s.terrainSurface.applyTerrainScatter(u, block); + let g = null; + s.objectsGroup.subscribe((v) => (g = v))(); + return { sent: JSON.stringify(block), stored: JSON.stringify(g.getObjectByProperty('uuid', u).userData.terrainScatter) }; + }, terrain); + h.check(legacy.stored === legacy.sent, `a 1.38 block is stored byte-identical (${legacy.stored.length} bytes)`); + + // ---- §10 a pack item --------------------------------------------------------------------------- + const pack = await A.page.evaluate(async () => { + const s = window.__stores; + try { + await s.packs.loadPacks(); + const list = await Promise.race([s.scatterSources.packChoices(), new Promise((r) => setTimeout(() => r([]), 20000))]); + const pick = list.find((c) => /rock/i.test(c.name)) ?? list[0]; + return pick ? { pick, source: await s.scatterSources.resolveSourceChoice(pick.value) } : null; + } catch (e) { + return { error: String(e) }; + } + }); + if (!pack?.source) console.log(`SKIP §10: no pack items reachable (${JSON.stringify(pack)})`); + else { + h.check(pack.source.kind === 'pack' && !!pack.source.path, `a pack item resolves to its path (${pack.source.path})`); + await A.page.evaluate(([u, src]) => window.__stores.terrainPaint.addScatterSet(u, { id: 'kit', source: src, seed: 9, gen: { count: 30, scaleMin: 1, scaleMax: 1 } }), [terrain, pack.source]); + await h.eventually(() => drawnOf(B.page, terrain), (v) => (v.kit?.ids.length ?? 0) > 0, 'B fetches the pack item and draws it', 40000); + const kitBlock = await blockOf(B.page, terrain); + const kitSet = kitBlock.sets.find((s) => s.id === 'kit'); + h.check(!!kitSet && !kitSet.data && JSON.stringify(kitSet).length < 400, `only the path + params travel (${JSON.stringify(kitSet)?.length} bytes)`); + } + + await h.finish(browser); +}); diff --git a/tests/unit/scatterThin.test.js b/tests/unit/scatterThin.test.js new file mode 100644 index 000000000..ebaa5aca7 --- /dev/null +++ b/tests/unit/scatterThin.test.js @@ -0,0 +1,68 @@ +// 51 — device thinning, the pure planner: far instances go first, near ones never, the drawn +// triangles stay inside the budget, the edge fades, and Auto reads the device. +import { describe, test, expect } from 'vitest'; +import { thinPlan, fadeAt, scatterBudget, thinProfileOf, NEAR_KEEP, SCATTER_SHARE } from '../../src/lib/scatterThinCore.js'; + +/** n instances spread from 0 to `far` metres, `t` triangles each @param {number} n @param {number} far @param {number} t */ +const row = (n, far, t) => ({ dists: Float32Array.from({ length: n }, (_, i) => (i / (n - 1)) * far), tris: new Float32Array(n).fill(t) }); + +describe('51 thinning planner', () => { + test('under budget: nothing thinned', () => { + const { dists, tris } = row(100, 200, 100); + expect(thinPlan(dists, tris, 1e6).cutoff).toBe(Infinity); + }); + + test('over budget: the cutoff keeps the nearest that fit, inside the budget', () => { + const { dists, tris } = row(1000, 500, 1000); // 1M triangles over 500 m + const plan = thinPlan(dists, tris, 250000); + expect(plan.cutoff).toBeGreaterThan(NEAR_KEEP); + expect(plan.cutoff).toBeLessThan(500); + const kept = Array.from(dists).filter((d) => d < plan.cutoff).length; + expect(kept * 1000).toBeLessThanOrEqual(250000); + expect(kept).toBeGreaterThanOrEqual(249); + // nearest first: every kept instance is nearer than every dropped one + expect(plan.drawnTris).toBe(kept * 1000); + }); + + test('near instances are never thinned, even past the budget', () => { + const { dists, tris } = row(100, 20, 100000); // all within NEAR_KEEP, 10M triangles + expect(thinPlan(dists, tris, 1000).cutoff).toBe(Infinity); + const mix = { dists: Float32Array.from([1, 5, 10, 29, 31, 60]), tris: new Float32Array(6).fill(1000) }; + const plan = thinPlan(mix.dists, mix.tris, 500); + expect(plan.cutoff).toBeGreaterThanOrEqual(NEAR_KEEP); + expect(Array.from(mix.dists).filter((d) => fadeAt(d, plan) > 0).length).toBe(4); + }); + + test('the edge fades: 1 inside, 0 past the cutoff, between in the band', () => { + const plan = { cutoff: 100, fadeStart: 80 }; + expect(fadeAt(50, plan)).toBe(1); + expect(fadeAt(100, plan)).toBe(0); + const mid = fadeAt(90, plan); + expect(mid).toBeGreaterThan(0); + expect(mid).toBeLessThan(1); + expect(fadeAt(85, plan)).toBeGreaterThan(fadeAt(95, plan)); + }); + + test('the budget is the device line share, raised to what the scene leaves', () => { + expect(scatterBudget(300000)).toBe(300000 * SCATTER_SHARE); + expect(scatterBudget(300000, 50000)).toBe(250000); + expect(scatterBudget(300000, 290000)).toBe(75000); + expect(scatterBudget(Infinity)).toBe(Infinity); + }); + + test('profiles: auto reads the device, a pick wins, off draws everything', () => { + expect(thinProfileOf('auto', { xr: true, coarse: true })).toBe('quest'); + expect(thinProfileOf('auto', { xr: false, coarse: true })).toBe('phone'); + expect(thinProfileOf('auto', { xr: false, coarse: false })).toBe('desktop'); + expect(thinProfileOf('quest', { xr: false, coarse: false })).toBe('quest'); + expect(thinProfileOf('off', { xr: true, coarse: true })).toBe(null); + }); +}); + +describe('51 thinning runtime', () => { + test('the module evaluates (its store subscribe runs at import — the TDZ rule)', async () => { + const mod = await import('../../src/lib/scatterThin.js'); + expect(typeof mod.registerThinMesh).toBe('function'); + expect(mod.scatterThinStats().meshes).toBe(0); + }); +}); From 31ae5acbeef17f484d332580dcb7fd4cceaffe22 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 22 Oct 2026 20:35:38 +0300 Subject: [PATCH 12/65] [fix] 51-surface: a group host's surface takes its children in a peer-independent order - groupSurfaceOf sorts the child meshes by their pose in the group, vertex count and name (never a uuid, never tree order: a peer that built the group from messages adds the children as they arrive), so a seeded fill lands on the same triangles everywhere - e2e scatter-surface: density checked at the format's 4/m2 cap, spacing at a packable 0.2 m plus a past-saturation check (COUNT places what fits and stops), the grouped boxes are placed with replicated moves - 39/39 green two peers; terrain-paint 32, terrain-sculpt (2x lane, 2x base A/B: an earlier one-off red was a flake), mesh-sculpt 13, scatter-colliders 23 green Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/terrainSurface.js | 19 ++++++++++++++++--- tests/e2e/scatter-surface.test.cjs | 30 ++++++++++++++++++++++++------ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/lib/terrainSurface.js b/src/lib/terrainSurface.js index 9dcb6a13b..b55f6977c 100644 --- a/src/lib/terrainSurface.js +++ b/src/lib/terrainSurface.js @@ -606,15 +606,28 @@ function groupSurfaceOf(object) { if (!meshes.length) return null; object.updateWorldMatrix(true, true); _inv.copy(object.matrixWorld).invert(); + // ORDER: the merged triangle order decides which triangle a slot's draw lands on, so it must be + // the same on every peer — and a group's children are NOT in one order everywhere (a peer that + // built the group from messages adds them as they arrive). Sort by what every peer agrees on: + // the child's pose in the group, its vertex count, its name (never a uuid — a parsed model's + // child uuids are minted per peer). + const sorted = meshes + .map((m) => { + const rel = new THREE.Matrix4().multiplyMatrices(_inv, m.matrixWorld); + const pose = rel.elements.map((e) => Math.round(e * 1e4)); + return { m, rel, sortKey: pose.join(',') + '|' + m.geometry.attributes.position.count + '|' + (m.name ?? '') }; + }) + .sort((a, b) => (a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0)); + meshes.length = 0; /** @type {string[]} */ const parts = []; /** @type {any[]} */ const rels = []; - for (const m of meshes) { + for (const { m, rel, sortKey } of sorted) { const g = m.geometry; - const rel = new THREE.Matrix4().multiplyMatrices(_inv, m.matrixWorld); + meshes.push(m); rels.push(rel); - parts.push(uuidTag(g.attributes.position) + '.' + g.attributes.position.version + '.' + (g.index ? uuidTag(g.index) : '-') + '.' + rel.elements.map((e) => Math.round(e * 1e5)).join(',')); + parts.push(uuidTag(g.attributes.position) + '.' + g.attributes.position.version + '.' + (g.index ? uuidTag(g.index) : '-') + '.' + sortKey); } const key = 'g|' + parts.join('|'); let entry = surfaces.get(object); diff --git a/tests/e2e/scatter-surface.test.cjs b/tests/e2e/scatter-surface.test.cjs index 48733909d..d19c4a80e 100644 --- a/tests/e2e/scatter-surface.test.cjs +++ b/tests/e2e/scatter-surface.test.cjs @@ -178,18 +178,23 @@ h.run(async () => { await h.eventually(() => drawn(B.page, uuid), (d) => samePlaces(d, moved, 2e-3), 'B: the instances followed the move too', 15000); // ---- E3 fill: density, spacing, masks ------------------------------------------------------------ - await editSet(A.page, uuid, set.id, { gen: { ...set.gen, count: undefined, density: 20 } }); + await editSet(A.page, uuid, set.id, { gen: { ...set.gen, count: undefined, density: 4 } }); const dens = await drawn(A.page, uuid); - // sphere area ~ 4π (12.5 m² local): 20 / m² ~ 250 attempts, all placed (no mask, no spacing) - h.check(dens.length > 230 && dens.length < 260, `DENSITY 20/m² over the whole surface (${dens.length})`); - await editSet(A.page, uuid, set.id, { gen: { ...set.gen, count: 150, spacing: 0.25 } }); + // sphere area ~ 4π (~12.3 m² for the 32x16 chord sphere): 4 / m² (the format's cap) ~ 49 attempts, + // all placed (no mask, no spacing) + h.check(dens.length >= 45 && dens.length <= 51, `DENSITY 4/m² over the whole surface (${dens.length})`); + await editSet(A.page, uuid, set.id, { gen: { ...set.gen, count: 150, spacing: 0.2 } }); const spaced = await drawn(A.page, uuid); let minD = Infinity; for (let i = 0; i < spaced.length; i++) for (let j = i + 1; j < spaced.length; j++) minD = Math.min(minD, Math.hypot(...spaced[i].local.map((v, k) => v - spaced[j].local[k]))); h.check(spaced.length === 150, `COUNT with spacing still places the count (${spaced.length})`); - h.check(minD >= 0.25 - 1e-3, `minimum spacing: no two instances closer than 0.25 m (min ${minD.toFixed(3)})`); + h.check(minD >= 0.2 - 1e-3, `minimum spacing: no two instances closer than 0.2 m (min ${minD.toFixed(3)})`); + // past saturation (0.25 m packs ~120-140 on this sphere) COUNT places what fits, never overlaps + await editSet(A.page, uuid, set.id, { gen: { ...set.gen, count: 400, spacing: 0.25 } }); + const full = await drawn(A.page, uuid); + h.check(full.length > 80 && full.length < 400, `past saturation COUNT places what fits and stops (${full.length} of 400)`); await editSet(A.page, uuid, set.id, { gen: { ...set.gen, count: 120, slope: [0, 40] } }); const top = await drawn(A.page, uuid); h.check(top.length === 120 && top.every((p) => p.local[1] > Math.cos((42 * Math.PI) / 180) * 0.97), `SLOPE 0..40°: only the cap of the sphere (${top.length})`); @@ -281,7 +286,15 @@ h.run(async () => { await new Promise((r) => setTimeout(r, 300)); const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); const kids = g.children.slice(-2); - kids.forEach((o, i) => o.position.set(8 + i * 3, 0.5, 8)); + let peer; + w.peers.subscribe((v) => (peer = v))(); + // placed the replicated way, or B builds the group around boxes still at the origin + kids.forEach((o, i) => { + o.position.set(8 + i * 3, 0.5, 8); + o.updateMatrixWorld(true); + peer?.send({ type: 'move', uuid: o.uuid, pos: o.position.toArray(), rot: [0, 0, 0], scale: [1, 1, 1] }); + }); + await new Promise((r) => setTimeout(r, 800)); w.objectActions.applySelectionSet(kids.map((o) => o.uuid)); const uuid = w.objectActions.groupSelection(); await new Promise((r) => setTimeout(r, 300)); @@ -297,6 +310,11 @@ h.run(async () => { `the fill lies on both children's top faces (${gDrawn.length})` ); await h.eventually(() => drawn(B.page, grp.uuid), (d) => samePlaces(d, gDrawn, 2e-3), 'B draws the group fill identically', 15000); + { + const bd = await drawn(B.page, grp.uuid); + if (!samePlaces(bd, gDrawn, 2e-3)) + console.log(' group diag: A', gDrawn.length, JSON.stringify(gDrawn.slice(0, 3)), '\n B', bd.length, JSON.stringify(bd.slice(0, 3)), '\n blocks equal:', (await blockOf(A.page, grp.uuid)) === (await blockOf(B.page, grp.uuid))); + } await h.finish(browser); }); From 1f3b9dd531525790b17d5221197ac59de666be6a Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 12:02:05 +0300 Subject: [PATCH 13/65] [test] 51 union: scatterEditAdapter writes edits on the set format (overrides, painted rewrites, appends) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/unit/scatterEditAdapter.test.js | 102 ++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/unit/scatterEditAdapter.test.js diff --git a/tests/unit/scatterEditAdapter.test.js b/tests/unit/scatterEditAdapter.test.js new file mode 100644 index 000000000..fbca61601 --- /dev/null +++ b/tests/unit/scatterEditAdapter.test.js @@ -0,0 +1,102 @@ +// @ts-nocheck — a fixture test: sets are built by hand and asserted by the checks themselves +// 51 union — THE SEAM between 51-edit's instance lists and 51-sources' set format. The edit +// tools read what a set DRAWS (string ids) and hand back edited lists; blockOf must turn the +// difference into overrides / painted-record rewrites and leave everything else alone, so an +// edited seeded fill stays a seed + overrides (no explicit bytes) and reads back the same on a +// second "peer" (a JSON wire hop). +import { describe, test, expect, vi } from 'vitest'; +import { normalizeScatter, encodeInstances, instancesOf } from '../../src/lib/terrainScatterCore.js'; +import { base64ToBytes } from '../../src/lib/terrainPaintCore.js'; + +const B = { minX: -20, minZ: -20, sizeX: 40, sizeZ: 40 }; +// readScatter's two terrain reads, as the pure core answers them (no mask, no scene) +vi.mock('../../src/lib/terrainSurface.js', () => ({ + terrainBounds: () => B, + scatterSetInstances: (object, setId) => { + const set = normalizeScatter(object.userData.terrainScatter, base64ToBytes).sets.find((s) => s.id === setId); + return instancesOf(set, B, base64ToBytes); + } +})); +const { readScatter, blockOf, indexOfInstance, newSet } = await import('../../src/lib/scatterEditAdapter.js'); + +const b64 = (u) => Buffer.from(u).toString('base64'); +const ROCK = { kind: 'primitive', type: 'Dodecahedron' }; +const CONE = { kind: 'primitive', type: 'Cone' }; +const terrainWith = (block) => ({ userData: { terrain: true, terrainScatter: JSON.parse(JSON.stringify(block)) } }); +const wire = (block) => terrainWith(block); // a peer / a reload reads the same JSON +const drawn = (object, id) => { + const set = normalizeScatter(object.userData.terrainScatter, base64ToBytes).sets.find((s) => s.id === id); + return set ? instancesOf(set, B, base64ToBytes) : []; +}; +const seeded = { v: 1, sets: [{ id: 'rocks', source: ROCK, align: false, data: '', count: 0, seed: 777, gen: { count: 50, scaleMin: 0.5, scaleMax: 2, rot: true }, tint: 0.4 }] }; +const painted = [ + { x: 1, z: 2, yaw: 0.5, scale: 1.2 }, + { x: -3, z: 4, yaw: 1, scale: 0.8 }, + { x: 5, z: -6, yaw: 2, scale: 1 } +]; +const mixed = { v: 1, sets: [{ id: 'p:Cone', source: CONE, align: true, data: b64(encodeInstances(painted, B)), count: 3, collider: 'box' }] }; + +describe('51 union: scatterEditAdapter over the set format', () => { + test('an untouched read writes the block back unchanged', () => { + for (const block of [seeded, mixed]) { + const o = terrainWith(block); + const data = readScatter(o); + expect(blockOf(data.sets, data.bounds)).toEqual(normalizeScatter(o.userData.terrainScatter, base64ToBytes)); + } + }); + + test('editing a seeded fill writes overrides, keeps seed/gen/tint, and reads back the edit on a peer', () => { + const o = terrainWith(seeded); + const data = readScatter(o); + const set = data.sets[0]; + expect(set.list.length).toBe(50); + const i = indexOfInstance(set, 'g7'); + set.list = set.list.map((p, k) => (k === i ? { ...p, x: 3.25, z: -4.5, yaw: 1.5, scale: 1.75 } : p)).filter((p) => p.id !== 'g9'); + const block = blockOf(data.sets, data.bounds); + const s = block.sets[0]; + expect(s.data).toBe(''); + expect(s.seed).toBe(777); + expect(s.gen).toEqual(seeded.sets[0].gen); + expect(s.tint).toBe(0.4); + expect(s.ov).toEqual({ 7: { x: 3.25, z: -4.5, yaw: 1.5, scale: 1.75 }, 9: { del: 1 } }); + const peer = drawn(wire(block), 'rocks'); + expect(peer.length).toBe(49); + expect(peer.find((p) => p.id === 'g9')).toBeUndefined(); + expect(peer.find((p) => p.id === 'g7')).toMatchObject({ x: 3.25, z: -4.5, yaw: 1.5, scale: 1.75 }); + // every other slot is exactly the seed's + const base = drawn(o, 'rocks'); + for (const p of peer) if (p.id !== 'g7') expect(p).toEqual(base.find((q) => q.id === p.id)); + }); + + test('painted records are rewritten / dropped in place; set fields ride through', () => { + const o = terrainWith(mixed); + const data = readScatter(o); + const set = data.sets[0]; + set.list = [{ ...set.list[0], yaw: 0.25 }, set.list[2]]; // p1 deleted, p0 turned + const block = blockOf(data.sets, data.bounds); + expect(block.sets[0]).toMatchObject({ id: 'p:Cone', align: true, collider: 'box', count: 2 }); + const peer = drawn(wire(block), 'p:Cone'); + expect(peer.map((p) => p.id)).toEqual(['p0', 'p1']); + expect(peer[0].yaw).toBeCloseTo(0.25, 3); + expect(peer[1].x).toBeCloseTo(5, 1); + }); + + test('id-less entries (objects -> scatter) append painted records; a new set is all records', () => { + const o = terrainWith(seeded); + const data = readScatter(o); + data.sets[0].list = [...data.sets[0].list, { x: 0, z: 0, yaw: 0, scale: 1 }, { x: 2, z: 2, yaw: 0, scale: 1 }]; + data.sets.push(newSet(CONE, [{ x: -1, z: -1, yaw: 0, scale: 1 }])); + const block = blockOf(data.sets, data.bounds); + expect(block.sets[0]).toMatchObject({ count: 2, seed: 777 }); + expect(block.sets[0].ov).toBeUndefined(); + expect(drawn(wire(block), 'rocks').length).toBe(52); + expect(block.sets[1]).toMatchObject({ id: 'p:Cone', count: 1 }); + }); + + test('a set erased to nothing leaves no row; no sets clears the block', () => { + const o = terrainWith(mixed); + const data = readScatter(o); + data.sets[0].list = []; + expect(blockOf(data.sets, data.bounds)).toBeNull(); + }); +}); From 660f7f174729e20c819406c6839b4dece5163986 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 14:27:23 +0300 Subject: [PATCH 14/65] [deps] three add-ons: three-mesh-bvh 0.9.16, @threlte/extras 9.22.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit three itself stays 0.186.1 (already npm latest; Dependabot moved it in 19ddcb6f, shipped since 1.18.0). r186 migration notes audited against src/ and scripts/: no use of toTrianglesDrawMode, LightProbeGrid*, Source, SimplifyModifier, PCFSoftShadowMap on WebGPU, GTAONode, the Sky `up` uniform, and no Object3D subclass with its own dispose(). - three-mesh-bvh 0.9.15 -> 0.9.16: BVHHelper fix for r186 (peer three >=0.159). - @threlte/extras 9.22.0 -> 9.22.3: WebGPU variants + fixes, drops the three-viewport-gizmo dependency (unused here; the app renders no threlte ). Peer ranges: postprocessing 6.39.5 three >=0.168 <0.187, three-bvh-csg >=0.179, threlte core/xr >=0.172/>=0.160 — all satisfied by 0.186.1. Co-Authored-By: Claude Opus 5.5 (1M context) --- package-lock.json | 26 ++++++++------------------ package.json | 4 ++-- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1a04546db..095a7ba07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@shaderfrog/core": "^4.0.2", "@shaderfrog/glsl-parser": "^7.0.1", "@threlte/core": "^8.6.1", - "@threlte/extras": "^9.22.0", + "@threlte/extras": "^9.22.3", "@threlte/xr": "^1.6.1", "@xyflow/svelte": "^1.7.0", "codemirror": "^6.0.2", @@ -32,7 +32,7 @@ "svelte-hamburgers": "^5.0.0", "three": "^0.186.1", "three-bvh-csg": "^0.0.18", - "three-mesh-bvh": "^0.9.15" + "three-mesh-bvh": "^0.9.16" }, "devDependencies": { "@axe-core/playwright": "4.13.0", @@ -1842,16 +1842,15 @@ } }, "node_modules/@threlte/extras": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/@threlte/extras/-/extras-9.22.0.tgz", - "integrity": "sha512-1bJlcFMpY16RKMgILBAki6pPm4r2FDHx/KGmJFlFj4CxAF31VkciBjXx3nAeqiMl2Brevsr9wfaccZ2dxirzrg==", + "version": "9.22.3", + "resolved": "https://registry.npmjs.org/@threlte/extras/-/extras-9.22.3.tgz", + "integrity": "sha512-ZEoc+ZFdhTkH1IZgpU6AJMnfNMg3Y5W3jN226Z8YqAWH/OxN2FY41LwQccdel0yL0p41dm7MjuP4/CVTC8K0YQ==", "license": "MIT", "dependencies": { "@threejs-kit/instanced-sprite-mesh": "^2.5.1", "camera-controls": "^3.1.2", "three-mesh-bvh": "^0.9.1", "three-perf": "^1.0.11", - "three-viewport-gizmo": "^2.2.0", "troika-three-text": "^0.52.4" }, "peerDependencies": { @@ -5013,9 +5012,9 @@ } }, "node_modules/three-mesh-bvh": { - "version": "0.9.15", - "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.9.15.tgz", - "integrity": "sha512-9xdEDkCzFoT8VPFH9DznwFSdbaIEjMvM106Iqq7TJurrIheUKDcdW4prfJTCxryih4XS/UxWs0H3K4MLMRFyOQ==", + "version": "0.9.16", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.9.16.tgz", + "integrity": "sha512-2RK+5MJOaiHJhopURn2CUkOHiF3tFloD0uZBfhebV0CKB7mgVLpWUWaDpOyhNJRDkKKe7x4rRfTHZ32GwpXdkw==", "license": "MIT", "peerDependencies": { "three": ">= 0.159.0" @@ -5034,15 +5033,6 @@ "three": ">=0.170" } }, - "node_modules/three-viewport-gizmo": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/three-viewport-gizmo/-/three-viewport-gizmo-2.2.0.tgz", - "integrity": "sha512-Jo9Liur1rUmdKk75FZumLU/+hbF+RtJHi1qsKZpntjKlCYScK6tjbYoqvJ9M+IJphrlQJF5oReFW7Sambh0N4Q==", - "license": "MIT", - "peerDependencies": { - "three": ">=0.162.0 <1.0.0" - } - }, "node_modules/tinybench": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", diff --git a/package.json b/package.json index e46cc71aa..48a232e20 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@shaderfrog/core": "^4.0.2", "@shaderfrog/glsl-parser": "^7.0.1", "@threlte/core": "^8.6.1", - "@threlte/extras": "^9.22.0", + "@threlte/extras": "^9.22.3", "@threlte/xr": "^1.6.1", "@xyflow/svelte": "^1.7.0", "codemirror": "^6.0.2", @@ -73,7 +73,7 @@ "svelte-hamburgers": "^5.0.0", "three": "^0.186.1", "three-bvh-csg": "^0.0.18", - "three-mesh-bvh": "^0.9.15" + "three-mesh-bvh": "^0.9.16" }, "overrides": { "@sveltejs/kit": { From 95dcc880defd375369538dfc27cdc3e9f9459e9b Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 14:28:28 +0300 Subject: [PATCH 15/65] [deps] svelte 5.57.2, svelte-check 4.7.7, @lucide/svelte 1.61.0 - svelte 5.57.1 -> 5.57.2: patch fixes (async deriveds on destroyed components, untracked derived reads retaining dependencies, {:else} after {:else} now a compile error, flushSync flushes pending work first). - svelte-check 4.7.6 -> 4.7.7: modern CSS properties, tsconfig "include" aligned with tsc. check-ratchet unchanged: 299 errors / 39 warnings = baseline. - @lucide/svelte 1.48.0 -> 1.61.0: icon additions/revisions (icon art can shift; covered by the A/B screenshots). Co-Authored-By: Claude Opus 5.5 (1M context) --- package-lock.json | 24 ++++++++++++------------ package.json | 6 +++--- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 095a7ba07..74cf4a025 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@gltf-transform/core": "^4.5.1", "@gltf-transform/extensions": "^4.5.1", "@gltf-transform/functions": "^4.5.1", - "@lucide/svelte": "^1.48.0", + "@lucide/svelte": "^1.61.0", "@shaderfrog/core": "^4.0.2", "@shaderfrog/glsl-parser": "^7.0.1", "@threlte/core": "^8.6.1", @@ -48,9 +48,9 @@ "prettier": "^3.9.9", "prettier-plugin-svelte": "^4.1.1", "prettier-plugin-tailwindcss": "^0.8.1", - "svelte": "^5.57.1", + "svelte": "^5.57.2", "svelte-awesome-color-picker": "^4.1.3", - "svelte-check": "^4.7.6", + "svelte-check": "^4.7.7", "tailwindcss": "^4.3.3", "typescript": "^5.9.3", "vite": "^8.3.3", @@ -900,9 +900,9 @@ } }, "node_modules/@lucide/svelte": { - "version": "1.48.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.48.0.tgz", - "integrity": "sha512-bLimVDjehxu3fpu9XEUpqEkBk2sJAHAT2sVcLHhlM70EdNsXuj9ZnYENWv5xgw3+05h4i5TirAxTiTgnZZf8gw==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.61.0.tgz", + "integrity": "sha512-Vwj4ADoKgVogLFpEbXv2PS2hh2fj+aAqPuiNydI88Zkyb1l7veL4fXgRrOX3tgpESHhIGneNkSSYhHupvCBshg==", "license": "ISC", "peerDependencies": { "svelte": "^5" @@ -4876,9 +4876,9 @@ } }, "node_modules/svelte": { - "version": "5.57.1", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz", - "integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==", + "version": "5.57.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz", + "integrity": "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", @@ -4929,9 +4929,9 @@ } }, "node_modules/svelte-check": { - "version": "4.7.6", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", - "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.7.tgz", + "integrity": "sha512-Ge2IMdeTpbJuzNFGRiiMg0RWgbScysKQu7xwcyFJ22+DU9F5nPX51+Q3gHmGqRxYQMx2knreVWt5oCtfe0vf2g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 48a232e20..b9ac911b6 100644 --- a/package.json +++ b/package.json @@ -41,9 +41,9 @@ "prettier": "^3.9.9", "prettier-plugin-svelte": "^4.1.1", "prettier-plugin-tailwindcss": "^0.8.1", - "svelte": "^5.57.1", + "svelte": "^5.57.2", "svelte-awesome-color-picker": "^4.1.3", - "svelte-check": "^4.7.6", + "svelte-check": "^4.7.7", "tailwindcss": "^4.3.3", "typescript": "^5.9.3", "vite": "^8.3.3", @@ -55,7 +55,7 @@ "@gltf-transform/core": "^4.5.1", "@gltf-transform/extensions": "^4.5.1", "@gltf-transform/functions": "^4.5.1", - "@lucide/svelte": "^1.48.0", + "@lucide/svelte": "^1.61.0", "@shaderfrog/core": "^4.0.2", "@shaderfrog/glsl-parser": "^7.0.1", "@threlte/core": "^8.6.1", From 864a6e6963bb8953fdb055f4e1c622cfbce10618 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 14:29:18 +0300 Subject: [PATCH 16/65] [deps] vite 8.3.5, vitest 5.0.5 (closes both @vitest/mocker advisories) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vitest 5.0.2 -> 5.0.5 (+ @vitest/mocker/spy 5.0.5): fixes GHSA-qxg6-89mg-rqv8 (code injection via unauthenticated manual mock registration) and GHSA-xp26-3454-mjgp (Windows backslash traversal past the mocker redirect allowlist) — the two open Dependabot alerts. `npm audit` = 0 vulnerabilities (was 2 moderate). - vite 8.3.3 -> 8.3.5 (rolldown 1.2.15): build preload-helper fixes (waits for in-flight stylesheets, chunk import map hashing), css `%` in url(); the rest is bundled-dev only (not used here). vitest: 206 files / 2572 tests, same as the base. Co-Authored-By: Claude Opus 5.5 (1M context) --- package-lock.json | 272 ++++++++++++++++++++++++---------------------- package.json | 4 +- 2 files changed, 145 insertions(+), 131 deletions(-) diff --git a/package-lock.json b/package-lock.json index 74cf4a025..32aabb10c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,8 +53,8 @@ "svelte-check": "^4.7.7", "tailwindcss": "^4.3.3", "typescript": "^5.9.3", - "vite": "^8.3.3", - "vitest": "^5.0.2" + "vite": "^8.3.5", + "vitest": "^5.0.5" }, "engines": { "node": ">=24" @@ -924,9 +924,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.151.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", - "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", "dev": true, "license": "MIT", "funding": { @@ -999,9 +999,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", - "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", "cpu": [ "arm" ], @@ -1016,9 +1016,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", - "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", "cpu": [ "arm64" ], @@ -1033,9 +1033,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", - "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", "cpu": [ "arm64" ], @@ -1050,9 +1050,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", - "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", "cpu": [ "x64" ], @@ -1067,9 +1067,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", - "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", "cpu": [ "x64" ], @@ -1084,9 +1084,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", - "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", "cpu": [ "arm" ], @@ -1101,9 +1101,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", - "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", "cpu": [ "arm64" ], @@ -1121,9 +1121,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", - "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", "cpu": [ "arm64" ], @@ -1141,9 +1141,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", - "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", "cpu": [ "ppc64" ], @@ -1161,9 +1161,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", - "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", "cpu": [ "s390x" ], @@ -1181,9 +1181,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", - "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", "cpu": [ "x64" ], @@ -1201,9 +1201,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", - "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", "cpu": [ "x64" ], @@ -1221,9 +1221,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", - "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", "cpu": [ "arm64" ], @@ -1238,9 +1238,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", - "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", "cpu": [ "arm64" ], @@ -1255,9 +1255,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", - "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", "cpu": [ "x64" ], @@ -2147,22 +2147,22 @@ } }, "node_modules/@vitest/mocker": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz", - "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.2", + "@vitest/spy": "5.0.5", "estree-walker": "^3.0.3", - "magic-string": "^1.2.3" + "magic-string": "^1.4.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "msw": "^2.4.9", + "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { @@ -2185,9 +2185,9 @@ } }, "node_modules/@vitest/mocker/node_modules/magic-string": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", - "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2195,9 +2195,9 @@ } }, "node_modules/@vitest/spy": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz", - "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", "dev": true, "license": "MIT", "funding": { @@ -2430,9 +2430,9 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -3865,9 +3865,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -4156,9 +4156,9 @@ } }, "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "dev": true, "funding": [ { @@ -4176,9 +4176,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.18", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -4455,13 +4455,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", - "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.151.0", + "@oxc-project/types": "=0.155.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -4471,21 +4471,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.11", - "@rolldown/binding-android-arm64": "1.2.11", - "@rolldown/binding-darwin-arm64": "1.2.11", - "@rolldown/binding-darwin-x64": "1.2.11", - "@rolldown/binding-freebsd-x64": "1.2.11", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", - "@rolldown/binding-linux-arm64-gnu": "1.2.11", - "@rolldown/binding-linux-arm64-musl": "1.2.11", - "@rolldown/binding-linux-ppc64-gnu": "1.2.11", - "@rolldown/binding-linux-s390x-gnu": "1.2.11", - "@rolldown/binding-linux-x64-gnu": "1.2.11", - "@rolldown/binding-linux-x64-musl": "1.2.11", - "@rolldown/binding-openharmony-arm64": "1.2.11", - "@rolldown/binding-win32-arm64-msvc": "1.2.11", - "@rolldown/binding-win32-x64-msvc": "1.2.11" + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" } }, "node_modules/sade": { @@ -4777,9 +4777,9 @@ } }, "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, @@ -5034,9 +5034,9 @@ } }, "node_modules/tinybench": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", - "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", "dev": true, "license": "MIT", "engines": { @@ -5044,9 +5044,9 @@ } }, "node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -5218,16 +5218,16 @@ } }, "node_modules/vite": { - "version": "8.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", - "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.11", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "bin": { @@ -5244,7 +5244,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -5604,25 +5604,25 @@ } }, "node_modules/vitest": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz", - "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.2", - "chai": "^6.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", - "magic-string": "^1.2.3", - "obug": "^2.1.4", + "magic-string": "^1.4.3", + "obug": "^3.0.0", "picomatch": "^4.0.7", - "std-env": "^4.2.0", - "tinybench": "^6.1.4", - "tinyexec": "^1.3.0", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", - "why-is-node-running": "^3.2.1" + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" @@ -5637,12 +5637,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.2", - "@vitest/browser-preview": "5.0.2", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.2", - "@vitest/coverage-v8": "5.0.2", - "@vitest/ui": "5.0.2", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" @@ -5687,15 +5687,29 @@ } }, "node_modules/vitest/node_modules/magic-string": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz", - "integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, + "node_modules/vitest/node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -5742,9 +5756,9 @@ } }, "node_modules/why-is-node-running": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz", - "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index b9ac911b6..4da23662b 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,8 @@ "svelte-check": "^4.7.7", "tailwindcss": "^4.3.3", "typescript": "^5.9.3", - "vite": "^8.3.3", - "vitest": "^5.0.2" + "vite": "^8.3.5", + "vitest": "^5.0.5" }, "dependencies": { "@codemirror/lang-javascript": "^6.2.5", From e78cb84fb621b1af282e31015fb4b9aec1ec5740 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 14:40:44 +0300 Subject: [PATCH 17/65] [deps] prettier 3.9.10, iwer + @iwer/devui 2.7.0, @axe-core/playwright 4.14.0 The exact pins stay exact (48 G2 pinned iwer/devui; axe pinned since 53). - iwer / @iwer/devui 2.6.0 -> 2.7.0: hand poses/microgestures, run_sequence, and "spec-correct select and squeeze events". vr-iwer 36/0, vr-comfort-iwer 25/0, race-vr-shadows-iwer 13/0. - @axe-core/playwright + axe-core 4.13.0 -> 4.14.0: label-content-name-mismatch is now ON by default, plus false-positive fixes. a11y-axe 12/0 (0 serious/critical nodes on every surface), union-53 18/0. - prettier 3.9.9 -> 3.9.10 (patch). Co-Authored-By: Claude Opus 5.5 (1M context) --- package-lock.json | 45 ++++++++++++++++++++++++--------------------- package.json | 8 ++++---- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index 32aabb10c..ab0e1a6aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,17 +35,17 @@ "three-mesh-bvh": "^0.9.16" }, "devDependencies": { - "@axe-core/playwright": "4.13.0", - "@iwer/devui": "2.6.0", + "@axe-core/playwright": "4.14.0", + "@iwer/devui": "2.7.0", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.70.3", "@sveltejs/vite-plugin-svelte": "^7.3.1", "@tailwindcss/vite": "^4.3.3", "flowbite": "^3.1.2", - "iwer": "2.6.0", + "iwer": "2.7.0", "peer": "^1.0.2", "playwright": "^1.63.0", - "prettier": "^3.9.9", + "prettier": "^3.9.10", "prettier-plugin-svelte": "^4.1.1", "prettier-plugin-tailwindcss": "^0.8.1", "svelte": "^5.57.2", @@ -61,13 +61,16 @@ } }, "node_modules/@axe-core/playwright": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", - "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.14.0.tgz", + "integrity": "sha512-SGUhG5PIHd2eMdpgBF2PyQLOhX+qyDR2PT4cmoS4Qk2ak+jX/rrSvst9l0PGv5WTHolKpUMvVmoHpr4cZgf1RQ==", "dev": true, "license": "MPL-2.0", "dependencies": { - "axe-core": "~4.13.0" + "axe-core": "~4.14.0" + }, + "engines": { + "node": ">=22.12.0" }, "peerDependencies": { "playwright-core": ">= 1.0.0" @@ -793,9 +796,9 @@ } }, "node_modules/@iwer/devui": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@iwer/devui/-/devui-2.6.0.tgz", - "integrity": "sha512-0wzwiUAvbGNsQOGcOyW6RR2MeWH900FjBL/mbhzWSickj+wyOR0Pl48CecwPSidDOew+6c4k29r4PJgEl/8l+g==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@iwer/devui/-/devui-2.7.0.tgz", + "integrity": "sha512-ilEXc/h+6SSlJiX6No9OKPekeO1lAAsXDHfaGZ6jFCuMUAxKopwDySYYn8Ld9vql8dh0QK39IpXdOKUiIMoGCw==", "dev": true, "license": "MIT", "dependencies": { @@ -809,7 +812,7 @@ "zustand": "^5.0.13" }, "peerDependencies": { - "iwer": "^2.5.0" + "iwer": "^2.7.0" } }, "node_modules/@iwer/devui/node_modules/three": { @@ -2313,9 +2316,9 @@ } }, "node_modules/axe-core": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", - "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.14.0.tgz", + "integrity": "sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==", "dev": true, "license": "MPL-2.0", "engines": { @@ -3373,9 +3376,9 @@ } }, "node_modules/iwer": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/iwer/-/iwer-2.6.0.tgz", - "integrity": "sha512-DNihqCixizrNjPKScTt0aOy9+YP1pn0Jxj1V6tBOV0ceRm6YrEMLo8QhEwYspPYJ9QZjDJShDMdZAaPXDPaSAw==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/iwer/-/iwer-2.7.0.tgz", + "integrity": "sha512-zC4VfcwFenuaIU/VJgGfBcR2tfALtNZLSn2vZR7PnNU93S6d9bIK07DRuSMGMgdfPPln41stU/wZa6goWA6bRg==", "dev": true, "license": "MIT", "dependencies": { @@ -4201,9 +4204,9 @@ } }, "node_modules/prettier": { - "version": "3.9.9", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", - "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 4da23662b..fd70cc333 100644 --- a/package.json +++ b/package.json @@ -28,17 +28,17 @@ "check:zindex": "node scripts/check-zindex.cjs" }, "devDependencies": { - "@axe-core/playwright": "4.13.0", - "@iwer/devui": "2.6.0", + "@axe-core/playwright": "4.14.0", + "@iwer/devui": "2.7.0", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.70.3", "@sveltejs/vite-plugin-svelte": "^7.3.1", "@tailwindcss/vite": "^4.3.3", "flowbite": "^3.1.2", - "iwer": "2.6.0", + "iwer": "2.7.0", "peer": "^1.0.2", "playwright": "^1.63.0", - "prettier": "^3.9.9", + "prettier": "^3.9.10", "prettier-plugin-svelte": "^4.1.1", "prettier-plugin-tailwindcss": "^0.8.1", "svelte": "^5.57.2", From 342bba6c043d43ada06e0537cb1935af3d16067a Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 14:40:44 +0300 Subject: [PATCH 18/65] [deps] tools/agent: @roamhq/wrtc 0.8.0 -> 0.10.0 0.x minor (outside ^0.8.0), taken on proof (QUESTIONS-deps-1 Q1): libwebrtc M98 -> M106, fixes a segfault/abort at process exit (destructors ran after V8 shut down) and too-early GCs; RTCRtpTransceiverDirection "stopped"; spec addIceCandidate end-of-candidates. Proof: tools/agent npm test 16/16; an in-process two-peer data-channel loopback (offer/answer, ping -> pong) on 0.8.0 and 0.10.0; e2e agent-bridge 5/0 (real browser session <-> the Node agent over WebRTC: approve, create/move/color/delete land, Users list). npm audit (tools/agent) = 0. Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/agent/package-lock.json | 48 +++++++++++++++++------------------ tools/agent/package.json | 2 +- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/tools/agent/package-lock.json b/tools/agent/package-lock.json index 1590fdcb7..9e091bc91 100644 --- a/tools/agent/package-lock.json +++ b/tools/agent/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.32.1", - "@roamhq/wrtc": "^0.8.0", + "@roamhq/wrtc": "^0.10.0", "peerjs": "^1.5.4", "ws": "^8.18.0" }, @@ -79,23 +79,23 @@ } }, "node_modules/@roamhq/wrtc": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@roamhq/wrtc/-/wrtc-0.8.0.tgz", - "integrity": "sha512-C0V/nqc4/2xzORI5qa4mIeN/8UO3ywN1kInrJ9u6GljFx0D18JMUJEqe8yYHa61RrEeoWN3PKdW++k8TocSx/A==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@roamhq/wrtc/-/wrtc-0.10.0.tgz", + "integrity": "sha512-yFqQQ0EV1ZUHaphh3tmjoxPi2wzhW2vjmzoAVNRRLUjXYd2e1nvwi9TKfE2w4WNvNws/hBkouvOt23Xo9FkXkQ==", "license": "BSD-2-Clause", "optionalDependencies": { - "@roamhq/wrtc-darwin-arm64": "0.8.0", - "@roamhq/wrtc-darwin-x64": "0.8.0", - "@roamhq/wrtc-linux-arm64": "0.8.1", - "@roamhq/wrtc-linux-x64": "0.8.1", - "@roamhq/wrtc-win32-x64": "0.8.0", + "@roamhq/wrtc-darwin-arm64": "0.10.0", + "@roamhq/wrtc-darwin-x64": "0.10.0", + "@roamhq/wrtc-linux-arm64": "0.10.0", + "@roamhq/wrtc-linux-x64": "0.10.0", + "@roamhq/wrtc-win32-x64": "0.10.0", "domexception": "^4.0.0" } }, "node_modules/@roamhq/wrtc-darwin-arm64": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@roamhq/wrtc-darwin-arm64/-/wrtc-darwin-arm64-0.8.0.tgz", - "integrity": "sha512-OtV2KWO7zOG3L8TF3KCt9aucynVCD/ww2xeXXgg+FLkya3ca0uzehN8EQJ3BL4tkInksbFJ2ssyu9cehfJ3ZuA==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@roamhq/wrtc-darwin-arm64/-/wrtc-darwin-arm64-0.10.0.tgz", + "integrity": "sha512-vFdi79jWuPHcnUcnuOjTvyKtmY/RI2xRQo9Y6RsIjIlYePN/7LTy00c+Ivrz4prYAPbp0oHscl7PDV64VUqGTQ==", "cpu": [ "arm64" ], @@ -106,9 +106,9 @@ ] }, "node_modules/@roamhq/wrtc-darwin-x64": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@roamhq/wrtc-darwin-x64/-/wrtc-darwin-x64-0.8.0.tgz", - "integrity": "sha512-VY7Vzt/SDDDCpW//h8GW9bOZrOr8gWXPZVD9473ypl4jyBIoO57yyLbHzd1G0vBUkS6szsHlQCz1WwpI30YL+g==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@roamhq/wrtc-darwin-x64/-/wrtc-darwin-x64-0.10.0.tgz", + "integrity": "sha512-H6852g2xYCuaR+/TrthpdMafs4bMfAUEpvRDhsIguzrK7Dz+MKpNI8MkwdqJN8W65J+7w7k+YqXIkTHe7Fz/cg==", "cpu": [ "x64" ], @@ -119,9 +119,9 @@ ] }, "node_modules/@roamhq/wrtc-linux-arm64": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@roamhq/wrtc-linux-arm64/-/wrtc-linux-arm64-0.8.1.tgz", - "integrity": "sha512-FBJLLazlWkGQUXaokC/rTbrUQbb0CNFYry52fZGstufrGLTWu+g4HcwXdVvxh1tnVtVMvkQGk+mlOL52sCxw0A==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@roamhq/wrtc-linux-arm64/-/wrtc-linux-arm64-0.10.0.tgz", + "integrity": "sha512-fEuJbNjprxQG6QlFd2iqBW9x028RDSho6izVg7gyt8irdPiXWOxzOxNnYMs/B2fohBTd1wD4Qxfivl07/dCR8A==", "cpu": [ "arm64" ], @@ -132,9 +132,9 @@ ] }, "node_modules/@roamhq/wrtc-linux-x64": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@roamhq/wrtc-linux-x64/-/wrtc-linux-x64-0.8.1.tgz", - "integrity": "sha512-I9oWG7b4uvWO1IOR/aF34n+ID6TKVuSs0jd19h5KdhfRtw7FFh9xxuwN9rONPxLVa6fS0q+MCZgAf8Scz89L8Q==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@roamhq/wrtc-linux-x64/-/wrtc-linux-x64-0.10.0.tgz", + "integrity": "sha512-H32lK2eFg3sVb/9nkHIX5HIisxFoS82Gpesuea+zqAyRpRzSd5NpFXx28bVy9wQyRrNtj8k0bTUgEzWRzSbYCA==", "cpu": [ "x64" ], @@ -145,9 +145,9 @@ ] }, "node_modules/@roamhq/wrtc-win32-x64": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@roamhq/wrtc-win32-x64/-/wrtc-win32-x64-0.8.0.tgz", - "integrity": "sha512-R2fxl41BLWPiP4eaTHGLzbbVvRjx1mV/OsgINCvawO7Hwz5Zx9I45+Fhrw3hd4n5amIeSG9VIF7Kz8eeTFXTGQ==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@roamhq/wrtc-win32-x64/-/wrtc-win32-x64-0.10.0.tgz", + "integrity": "sha512-wEVXMvLrBizdLyrd+Zc7zb7zpwUuHUBXwrdIvI69e3i/AA8YsVYI2xo/sxk6GoQ+o8a14ONc4SStDS35TCjg+w==", "cpu": [ "x64" ], diff --git a/tools/agent/package.json b/tools/agent/package.json index ccf2c82c1..bed5cd73f 100644 --- a/tools/agent/package.json +++ b/tools/agent/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.32.1", - "@roamhq/wrtc": "^0.8.0", + "@roamhq/wrtc": "^0.10.0", "peerjs": "^1.5.4", "ws": "^8.18.0" } From f50bd27862205028b4160c2da0dc88cdaa779693 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 14:41:25 +0300 Subject: [PATCH 19/65] [deps] playwright 1.63.0 -> 1.64.1 (Chromium 156.0.8078.4, chromium-1248) 1.64.0: WebMCP, video fps/style, locator.within(), page.getByRef(). Breaking changes checked against this repo: device descriptors now forward `screen` (no `devices[...]` used here), JSX/snapshot runner changes (no @playwright/test runner here), elements in hidden iframes count as hidden (the battery covers it). 1.64.1: Chromium 155+ preflighted cross-origin redirect abort with routes registered (fixed). Hosts: chromium-1248 installed on the Deck beside 1234/1243; the PC run-server (:39001) and tp-lanes serve 1.64.1 beside 1.62.1/1.63.0 (mux-pc-host, 2026-10-23). Co-Authored-By: Claude Opus 5.5 (1M context) --- package-lock.json | 16 ++++++++-------- package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index ab0e1a6aa..a25b43104 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "flowbite": "^3.1.2", "iwer": "2.7.0", "peer": "^1.0.2", - "playwright": "^1.63.0", + "playwright": "^1.64.1", "prettier": "^3.9.10", "prettier-plugin-svelte": "^4.1.1", "prettier-plugin-tailwindcss": "^0.8.1", @@ -4130,13 +4130,13 @@ } }, "node_modules/playwright": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", - "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.63.0" + "playwright-core": "1.64.1" }, "bin": { "playwright": "cli.js" @@ -4146,9 +4146,9 @@ } }, "node_modules/playwright-core": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", - "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index fd70cc333..946dbb73d 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "flowbite": "^3.1.2", "iwer": "2.7.0", "peer": "^1.0.2", - "playwright": "^1.63.0", + "playwright": "^1.64.1", "prettier": "^3.9.10", "prettier-plugin-svelte": "^4.1.1", "prettier-plugin-tailwindcss": "^0.8.1", From e4ea26cfe698ba82fa3bb49ec5152d29744b12d3 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 23:19:41 +0300 Subject: [PATCH 20/65] =?UTF-8?q?[feat]=20deps-auto=203:=20pc-battery=20?= =?UTF-8?q?=E2=80=94=20the=20GPU=20half=20of=20the=20dependency=20gate,=20?= =?UTF-8?q?pulled=20from=20a=20desktop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/ci/pc-battery.sh | --poll: refuses a fork head or an author other than dependabot[bot] / the logged-in maintainer before checking anything out; runs /kit, the app surfaces, the level shots (base twice = the noise floor) and perf-games frame time base vs head on a real GPU, posts the pc-battery commit status with a secret-gist summary (numbers only) - poll = open PRs into deps with a green deps-gate and no pc-battery status; no self-hosted runner (public repo), the timer lives in the desktop's local config; PC_BATTERY_WRAP serialises GPU use - tests/e2e/tools/ab-shots.cjs: the integrators' A/B surface set made generic + a PNG-dir diff with a base-vs-base noise floor; battery.json gains the pc games/levels block - battery.cjs: the dev-server probe asks both loopbacks (vite may listen on ::1 only) Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/ci/pc-battery.sh | 202 +++++++++++++++++++++++++++++++++ tests/e2e/tools/ab-shots.cjs | 210 +++++++++++++++++++++++++++++++++++ 2 files changed, 412 insertions(+) create mode 100755 scripts/ci/pc-battery.sh create mode 100644 tests/e2e/tools/ab-shots.cjs diff --git a/scripts/ci/pc-battery.sh b/scripts/ci/pc-battery.sh new file mode 100755 index 000000000..d3041a678 --- /dev/null +++ b/scripts/ci/pc-battery.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# deps-auto — THE GPU HALF OF THE DEPENDENCY GATE, PULLED FROM A DESKTOP. +# +# GitHub's runners have no GPU, so deps.yml's battery cannot see a shader that compiles differently or a +# frame that got slower. This script runs on a machine that has one. Nothing on GitHub can reach it and no +# runner of ours is registered (this repo is public: a self-hosted runner would run a stranger's PR), so it +# PULLS: a timer on that machine runs `--poll`, which picks the open PRs into `deps` whose `deps-gate` is +# green and which carry no `pc-battery` status yet, and runs each one: +# +# 1 /kit in every theme, base vs head (tests/e2e/tools/kit-diff.cjs) +# 2 the app surfaces, desktop + two phone shapes (tests/e2e/tools/ab-shots.cjs shoot/diff) +# 3 the level shots, base twice (the noise floor) + head (scripts/perf-levels.cjs SHOTS=1) +# 4 fps on the heaviest games, base/head alternating x2 (scripts/perf-games.cjs) +# +# and posts the commit status `pc-battery` (success | failure, the summary as a secret gist for its link). +# The games and levels are battery.json's `pc` block. +# +# WHO IT RUNS FOR: the PR's head must be a branch of THIS repository (never a fork) and its author +# dependabot[bot] or the account `gh` is logged in as (plus PC_BATTERY_AUTHORS). Anything else is +# refused before a byte of it is checked out — it would be running someone else's code on your desktop. +# +# scripts/ci/pc-battery.sh [--dry-run] one PR (--dry-run: say what would happen, post nothing) +# scripts/ci/pc-battery.sh --poll [--dry-run] every eligible open PR into deps, oldest first +# +# Environment (all optional; machine-specific values belong in the timer's unit, never in this file): +# PC_BATTERY_REPO owner/name (default: theprototype-app/core) +# PC_BATTERY_DIR work dir: clones, worktrees, evidence (default: ~/.cache/pc-battery) +# PC_BATTERY_PORT first of two dev-server ports (default: 5610; head = +1) +# PC_BATTERY_AUTHORS extra allowed authors, space-separated +# PC_BATTERY_WRAP a command prefix that serialises GPU use with other jobs on the machine, e.g. an +# exclusive e2e slot; the measuring part runs inside it (default: none) +# PC_BATTERY_FPS_MAX allowed head/base p50 frame-time ratio (default: 1.15) +set -uo pipefail + +REPO=${PC_BATTERY_REPO:-theprototype-app/core} +DIR=${PC_BATTERY_DIR:-$HOME/.cache/pc-battery} +PORT=${PC_BATTERY_PORT:-5610} +FPS_MAX=${PC_BATTERY_FPS_MAX:-1.15} +DRY=0 +MODE=one +PR= +for a in "$@"; do + case "$a" in + --dry-run) DRY=1 ;; + --poll) MODE=poll ;; + [0-9]*) PR=$a ;; + *) echo "usage: pc-battery.sh [--dry-run] | --poll [--dry-run]" >&2; exit 2 ;; + esac +done +[ "$MODE" = poll ] || [ -n "$PR" ] || { echo "usage: pc-battery.sh [--dry-run] | --poll [--dry-run]" >&2; exit 2; } +mkdir -p "$DIR" +log() { echo "[pc-battery $(date +%H:%M:%S)] $*"; } + +ME=$(gh api user -q .login) || { echo "gh is not logged in" >&2; exit 1; } +ALLOWED=" dependabot[bot] app/dependabot $ME ${PC_BATTERY_AUTHORS:-} " + +# --- the filter: decided from GitHub's metadata, before anything is checked out +# eligible -> prints "ok " or "no " +eligible() { + local j + j=$(gh pr view "$1" -R "$REPO" --json state,isCrossRepository,headRepository,headRepositoryOwner,author,headRefOid,baseRefName) || { echo "no unreadable"; return; } + local state cross owner name author sha base + state=$(jq -r .state <<<"$j"); cross=$(jq -r .isCrossRepository <<<"$j") + owner=$(jq -r .headRepositoryOwner.login <<<"$j"); name=$(jq -r .headRepository.name <<<"$j") + author=$(jq -r .author.login <<<"$j"); sha=$(jq -r .headRefOid <<<"$j"); base=$(jq -r .baseRefName <<<"$j") + [ "$state" = OPEN ] || { echo "no not open ($state)"; return; } + [ "$cross" = false ] && [ "$owner/$name" = "$REPO" ] || { echo "no head is not a branch of $REPO ($owner/$name)"; return; } + case "$ALLOWED" in *" $author "*) ;; *) echo "no author $author is not allowed"; return ;; esac + echo "ok $sha $base" +} + +# gate_green : deps.yml's deps-gate concluded success on this commit +gate_green() { + [ "$(gh api "repos/$REPO/commits/$1/check-runs?check_name=deps-gate" -q '[.check_runs[] | select(.conclusion == "success")] | length')" -gt 0 ] +} +has_status() { + [ "$(gh api "repos/$REPO/commits/$1/status" -q '[.statuses[] | select(.context == "pc-battery")] | length')" -gt 0 ] +} +status() { # [url] + [ "$DRY" = 1 ] && { log "would post pc-battery=$2 ($3)"; return; } + gh api "repos/$REPO/statuses/$1" -f state="$2" -f context=pc-battery -f description="${3:0:139}" ${4:+-f target_url="$4"} > /dev/null +} + +# --- the run +run_pr() { + local pr=$1 sha=$2 base=$3 + local W="$DIR/runs/$pr-${sha:0:7}" C="$DIR/core" + log "PR #$pr head ${sha:0:7} base $base" + [ "$DRY" = 1 ] && { log "would run the GPU battery on #$pr"; return 0; } + status "$sha" pending "Running on the desktop GPU" + rm -rf "$W"; mkdir -p "$W" + [ -d "$C/.git" ] || git clone -q "https://github.com/$REPO.git" "$C" + git -C "$C" fetch -q origin "+refs/heads/$base:refs/remotes/origin/$base" "+refs/pull/$pr/head:refs/pc-battery/$pr" || return 1 + [ "$(git -C "$C" rev-parse "refs/pc-battery/$pr")" = "$sha" ] || { log "head moved since the check: next poll"; return 1; } + local mb; mb=$(git -C "$C" merge-base "origin/$base" "$sha") + for side in base head; do + git -C "$C" worktree remove --force "$DIR/$side" 2>/dev/null; rm -rf "$DIR/$side" + done + git -C "$C" worktree add -q --detach "$DIR/base" "$mb" && git -C "$C" worktree add -q --detach "$DIR/head" "$sha" || return 1 + for side in base head; do + (cd "$DIR/$side" && npm ci --no-audit --no-fund > "$W/npm-$side.log" 2>&1 && npm run certs > /dev/null 2>&1) || { log "npm ci failed on $side"; return 1; } + done + (cd "$DIR/head" && npx playwright install chromium > "$W/playwright.log" 2>&1) + # the sibling content at the refs the head's battery pins + local B="$DIR/head/tests/e2e/battery.json" + for sib in scenes modules; do + local repo ref; repo=$(jq -r ".refs.$sib.repo" "$B"); ref=$(jq -r ".refs.$sib.ref" "$B") + [ -d "$DIR/$sib/.git" ] || git clone -q "https://github.com/$repo.git" "$DIR/$sib" + git -C "$DIR/$sib" fetch -q origin && git -C "$DIR/$sib" checkout -q --detach "$ref" || return 1 + done + (cd "$DIR/modules" && npm ci --no-audit --no-fund > "$W/npm-modules.log" 2>&1 && node scripts/pack.cjs --all --force > "$W/pack.log" 2>&1) || { log "module pack failed"; return 1; } + local games levels; games=$(jq -r '.pc.games | join(",")' "$B"); levels=$(jq -r '.pc.levels | join(",")' "$B") + + # servers: base on PORT, head on PORT+1, started one after the other (a cold boot is the slow part) + local pids=() + serve() { (cd "$1" && setsid npm run dev -- --port "$2" --strictPort --host localhost > "$W/dev-$2.log" 2>&1 & echo $! > "$W/dev-$2.pid"); pids+=("$(cat "$W/dev-$2.pid")") + for _ in $(seq 1 120); do curl -sk --max-time 300 -o /dev/null "https://localhost:$2/" && break; sleep 2; done + curl -sk --max-time 300 -o /dev/null "https://localhost:$2/"; curl -sk --max-time 300 -o /dev/null "https://localhost:$2/kit"; } + stop_servers() { for p in "${pids[@]}"; do kill -- -"$p" 2>/dev/null; done; } + trap stop_servers RETURN + serve "$DIR/base" "$PORT"; serve "$DIR/head" $((PORT + 1)) + local UB="https://localhost:$PORT/" UH="https://localhost:$((PORT + 1))/" + + # every probe runs from the HEAD tree against both URLs, so only the app differs + cat > "$W/measure.sh" <> "$W/rc.txt" +node tests/e2e/tools/ab-shots.cjs shoot $UB "$W/surf/base" && node tests/e2e/tools/ab-shots.cjs shoot $UH "$W/surf/head" +node tests/e2e/tools/ab-shots.cjs diff "$W/surf/base" "$W/surf/head" "$W/surf/diff"; echo "surfaces rc=\$?" >> "$W/rc.txt" +for t in base1:$UB head:$UH base2:$UB; do + SHOTS=1 APP_URL=\${t#*:} node scripts/perf-levels.cjs --dir "$DIR/scenes" --only $levels --label ab --out "$W/lvl/\${t%%:*}" > "$W/lvl-\${t%%:*}.log" 2>&1 +done +node tests/e2e/tools/ab-shots.cjs diff "$W/lvl/base1" "$W/lvl/head" "$W/lvl/diff" --noise "$W/lvl/base2"; echo "levels rc=\$?" >> "$W/rc.txt" +for i in 1 2; do for t in head:$UH base:$UB; do + APP_URL=\${t#*:} node scripts/perf-games.cjs --only $games --label \${t%%:*}-\$i --out "$W/fps" > "$W/fps-\${t%%:*}-\$i.log" 2>&1 +done; done +EOF + : > "$W/rc.txt" + ${PC_BATTERY_WRAP:-} bash "$W/measure.sh" > "$W/measure.log" 2>&1 + stop_servers; trap - RETURN + + # fps: same scene (calls / triangles within 2 %) and no slower than FPS_MAX x the slower base run + node - "$W/fps" "$FPS_MAX" > "$W/fps.md" <<'EOF' +const fs = require('fs'); const [dir, max] = [process.argv[2], Number(process.argv[3])]; +const read = (l) => { try { return JSON.parse(fs.readFileSync(`${dir}/perf-${l}.json`, 'utf8')).rows; } catch { return []; } }; +const runs = { base: [read('base-1'), read('base-2')], head: [read('head-1'), read('head-2')] }; +const by = (rows, slug) => rows.find((r) => r.slug === slug); +const slugs = [...new Set([...runs.base[0], ...runs.head[0]].map((r) => r.slug))]; +let bad = 0; const out = ['| game | calls base→head | tris base→head | p50 ms base (max) → head (min) | verdict |', '|---|---:|---:|---:|---|']; +for (const s of slugs) { + const b = runs.base.map((r) => by(r, s)).filter(Boolean); const h = runs.head.map((r) => by(r, s)).filter(Boolean); + if (!b.length || !h.length) { bad++; out.push(`| ${s} | – | – | – | **missing run** |`); continue; } + const near = (x, y) => Math.abs(x - y) <= Math.max(2, 0.02 * Math.max(x, y)); + const bp = Math.max(...b.map((r) => r.p50)), hp = Math.min(...h.map((r) => r.p50)); + const scene = near(b[0].medianCalls, h[0].medianCalls) && near(b[0].medianTriangles, h[0].medianTriangles); + const ok = scene && hp <= bp * max; if (!ok) bad++; + out.push(`| ${s} | ${b[0].medianCalls}→${h[0].medianCalls} | ${b[0].medianTriangles}→${h[0].medianTriangles} | ${bp.toFixed(1)} → ${hp.toFixed(1)} | ${ok ? 'ok' : scene ? '**slower**' : '**scene changed**'} |`); +} +console.log(out.join('\n')); fs.writeFileSync(`${dir}/verdict`, bad ? 'fail' : 'ok'); +EOF + local fps; fps=$(cat "$W/fps/verdict" 2>/dev/null || echo fail); echo "fps rc=$([ "$fps" = ok ] && echo 0 || echo 1)" >> "$W/rc.txt" + + # the summary: numbers only (no host, path or user names; it is public through the status link) + local fails; fails=$(grep -c 'rc=[1-9]' "$W/rc.txt"); local rcs; rcs=$(tr '\n' ' ' < "$W/rc.txt") + { + echo "# pc-battery — PR #$pr at ${sha:0:7} vs ${mb:0:7} ($base)" + echo; echo "Desktop GPU. $rcs"; echo + echo "## /kit"; cat "$W/kit/diff/report.md" 2>/dev/null || echo "(no report)"; echo + echo "## App surfaces"; cat "$W/surf/diff/report.md" 2>/dev/null || echo "(no report)"; echo + echo "## Level shots (base twice = the noise floor)"; cat "$W/lvl/diff/report.md" 2>/dev/null || echo "(no report)"; echo + echo "## Frame time (perf-games, base/head alternating x2)"; cat "$W/fps.md" + } > "$W/summary.md" + local url; url=$(gh gist create -d "pc-battery $REPO#$pr ${sha:0:7}" "$W/summary.md" 2>/dev/null | tail -1) + if [ "$fails" = 0 ]; then + status "$sha" success "Desktop GPU: kit, surfaces, levels and fps match the base" "$url" + else + status "$sha" failure "Desktop GPU: $rcs" "$url" + fi + log "PR #$pr: $rcs -> ${url:-no gist}" + return 0 +} + +exec 9> "$DIR/lock" +flock -n 9 || { log "another pc-battery run holds $DIR/lock"; exit 0; } + +if [ "$MODE" = one ]; then + read -r verdict sha base <<<"$(eligible "$PR")" + [ "$verdict" = ok ] || { log "PR #$PR refused: $sha $base"; exit 3; } + run_pr "$PR" "$sha" "$base" + exit $? +fi + +# --poll: open PRs into deps, oldest first, green gate, no pc-battery status yet +for pr in $(gh pr list -R "$REPO" --base deps --state open --json number -q 'sort_by(.number) | .[].number'); do + read -r verdict sha base <<<"$(eligible "$pr")" + if [ "$verdict" != ok ]; then log "#$pr skipped: $sha $base"; continue; fi + has_status "$sha" && continue + gate_green "$sha" || { log "#$pr: deps-gate not green on ${sha:0:7} (yet)"; continue; } + run_pr "$pr" "$sha" "$base" +done diff --git a/tests/e2e/tools/ab-shots.cjs b/tests/e2e/tools/ab-shots.cjs new file mode 100644 index 000000000..3c977cdcb --- /dev/null +++ b/tests/e2e/tools/ab-shots.cjs @@ -0,0 +1,210 @@ +// tests/e2e/tools/ab-shots.cjs — deps-auto: THE A/B SCREENSHOT SET, for a before/after of a change that should +// look like nothing (a dependency bump). The integrators' release A/B, made generic for scripts/ci/pc-battery.sh. +// +// node tests/e2e/tools/ab-shots.cjs shoot +// app surfaces (start, object list, Inspector, Settings, logo menu) x dark/light x desktop 1440x900 + a +// folded (390x896) and an unfolded (770x850) touch phone -> /--.png +// The 3D canvas is hidden (a GPU's anti-aliasing is not a UI change), transitions are off and the +// random 5-letter peer id is masked. +// node tests/e2e/tools/ab-shots.cjs diff [--noise ] +// every PNG present in both dirs: changed pixels (a channel moved more than AB_TOLERANCE, default 16) +// -> /-diff.png + report.md + report.json. With --noise, the same diff between two +// BASE runs is the floor (animated scenes never shoot the same twice): a shot fails only when +// head-vs-base moved more than 1.5 x the floor + AB_MAX (fraction, default 0.0001). Exit 1 on a failure. +// +// Like kit-diff.cjs the decode and the compare run in a browser page (no image library in the repo). +const fs = require('fs'); +const path = require('path'); +const { chromium } = require('playwright'); + +const THEMES = (process.env.AB_THEMES || 'dark,light').split(','); +const DEVICES = { + desktop: { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 }, + 'phone-folded': { viewport: { width: 390, height: 896 }, deviceScaleFactor: 2.9, hasTouch: true, isMobile: true }, + 'phone-unfolded': { viewport: { width: 770, height: 850 }, deviceScaleFactor: 2.9, hasTouch: true, isMobile: true } +}; +const TOLERANCE = Number(process.env.AB_TOLERANCE ?? 16); +const MAX = Number(process.env.AB_MAX ?? 0.0001); + +/** @param {any} ctx @param {string} url */ +async function boot(ctx, url) { + const p = await ctx.newPage(); + // a dev server's second fresh context sometimes never publishes the stores (deps re-optimised): reload + for (let attempt = 1; ; attempt++) { + await p.goto(url, { waitUntil: 'domcontentloaded', timeout: 120000 }).catch(() => {}); + const ok = await p.waitForFunction(() => /** @type {any} */ (window).__stores?.themes, null, { timeout: 90000 }).then( + () => true, + () => false + ); + if (ok) break; + if (attempt === 4) throw new Error('app never booted'); + } + await p.waitForTimeout(3500); + await p.locator('#restore-session-dismiss').click({ timeout: 1500 }).catch(() => {}); + return p; +} + +/** @param {string} url @param {string} out */ +async function shoot(url, out) { + fs.mkdirSync(out, { recursive: true }); + const b = await chromium.launch({ headless: true }); + let failed = 0; + for (const [dev, opts] of Object.entries(DEVICES)) { + for (const theme of THEMES) { + const ctx = await b.newContext({ ignoreHTTPSErrors: true, reducedMotion: 'reduce', ...opts }); + await ctx.addInitScript((t) => { + localStorage.setItem('debugStores', 'true'); + localStorage.setItem('hasSeenDisclaimer', 'true'); + localStorage.setItem('hasSeenWelcome', 'true'); + localStorage.setItem('theme', t); + }, theme); + /** @type {any} */ + let p; + try { + p = await boot(ctx, url); + } catch { + console.log('boot failed', dev, theme); + failed++; + await ctx.close(); + continue; + } + await p.addStyleTag({ content: 'canvas{visibility:hidden!important} *{transition:none!important;animation:none!important;caret-color:transparent!important}' }); + /** @param {string} name */ + const shot = async (name) => { + await p.waitForTimeout(900); + await p.screenshot({ path: path.join(out, `${name}-${dev}-${theme}.png`), mask: [p.getByText(/^[A-Z0-9]{5}$/)] }).catch(() => failed++); + }; + const reset = () => + p.evaluate(() => { + const s = /** @type {any} */ (window).__stores; + s.settingsOpen.set(false); + s.inspectorClose.set(true); + s.objectListClose?.set(true); + }); + await p.evaluate(() => /** @type {any} */ (window).__stores.commandsHandler.sceneCommand('/create box')); + await p.waitForTimeout(700); + await reset(); + await shot('start'); + await p.evaluate(() => /** @type {any} */ (window).__stores.objectListClose?.set(false)); + await shot('list'); + await reset(); + await p.evaluate(() => { + const s = /** @type {any} */ (window).__stores; + /** @type {any} */ + let g; + s.objectsGroup.subscribe((/** @type {any} */ x) => (g = x))(); + const box = g?.children?.[g.children.length - 1]; + if (box) s.objectActions?.selectObject?.(box) ?? s.selectedObjects.set([box.uuid]); + s.inspectorClose.set(false); + }); + // the Inspector shows the box's random uuid: not a difference worth a red + await p.waitForTimeout(900); + await p.screenshot({ path: path.join(out, `inspector-${dev}-${theme}.png`), mask: [p.getByText(/^[A-Z0-9]{5}$/), p.getByText(/[0-9a-f]{8}-[0-9a-f]{4}-/)] }).catch(() => failed++); + await reset(); + await p.evaluate(() => { + const s = /** @type {any} */ (window).__stores; + s.settingsSection.set('interface'); + s.settingsOpen.set(true); + }); + await p.waitForSelector('dialog.settings-dialog .ss-page', { timeout: 15000 }).catch(() => {}); + await shot('settings'); + await reset(); + await p.locator('#logo-menu').click({ timeout: 3000 }).catch(() => {}); + await shot('menu'); + await ctx.close(); + } + } + await b.close(); + return failed; +} + +/** @param {any} page @param {string} a @param {string} b @param {string|null} diffOut */ +async function compare(page, a, b, diffOut) { + const r = await page.evaluate( + async (/** @type {{a: string, b: string, tol: number, want: boolean}} */ { a, b, tol, want }) => { + const load = (/** @type {string} */ s) => + new Promise((res, rej) => { + const i = new Image(); + i.onload = () => res(i); + i.onerror = rej; + i.src = 'data:image/png;base64,' + s; + }); + const [ia, ib] = /** @type {HTMLImageElement[]} */ (await Promise.all([load(a), load(b)])); + const w = Math.max(ia.width, ib.width); + const h = Math.max(ia.height, ib.height); + const draw = (/** @type {HTMLImageElement} */ img) => { + const c = document.createElement('canvas'); + c.width = w; + c.height = h; + const x = /** @type {CanvasRenderingContext2D} */ (c.getContext('2d')); + x.fillStyle = '#f0f'; + x.fillRect(0, 0, w, h); + x.drawImage(img, 0, 0); + return x.getImageData(0, 0, w, h); + }; + const da = draw(ia); + const db = draw(ib); + const oc = document.createElement('canvas'); + oc.width = w; + oc.height = h; + const ox = /** @type {CanvasRenderingContext2D} */ (oc.getContext('2d')); + const od = ox.createImageData(w, h); + let changed = 0; + for (let i = 0; i < da.data.length; i += 4) { + const d = Math.max(Math.abs(da.data[i] - db.data[i]), Math.abs(da.data[i + 1] - db.data[i + 1]), Math.abs(da.data[i + 2] - db.data[i + 2])); + if (d > tol) { + changed++; + od.data.set([255, 0, 0, 255], i); + } else { + const g = 200 + ((db.data[i] + db.data[i + 1] + db.data[i + 2]) / 3) * 0.2; + od.data.set([g, g, g, 255], i); + } + } + if (!want) return { changed, total: w * h, png: '' }; + ox.putImageData(od, 0, 0); + return { changed, total: w * h, png: oc.toDataURL('image/png').split(',')[1] }; + }, + { a: fs.readFileSync(a).toString('base64'), b: fs.readFileSync(b).toString('base64'), tol: TOLERANCE, want: !!diffOut } + ); + if (diffOut && r.changed) fs.writeFileSync(diffOut, Buffer.from(r.png, 'base64')); + return { changed: r.changed, fraction: r.changed / r.total }; +} + +/** @param {string} base @param {string} head @param {string} out @param {string|null} noise */ +async function diff(base, head, out, noise) { + fs.mkdirSync(out, { recursive: true }); + const names = fs.readdirSync(base).filter((n) => n.endsWith('.png') && fs.existsSync(path.join(head, n))).sort(); + const b = await chromium.launch({ headless: true }); + const page = await b.newPage(); + const rows = []; + for (const n of names) { + const d = await compare(page, path.join(base, n), path.join(head, n), path.join(out, n.replace(/\.png$/, '-diff.png'))); + const floor = noise && fs.existsSync(path.join(noise, n)) ? (await compare(page, path.join(base, n), path.join(noise, n), null)).fraction : 0; + const fail = d.fraction > floor * 1.5 + MAX; + rows.push({ name: n, changed: d.changed, fraction: d.fraction, floor, fail }); + } + await b.close(); + const missing = fs.readdirSync(base).filter((n) => n.endsWith('.png') && !names.includes(n)); + const pct = (/** @type {number} */ f) => (f * 100).toFixed(3) + ' %'; + let md = `| shot | changed px | head vs base | base vs base |\n|---|---:|---:|---:|\n`; + md += rows.map((r) => `| ${r.fail ? '**' + r.name + '**' : r.name} | ${r.changed} | ${pct(r.fraction)} | ${noise ? pct(r.floor) : '–'} |`).join('\n') + '\n'; + if (missing.length) md += `\nNot shot on the head: ${missing.join(', ')}\n`; + const failed = rows.filter((r) => r.fail).length + missing.length; + md = `${failed ? `**${failed} shot(s) changed**` : `${rows.length} shots, no change beyond the noise`}\n\n` + md; + fs.writeFileSync(path.join(out, 'report.md'), md); + fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify({ rows, missing }, null, 1)); + console.log(md); + return failed; +} + +if (require.main === module) { + const [cmd, ...a] = process.argv.slice(2); + const ni = a.indexOf('--noise'); + const noise = ni >= 0 ? a.splice(ni, 2)[1] : null; + const run = cmd === 'shoot' && a.length >= 2 ? shoot(a[0], a[1]) : cmd === 'diff' && a.length >= 3 ? diff(a[0], a[1], a[2], noise) : null; + if (!run) { + console.error('usage: ab-shots.cjs shoot | diff [--noise ]'); + process.exit(2); + } + run.then((failed) => process.exit(failed ? 1 : 0)); +} From 7a9f5c81caa232fa882df91a16e5bb5aff075055 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 23:22:37 +0300 Subject: [PATCH 21/65] [fix] deps-auto 3b: pc-battery needs no jq; a pending status older than PC_BATTERY_STALE is re-run (the desktop slept mid-run) Co-Authored-By: Claude Opus 5.5 (1M context) --- CONTRIBUTING.md | 7 +++++++ RELEASING.md | 45 ++++++++++++++++++++++++++++++++++++++++ scripts/ci/pc-battery.sh | 41 ++++++++++++++++++++++++++---------- 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 98baa1989..cad52feba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,13 @@ two-peer session on your desk. - Screenshots/clips for anything visual. Playwright can capture them. - Look at issues labeled **good first issue** if you want a scoped entry point. +## Dependency updates + +Dependabot opens its PRs against the `deps` branch, where `deps.yml` runs the full gate plus the e2e +battery and merges patch / development-only / Actions updates by itself. Please do not bump +dependencies in a feature PR; if one needs a newer version, say so in the PR and it goes through +`deps`. The whole loop is in [RELEASING.md](RELEASING.md#dependency-updates-the-deps-branch). + ## Questions GitHub Discussions for ideas/help, issues for bugs (template asks for repro diff --git a/RELEASING.md b/RELEASING.md index 768e5289c..0f9ba443c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -45,6 +45,51 @@ The toolchain is pinned too: `.nvmrc` / `.mise.toml` (Node) and `packageManager` `devEngines` in package.json (npm). Bump them together — ci.yml's "toolchain pins agree" step fails when they disagree. +## Dependency updates: the `deps` branch + +Dependabot's version updates never open against `main`. They open against the long-lived **`deps`** +branch (`.github/dependabot.yml`, `target-branch: deps`; monthly, npm at the root and in `tools/agent`, +plus GitHub Actions), and `.github/workflows/deps.yml` decides what happens to each one: + +1. **The gate.** ci.yml and perf-budget.yml run as reusable workflows (build, the svelte-check ratchet, + vitest, scene-lint, kit-diff, a11y, supply chain, the perf budget), then the e2e battery in shards on + GitHub-hosted runners (SwiftShader, no GPU). The battery is `tests/e2e/battery.json`; the shard runner + is `scripts/ci/battery.cjs`. One check sums it all up: **`deps-gate`**. +2. **The GPU half.** `pc-battery` is a commit status posted from a desktop with a real GPU + (`scripts/ci/pc-battery.sh`: /kit, the app surfaces, the level shots and frame time, base vs head). + Nothing on GitHub can reach that machine and the repo registers no self-hosted runner (it is + public): a timer there runs `scripts/ci/pc-battery.sh --poll`, which picks up open PRs into `deps` + whose `deps-gate` is green, from a branch of this repo, by Dependabot or the maintainer, and still + without a `pc-battery` status. For a PR that changes nothing the app ships, deps.yml posts + `pc-battery` itself as "not needed". +3. **The policy** (`scripts/ci/deps-policy.cjs`, unit-tested in `tests/unit/depsCi.test.js`): + + | update | once green | + |---|---| + | patch, a development-only package, any GitHub Action | GitHub auto-merge into `deps` | + | a minor of something that ships (a `dependency`, or svelte / vite / kit / tailwind / flowbite) | label `deps-ready`; the monthly deps lane merges it after the release notes + a visual review | + | any Playwright bump | `deps-ready`: the e2e hosts outside GitHub must serve the same browser first | + | a major | `needs-migration` + a comment; a planned migration, never automation | + + The ruleset on `deps` requires `deps-gate` and `pc-battery`, so an auto-merge of a runtime patch waits + for the desktop. The ruleset also forbids deleting `deps`: it is never cleaned up with merged branches. + +**Integrators:** at union time, `git merge origin/deps` into `feat/` before the battery, so the bumps +ship with a tested union and `main` never takes a dependency change on its own. After the release, bring +`deps` up to the released `main` (`git push origin main:deps` when it fast-forwards, else merge `main` +into `deps`). + +**Security updates** are GitHub's exception: they always target `main`, whatever `target-branch` says. +deps.yml runs the same battery and labels on them there; nothing auto-merges into `main`, a person does. + +**When the battery list moves** (an integrator's battery added or dropped suites), refresh it from that +run: `node scripts/ci/battery.cjs refresh --list ... --summary ...` +(names come from the lists, seconds from the summaries). `--results ` takes seconds measured on the +runners instead (deps.yml's `battery-durations` artifact). A suite goes into `optional` (runs, reported, +does not gate) only when it is red or flaky on the unchanged base too, with that reason on its line; +`skip` is for what a public runner cannot do at all. The shard count follows the recorded seconds +(`shards.target_minutes`, at most `shards.max`). + ## Recreate `release/next` after the tag push The repo has `delete_branch_on_merge: true`, so merging the release PR DELETES diff --git a/scripts/ci/pc-battery.sh b/scripts/ci/pc-battery.sh index d3041a678..86edfe9df 100755 --- a/scripts/ci/pc-battery.sh +++ b/scripts/ci/pc-battery.sh @@ -30,6 +30,8 @@ # PC_BATTERY_WRAP a command prefix that serialises GPU use with other jobs on the machine, e.g. an # exclusive e2e slot; the measuring part runs inside it (default: none) # PC_BATTERY_FPS_MAX allowed head/base p50 frame-time ratio (default: 1.15) +# PC_BATTERY_STALE seconds after which a `pending` status of ours counts as abandoned (default: 10800) +# Needs git, gh (logged in: repo status + gist), node/npm at the repo's .nvmrc, curl, flock. set -uo pipefail REPO=${PC_BATTERY_REPO:-theprototype-app/core} @@ -57,14 +59,13 @@ ALLOWED=" dependabot[bot] app/dependabot $ME ${PC_BATTERY_AUTHORS:-} " # --- the filter: decided from GitHub's metadata, before anything is checked out # eligible -> prints "ok " or "no " eligible() { - local j - j=$(gh pr view "$1" -R "$REPO" --json state,isCrossRepository,headRepository,headRepositoryOwner,author,headRefOid,baseRefName) || { echo "no unreadable"; return; } - local state cross owner name author sha base - state=$(jq -r .state <<<"$j"); cross=$(jq -r .isCrossRepository <<<"$j") - owner=$(jq -r .headRepositoryOwner.login <<<"$j"); name=$(jq -r .headRepository.name <<<"$j") - author=$(jq -r .author.login <<<"$j"); sha=$(jq -r .headRefOid <<<"$j"); base=$(jq -r .baseRefName <<<"$j") + local row + row=$(gh pr view "$1" -R "$REPO" --json state,isCrossRepository,headRepository,headRepositoryOwner,author,headRefOid,baseRefName \ + -q '[.state, .isCrossRepository, .headRepositoryOwner.login + "/" + .headRepository.name, .author.login, .headRefOid, .baseRefName] | @tsv') || { echo "no unreadable"; return; } + local state cross head author sha base + IFS=$'\t' read -r state cross head author sha base <<<"$row" [ "$state" = OPEN ] || { echo "no not open ($state)"; return; } - [ "$cross" = false ] && [ "$owner/$name" = "$REPO" ] || { echo "no head is not a branch of $REPO ($owner/$name)"; return; } + [ "$cross" = false ] && [ "$head" = "$REPO" ] || { echo "no head is not a branch of $REPO ($head)"; return; } case "$ALLOWED" in *" $author "*) ;; *) echo "no author $author is not allowed"; return ;; esac echo "ok $sha $base" } @@ -73,7 +74,16 @@ eligible() { gate_green() { [ "$(gh api "repos/$REPO/commits/$1/check-runs?check_name=deps-gate" -q '[.check_runs[] | select(.conclusion == "success")] | length')" -gt 0 ] } -has_status() { +# settled : a final pc-battery status, or a pending one younger than PC_BATTERY_STALE seconds. An older +# pending one is a run this machine never finished (it slept or rebooted mid-run): run it again. +settled() { + local row; row=$(gh api "repos/$REPO/commits/$1/statuses" -q '[.[] | select(.context == "pc-battery")][0] | [.state, .updated_at] | @tsv') + [ -n "$row" ] || return 1 + local st at; IFS=$'\t' read -r st at <<<"$row" + [ "$st" != pending ] && return 0 + [ $(($(date +%s) - $(date -d "$at" +%s))) -lt "${PC_BATTERY_STALE:-10800}" ] +} +status() { [ "$(gh api "repos/$REPO/commits/$1/status" -q '[.statuses[] | select(.context == "pc-battery")] | length')" -gt 0 ] } status() { # [url] @@ -104,12 +114,12 @@ run_pr() { # the sibling content at the refs the head's battery pins local B="$DIR/head/tests/e2e/battery.json" for sib in scenes modules; do - local repo ref; repo=$(jq -r ".refs.$sib.repo" "$B"); ref=$(jq -r ".refs.$sib.ref" "$B") + local repo ref; repo=$(node -p "require('$B').refs.$sib.repo"); ref=$(node -p "require('$B').refs.$sib.ref") [ -d "$DIR/$sib/.git" ] || git clone -q "https://github.com/$repo.git" "$DIR/$sib" git -C "$DIR/$sib" fetch -q origin && git -C "$DIR/$sib" checkout -q --detach "$ref" || return 1 done (cd "$DIR/modules" && npm ci --no-audit --no-fund > "$W/npm-modules.log" 2>&1 && node scripts/pack.cjs --all --force > "$W/pack.log" 2>&1) || { log "module pack failed"; return 1; } - local games levels; games=$(jq -r '.pc.games | join(",")' "$B"); levels=$(jq -r '.pc.levels | join(",")' "$B") + local games levels; games=$(node -p "require('$B').pc.games.join(',')"); levels=$(node -p "require('$B').pc.levels.join(',')") # servers: base on PORT, head on PORT+1, started one after the other (a cold boot is the slow part) local pids=() @@ -196,7 +206,16 @@ fi for pr in $(gh pr list -R "$REPO" --base deps --state open --json number -q 'sort_by(.number) | .[].number'); do read -r verdict sha base <<<"$(eligible "$pr")" if [ "$verdict" != ok ]; then log "#$pr skipped: $sha $base"; continue; fi - has_status "$sha" && continue + # settled : a final pc-battery status, or a pending one younger than PC_BATTERY_STALE seconds. An older +# pending one is a run this machine never finished (it slept or rebooted mid-run): run it again. +settled() { + local row; row=$(gh api "repos/$REPO/commits/$1/statuses" -q '[.[] | select(.context == "pc-battery")][0] | [.state, .updated_at] | @tsv') + [ -n "$row" ] || return 1 + local st at; IFS=$'\t' read -r st at <<<"$row" + [ "$st" != pending ] && return 0 + [ $(($(date +%s) - $(date -d "$at" +%s))) -lt "${PC_BATTERY_STALE:-10800}" ] +} +status "$sha" && continue gate_green "$sha" || { log "#$pr: deps-gate not green on ${sha:0:7} (yet)"; continue; } run_pr "$pr" "$sha" "$base" done From 209225a2ccec88373180d7b342ad474985a5c52c Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 24 Oct 2026 00:49:00 +0300 Subject: [PATCH 22/65] [fix] deps-auto 2b: the shard runner tells the suites where the app is (APP_URL); the perf gate runs as three parallel parts in deps.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run 1 on GitHub: every suite timed out on the suites' hosts-file default origin - scripts/perf-games.cjs --check --part k/n: deterministic longest-first split by measured cost (perf-budget.yml takes part/parts as workflow_call inputs; standalone runs are unchanged) — one job was ~60 min (games 23, levels 36), the floor under the whole deps gate Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/deps.yml | 8 ++++++++ .github/workflows/perf-budget.yml | 17 +++++++++++++---- scripts/ci/battery.cjs | 3 ++- scripts/perf-games.cjs | 22 +++++++++++++++++++++- 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deps.yml b/.github/workflows/deps.yml index b02000fec..e66da0094 100644 --- a/.github/workflows/deps.yml +++ b/.github/workflows/deps.yml @@ -83,10 +83,18 @@ jobs: if: needs.scope.outputs.gate_ci == 'true' uses: ./.github/workflows/ci.yml + # the budget gate in three parallel parts (~20 min each instead of ~60 in one) perf: needs: scope if: needs.scope.outputs.gate_ci == 'true' + strategy: + fail-fast: false + matrix: + part: [1, 2, 3] uses: ./.github/workflows/perf-budget.yml + with: + part: ${{ matrix.part }} + parts: 3 plan: needs: scope diff --git a/.github/workflows/perf-budget.yml b/.github/workflows/perf-budget.yml index eb3918cb9..0d4d44bb3 100644 --- a/.github/workflows/perf-budget.yml +++ b/.github/workflows/perf-budget.yml @@ -20,18 +20,26 @@ on: push: branches: [main, release/next] workflow_call: + inputs: + # deps-auto: deps.yml runs the gate as `parts` parallel jobs, this one = `part` (scripts/perf-games.cjs --part) + part: + type: number + default: 0 + parts: + type: number + default: 1 workflow_dispatch: permissions: contents: read concurrency: - group: perf-budget-${{ github.ref }} + group: perf-budget-${{ github.ref }}-${{ inputs.part }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: budget: - name: perf-budget + name: perf-budget${{ inputs.part && format(' {0}/{1}', inputs.part, inputs.parts) || '' }} runs-on: ubuntu-latest timeout-minutes: 75 steps: @@ -91,13 +99,14 @@ jobs: APP_URL: https://localhost:5173/ PERF_SCENES_DIR: ${{ runner.temp }}/perf-deps/scenes PERF_MODULES_DIR: ${{ runner.temp }}/perf-deps/modules - run: node scripts/perf-games.cjs --check --label ci --out perf/out + PART: ${{ inputs.part && format('{0}/{1}', inputs.part, inputs.parts) || '' }} + run: node scripts/perf-games.cjs --check --label ci${PART:+-${PART%/*}} --out perf/out ${PART:+--part $PART} - name: the table if: always() uses: actions/upload-artifact@v7 with: - name: perf-budget + name: perf-budget${{ inputs.part && format('-{0}', inputs.part) || '' }} path: perf/out/ if-no-files-found: ignore diff --git a/scripts/ci/battery.cjs b/scripts/ci/battery.cjs index f994cf223..6e97ae403 100755 --- a/scripts/ci/battery.cjs +++ b/scripts/ci/battery.cjs @@ -181,7 +181,8 @@ async function run(argv) { const only = opt(argv, '--only'); fs.mkdirSync(path.join(out, 'logs'), { recursive: true }); const mine = only ? only.split(',') : assign(b, of)[shard - 1].suites; - const url = process.env.APP_URL || `https://localhost:${port}/`; + // the suites' own default is a hosts-file origin: say where the app is + const url = (process.env.APP_URL ||= `https://localhost:${port}/`); let server = argv.includes('--serve') ? await serve(port, path.join(out, 'logs', 'dev-server.log')) : null; /** @type {SuiteResult[]} */ const results = []; diff --git a/scripts/perf-games.cjs b/scripts/perf-games.cjs index 84ce57de0..4e62a8aad 100644 --- a/scripts/perf-games.cjs +++ b/scripts/perf-games.cjs @@ -54,6 +54,9 @@ const flag = (name) => argv.includes('--' + name); // 34 B2 `--check`: THE BUDGET GATE (see the header of perfBudget.cjs and perf/README.md) — // counts only, in ONE pinned profile, games AND levels, judged against perf/budgets.json; // exit 1 when red. `--games-only` / `--levels-only` narrow it, `--budgets ` swaps the file. +// deps-auto `--part k/n`: this run measures part k of n (deterministic, balanced by measured cost — a game +// ~100 s, a level ~60 s + ~80 s per viewpoint on a runner), so a workflow can run the gate as n parallel jobs. +// A part judges only what it measured (an allow entry for a target it skipped is not "stale"). const CHECK = flag('check'); const budgetLib = require('./perfBudget.cjs'); const BUDGETS = CHECK ? budgetLib.loadBudgets(arg('budgets', budgetLib.BUDGETS_FILE)) : null; @@ -314,7 +317,24 @@ async function runCheck() { const pick = (slug) => !ONLY.length || ONLY.includes(slug); const { checkLevel, VIEWS } = require('./perf-levels.cjs'); const games = levelsOnly ? [] : (index.games || []).filter((g) => pick(g.slug)).map((g) => ({ slug: g.slug, title: g.title, scene: g.scene, modules: g.modules || [] })); - const levels = gamesOnly ? [] : (index.templates || []).filter((t) => VIEWS[t.slug] && pick(t.slug)); + let levels = gamesOnly ? [] : (index.templates || []).filter((t) => VIEWS[t.slug] && pick(t.slug)); + const part = /^(\d+)\/(\d+)$/.exec(arg('part', '') || ''); + if (part) { + const [k, n] = [Number(part[1]), Number(part[2])]; + /** @type {{kind: string, slug: string, cost: number}[]} */ + const items = [...games.map((g) => ({ kind: 'game', slug: g.slug, cost: 100 })), ...levels.map((t) => ({ kind: 'level', slug: t.slug, cost: 60 + 80 * VIEWS[t.slug].length }))]; + items.sort((a, b) => b.cost - a.cost || (a.slug < b.slug ? -1 : 1)); + const load = Array(n).fill(0); + const mine = new Set(); + for (const it of items) { + const best = load.indexOf(Math.min(...load)); + load[best] += it.cost; + if (best === k - 1) mine.add(it.kind + ':' + it.slug); + } + games.splice(0, games.length, ...games.filter((g) => mine.has('game:' + g.slug))); + levels = levels.filter((t) => mine.has('level:' + t.slug)); + console.log(`part ${k}/${n}: ${games.length} games, ${levels.length} levels`); + } if (!games.length && !levels.length) { console.error('budget gate: nothing to measure (scenes ' + (SCENES_DIR || SCENES_REPO + '@' + SCENES_REF) + ')'); process.exit(2); From 8c88200e70f457c8bc3a5e9e9b7845a53c3f8ed3 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 24 Oct 2026 02:48:28 +0300 Subject: [PATCH 23/65] [fix] 51 union E5: To objects brings a logic prefab back with its logic and places pack items - a prefab's scatter LOOK keeps its prefab id on the source (sourceOf keeps an optional `prefab`, sourceKey unchanged), so instancesToObjects instantiates the full prefab when this machine's library still holds it (the user's 2026-10-21 rule), else the look - pack-item sources become objects through the pack cache + importFile with the packRef (was: 'not in your Library') - the template offset is a matrix: a prefab lands centred on its look's base with its own root scale - unit: scatterSetFormat prefab id round-trip Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/scatterConvert.js | 71 +++++++++++++++++++++++------ src/lib/scatterSources.js | 8 ++-- src/lib/terrainScatterCore.js | 5 +- tests/unit/scatterSetFormat.test.js | 11 +++++ 4 files changed, 75 insertions(+), 20 deletions(-) diff --git a/src/lib/scatterConvert.js b/src/lib/scatterConvert.js index c9eedd809..ea37acc70 100644 --- a/src/lib/scatterConvert.js +++ b/src/lib/scatterConvert.js @@ -18,9 +18,9 @@ import { showConfirm } from './confirmDialog'; // becomes a real object at exactly the pose it was drawn with — same base on the ground, same // turn, same tilt when the set aligns to slope, same size. The way every object is made is // one the session already replicates: ONE template (a `/create` of the primitive, an import -// of the Library model, or — a logic prefab source — `instantiatePrefab`, which brings the -// prefab WITH its logic), then `duplicateObject` per instance (the small `duplicate` -// message + a `move`), then the template goes. The instances leave the set. All of it is ONE +// of the Library model or the pack item, or — a prefab's look that remembers its prefab — +// `instantiatePrefab`, which brings the prefab WITH its logic), then `duplicateObject` per +// instance (the small `duplicate` message + a `move`), then the template goes. The instances leave the set. All of it is ONE // history batch, so one undo puts the instances back and takes the objects away on every // peer. More than CONVERT_CONFIRM_ABOVE asks first. // @@ -73,8 +73,9 @@ function poseAt(object, world) { send({ type: 'move', uuid: object.uuid, pos: object.position.toArray(), rot: object.rotation.toArray(), scale: object.scale.toArray() }); } -/** Make the ONE template object for a source. Resolves {uuid, offset} (offset = how far the - * object's own origin sits above the instance base), or null. @param {any} source */ +/** Make the ONE template object for a source. Resolves {uuid, offset} (offset = where the + * object's own transform relative to the instance base, a matrix in the instance's frame), or null. + * @param {any} source */ async function makeTemplate(source) { const commands = await import('./commandsHandler.svelte'); const group = /** @type {any} */ (get(objectsGroup)); @@ -88,7 +89,15 @@ async function makeTemplate(source) { const materials = await import('./materialsHandler'); materials.setObjectColor(object.uuid, spec.color); materials.setMaterialParam(object.uuid, 'roughness', spec.roughness); - return { uuid: object.uuid, offset: spec.offset }; + return { uuid: object.uuid, offset: new THREE.Matrix4().makeTranslation(0, spec.offset, 0) }; + } + if (source.kind === 'library' && source.prefab) { + // a logic prefab scatters its LOOK only (51-sources); a real object is the FULL prefab with + // its logic (the user's rule) — when this machine's prefab library still holds it + const { prefabById, instantiatePrefab } = await import('./prefabs'); + const prefab = prefabById(source.prefab); + const object = prefab ? instantiatePrefab(prefab) : null; + if (object) return { uuid: object.uuid, offset: lookOffset(object) }; } if (source.kind === 'library') { const explorer = await import('./explorer'); @@ -97,18 +106,50 @@ async function makeTemplate(source) { if (!item || !blob) return null; const { importFile } = await import('./fileHandler.svelte'); const uuid = await importFile(new File([blob], item.name), String(item.name).replace(/\.\w+$/, ''), undefined, [0, 0, 0]); - return uuid ? { uuid, offset: 0 } : null; + return uuid ? { uuid, offset: new THREE.Matrix4() } : null; } - if (source.kind === 'prefab') { - // a logic prefab scatters its LOOK only (51-sources); a real object is the FULL prefab - const { prefabs, instantiatePrefab } = await import('./prefabs'); - const prefab = /** @type {any[]} */ (get(prefabs)).find((p) => p.id === source.id); - const object = prefab ? instantiatePrefab(prefab) : null; - return object ? { uuid: object.uuid, offset: 0 } : null; + if (source.kind === 'pack') { + // 51 union: a pack item is placed the way the Explorer places one (its file from the pack + // cache, the packRef stamped so a save writes the small reference) + const refs = await import('./packRefs'); + const url = refs.packRefUrl(source); + const name = source.item || String(source.path.split('/').pop() ?? 'model').replace(/\.\w+$/, ''); + let buffer; + try { + const { fetchPackBuffer } = await import('./packCache'); + buffer = await fetchPackBuffer(url); + } catch { + return null; + } + const { importFile } = await import('./fileHandler.svelte'); + const uuid = await importFile(new File([buffer], name + '.glb'), name, 'glb', [0, 0, 0], undefined, { + packRef: refs.packRefFromUrl(url, { pack: source.pack, item: name }) + }); + return uuid ? { uuid, offset: new THREE.Matrix4() } : null; } return null; } +/** Where an object sits relative to the base of its LOOK, as a matrix in the instance's frame: + * scatterSources.lookSource stands the look centred on x/z with its lowest point at y 0, unturned, + * with the root's own scale baked in — so the real object is that offset, then its own scale. + * @param {any} object */ +function lookOffset(object) { + const saved = { p: object.position.clone(), q: object.quaternion.clone() }; + object.position.set(0, 0, 0); + object.quaternion.identity(); + object.updateMatrixWorld(true); + const box = new THREE.Box3(); + object.traverse((/** @type {any} */ n) => { + if (n.isMesh) box.expandByObject(n); + }); + object.position.copy(saved.p); + object.quaternion.copy(saved.q); + object.updateMatrixWorld(true); + const at = box.isEmpty() ? new THREE.Vector3() : new THREE.Vector3(-(box.min.x + box.max.x) / 2, -box.min.y, -(box.min.z + box.max.z) / 2); + return new THREE.Matrix4().compose(at, new THREE.Quaternion(), object.scale.clone()); +} + /** * Instances → real objects. `scope` 'selection' = the Select tool's picks; 'set' = every * instance of `setId` on terrain `uuid`. Resolves the new objects' uuids ([] when nothing @@ -169,11 +210,11 @@ export async function instancesToObjects(what, opts = {}) { const source = set.sources?.[src]?.source ?? set.source; const template = await makeTemplate(source); if (!template) { - showToast('Could not make ' + (source.name || source.type || source.item || 'the model') + ' — it is not in your Library'); + showToast('Could not make ' + (source.name || source.type || source.item || 'the model') + (source.kind === 'pack' ? ' — its pack could not be reached' : ' — it is not in your Library')); continue; } // the set's sink pushes each instance down its own up axis (terrainSurface.instanceMatrices) - const lift = new THREE.Matrix4().makeTranslation(0, template.offset - (set.sink ?? 0), 0); + const lift = new THREE.Matrix4().makeTranslation(0, -(set.sink ?? 0), 0).multiply(template.offset); for (const i of list) { const inst = set.list[i]; if (!inst) continue; diff --git a/src/lib/scatterSources.js b/src/lib/scatterSources.js index a7e3bd8ca..faad78abf 100644 --- a/src/lib/scatterSources.js +++ b/src/lib/scatterSources.js @@ -130,10 +130,10 @@ export const LOOK_ONLY = 'Look only: model + materials (its logic is not scatter * A subtree's LOOK as a Library model: a clone with every userData dropped, standing with its * lowest point at y 0 (an instance stands on the ground the way a primitive does), its own scale * kept, its place and turn not — then a GLB, the Library, and a push to the peers. - * @param {any} root @param {string} name @param {string} note + * @param {any} root @param {string} name @param {string} note @param {{prefab?: string}} [extra] * @returns {Promise} a library source */ -export async function lookSource(root, name, note) { +export async function lookSource(root, name, note, extra = {}) { const clone = root.clone(true); clone.traverse((/** @type {any} */ n) => { n.userData = {}; @@ -155,7 +155,7 @@ export async function lookSource(root, name, note) { const item = await explorer.addItemFromBytes(bytes, file, null, { kind: 'object' }); if (!item?.hash) return null; void import('./assetShare').then((m) => m.sendAsset(item.hash)).catch(() => {}); - return sourceOf({ kind: 'library', hash: item.hash, name: item.name ?? file, note }); + return sourceOf({ kind: 'library', hash: item.hash, name: item.name ?? file, note, ...extra }); } /** A prefab as a scatter source: its look only. @param {string} id */ @@ -167,7 +167,7 @@ export async function sourceFromPrefab(id) { showToast('That prefab is not in your library'); return null; } - return lookSource(object, entry.name || 'Prefab', 'Prefab "' + (entry.name || 'Prefab') + '" — ' + LOOK_ONLY.toLowerCase()); + return lookSource(object, entry.name || 'Prefab', 'Prefab "' + (entry.name || 'Prefab') + '" — ' + LOOK_ONLY.toLowerCase(), { prefab: id }); } /** A scene object as a scatter source: a pristine pack piece as its pack item, anything else as diff --git a/src/lib/terrainScatterCore.js b/src/lib/terrainScatterCore.js index 1bcd48008..11c29d072 100644 --- a/src/lib/terrainScatterCore.js +++ b/src/lib/terrainScatterCore.js @@ -143,13 +143,16 @@ const PATH_RE = /^(?:https?:\/\/)?[\w\-./%~:+@]{1,400}$/; /** 51 E1: the short note a source row shows (a prefab / an object scattered as its LOOK only) */ const noteOf = (/** @type {any} */ s) => (typeof s.note === 'string' && s.note ? { note: s.note.slice(0, 160) } : {}); +// 51 union: a prefab's LOOK remembers which prefab it came from, so "To objects" can bring the +// whole prefab back WITH its logic where that prefab is in the library (the user's 2026-10-21 rule) +const prefabOf = (/** @type {any} */ s) => (typeof s.prefab === 'string' && HASH_RE.test(s.prefab) ? { prefab: s.prefab } : {}); /** @param {any} s @returns {any} */ export function sourceOf(s) { if (!s || typeof s !== 'object') return null; if (s.kind === 'primitive' && SCATTER_PRIMITIVES.includes(s.type)) return { kind: 'primitive', type: s.type }; if (s.kind === 'library' && typeof s.hash === 'string' && HASH_RE.test(s.hash)) - return { kind: 'library', hash: s.hash, name: typeof s.name === 'string' ? s.name.slice(0, 120) : '', ...noteOf(s) }; + return { kind: 'library', hash: s.hash, name: typeof s.name === 'string' ? s.name.slice(0, 120) : '', ...noteOf(s), ...prefabOf(s) }; // 51 E1: a pack item, by the same {pack, item, path} a placed kit piece's packRef carries — every // peer fetches it from the pack CDN, nothing travels but the path if (s.kind === 'pack' && typeof s.path === 'string' && PATH_RE.test(s.path) && !s.path.includes('..')) diff --git a/tests/unit/scatterSetFormat.test.js b/tests/unit/scatterSetFormat.test.js index f4a4513c4..69f02172d 100644 --- a/tests/unit/scatterSetFormat.test.js +++ b/tests/unit/scatterSetFormat.test.js @@ -210,6 +210,17 @@ describe('51 set format: mixed sets, tint, sink, sources, validation', () => { expect(sourceOf({ kind: 'library', hash: 'abc', name: 'Rock', note: 'Prefab "Gate" — look only' }).note).toBe('Prefab "Gate" — look only'); }); + test('51 union: a prefab look keeps its prefab id (To objects brings the prefab back), keyed by its look', () => { + const id = '3f0c1a52-9b7e-4d2a-8c11-0a6b5f1e2d34'; + const s = sourceOf({ kind: 'library', hash: 'abc', name: 'Gate', note: 'look only', prefab: id }); + expect(s).toEqual({ kind: 'library', hash: 'abc', name: 'Gate', note: 'look only', prefab: id }); + expect(sourceKey(s)).toBe('l:abc'); + expect(sourceOf({ kind: 'library', hash: 'abc', name: 'Rock' })).toEqual({ kind: 'library', hash: 'abc', name: 'Rock' }); + expect(sourceOf({ kind: 'library', hash: 'abc', prefab: ' + + %sveltekit.head% diff --git a/static/boot-prefs.js b/static/boot-prefs.js new file mode 100644 index 000000000..19b819a6b --- /dev/null +++ b/static/boot-prefs.js @@ -0,0 +1,22 @@ +// Applied before first paint (src/app.html loads this synchronously in ). A file and not an +// inline