Repository navigation
52-paint: V3 controller brushes + V5 universal two-grip world grab - #432
Merged
Merged
Conversation
…tic 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<k>/p<j>),
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) <noreply@anthropic.com>
…wn file
- ScatterSection.svelte = the Scatter <Section> block of TerrainPaintSection, moved
verbatim (set list, align, collider, remove, Scatter... button)
- TerrainPaintSection renders <ScatterSection {object} /> 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) <noreply@anthropic.com>
…e/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) <noreply@anthropic.com>
…and a brush on any mesh
- buildSurface(positions, index?, normals?): the host's LOCAL triangles + an area CDF;
placeOnSurface(u, v) is uniform by area (u picks the triangle, its remainder + v the
barycentric), smooth normals interpolated when the mesh has them
- generateSurfaceSlots(gen, S, {draws}): FILL by count (keeps drawing until count are
PLACED, cap 30 x count attempts) or density (density x area attempts), masks slope /
height (local frame, so a moved host never reshuffles) / painted terrain layer, minimum
spacing as 3D Poisson-disk dart throwing in slot order; slot k = the caller's per-slot
stream, so raising the count adds without moving and every peer gets the same layout
- surfaceDab: the brush on a curved surface (sphere around the hit, the terrain brush's
density + spacing rules), eraseInSphere, a triangle grid for the query
- 16-byte records for a mesh host's painted instances (xyz over a stored box, octahedral
normal, yaw, scale) + boxOf / boxFrom
- vitest scatterSurfaceCore: 25 checks incl. two runs + two peers on one seed, spacing with
its counterfactual, saturation, records round trip; PERF on 99,904 triangles: build
~11-25 ms, fill 5,000 ~5-10 ms, fill 5,000 + spacing 0.3 + slope mask ~26-54 ms (node,
warm vs cold) - no worker needed
- svelte-check = baseline, vitest 2515 green
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…look, mixed weights, tint, sink - scatterSources.js: ONE catalog (primitives, Library, pack items, prefabs) shared by the brush toolbar, Properties > Scatter and the object menu; a prefab or a scene object scatters as its LOOK only (userData dropped, base at y 0, GLB filed in the Library by hash + pushed to peers) with a note the source row shows; a pristine kit piece scatters as its pack item (no bytes) - object menu: "Use as scatter source" (sets the brush's source) - Properties > Scatter: per-set model list (weights for a mix, remove), "Add a model to the mix", Tint and Sink scrubs (one undo entry + one message per drag), Re-roll for seeded sets, counts read what a set DRAWS - terrainScatterCore: withSourceAdded / withSourceRemoved (a removed model's painted records go with it; a mix of one becomes a plain set); terrainPaint: addScatterSource, removeScatterSource, setScatterSourceWeight, scatterBlockNow; debug hook scatterSources Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… (checkJs covers tests/unit) - svelte-check back to 299/39 = baseline (phase 1 had added 50, all in the test fixtures) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…acing in the set format
E2 - scatter on ANY mesh
- Inspector: the Scatter section (ScatterSection.svelte) renders on every plain mesh right after
the Water section (Add bubble / pour emitter); its "Add scatter" (#scatter-add) adds a seeded
FILL set with the brush's current source; a Terrain keeps its section beside Terrain paint
- the set format (terrainScatterCore, 51-sources' file, minimal): a set on a non-terrain mesh
carries host: 'mesh' + box; its painted records are 16 bytes (scatterSurfaceCore); absent on
every terrain set, so 1.38 terrain blocks stay byte-identical; overrides may carry y
- terrainSurface: ONE instance list per set (setInstances / scatterSetInstances) for the
renderer, colliders, brush and edit tools; a mesh host fills its own triangles (surface
cached per geometry identity); instances live in the host's LOCAL frame, so the existing
holder (matrixWorld copy) makes them follow a move / turn / scale on every peer
- the brush on a mesh: enterSculpt(uuid, {scatter: true}) = a scatter-only session that never
touches the geometry (no soup conversion, no size cap, nothing sent); the sphere brush
(surfaceDab) paints ON the surface, erase removes inside the sphere; one undo per stroke
- SculptToolbar offers Scatter on meshes (Paint stays terrain-only)
E3 - fill mode and masks (ScatterFillRows.svelte, per set)
- Count | Density (Segmented), minimum spacing, slope range, height range (local frame),
painted terrain layer + coverage, Re-roll, Remove fill; drags preview throttled and commit
ONE undo entry on release
- COUNT means count PLACED (generateSlots keeps drawing until placed, cap 30 x count) on
terrains too; terrain masks read the height grid + the splat (a layer-masked fill re-lays
when the paint changes); the Inspector shows the DRAWN count per set (scatterCounts)
- vitest: scatterSurfaceCore 28 (+ mesh-set format, ids on two peers, edits through the
16-byte codec); scatterSetFormat's mask test updated to count-placed; 2541 green
- svelte-check 299/39 = baseline; e2e suite scatter-surface written, queued (snapshot run)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…atter 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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
…atter 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) <noreply@anthropic.com>
…evice 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) <noreply@anthropic.com>
…-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) <noreply@anthropic.com>
…moved Scatter section - 51-sources added its per-set rows (model mix + weights, add a model, tint, sink) to the Scatter section in TerrainPaintSection.svelte; 51-surface had moved that section VERBATIM into ScatterSection.svelte (512623e, QUESTIONS Q2). TerrainPaintSection keeps our side; their rows, scrub helpers and imports now live in ScatterSection.svelte. - Drawn counts stay on scatterCounts (masks + mesh surfaces aware); their "Generated from seed N" Re-roll row is folded into the Fill block's Re-roll (the seed is in its title), so a set shows one Re-roll. - withSourceRemoved honours a host:'mesh' set's 16-byte records through io.records (as withOverrides does); rewriteSet passes surfaceRecords. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ry element a 0.01 ms one motion.css set transition-duration: 0.01ms !important on every element under data-motion=reduce. Every element's default is `transition: all 0s`, so that turned each one into an element that transitions EVERY property: a style set and read back in the same task read the old value (found by CI kit-diff, which shoots /kit with reducedMotion: 'reduce' - every row of the contrast table read the first pair's 13.26:1). Transitions now go to 0s; animations keep 0.01ms so animationend still fires. Nothing waits on transitionend except Connect's re-measure, which also measures on the next tick. a11y-scale reads 0s for the reduced probe. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Release 1.41.0 — Accessibility: UI scale, High contrast, full keyboard, screen readers, accessible games and VR comfort
…o the 51-sources format Conflicts (QUESTIONS-51-surface A2), both sides kept: - SculptToolbar: Scatter panel on any host; source note row kept; 51-edit's tool row (Paint/Erase/Rotate/Scale/Relax/Select) on a terrain, Paint + Erase (the 47 erase flag) on a mesh host; Affects lists the drawn count + sourceLabel. - TerrainPaintSection: <ScatterSection> kept, 51-edit's Detail (splat resolution) row kept; the per-set "To objects" button moved into ScatterSection (terrain sets). - terrainSculpt: exitSculpt clears sculptScatterOnly AND ends the edit session; paintOp keeps scatter on meshes, 'scatter-edit' on a terrain only; strokeMove passes y; startScatterEditSession moved back into enterSculpt (auto-merge had put it in enterMeshScatter, where isTerrain does not exist). - terrainSurface layoutScatter: HEAD (sources/tint/sink/thinning); picking reads mesh.userData.instanceIds. Format seam (QUESTIONS-51-edit Q1): scatterEditAdapter re-pointed at 51-sources' API - lists = scatterSetInstances (string ids g<k>/p<j>), blockOf diffs each edited list into withOverrides (moves -> overrides, drops -> del, painted records rewritten; seed/gen/ sources/tint/sink ride through), id-less entries append painted records. Picking maps instanceIds -> list index; scatterEditCore setPoses/transformGroup keep id/src/tint; scatterConvert makes each instance of a mixed set from its own source and honours sink. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…verrides, painted rewrites, appends) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…:50, Back to 1:1 - both grips = the world in Edit whatever the rays hit or the hands are inside (Open Brush / Gravity Sketch / ShapesXR); a first-hand grab is TENTATIVE (no selection, nothing sent) until it moves > 2 cm or 250 ms pass; an unmoved first grab joined by the second grip goes back to its start pose with no undo entry and the world grab starts - two-hand object scale (+ rotate with the hands' axis) only when the first hand already MOVED the object; a second hand INSIDE another small object takes that one instead (33 G4 by touch) - grip-by-contact skips objects that enclose the headset, are larger than 2 m, are terrain or are locked (vrGrip.contactGrabAllowed, smallest containing object wins); terrain never moves by grip - Settings > VR "Both grips scale the world: Always / Only in empty air" (vrBothGripsWorld) - detents 1:50 1:10 1:5 1:2 1:1 2x 5x 10x (WORLD_SCALE_MIN 0.05 -> 0.02), readout "1:10" below life size, the 53 comfort vignette hears the world gesture's scale/yaw rate, radial root "Back to 1:1" while the world is off life size - suites: vr-universal-world-grab (new, IWER; red on the 51 union in 1/3/4/5), vr-grips-by-mode, vr-two-hand-grab, vr-world-grab adapted; unit vrGrip / vrWorldSnap / gameShell Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…iniature too - Sculpt ring gains Paint ▸ (layers 1-4, terrain only) and Scatter ▸ (paint/erase/rotate/scale/relax + Source ▸) - Source ▸ wheel: current + in-use sources + primitives; stick Y = density while scatter-painting - haptic pulse on stroke start; tracked hands are asked to pick up a controller - brush label reads layer/tool, radius and strength or density; ring scales with the world (miniature) - wheel picks apply synchronously (primed sculpt module), no late dynamic import - e2e: vr-brushes-iwer (wheels, strokes, undo, miniature 1:10 placement), vr-brushes-perf (Quest profile, 2k instances) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- several callbacks share one XR frame and most are empty, so per-callback medians read 0 ms - lane run (--local, Quest profile, 2k instances): paint +0.2 ms, scatter +0.3 ms, rotate +3.0 ms median over idle Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Collaborator
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
52-paint — V3 controller brushes + V5 universal two-grip world grab
Base: origin/feat/51 (1f3b9dd, the 51 union, per the 52 rules: paint waits for 51-int). Opened by the orchestrator (theprototype-41) from the lane's finished handover; the lane left it unpushed.
Done
74d2073 V5 universal two-grip world grab. Base RED in vr-universal-world-grab §1,3,4,5 (+crash; §2 passes on
base too, said in the suite header). Lane ALL PASS: vr-universal-world-grab, vr-two-hand-grab, vr-world,
vr-grips-by-mode, vr-grab, vr-world-grab, vr-comfort-iwer, vr-radial-menu (logs p1-existing.log, v5-run1/2.log).
The one vr-world dollhouse red was a flake (A/B green on base and lane).
22e92b1 V3 controller brushes. vr-brushes-iwer: base RED (no wheels, crash on cursorDebug), lane ALL PASS
(v3-run2.log); vr-world, terrain-sculpt, terrain-paint, scatter-edit ALL PASS on the lane (v3-run1.log).
Unit 206 files / 2585 tests green; check-ratchet 299/39 = baseline after each phase.
Forks: QUESTIONS-52-paint.md Q1-Q3 (taken on the recommendation).
118a4ca vr-brushes-perf (--local, IWER Quest 3, budget target quest, 24 m terrain + 2 000 Cone instances,
29 draw calls): XR frame summed per frame — idle 2.3 ms median; paint +0.2, scatter +0.3, rotate +3.0 ms median
over idle (gate 4); p95 over idle 1.1 / 0.9 / 5.9 ms (gate 7). ALL PASS (logs/perf-run2.log). perf-run1 timed
callbacks one by one (0 ms medians, meaningless) — fixed in that commit. Rotate (51's edit brush over 2k) is
the heavy one: watch it on the Quest.
Next / half-finished
For the orchestrator
Shared files touched (52-measure / 52-browser merges): src/lib/vr/grip.js, src/lib/vrGrip.js,
src/lib/vrRadialMenu.js (Sculpt ring entries + root
world:back; the primed sculptRef block now sits ABOVEregisterBuiltins();— a TDZ trap if another lane adds entries that read it), src/lib/vr/radial.js,src/components/play/VRMenu.svelte ($vrMenuOpen in deriveSectors deps), src/lib/comfortVignette.js,
src/lib/terrainSculpt.js, src/lib/vr/sculpt.js, src/lib/vr/settingsSchema.js, src/lib/vr/prefs.js,
src/lib/vr/worldSnap.js, src/lib/vr/state.js. Adapted suites: vr-grips-by-mode, vr-two-hand-grab, vr-world-grab, vr-world.
Docs (VR page) should say:
and pulling the hands apart/together scales it (miniature down to 1:50), twisting turns it. To scale or turn an
OBJECT with two hands, grab it with one hand and move it a little first (about 2 cm), then add the second grip.
A grip that has not moved the object yet and is joined within a quarter second by the other grip lets the object
go back where it was (no undo step) and grabs the world instead.
life size. Back to 1:1 sits on the quick menu's root ring while the world is scaled/turned/moved.
object scale that object).
Relax + Source ▸ (current, in-use sources, primitives). Trigger paints; thumbstick X = radius, Y = strength (or
density while scatter-painting); a ring on the surface shows the brush; a buzz when a stroke starts; the label
reads e.g. "Paint 3 · 3.0 m · 50%" / "Sphere · 3.0 m · 0.50/m²". Works in a miniature (ring and stroke follow
the ray at the world's scale). Tracked hands are asked to pick up a controller.
Changelog lines:
readout, Back to 1:1 on the quick menu and the comfort vignette while scaling. Move an object first to scale it
with two hands. New Settings ▸ VR "Both grips scale the world" (Always / Only in empty air).
controller, with a brush ring, radius/strength/density on the stick and a buzz on stroke start — in miniature too.
Real-Quest rows (int checklist + device checklist page): world grab feel + detent ticks + vignette while
scaling; miniature 1:50 and Back to 1:1; grab-then-second-grip object scale/rotate; brush strokes at 72 Hz with 2k
scatter, ROTATE especially (+3 ms on the Deck; no dropped frames); stroke-start buzz; hand tracking shows "Pick up a controller" (not IWER-provable).
🤖 Generated with Claude Code
https://claude.ai/code/session_01UQqbSd3GVvhbqGSSiQpxv4