Changelog¶
Unreleased¶
v4.31.0 — The demo apps stop overlapping themselves (2026-08-17)¶
Five days of work whose common thread is what you see. The sample apps had drifted into 37 hand-rolled sliders, three copies of an accent palette (one with no dark variant), five incompatible slider shapes on iOS, dead-end cards that opened nothing, and floating surfaces that covered the lists underneath them. All of it is now one shared vocabulary, with a quality-gate check that fails a demo which re-invents it — and a "What's new" card in the Android demo so a release is visible from the phone instead of from the repository.
Underneath, the usual invisible half: a double free in the AR light estimator, a
.filamat ABI mismatch that would have failed the first createMaterial() on the
static site, a collider that never refreshed when its geometry changed, and a release
lint that could only ever fail at deploy time.
Added¶
- The Android demo's Samples tab opens with a "What's new" card, and shipping a release is what updates it (#3232). Anyone opening the demo app had no way to tell a screen fixed yesterday from one untouched for a year: the grid presents every sample identically, and the only record of what changed lives in a
CHANGELOG.mdnobody reads from a phone. The card heads the grid and opens aModalBottomSheetlisting the last three releases' Added / Fixed / Changed / Performance / Removed headlines, plus every demo still markedInReview.
It is a grid item, not an overlay. The card is an ordinary sibling of the demo cards inside the same lazy grid — it scrolls with them, it reserves its own space, and it can never float over a list the way the surfaces removed in #2194 and #2358 did. That is deliberate: this repo has spent two issues undoing bottom-anchored floating elements, and a "what's new" banner is exactly the shape that reintroduces them.
No second source of truth. A new bundleChangelogAsset Gradle task copies the repo-root CHANGELOG.md into the APK assets at build time, and WhatsNewChangelog parses it on device — so the screen cannot drift from the release notes, because it is the release notes. WhatsNewAssetIntegrityTest fails the build if the asset is missing or unparseable, which is the failure mode that would otherwise ship an empty sheet to the Play Store.
Changed¶
- The App Store submit program now lives in
.github/scripts/app_store_submit.py. It was a 932-line Python heredoc inside.github/workflows/app-store.yml, which made one step 65% of the workflow and forced its self-test to regex-carve the code back out of the YAML. The workflow file drops from 1444 to 519 lines. ci.yml's path-filter checkout is now blobless and sparse, cutting the job from ~2m40s to ~5s.fetch-depthwas not the lever — it was already at its floor of 1, and the depth-1 pack for this repo is 722 MiB of binary assets.dorny/paths-filteronpushruns exactly one history command,git diff --no-renames --name-status <before>..<head>, which is a commit-to-commit tree diff that reads no file contents, sofilter: blob:noneplussparse-checkout: .githubproduce a byte-identical diff from a 3.5 MiB.git. The job'stimeout-minutesgoes 5 → 10, which is now ~100x headroom rather than the 2m20s margin that let a merely-slow checkout trip the cap.- The sample apps now share one UI vocabulary, and a gate keeps them sharing it. Every demo used to type its own controls, so the Android demo app had 37 hand-rolled sliders across 21 files — each pairing a
Text("Name: ${"%.2f".format(…)}")with a bareSlider, drifting on decimal count, typography and where the unit went — and the per-category accent palette existed in three copies. One of those copies carried a "keep these in sync by hand" comment; another had no dark variant at all, so in dark mode the Explore grid rendered the light hues that the dark palette exists to avoid at >9:1 contrast against an M3 darksurfaceContainer.samples/commonnow definesLabeledSlider(name on the leading edge, value on the trailing edge, so a column of them reads as a table instead of a stack of sentences) andDemoCategoryAccent(one palette, both schemes, unit-tested for key parity). All 37 sliders and all three palette copies now go through them..claude/scripts/check-demo-design-system.pyfails the quality gate on a demo that re-invents either — it keys on the palette's shape rather than its hex values, because0xFF6446CDis both the "3D Basics" accent and the theme's brand tertiary, and its own self-test drives the failing path, including the label that nests quotes inside a string template where a flat regex silently stops looking. - The iOS demos now use the same slider as the Android demos, and the design-system gate reads Swift. The iOS sample app carried one control in five mutually incompatible shapes across nine files — a label with no value shown, a label/readout row with the track below it, a value fused into its own label, a track bookended by sun glyphs, and a helper local to a single file — disagreeing on typography, on whether the readout was monospaced, on the width reserved for it, on tint (
.blue,.orange,.yellow, picked per demo), and on whether the control was accessible at all.Views/Components/LabeledSlider.swiftnow owns it under the same contract as its Android twin: label leading, value trailing, one thin space before the unit, POSIX formatting so a readout copies straight into code, and a hidden label row so VoiceOver keeps the track's adjustable trait and announces the value once. Eight sliders across six files migrated;DynamicSkyDemoandMovableLightDemoare deliberate compositions rather than copies of the shared control and are left as they are.check-demo-design-system.pynow refuses the two Swift shapes as well as the Kotlin one, inspecting the block above a track instead of grepping forSlider(, so a bespoke design is not mistaken for drift.
Fixed¶
- The version bumper stops rewriting the homepage's GA4 stream id (#3234).
website-static/index.htmlis the one file swept for version strings without an anchor. At v4.6.0 that sweep still used the old version as an unescaped regex, so4.5.0— meaning4+any+5+any+0— matched43570insideStream ID: 14357002837and rewrote it to14.6.002837. Escaping the dots afterwards closed the entry door but not the damage: the comment now held a literal version string, so the next 25 releases faithfully re-bumped it, all the way to14.31.0. The id is restored to14357002837, the sweep skipsStream ID:lines by address, and a new deterministic check fails the gate if any stream id ever contains a dot again — the same guard shape the SVG path-data corruption got in #2562, on the carrier nobody had thought to cover. - The deployed-site version check no longer reddens every release's pre-push gate (#3234).
sceneview.github.io/index.htmlis written bydocs.ymlafter the tag is pushed, so during a release it correctly holds the previous version; demanding the in-flight one madesync-versions.shexit non-zero on a file no human edits. It now accepts the last tag as well as the current version — and still fails on anything older, which is a stale clone or a broken deploy. - A version bump regenerates
gpt/knowledge-*.mdinstead of leaving them drifted (#3234). Those four files are generated fromllms.txt, whichsync-versions.sh --fixrewrites, so every release used to fail its own pre-push gate on a drift whose only correct answer was always "run the generator".--fixnow runs it (sourcing nvm whennodeis off a non-interactive PATH, and saying so rather than silently skipping when it is genuinely absent). - The static site's
.filamatblobs are compiled for the runtime that loads them (#2783). The threewebsite-static/materials/*.filamatblobs rode the Androidfilamentpin and shipped at MATERIAL_VERSION 72, while the page vendors a v70 Filament.js — Filament refuses any package whose version is not an exact match, so the firstcreateMaterial()added to that page would have failed at load (latent, since the site renders through gltfio ubershaders). They now compile with a dedicatedfilamentWebsitepin and are v70. The vendored runtime turned out to be 1.70.1, not the 1.70.2 that three pages and the materials README claimed; the bytes are now sha256-pinned inwebsite-static/js/filament/RUNTIME.json, and.claude/scripts/check-web-filamat-abi.shfails the quality gate if any web blob, pin, runtime or label diverges again. ViewNodenow forwards touch events to its embeddedView(#2845). The picked hit point is converted to a view pixel and the wholeDOWN → MOVE → UPstream is dispatched into the embedded hierarchy, soButton.onClick, press states, ripples and inner scrolling finally work inside aViewNode— previously nothing ever reached that view tree and every embedded control was decorative. As on screen, a gesture the content consumes no longer reaches the scene gesture listener or the camera manipulator, and a pointer dragged off the quad gets anACTION_CANCELinstead of a stuck press; setisTouchForwardingEnabled = falseon the node to opt out. This is a breaking runtime-behaviour change, not an API break: the public surface is purely additive, but forwarding is default-on, so an existing app that changes nothing loses the gestures itsViewNodecontent consumes — a MaterialSurface/Cardconsumes touches even with nothing clickable inside. Apps relying ononSingleTapUp = { _, node -> if (node is ViewNode) … }reaching the scene listener must opt out per node.- A
ViewNode's collider no longer stays stuck at 1 × 1 unit (#2845). Resizing the quad refreshed Filament's bounding box but never the collision shape, sohitTestonly ever reported a hit on the quad's central 1 × 1 square — a 410 × 420 px card lost its outer ~30% margin, usually where the buttons are. Picking aViewNode(and now clicking inside it) is accurate over the whole quad. - A
ViewNodebuilt withinvertFrontFaceWinding = trueno longer maps touches to the un-mirrored pixel (#2845). That flag mirrors the content horizontally in the shader; the touch mapping now mirrors with it, so tapping the visibleCancelin aRow { Button("Cancel"); Button("OK") }no longer firesOK. - iOS: turning "Spin scene" off no longer blanks the Multi-Model demo (#2935).
SceneView's turntable ran in an un-keyed.task, so it readautoRotate(speed:)exactly once, at view appear — a host that toggled its spin control changed nothing, and the only way to make one work was to re-key the wholeSceneViewwith SwiftUI's.id(_:). That is the renderer-teardown anti-patterncontentID(_:)was introduced to avoid: a rebuiltRealityViewon the iOS 26 Simulator intermittently renders nothing at all — no model, no skybox — and never recovers (#3008), which is exactly the black viewport the toggle produced. The loop is now keyed on the rotation policy, so.autoRotate(speed:)is reactive: pass0to freeze and a non-zero speed to spin, and the turntable starts, stops or re-speeds under the same renderer. The iOS demo drives its "Spin scene" toggle from the value alone and no longer re-keys. - The CREDITS file inside the Play Store APK was neither generated nor gated (#2941).
generate-credits.pywrote one file,assets/CREDITS.md, andci.yml→repo-hygienepluspre-push-check.shchecked that same one — while the repository tracked fiveCREDITS.mdfiles. The copy bundled in the APK had last been touched on 2026-06-04 against 2026-07-28 for the generated one, and 6 of the 19 assets it ships alongside (audio/bell.wav,augmented_images/qrcode.png,mediapipe/pose_landmarker_lite.task,splats/rainbow_sphere.ply,textures/sceneview_logo.png,videos/sample.mp4) had no attribution line anywhere in it. Two CC-BY-4.0 models it did list —shiba.glbandthreejs_soldier.glb— were filed under a heading with no author at all, which is the clause CC-BY 4.0 §3(a) actually requires. The generator now owns every shipped copy:assets/CREDITS.mdfrom the full catalogue, the APK copy from the files genuinely present insamples/android-demo/src/main/assets/, and the two demo audio credits as byte-for-byte mirrors ofassets/audio/CREDITS.md(hand-written on purpose —bell.wavis ffmpeg-generated and is not a catalogue asset). A bundled file that matches neither a catalogue entry nor an explicit declaration in the script now fails the gate instead of shipping uncredited, andtest-generate-credits.sh— new, wired intorepo-hygiene, sopre-push-check.shleg 19 discovers it too — pins all of that by mutation, including a case that fails if a sixthCREDITS.mdis added to the repository without being named in the generator. - iOS:
ReflectionProbeNode.intensityis applied as the linear multiplier it documents (#2956). The authored value went straight into RealityKit'sImageBasedLightComponent.intensityExponent, which scales by2^x— so the documented1.0default rendered at ×2.0, the same defect #2897 fixed forSceneEnvironment.intensity. It now converts through the sameintensityExponent(forMultiplier:). A second bug went with it: a multiplier set beforeenvironmentTexture(_:)— including everybox(intensity:)/sphere(intensity:)argument — was silently dropped, because setting a texture installed a fresh component at exponent 0. The multiplier now survives, whichever order the two are called in. android-demo: the Materials section now derivesautoAnimatefrom QA mode (#2958). Both subject nodes were mounted withoutautoAnimate, inheritingModelNode'struedefault, while QA mode only freezes the orbit yaw — so an animated subject would have made the section's golden screenshots drift. They now passautoAnimate = !DemoSettings.qaModelikeModelViewerDemo, guarded by a unit test since every subject shipping today is static.claim.sh's open-PR collision report named the entire open-PR backlog instead of the colliding PR (#2998).gh pr list --json number,title,headRefNameemits its whole array as ONE line, and the guard read it with a line-orientedgrep -Ei: as soon as any single PR referenced the issue, the match returned every open PR on the repo. Detection stayed correct — the collision was reported — but attribution was noise, so the message whose entire job is to say who already holds this issue, and on which branch said nothing actionable. Reproduced on the report's own case: five open PRs, only #2997 referencing issue 2835, all five printed. That matters beyond cosmetics, because a session that cannot act on a guard learns to--forcepast it, and--forceis exactly what the #2300 duplicate-implementation race needs to happen again. The parsing now filters where the unit is a PR rather than a line (jq), and prints one#<n> <branch> — <title>row per genuine collision; withoutjqthe same detection runs through the old grep but labels its output(unattributed — jq missing, cannot name the PR)rather than letting a reader mistake the backlog for the culprit. #2998 also asked for a sweep of the samegh --json | grepshape repo-wide: it found exactly one other instance,issue_has_labelin this same file, and the measured verdict is that it is not exploitable — its pattern carries its own quotes, so"in-progress"matches neitherin-progress-blockednornot-in-progress(checked in both directions). It is left alone deliberately, with the verdict recorded at the call site and pinned by tests, because rewriting a working guard on a hypothesis is how a real bug gets introduced.test-claim-collision-report.shcovers 18 scenarios inrepo-hygiene, extracting the shipped function out ofclaim.shat run time so the suite can never drift into testing a more-correct mirror, and keeping the pre-fix grep as a mutant that must fail the repro. Nothing else exercised this code:claim.shonly talks toghon a real machine, so CI never ran the parsing until it mattered.- The MCP server's lint script now runs in CI — and cleaning its baseline uncovered a real bug: every generated Gradle coordinate said
${LATEST_SCENEVIEW_RELEASE}instead of a version (#3054).mcp/package.json'sbiomescript was invoked by no workflow (grep over.github/workflows/: onlyrn-ts-check.ymlmentioned Biome at all) and took no path argument, so it swept everything inbiome.json'sfiles.includes— including, since #3052, the React Native sources, which it then linted withmcp/'s own Biome binary. Among the 108 errors that unread baseline had accumulated sat 32 double-quoted strings holding${LATEST_SCENEVIEW_RELEASE}, a template placeholder that never interpolates:get_sample,generate_sceneandget_platform_setuphanded their callersio.github.sceneview:sceneview:${LATEST_SCENEVIEW_RELEASE}verbatim as the dependency to add, and Android's status line readStable (v${LATEST_SCENEVIEW_RELEASE}).noTemplateCurlyInStringhad been reporting it the whole time, to nobody. The baseline is now at zero errors, the scripts are renamedlint/lint:fixand scoped tomcp/src mcp/scriptsto match the RN package, and the newmcp-ts-check.ymlruns Biome plustscon every PR touching the server — with the same file-count assertion asrn-ts-check.yml, because Biome exits 0 when a path silently drops out of the config. Five tests pin the interpolation fix directly:noTemplateCurlyInStringis a warning, so the lint job alone would not fail on a relapse. Two things stay open on purpose: the 113 remaining warnings, andwebsite-static/js/sceneview.js— inbiome.json's includes, reached by no script now thatmcp's no longer sweeps the repo, and carrying a ~120-error baseline of its own. - The release pipeline now refuses to publish a patch version that carries a source-incompatible change (#3061).
release.ymlperformed five irreversible publications — Maven Central, three npm packages and pub.dev — without ever checking that the version being tagged was allowed to carry such a change. A singlebreaking-change-guardjob now runs first and every publishing job waits on it, on both the tag and the manual-dispatch paths. - The pre-push gate no longer shares one log directory with every other worktree on the machine (#3074).
pre-push-check.shwrote its logs to${TMPDIR:-/tmp}/sceneview-pre-push, a path with no worktree component, while this repo runs many worktrees in parallel by design — so concurrent runs overwrote each other's files. Measured three ways:Full log: …/api-check.logcould name another session's failure, leg 19's self-test list was discovered in one worktree and executed in another (#3131), and a neighbour's: > selftests.txttruncated the list under the running loop's open descriptor so the gate printed✓ 35 gate self-test(s) passover a loop that ran twenty (#3137) — a false GREEN in the gate whose purpose is to stop false greens. The directory is now derived from the checkout root (stable per worktree, so nothing accumulates to garbage-collect), still0700and now0700on its parent too, andtest-pre-push-log-dir.shreplays the truncation with a control that reproduces the old collapse. - A glTF can no longer buy unbounded WebP decode work on Android (#3136).
WebPTextureTranscoderalready bounded each image's size withMAX_TEXTURE_EDGE = 8192, but the image count comes from the file: a few KB of JSON could declare thousands of images all pointing at one tiny WebP that inflates to 8192², decoded one after another on the calling thread — the main one through the@MainThreadcreateModelentry points. AMAX_TRANSCODED_IMAGES = 256budget now caps it — far above any real model — and everything past it is reported through the existingonUnsupportedpath, the same contract as an oversized image rather than a silent drop. This mirrors the capsceneview-webgained in #3133, so the two twins no longer diverge. Agent reviewno longer reports aclaude-code-actionstartup refusal as a blocking review failure (#3140). Whenmain's copy ofpr-review.ymlchanges between the moment a run pins its workflow ref and the moment it reaches a runner, the action skips itself and writes no verdict — the PR then got a redREVIEW_INCOMPLETEpointing at the previous run's green comment. The workflow now recognises that outcome by comparing blob hashes (never the action's log wording), and only when no verdict file exists, so it reports an explainedNOT_EVALUATEDthat replaces the stale comment and can never relabel a review that actually ran.- Device-QA: the Maestro budget is now per flow, and an expired one reports
timeout, notfailed(#3141). A single 900 s bound wrapped the wholecatalog.yamlaggregator, so a full android or ios leg could only ever end atrc=124— three of seven flows on android, one of eight on iOS, every executed stepCOMPLETED— and that clock verdict was reported with the same word as a real demo crash.lib/maestro.shnow expands an aggregator flow and runs each per-category flow under its ownMAESTRO_FLOW_TIMEOUT, anddevice-qa.shrecords a distincttimeoutstatus naming the flow it stopped on, graded with exactly the weight offailedeverywhere (a required leg ontimeoutstill blocks the release gate). An iOS release cut while the previous one is still in App Review now defers with a readable message naming the blocking version and its state, instead of dying on a raw traceback. App Store Connect allows one non-live version at a time, so thePOST /v1/appStoreVersions409 is Apple's normal answer — not a broken run. The submission stops there and touches nothing: continuing would have reached the stale-submission cleanup, whose open states includeIN_REVIEW, and withdrawn the previous release from review. workflow_dispatchApp Store submissions no longer crash resolving the version. With no tag to read, the program falls back toVERSION_NAMEingradle.properties— but that branch opened withos.environ.get("GITHUB_WORKSPACE", os.path.join(os.path.dirname(__file__), ...)), and Python evaluates a.get()default eagerly. Fed topython3on stdin as a heredoc,__file__was undefined, so every manual dispatch raisedNameErrorthere even thoughGITHUB_WORKSPACEwas set. Running the program as a file gives it a__file__and makes the documented fallback work.- A hard rule was enforced by a guard that advertised how to bypass it.
CLAUDE.mdstates "never setEMU_LEASE_TAKEOVER=1. A blocking hook refuses it"; guard 2 ofhook-dispatch.shlisted that variable as an allowed escape hatch, and its own refusal message told the reader to use it — at the exact moment a session hits the rule.test-hook-lease-guard.shpinned the behaviour asdeliberate takeover -> allowed, so the rule was unenforced, contradicted where it was most likely to be read, and its violation was regression-tested in place. The guard now refuses the variable and the test asserts the refusal. This deliberately breaks the "escape hatches must mirrorlib/emulator-select.shexactly" property, and both files carry the reason: the lib serves an operator at a terminal who can see the peer's run and judge, the hook serves a session that cannot. Driving an emulator from a terminal is unaffected — the hook only sees commands issued through a session — and a session keeps two honest routes: inherit its own lease, or provision its own device. The same sweep corrected instructions that had drifted from the repo:automation-maplisted five hooks wheresettings.jsondeclares two (the other three post-edit hooks and the post-push hook were removed on 2026-08-11) and required a quality gate that contradictedCLAUDE.md's own first rule;cross-platformwas loadable for "assessing parity" without naming a single parity tool and pinned every platform atv3.3.0;device-qahardcoded demo counts that were already wrong and advertised two subjects that live inandroid-tooling; andCLAUDE.mdopened on a link to a gitignoredSTATE.md, dead in every worktree.sync-versions.shno longer emits a permanentSKIProw for aCLAUDE.mdcoordinate that is not coming back, but still compares one that is present — #3128 settled that predicate and deleting the check would have stripped the reference it points at. - A push to
mainwhereDetect changed pathsnever finished now turns the run red instead of reading as "nothing to build" (#3148). Nineci.ymljobs declareneeds: changes; when that job hits its owntimeout-minutesit concludescancelled, all nine reportskipped, and GitHub gradesskippedas passing. On a PR theCI Gateaggregator catches it, butci-gate.ymlison: pull_requestonly — so two pushes tomain(266eabc044,f8d7868bfc) landed with nine legs never executed and nothing red, on the branch that deploys to the stores. A new push-sidePath filter completedjob fails unlesschangesconcludedsuccess. It is guarded on!cancelled(), notalways(), so amainrun legitimately superseded by a second push is still not reported as a failure. Frame.hasUpdatedTrackable()ignored its argument and returned a collection (#3157). The AR helper discarded thetrackableit was passed and returnedgetUpdatedTrackables(T::class.java), soif (frame.hasUpdatedTrackable(plane))never compiled and the obvious reading of the name was wrong. It now returnsBoolean— whether that exact trackable was updated this frame. UsegetUpdatedTrackables()(orgetUpdatedPlanes()and friends) when you want the collection. Source-breaking for any caller that used this misnamed helper as a collection getter — it is aninlineextension, so it never appeared inarsceneview.apiand the binary-compatibility gate could not see the change.- The pre-push gate can no longer reach a verdict from another checkout's build output (#3159). With several clones building at once, the shared Gradle daemon interleaves their console output: a gate run in one worktree produced an
api-check.logfull ofw: file:///private/tmp/sv-3136/sceneview-core/src/.../Earcut.kt— a different clone entirely. That run happened to end in the correct refusal, but only because the competing build failed; nothing compared the log to the tree being pushed, so a greenapiCheckover foreign code would have passed silently. Two consecutive runs on the same unchanged tree also disagreed (2 CHECK(S) FAILED, then0 failed, 1 could not run), and a verdict that depends on what else is running is not a verdict.lib/gradle-run.shnow detects source paths belonging to another checkout and routes them to the existing COULD NOT RUN state, which already exits non-zero — so this withholds a pass, and never converts a real failure into one. The check sits at the sharedgradle_runchoke point rather than in theapiCheckleg, so all seven Gradle legs are covered and a future leg inherits it without an edit; a build whose log is contaminated returns a distinguished exit code, which means a caller that has never heard of the problem still takes its failure branch. The match is deliberately narrow — only absolute paths containing/src/, so~/.gradle/cachesjars and toolchain paths cannot trip it (measured: zero hits across all 60 logs of a clean full-gate run). It detects contamination, which is all a log can show; it does not claim to prove the verdict covered this tree, so its only use is to withhold a pass. Pinned bytest-gradle-run.sh, including the foreign path measured on the real run, the local tree's own identically-shaped diagnostics, a dependency-cache path, this repo's actual gate logs, and two mutants requiring the suite to go red if either half of the detector is removed. - Three AR defects that only a physical device could expose, plus the test that hid one of them. ARCore ships no arm64 emulator build (#2754), so every AR session the project had ever tested was a
qa_modefallback; the first pass on real hardware (Pixel 4a, 2026-08-14, all 34 AR demos) turned up three. (1)ARSession.configurecalledsuper.configure(config)as its first statement, so all four defensive fallbacks — depth mode, flash mode, front-camera light estimation, scene semantics — ran only after the unsupported config had already reached ARCore. A fallback applied after the fact can only fix the next call, which never happens when the current one throws and takes session creation down with it: a front-camera Augmented Faces session died withUnsupportedConfigurationExceptionfrom ARCore'slighting_estimation_hdr.cc, three lines above the front-camera guard written to prevent exactly that, andar-facerendered a black viewport behind "The front camera could not be started on this device". The guards now run before the super call. (2)Frame.semanticLabelFractiondocumented that it returns0fwhenSemanticMode.ENABLEDis not set, but caught onlyNotYetAvailableException— ARCore reports "this session has no semantics" asAR_ERROR_FATAL, so the documented case was the one that threw. On a device without the Scene Semantics model the call raised once per frame: 44FatalExceptionstack traces in nine seconds inar-people-occlusion, seven more inar-scene-semantics, while the session itself stayed healthy becauseconfigurehad already fallen the mode back toDISABLED. It now returns0fas documented; a genuinely dead session still surfaces through the nextsession.update(). (3)ARMLObjectLabelDemothrottled its detector on wall-clock time alone, which says "160 ms have passed", not "the previous detection released its image" — ML Kit holds the CPU image until its listener fires, and on a mid-range device detection outlasts the interval, so the next window acquired a second image while the first was still in flight and drained ARCore's 2–3 slot pool (28ResourceExhaustedExceptionin a nine-second run). An in-flight guard now gates acquisition, cleared on every exit path including the ones that throw before the listeners are ever attached. Separately,LightEstimatorConcurrentDestroyTestasserted that no reader may see a non-null texture field after observingisDestroyed— the exact opposite of whatdestroy()deliberately does, latching the gate before freeing the textures so a lateupdate()bails out instead of racingengine.destroyTexture. The window is benign by construction and only the test's reflection could observe it; the assertion passed on emulator timing and failed on the Pixel 4a's slower CPU. It is gone, and the reason it must not come back is recorded where it stood. ARMLObjectLabelDemo's in-flight guard is now released on task cancellation too. #3160 added the guard because a wall-clock throttle is not a concurrency guard, and released the ARCore CPU image and the flag on the detection task's success and failure.Taskhas a third terminal state. WithoutaddOnCanceledListener, "released on every exit path" rested on an ML Kit implementation detail — thatprocess(InputImage)exposes noCancellationTokenand reports teardown as a failure rather than a cancel — instead of on theTaskcontract itself. Were that detail to change, a cancel would leak one of ARCore's two-to-three CPU image slots and latch the guard closed, silently ending object detection for the rest of the session. The listener is attached, so the guarantee now rests on the contract.- A native double free in
LightEstimatorteardown, and the reason three test suites all missed it. The two cubemap-texture setters freed the oldTextureand wrote the new one as two separate steps —runCatching { field?.let { engine.destroyTexture(it) } }thenfield = value. That reads as null-safe, anddestroy()'s KDoc leaned on exactly that word to claim idempotency. Null-safety is not atomicity: it makes a repeated sequentialdestroy()cheap, while leaving two concurrent callers free to read the same non-null texture and both hand it toengine.destroyTexture.LightEstimator.destroy()is called fromDisposableEffect.onDisposewhose lambda can re-run before the previous teardown finishes, so this is a reachable path, and it does not fail as a catchable exception — it aborts the process. Measured on a Pixel 4a (2026-08-14):SIGABRTinscudo::reportHeaderRacebeneathJava_com_google_android_filament_Engine_nDestroyTexture, viaLightEstimator.destroy→setCubeMapTexture, on the first run of the existingmany_threads_calling_destroy_inParallel_isIdempotentAndCrashFreestress test. Both setters now swap throughAtomicReference.getAndSet, so exactly one caller ever receives a given texture to free — and the new value is published before the old one is freed, closing the window in which the field still pointed at an already-destroyed texture. Why nothing caught it: the androidTest stress that reproduces it needs a real FilamentEngineand so never runs in CI; the pure-JVM mirror models the setter, and its model already usedgetAndSet— it modelled an implementation more correct than the code it claimed to pin, which is the one condition under which a mirror cannot fail on production's bug; and the emulator's thread interleaving is too coarse to hit the window even when the suite does run. A source-contract test (LightEstimatorTextureSwapTest) now asserts the atomic shape by reading the production source, verified to fail when the swap is reverted. - iOS CI no longer fails when a runner's simulator list is still warming up (#3174). Jobs died with
Unable to find a device matching the provided destination specifier: { platform:iOS Simulator, OS:latest, name:iPhone 16 Pro }, which reads like the runner not having that device. It is not. Inside a single job on a singlemacos-15runner,Build Swift Package (iOS)failed at 14:07:45 with no concrete simulator in the available-destination list — onlyMy Mac/Any iOS Simulator Deviceplaceholders — and 16 seconds laterBuild & test iOS sample demoresolved that exact device,{ id:DB7A4F45-…, OS:26.2, name:iPhone 16 Pro }, and went green. The device was there all along; CoreSimulator had not finished enumerating it. A rerun starts over on a warmer machine, which is why this read as flakiness and why the retry was green. All iOS entry points now go through one resolver,.claude/scripts/lib/ios-simulator.sh, which waits for CoreSimulator (up toIOS_SIM_WAIT_SECONDS, default 180) and then handsxcodebuilda UDID — replacing six independentname=<model>pins across three entry points: three destinations inios.yml, two inrender-tests.yml(on the olderiPhone 16, so that non-blocking post-merge leg had more room to rot), andSIMULATOR=inios-device-qa.sh. Resolving by UDID is the smaller half:ios.yml's ownSelect Xcodestep walks a preference list, so a model name is a promise about a machine we do not own. The wait is the half that fixes the measured defect — resolving without it would have been strictly worse than the bug, since a resolver running at the top of a job lands in the same cold window and would have killed the whole job where today one step dies and the rest still runs. It deliberately does not degrade either: once the timeout expires it returns non-zero and dumps the device list rather than yielding an empty destination, which would turn a broken runner into a quiet pass — the false-green class already paid for in #1515 and #2878. Both halves are pinned by a new hermetic self-test inrepo-hygiene,test-ios-simulator.sh, whose own mutation test requires it to go red when the wait is removed, when the timeout is made to succeed, or when device selection stops preferring the newest runtime. That self-test also checks the call sites, not just the library: the first cut of this change wired the resolver asecho "IOS_DEST=$(…)" >> "$GITHUB_ENV", and underset -ea command substitution failing inside another command's arguments does not abort —echoexits 0, so the step wrote an empty destination and passed, making the fail-closed guarantee inert in CI. Onrender-tests.yml, where everyxcodebuildis|| true, that was a false green of exactly the kind the step exists to prevent. Gradle gates now name the host when a cached file goes missing, instead of blaming the code. Three failure shapes — a hollow wrapper distribution, an artefact evicted fromcaches/modules-2, and a worktree-local configuration cache pointing at an evicted transform — are one event: the file is gone but the metadata indexing it survived, so Gradle reports a dependency, API or plugin problem. Each is now classified as a host setup failure with its own remedy, and the remedies are kept distinct because one of the three is worktree-local and must never send anyone into the shared~/.gradle. CLAUDE.mdstated two different, both-wrong counts of the automation surface. Line 33 said.claude/scripts/"holds the other 109"; line 94 called it "110 checks and harnesses". The real figure the day this landed was 113 (.sh+.py, non-recursive; 129 countinglib/) — and the two lines had already disagreed with each other before they disagreed with the tree, which is what a number nobody can verify at read time looks like as it rots. The file is re-sent on every turn of every session, so both numbers were asserted continuously and neither was ever true. Bumping them to 113 would have bought about a day: this very batch added a script, and any PR that adds one re-breaks the claim. So the count is gone rather than corrected, on both lines. Nothing consumed it — no gate, no script, no skill asserts a script count anywhere in the repo, and the twoCLAUDE.mdlines were its only occurrences — which is the point: it carried no decision, only the cost of being wrong.automation-mapremains the index, and it enumerates rather than counts, so it cannot drift the same way.- A declared asset could be reported as undeclared, intermittently, by the licence-compliance gate.
grep -qexits on its first match and closes the pipe; a producer still writing then takesEPIPEand returns non-zero, and underset -o pipefailthe pipeline reports failure because the match succeeded. The membership test inverts and a present item is reported absent. Measured on 2026-08-14:validate-demo-assets.shflaggedcyberpunk_car.usdz— declared inassets/catalog.jsonwith its CC-BY-4.0 licence and author — as undeclared on one run ofpre-push-check.sh, printingprintf: write error: Broken pipeon the comparison line itself, while five standalone re-runs were clean and the diff under test contained two prose files and no assets. Four sites now use a herestring, which keeps the shell out of the pipeline so onlygrep's own status governs. The consequence was not uniform, which is why the form is pinned centrally rather than per script: invalidate-demo-assets.sha lost race is a false red in a compliance gate (twice — the catalogue comparison, and the allowlist skip that would then compare an engine asset against the catalogue); intest-context-budget.shit reports an indexed skill as an orphan; incleanup-branches-worktrees.shit means a branch that does have an open PR is not classifiedOPEN, i.e. the wrong answer lands on the deletion side, and the odds grow with the branch list.test-pipefail-membership.shpins the call-site form — deterministic and mutation-tested, and it caught the fourth site the manual pass had missed — while the race itself is measured and printed as evidence rather than asserted, so a future toolchain that stops losing it cannot turn this suite into the same class of flake it removes. - The PR-review gate no longer reports contamination when the base branch moves mid-run (#3182).
pr-review.ymlpinned the base SHA before the fan-out, whileclaude-code-actionresolves the base branch at its own runtime — a 3-second window in run 31820409662 was enough for the action to restore one commit's bytes while the assertion demanded another's, and the job died onM CLAUDE.md, a file the PR never touched.assert-review-tree-clean.shnow accepts a second trusted ref via--also-base: a file matching either the pinned SHA or the current base tip is a restore, anything else is still contamination. Both refs are commits on a protected branch no PR controls, so accepting either hides nothing, and an unresolvable second ref degrades to the strict single-base behaviour and warns. - Depth visualization overlay was rotated 90° in portrait (#3184). ARCore delivers the depth image in the landscape camera-sensor frame, which does not follow the display, and the
ar-depth-visualizationdemo blitted it to a full-screen overlay unrotated — so the false-color layer sat sideways over the camera feed on any portrait device. The colorize pass now writes each sample straight into its rotated destination (no extra pass, no second buffer) and the bitmap is allocated at the rotated size. Thellms.txtdepth-overlay recipe taught the same unrotated pattern and has been corrected. - AR plane shadow catchers shaded against a fallback normal instead of the plane's own (#3186, #3188). The V1 plane visualizer — the default plane renderer since v4.16.1 — built its mesh with a POSITION-only vertex buffer, while
PlaneRendererappliesplane_renderer_shadow.filamatto that same mesh. That material is ashadowMultipliershadow catcher, and Filament addsTANGENTSto a shadow-multiplier material's required attributes even though the shader isunlitand never names a normal: itsMAT_REQAchunk reads0x3, againstplane_renderer.filamat's0x1. Filament does not fail a build for the mismatch — it logsmissing required attributes (0x3), declared=0x1and shades against OpenGL's generic vertex-attribute fallback, the identity quaternion, which decodes to a normal pointing sideways in the plane's own frame. So the shadow submesh of every detected plane was lit by the wrong normal, and the warning fired both on every renderable rebuild and on every per-framesetMaterialInstanceAt, saturating the 30-line log capture that in-app bug reports attach. The mesh now declaresTANGENTSalongsidePOSITION. The frame is constant — V1's mesh is flat in the plane's own frame and the pose rides on the entity transform — so it is uploaded once at construction and the per-frame path stays exactly as cheap as before: one position upload, one index upload. - A resized node now picks at the size it renders at (#3194).
updateGeometrypushed the new bounding box to Filament — which is what culling and rendering use — butcollisionShapewas separate state that kept whatever box the node was built with, and nothing refreshed it. Assign aCubeNode'ssizeafter construction and it rendered large whilehitTeststill reported the old box: a ray through the grown corner found nothing. All elevenupdateGeometryoverloads were affected (GeometryNode,PlaneNode,CubeNode,SphereNode,CylinderNode,ConeNode,TorusNode,CapsuleNode,LineNode,PathNode,ShapeNode), andViewNodeworst of all — its quad is sized from the measured view, so the collider stayed atPlane.DEFAULT_SIZE's 1 × 1 × 0 forever and a 410 × 420 px card lost its outer ~30 % tohitTest, which is where the buttons usually are. The collider is now re-derived inRenderableNode.setGeometry, the single choke point every overload passes through — hoisting it intoGeometryNode.updateGeometryinstead would have missed every shape-specific node, since those callsetGeometrydirectly. AssigningNode.collisionShapeby hand still opts a node out of the automatic refresh, including a deliberatenull, so no app that manages its own collider changes behaviour;RenderableNode.updateCollisionShape()opts back in. No public signature changed. - Resizing a cube no longer makes it disappear.
Cube.update()passedgetVertices(center, size)while the declaration — and the builder — are(size, center).SizeandPositionare bothFloat3typealiases, so the transposition was type-correct and invisible to the compiler.cubeNode.updateGeometry(size = Size(3f))on a cube centred at the origin therefore built a zero-sized box centred at(3, 3, 3): it vanished from rendering and from hit-testing. Only cubes resized after construction were affected — the builder always had it right, which is why this went unnoticed. Found while fixing #3194, whose reproduction case it made impossible to satisfy. All ten geometries were audited for the same transposition;Cubewas the only one, and a unit test now pins every geometry'supdate()against its builder. The pre-push gate no longer reads a relative path as another clone's tree. Its foreign-tree detector required a leading/without checking what preceded it, sosamples/android-demo/src/main/.../GeneratedDemos.kt— a line the gate writes itself — yielded/android-demo/src/…and graded the repository's own clean runCOULD NOT RUN, refusing every push. The pre-push gate now recognises a foreign source tree when the log announces it in brackets —[/Users/other/clone/src/main/A.kt]. The delimiter class that lets a path start after:,=or a quote had no[, so the pattern could not begin there and a contaminated log graded clean. #3195 fixed the same function from the false-red side; this is the false-green half, and the union of both. - The pre-push gate now finds a node installed by nvm (#3202).
It announced
node not foundand skipped its two JS legs on a host where node works — measured four times across 2026-08-15 → 16. The gate runs from a non-interactive shell, which never loadsnvm.sh, sowhich nodereturned nothing while node was installed. Resolution order is$NODE_CMD→ PATH → Homebrew → nvm's default alias → its newest install, version-sorted sov9cannot outrankv22, and overridable through$NODE_RESOLVE_PREFIXES. - The pre-push gate ran 4 of the 7 unit-test tasks CI runs (#3205).
:sceneview-core:androidTest,:samples:common:testDebugUnitTestand:samples:android-tv-demo:testDebugUnitTestwere named neither in the legs nor in the "deliberately not covered" list, which the script's own header defines as an unaudited gap rather than a decision. The last of the three had been wired into CI the day before by #3193 — a fix for a test suite no workflow invoked, which left the local gate one storey behind. The gate now runs all seven, andtest-ci-parity-gradle-tasks.shderives both lists from disk so the next task added to that CI job cannot go unrun in silence: it must be run locally, covered by a module's aggregate:m:test, or excused by a writtenCI-PARITY-EXCLUDE:line. - A
ViewNode's hand-assignedcollisionShapenow survives a resize.ViewNode.updateGeometrySize()ended with an unconditionalupdateCollisionShape(), added when the stale-collider fix was still scoped to that one call site (#2845). Being unconditional, it overwrote a collider the app had set by hand on every resize — and resurrected a pickable one on a node deliberately made unpickable withcollisionShape = null. The refresh now lives inRenderableNode.setGeometryfor every node type (#3194), so the explicit call is redundant: removing it restores the documented opt-out and drops a second AABB read per resize.ViewNode's collider still tracks its quad, through the shared path. - The demo app's overlapping Compose components are gone, and the shape that produced them is now refused. Two defects, one cause. On the demo grid, the "In review" pill was an overlay floating over a centred category icon, and the committed goldens recorded it covering the icon's corner — at large font scale, swallowing it whole. In the demos, every screen had three tenants competing for the same strip of pixels above the system bars — the Settings FAB at bottom-end,
SceneActionBarat bottom-start, and a hand-placed status banner at bottom-center — and 25 demo files placed theirs by hand, at a clearance constant of their own choosing. A sweep found fifteen confirmed collisions, most visible on first launch: onARTerrainAnchorDemothe banner shown to anyone who cloned the repo without an ARCore Cloud key ran under the "Drop here" button and under the Settings FAB at once. In both cases the fix is structural rather than a margin: the chip is now a sibling of the icon in aRow, andDemoScaffold'sbottomOverlayslot is now a bottom-alignedColumnwhose receiver is aColumnScope, so a banner, a legend and an action bar stack instead of sharing pixels. Siblings cannot overlap, so no clearance has to be re-tuned when a sentence gets longer or the font scale grows.SceneActionBargains aColumnScopeoverload for the slot, its KDoc loses the claim that status pills "never collide with this bottom-start bar" (they did — that sentence is why 25 demo files hand-placed theirs), and the new sharedDemoStatusBannerreplaces the per-demo colour guesswork that had been giving "ARCore is initialising" the same red as "no API key configured".check-demo-bottom-overlay.pyrefuses any bottom anchoring outside the slot, with a self-test that drives its failing path. - The demos' bottom banners no longer run under the Settings button, at any font scale or in any language. Stacking the bottom band fixed the demos' own elements colliding with each other; it did not fix them colliding with the Settings cluster in the opposite corner, because the width reserved for that cluster was a constant —
104 dp, derived from a peek chip measured at 79 dp with the English word "Settings" at font scale 1.0. The chip is text. At font scale 1.3 it outgrows the band and an overlay that faithfully follows the documented idiom still ends up underneath it, which device-QA hit onar-measureandar-terrain-anchorat once; both were clean at 1.0, which is exactly why nobody had seen it. Measuring the reserve then exposed the larger half of the defect:peekHeaderlets a demo put its own sentence on that chip, andar-measure's first-launch header is "Tap a surface to drop the first point" — ≈ 230 dp of a 411 dp screen, at the default font scale. Three demos were overlapping on every device, not just at large text. So the scaffold now measures the real cluster instead of predicting it, the peek chip is capped at a third of the screen and ellipsises (a peek chip should peek), and the centred-pill idiom drops its symmetric inset for an end-only one — reserving one occupied corner on both edges spent the reserve twice and leftar-measure's banner 73 dp wide where it now gets 242 dp. Five new JVM tests measure the real bottom band at font scales 1.0, 1.3 and 2.0 and with a wordy peek header, asserting both that the overlay clears the chip and that it keeps a usable width — the overlap they pin is ~4 dp at 1.3, which no screenshot review was ever going to catch. - The demo app's feedback button no longer covers the cards it floats over. It was a FAB pinned to a fixed band at the bottom-start of the four root tabs, and two rounds of mitigation had already been spent on it: 168 dp reserved at the bottom of every tab so it would not mask the last item (#2194), then hidden at rest and revealed on scroll so it would not mask a card resting in its band (#2358). The second one was written down as "once scrolling, the overlapped card has moved out of the chip's fixed band" — true of the one card that rested there, and false of every list taller than the viewport, where another card immediately takes its place. Past the first scroll the FAB masked card text at every position; the only clean position was the top, which is exactly where it was hidden. Device QA hit it on the Samples tab. No clearance constant fixes that shape, so it stops floating: feedback is now a card in the About tab, among Sponsor and Credits, where it is a sibling of its neighbours instead of an overlay on them. The in-context path is unchanged — a demo screen still opens the same sheet from its own top app bar, carrying the demo id into the report.
FeedbackChrome(both scroll drivers and the AR-session visibility flag),FeedbackButton, the slide-inAnimatedVisibilityandFEEDBACK_FAB_RESERVED_SPACEare all deleted, and the 168 dp of dead gutter at the bottom of Explore, AR View, Samples and About becomes content. - Every card in the iOS demo app now opens a real screen. The Samples tab shipped 69 cards, 21 of which opened nothing: their
*Scene.swiftfile carried// @available falseand adestinationofAnyView(EmptyView()), so tapping "ViewNode", "Post-processing", "Secondary Camera", "AR Streetscape" or any of the other 17 landed on a "Coming soon" placeholder — a dead end, and the most visible defect a showcase app can have. All 21 stub scenes are deleted; the catalog is 48 cards, all live. None of the 21 was a partial implementation worth finishing:SceneViewSwift.ViewNodeis deprecated and renders a bare placeholder plane (#1035), and there is no RealityKit post-processing, secondary-camera, video-recording or Gaussian-splat surface to wire a screen to. The cross-platform QR-code contract is unchanged —sceneview://demo/<id>for an Android-only id is no longer a registered id, so it now reaches the same honestDeepLinkPlaceholderthroughSceneViewDemoApp.onOpenURL's unregistered-id fallback instead of claiming a catalog entry that does not exist.DemoRegistryGuardTests.testNoSamplesCardIsADeadEndfails the build if an@available falsecard is ever added back, andparity-manifest.ymlrecords the 20 android-only ids socheck-demo-id-parity.shkeeps the two platforms' ledgers honest. - Shipping the demo to Play stopped working the moment the "What's new" card landed; the build now declares the asset it generates. #3232 added a
bundleChangelogAssettask that copies the repo-rootCHANGELOG.mdinto the demo's assets, and registered the output directory by appending it tosourceSets.main.assets.srcDirs. AsrcDirsentry is invisible to Gradle's task graph, so every consumer had to be named by hand — the task list did name the asset merge, package and compress tasks, and missedlintVitalAnalyzeRelease. Gradle refused the build with "uses this output of taskbundleChangelogAssetwithout declaring an explicit or implicit dependency", andDeploy Demo to Play Internalwent red onmain.
lintVital only exists in release, which is exactly why no pull-request check could catch it: the whole PR pipeline was green, and the failure appeared for the first time on the merge commit, at deploy time. Naming consumers by hand was the defect, not the missing name — the next task to read that directory would have broken the same way.
The generated directory is now registered through variant.sources.assets.addGeneratedSourceDirectory(...), the AGP API whose contract is precisely that the producer becomes a declared dependency of every consumer AGP wires, present and future. The copy moved from a Copy task to a small typed task with a @OutputDirectory, because that API owns the output location.
Performance¶
- Two allocation-heavy math conversions (#3157).
Mat4.toColumnsDoubleArray()went through aFloatArray, a boxedList<Double>and then aDoubleArray;FloatArray.toLinearSpace()went through a boxedList<Float>. Both now fill their result array directly — one allocation each instead of three and two.
Removed¶
- iOS/macOS:
CameraControlMode.gimbalis gone — RealityKit has no gimbal camera mode (#3082).RealityFoundation.CameraControls(which RealityKit re-exports) is a struct of five static values —.none,.tilt,.pan,.orbit,.dolly— and the interfaces bundled with Xcode 26.3 (iOS 26.2, macOS 26.2, visionOS 26.2) contain zero occurrences ofgimbal. SceneView nonetheless exposed a public.gimbalcase documented as "Equivalent toRealityKit.CameraControls.gimbal", pointing at a symbol that is not there.
What .cameraControls(.gimbal) does today, and what to write instead. It behaves as .orbit, on every platform, and always has: all three branches of the mode switch — visionOS, macOS and iOS — fell through to SceneView's hand-rolled orbit gesture path, so drag orbits around the pivot and pinch dollies. It never rotated the camera about three independent axes the way its documentation claimed. Replace .cameraControls(.gimbal) with .cameraControls(.orbit): a literal drop-in with byte-identical behaviour and no visual change. Callers who actually wanted a native Apple mode want .tilt or .dolly. Leaving .gimbal in place would fail to compile — deliberately, because a silent no-op rename would leave the caller believing they had a camera mode they never had.
Two historical CHANGELOG.md entries describe CameraControls.gimbal as real but SDK-gated ("only available in the iOS 18.2+ / macOS 15.2+ SDK (Xcode 16.2+)", "is iOS-only"). Those entries were mistaken; they are left as written because the changelog records history. The iOS demo's native-mode picker never offered the mode, so no demo behaviour changes.
Tests¶
test-app-store-submit.pygained two guards. It now asserts the workflow still invokes the program it tests — reading the program from a file instead of from its caller opened a drift hole the heredoc could not have, where a renamed or dropped step would leave the suite green while no release reached App Review (#2731). And it covers the dispatch-path version fallback above, which nothing had exercised.- A vertex-attribute contract between every plane visualizer's mesh and the materials applied to it. The mismatch above is fixed before a frame is ever drawn — the required set lives in the committed
.filamat, the declared set in the visualizer's source — so it is checkable headlessly, which matters because Filament reports it as a log line rather than an exception and no device test would have failed either.PlaneVisualizerAttributeContractTestparsesMAT_REQAout of each blob a plane renderer loads, parses theVertexAttributeset and buffer count out of the visualizer it drives, and fails when a material requires an attribute the mesh does not declare. It covers V1 and V2, and any material added to either from now on. - The TV demo's unit tests now run in CI; they never had (#3193).
samples/android-tv-demo/src/testholdsTvModelListTest, 5 JVM tests asserting every entry inTvModelViewerActivity.modelsresolves to a file bundled in one of the module's two asset source dirs — written after fivemodels/*.glbpaths shipped without the files. Its own KDoc names:samples:android-tv-demo:testDebugUnitTestas the task that "fails fast on any PR that adds a model entry without bundling the file", but no workflow invoked it: enumerating everygradlewline in.github/workflows/*.yml, the TV module appeared only underassembleDebug. So the backstop was written for a CI it was never wired into, and the regression it guards could recur unnoticed.samples/android-tv-demo/**was already in theandroidpath filter gating theunit-testjob, so the module was compiled on every relevant PR while its tests were not run — the fix is one task on the existing./gradlewinvocation, no new job, runner or path trigger. The module's report path joins the failure-artifact upload alongside it.
Docs¶
- The AI-first doc surfaces no longer promise a camera mode the SDK cannot honour (#3082).
llms.txt, the regeneratedgpt/knowledge-api.md,docs/docs/cheatsheet-ios.mdand thesceneview-iosagent skill + cheatsheet all advertised.gimbalas a native Apple mode, which is the worst class of documentation debt for this repo: an AI that reads it emits code that does not compile. TheCameraControlModeblock now states outright that RealityKit'sCameraControlshas no.gimbal, and that.pan/.orbitare the two Apple modes SceneView deliberately implements itself for Android parity. ASceneView.swiftTODO(#1049 Phase 3)that gated the mode on "all CI runners use Xcode 16.2+" went with it — a condition ten Xcode majors in the past that could never have unblocked anything. - Version-bump docs now say the Flutter README's pub.dev caret range must not be bumped (#3149).
sync-versions.shalready enforced it, but neither theversioningskill nor/version-bumpsaid so — a session that "fixed" theWARNrow would pointflutter_sceneview: ^X.Y.Zat a version pub.dev does not serve yet, which resolves to nothing and failsflutter pub get.
v4.30.0 — 2026-08-12 — Cached Filament handles that stop lying, AR depth occlusion that occludes, WebP textures on the web, and a release that verifies what it published¶
Added¶
Engine.destroyLight/Engine.safeDestroyLight, andEngine.renderableGeneration(). Destroying a light component through theEnginehelper (rather thanlightManager.destroydirectly) is what keeps every cachedLightManagerhandle honest.renderableGeneration()exposes the renderable-side invalidation counter for code outsidesceneviewthat caches aRenderableInstanceof its own — read it before serving the cache and re-resolve whenever it changed.
Changed¶
- Every release publisher now re-verifies against its registry, not its exit code (#3021). pub.dev was the only one doing it; npm (
sceneview-web,@sceneview-sdk/react-native,sceneview-mcp) and Maven Central (sceneview,arsceneview,sceneview-core) trusted a zero exit. The five jobs now share.claude/scripts/verify-published-version.sh, so "published" means one thing instead of five hand-rolled loops drifting apart.sceneview-mcpis verified at the version inmcp/package.json— it stays on its own release track. - The Maven leg covers
sceneview-composetoo, and shares one propagation budget. A partial publish — one module's POM on Central, another's missing — is the shape an exit code cannot show, andsceneview-composewas missing from the first draft of that list. The four artifacts now share a single 15-minute deadline rather than one budget each, so the verification cannot outlive the job it runs in. AnINCONCLUSIVEexits 0, so it is the one outcome that can be scrolled past: the verifier itself writes it to the run's job summary — not the calling step, because a caller that has to remember is a caller that will forget. - The version reaches each verify step through
env:, never through${{ }}insiderun:. An expression substituted into the script text is code before bash parses it; bound to an environment variable it is data. The gate requires the binding to be read by the command as well, so a step cannot carry a correctenv:block above a call that verifies a literal. - Two ways the check could itself lie are handled explicitly. An unreachable registry is reported as
UNREACHABLE, never as "the package is absent" — a network outage must not become a confident claim about what shipped. And Maven Central, which served 404 on the POM for roughly half an hour after a green publish at v4.26.0, gets a propagation budget and an honestINCONCLUSIVEwhen it lapses, rather than manufacturing a red release out of an OSSRH sync delay.
Fixed¶
- Depth occlusion had no visible effect at all (#1617).
ARCameraStreambuilt its depth texture with nowidth/height, so Filament defaulted it to 1×1 and every per-frame upload wrote a single texel. The occlusion material read one constant depth for the whole screen, leaving virtual objects drawn on top of the real world however close it was. The texture is now sized from the ARCore depth image and rebuilt when the depth resolution changes. - Padded depth rows were sheared (#1617). The same upload passed ARCore's
Plane.rowStride— a byte count — asPixelBufferDescriptor's stride, which Filament reads in texels. RG8 is 2 bytes per texel, so the stride is now converted before upload. ARCameraStream.destroy()left its entity in the Filament scene (#2877). The entity was destroyed without being removed from the scene it had been added to, so a rebuilt camera stream — everyARSceneViewremount, including a depth-mode toggle — could hand a recycled entity id to a scene still holding the old one. The stream now remembers its scene, removes the entity on teardown, and recycles the id throughsafeRecycleEntity, matching theattachedScenepattern already used byNode.Node.transformInstanceandNode.parentInstanceno longer go stale after rapid node destroy churn (#2977). Both cache aTransformManagerEntityInstancehandle on the assumption it's stable for the node's lifetime, butTransformManagercompacts on removal by swapping the last live entity into the removed slot — silently reindexing that other entity's handle. Rapid destroy/create churn (e.g. a Compose recomposition swapping aModelNode's glTF asset) hit this reliably, freezingworldPosition/getWorldTransformreads on nodes whose cache went stale mid-lifetime —parentInstancestaleness was worse, since it also feeds thesetParentwrite path. Both caches are now invalidated via anEngine-wide generation counter, bumped on every transform-component destroy — including glTF asset teardown (ModelLoader.destroyModel), which destroys entities directly and previously triggered no invalidation at all.- The Flutter plugin now actually publishes to pub.dev (#3011).
release.yml's pub.dev job declaredpermissions: id-token: writebut no step ever spent the token: the pub client does not perform the OIDC exchange itself —dart-lang/setup-dartdoes — and this job usessubosito/flutter-action, which does not.flutter pub publishtherefore found no credential, fell back to interactive OAuth and waited on a browser that does not exist on a runner, soflutter_sceneviewstayed at 4.24.0 across all five of v4.25.0 → v4.29.0 — the first two runs hung until they were cancelled, the last three ended infailure. The job now exchanges the Actions id-token for a pub.dev credential explicitly (audiencehttps://pub.dev,flutter pub token add --env-var PUB_TOKEN) and fails fast with a named cause when the OIDC endpoint is absent, instead of publishing nothing quietly. - The publish step no longer prints a false diagnosis. It claimed a brand-new package's first version must be published manually;
flutter_sceneviewhas been on pub.dev since 4.24.0 (2026-07-20), so that sentence sent the reader after a problem that did not exist. - The publish log no longer ships inside the package.
tee publish.logwrote into the plugin directory pub was about to archive — the v4.29.0 run log listspublish.logamong the uploaded files. It now goes to$RUNNER_TEMP. - Web: WebP-textured glTF/GLB models now render textured (#3085). Filament.js registers no
image/webptexture provider, so an asset usingEXT_texture_webp— or plainimage/webpimages — loaded silently untextured.sceneview-webnow re-encodes the embedded WebP images to PNG in the browser before the model reaches Filament, exactly like the AndroidWebPTextureTranscoderdoes. A model that uses no WebP is passed through untouched. WebP images referenced by an external file URI still cannot be converted and are now reported with an actionable console error instead of rendering blank. - Cached
LightManagerandRenderableManagerhandles no longer go stale after an unrelated component is destroyed (#2991, #3123).LightNode.lightInstance,RenderableNode.renderableInstanceandARCameraStream.renderableInstancecached their handle for the component's lifetime, butLightManagerandRenderableManagerare the same packed-array store asTransformManager: removing a component swaps the last live entity into the freed slot and silently reindexes it. Destroying any light or any renderable could therefore leave a surviving node driving another entity's component — reads kept reporting whatever was written while the renderer used the real one. All three now re-resolve through the same generation-counter invalidationNode.transformInstancegot in #2978, so the hot read path stays a singleIntcompare. Engine.safeDestroyEntitynow invalidates those caches too (#3123).FEngine::destroy(Entity)tears down the renderable, light and transform components in one call, so it reindexes all three arrays.SplatNode.destroy()tears its batch entities down through exactly that path and nothing else, which left #2978's transform invalidation unfired for splat teardown.ModelLoader.destroyModellikewise now bumps all three, since a glTF asset carries renderable components on every mesh and light components wheneverKHR_lights_punctualis present.- A release could be blocked by a string no file in the repo is allowed to contain.
release-checklist.shgraded an absentio.github.sceneview:sceneview:coordinate inCLAUDE.mdas aFAIL, andFAILis a release blocker.CLAUDE.mdcarries no dependency snippet and is held at its current size bytest-context-budget.sh, so the coordinate is not coming back and the blocker could not be cleared by any change the repo permits — whilesync-versions.sh, the single source of truth for all 30+ version locations, grades that same inputSKIP. Two gates, one predicate, opposite verdicts. An absent coordinate is now skipped; aCLAUDE.mdthat does carry one is still compared and a stale one still fails, andREADME.md— the file a user copies the dependency line from — keeps its hard block for both the stale and the absent case. Nothing in CI ran the checklist, so the disagreement could only ever surface mid-release: newtest-release-checklist-version-checks.shnow runs inci.yml, extracts both blocks as shipped rather than re-typing them, and mutation-proves that restoring the old form reproduces the false blocker.
Tests¶
check-release-publish-verification.pypins the contracts, mutation-proven (#3011). A gate whose subject is an absence is the easy one to write green, so its suite reintroduces each real defect into a copy of the liverelease.ymland requires the gate to name it. It caught two false greens in its own first draft: the OIDC contract was satisfied by a comment mentioning the command, and the suite reported 12/12 while six cases never ran. It also runs as leg 21 ofpre-push-check.sh, becauserelease.yml's next real exercise is the next release — the worst moment to discover a verification step was dropped. Review found a third false green: the interactive-OAuth contract only asked that agrepfor the banner existed, so downgrading that branch to a::warning::kept the gate green over a publish that reaches interactive OAuth and ships nothing — the branch must now exit non-zero, and a mutant proves it.verify-published-version.shis covered by a stubbed, mutation-proven suite (#3021).npmandcurlare stubbed onPATH, so every branch is driven rather than hoped for, and each case asserts the exact verdict line. Four mutants prove the suite bites: degrading pub.dev's set-membership test to equality againstlatestbreaks the backport case, removing the reachable/unreachable distinction loses theUNREACHABLEverdict, hoisting the shared deadline above the first probe turns a spent budget into a fabricated red, and deleting the job-summary write leaves an exit-0 non-verification visible only in a log.Repo hygiene checksno longer fails on its own clock. The job was capped at 10 minutes, of whichactions/checkouttook 8m36s on run 31607603142 while all ~55 gates ran in 1m50s — so it was landing red with every step green, which says nothing about the tree. Raised to 20 minutes; not made shallow, because several gates there compute a merge-base and a shallow clone gives them a wrong one silently.- A PR that edits the review workflow now says why it was not reviewed (#3028).
claude-code-actionrefuses to run on a PR that modifiespr-review.yml(an anti-exfiltration guard), and the only trace left on the PR was a redAgent reviewcheck carrying no information — measured on #3025, #3017 and #2952. The check stays red, deliberately: a green or skipped job would fake coverage on a PR nobody read. What changes is that the explanation now reaches the PR as a comment — cause, and thegh workflow run pr-review.yml -f pr=<N>dispatch that does review it — instead of living only in the job summary, and the error annotation saysNOT REVIEWEDrather than naming the mechanism.test-selfmod-guard.shasserts the posted bytes, with a mutation that disarms the comment block: the exit code cannot prove this one, since the step is red either way. - The "NOT REVIEWED" comment a self-modifying PR gets (#3028) no longer writes its scratch file into the checkout.
Assert the reviewers left the tree cleancompares the tree against the base, so a file the job wrote itself would be reported as a reviewer having edited the repo — the same defect shape as thetee publish.logone fixed in #3130. The file now lives underRUNNER_TEMP. Its marker-comment lookup no longer depends ongh api --paginate --jqreturning a single id either:--jqruns once per page, so a match on several pages emitted several ids and built a malformed endpoint.test-selfmod-guard.shnow covers the update-in-place branch, which had no test at all, and asserts the exact sequence of API calls rather than the presence of one. CI Gatecan no longer pass green over a real failure hidden behind a name collision (#3033). The#2492latest-run-per-name collapse resolved by name only, so a check run from another workflow sharing a job name could displace a genuinefailure. Check runs are now attributed to their workflow file (via the Actions APIcheck_suite_id) and collapsed per(workflow, name)pair; the observation ledger keys on that pair too, and the conclusions log names the workflow whenever a name is ambiguous.- The CORE-CHECK GUARD no longer disarms on a partial first read (#3024). The guard cleared
missing_corewhenever no core check had ever been observed, which is indistinguishable from a docs-only PR — so a read that arrived beforeci.ymlregistered its check runs could conclude green over checks that had not started. The gate now asks the Actions API whether aci.ymlrun exists for the SHA and, once one is seen, keeps requiring the core checks. A PR whose paths are all in ci.yml'spaths-ignoreproduces no such run and is unaffected; an unreadable Actions API degrades to the previous behaviour, never looser. - A fork-controlled check name starting with
-nno longer blanks the gate's diagnostics (#3047).echo "$list"ate such a name as an option; every check-name list is now printed withprintf '%s\n'. - The
CI Gatepoll loop is now tested end to end (#3047). The#3018suites stayed 21/21 green with the fix's own wiring deleted fromci-gate.yml, because they only tested the helper scripts.test-ci-gate-loop.shextracts the workflow's own poll loop and runs it against scripted Checks API reads withgh,sleepanddatestubbed. The observation-ledger suite no longer pastes the collapse jq inline — it executes the qualifier the workflow executes. Every scenario is proven by mutation: reverting a fix turns the suite red on a named assertion, not merely on a non-zero exit. quality-gate.shno longer dies without printing a verdict. Underset -euo pipefailagrepthat matches nothing exits 1,pipefailpromotes that to the command substitution andset -ekills the script on the spot — before the summary block. Four such pipelines were live; the one in the cross-platform section fired on the safest possible input (a diff touchingsceneview/src/that adds no public API, i.e. a comment-only edit), so the gate reported "exited 1 without reaching its verdict" — indistinguishable from a real blocker. CI never saw it because theregit diff HEADis empty and the enclosing guard is false. Pinned bytest-quality-gate-pipefail.sh: the shell semantics are measured in a child shell, and a static rule over the real file is proven falsifiable against a fixture carrying the pre-fix line.
Docs¶
troubleshootingno longer says WebP textures are unfixable on the web. The remaining uncovered case is a WebP referenced by an external URI, not the web platform as a whole.Engine.renderableGeneration()now documents all three call sites that bump it. It previously nameddestroyRenderableandsafeDestroyEntityas "the only two calls that can reindex the array", omittingModelLoader.destroyModel— which reindexes it natively throughAssetLoader.destroyAssetwithout ever touchingRenderableManagerfrom Kotlin. Undercounting the reindex sites is exactly what made the stale-handle bug hard to see in the first place.Engine.safeDestroyEntityno longer says "all three" right after listing four components. It now states why the camera component has no generation counter: nothing in this codebase caches a camera instance handle.
v4.29.0 — 2026-08-12 — An idle scene that stops rendering, projections that admit what they cannot answer, and a tap name that no longer leaks a URL¶
Added¶
SceneView(isRendering = false)parks the frame loop on an idle scene (#3108). A static 3D screen kept callingwithFrameNanosat display rate forever, because Compose's frame clock does not idle on its own — on devices whose Choreographer keeps ticking a visually static UI (Samsung foldables in the report) that is a continuously rendered frame per vsync with nothing to show, and it reads to the user as battery drain and thermal throttling. The new parameter defaults totrue, so no source change is needed at a named call site; pre-compiled consumers must recompile, and a caller passing nine or more positional arguments gets a loud type error at slot 9, never a silent behaviour change.- The paused loop suspends rather than spins: it waits on the snapshot rather than polling on a timer, so an idle scene schedules no work at all instead of trading 60 GPU frames a second for 60 CPU wake-ups a second — and rendering resumes on the snapshot apply itself, not on the next poll tick.
SceneIsRenderingTestdiscriminates the two on virtual time, which is the only way to tell them apart from the outside. -
The parameter is also forwarded by the deprecated
Scenealias, and documented with the one thing that makes it easy to misuse: while it isfalsenothing is presented at all — a moved node, a camera change and a material edit all leave the last drawn frame on screen. Two cases are not just frozen and are handled explicitly: a new or resized surface holds no pixels at all — a swap chain is created empty, so a parked loop would leave a foldable unfold or an app return blank rather than stale, and the loop therefore always presents one frame into a new surface before parking again — and an async model load does not finalise while paused, because Filament finishes texture uploads inside the frame loop, so such a model renders untextured until rendering resumes. The dirty signal that drives the parameter must also be Compose state in both directions: aSystem.nanoTime()deadline never recomposes when it elapses, so it would pinisRenderingtotrueforever after the first mutation and silently disable the whole feature. It has to be driven from an "is anything dirty" signal that outlives the last mutation by a frame, not from "is an animation running", which is alreadyfalseat the instant a one-shot change is published. -
Not ported to
ARSceneView, on purpose: a live camera feed is never idle, so there is no idle frame to skip.SceneViewSwifthas no equivalent either — RealityKit owns its own render loop and exposes nothing to park — andsceneview-webalready ships the capability imperatively asstartRendering()/stopRendering(). Recorded as a row inllms.txt's "Android-only — no port planned" table so an AI does not generateARSceneView(isRendering = …)or an iOS.isRendering(_:)modifier that do not exist. - New Android demo
ar-measure— tap points on the real world and read the distance between them in centimetres on a 3D label anchored at the segment midpoint. Keep tapping for a chain with a running total, close the loop for a perimeter, and read the bounding box (W · H · D) of every point placed. Points are resolved in accuracy order — a detected plane's polygon first, then aDepthPoint, then the depth image directly viaFrame.hitTestDepth(so clutter, slopes and edges that never grow a plane are measurable too), then a raw feature point — and the demo names on screen which source produced each point. Answers #531, asked in 2024, closed unanswered by the stale bot, and re-asked by a second user in 2025. samples/android-demo/AR_MEASURE.mddocuments the use case (surveying a real space to size something you will build or 3D-print for it) and, explicitly, the accuracy ceiling: several centimetres without a ToF sensor, approaching one centimetre with ToF/LiDAR — enough for layout, never for a fitting dimension on a printed part.
Changed¶
- Rebuilt the contributor-facing working method around three measured laws — group calls, bound every result, keep one session to one subject — replacing ~40 lines of unenforced prose.
- Removed 14
PostToolUsereminder hooks that fired after the action they advised on, and were redundant with either a blocking gate orCLAUDE.md. A hook now either blocks or does not exist. - Deleted four saved workflows with no entry point (
device-qa-orchestrate,doc-drift-fix,phase2-reconcile,release-checkpoint) — each duplicated a script or a slash command that is the real path.triptychwas in that list until a check found/review highinvokes it; it stays, and the workflow README now states the rule it was deleted against: live means something invokes it. /releaseno longer stops to ask for the version or for permission to push — the version is derived from the changelog fragments (breaking → minor, else patch) and the gates are the authority.CLAUDE.md263 → 121 lines and.claude/workflows/README.md204 → 90, both re-sent on every turn of every session. The Claude Code statusline now shows unsafe work:●nuncommitted files and↑nunpushed commits, both silent when zero. These are the two signals that answer "is anything still stranded?" — previously that question cost a full model turn to answer.
Fixed¶
CI Gatewent red on roughly one PR run in four, with nothing failing (#3005).ci.yml'sDetect changed pathsjob checked out the repo before running the path filter, and that checkout was bimodal — 26–36 s or exactly the 5-minute timeout, never in between. A timed-out job reportscancelled, the eight jobs gated on it reportskipped, and the run read as "the path filter matched nothing" on the one required check onmain. The checkout now runs only onpush, where the filter actually needs git; on a pull request it reads the diff from the API and never touches the working tree. A new assertion fails the job if any filter reports something other thantrue/false, so a blank verdict can no longer skip every job and pass as green.check-doc-drift.shno longer reports a false "no public declaration was added/removed/retyped" for an uncommitted public-API change. The changed-file list unioned the working tree while the public-declaration delta was computed from a commit range only, so the gate was blind in the local pre-commit case a developer actually runs by hand.- The review workflow refused PRs that had never touched it (#3038, #2976).
pr-review.yml's self-modification guard compared the checkout against the base tip, then reported that the PR edited the review workflow — two different questions. Any branch that had simply not mergedmainsince that file last changed answered yes to the first and no to the second, and the guard is a hard failure, so the PR got no review at all along with an error that was false about it (measured on #2963 and #3036). The comparison now starts from the merge base, which is the question the messages claim to be answering; a PR that really does edit the workflow is still refused. worldToView/worldToScreennow returnnullfor points behind the camera instead of a mirrored pixel (#3059). Projecting a world position that sits at or behind the camera's eye plane divided by a clip-spacew <= 0, which yields a finite, mirrored coordinate on the wrong side of the view — neverNaN, so no downstreamisFinitecheck caught it. Consumers that project several world points (e.g. the eight corners of a bounding box for an on-screen overlay) got a plausible-but-wrong pixel and their overlay flickered off/on as a corner crossed the camera's eye plane during a camera pan. The perspective divide (shared, pureio.github.sceneview.math.worldToView(worldPosition, projectionMatrix, viewMatrix)insceneview-core, which the AndroidCamera.worldToViewandView.worldToScreenextensions now delegate to) guardsw <= 0and reportsnull, the honest "this point has no view-space position". Source-compatibility note:Camera.worldToViewandCameraComponent.worldToViewchange return type fromFloat2toFloat2?;View.worldToScreenwas alreadyFloat2?. This is a breaking source change:val p: Float2 = camera.worldToView(pos)no longer compiles, and direct callers need a?/!!. It is binary-compatible — nullability is not part of the JVM descriptor, so pre-compiled consumers keep linking — but perchangelog.d/README.mda nullability widening on a public return type is declared breaking and forces a MINOR release, never a patch.worldToScreencallers that already handled its nullable result need no change.- A tapped node's name could publish a URL's credentials (#3071). On the Flutter and React Native bridges,
nodeNamewas derived by taking the last/-separated segment of the model source. For a URL with no path (https://user:pass@cdn.example) that segment is the authority, so a signed CDN source put its own userinfo into the tap payload apps hand to labels and analytics. The derivation now cuts the authority off before taking the base name, on Flutter, React Native and the Swift bridge alike, and a source that yields no real name falls back (node_<index>on Flutter,nullon React Native) instead of reporting a host. - React Native's 3D
SceneView.onTapdoes fire on iOS. It was measured on an iPhone 17 Pro Max simulator with a model rendering: 5 taps on the model, 5 dispatches,nodeNamenaming the model every time. Every surface that called it unverified or probably broken now states the measured result —llms.txt, itsgpt/knowledge-*mirror (regenerated withnode tools/generate-gpt-knowledge.js, never hand-edited) and the unpublished.well-known/copy, the module'sREADMEandonTapJSDoc (source and published.d.ts), the React Native quickstart, the MCP platform-setup snippet, and the demo's own coverage card. - The same run measured the Flutter bridge back to back, against the same
SceneViewSwift build, the same simulator and the same entity graph (11
entities, 1 collision shape, 9 input targets): 6 taps on the model resolved
no entity, while the untargeted gesture arrived every time. So
#3045 is Flutter's platform-view touch delivery, not RealityKit's
entity-targeted hit test as its write-up states — React Native, which reaches
the same hook through a plain native view, is unaffected. Every Flutter-side
surface that stated the old root cause as fact now states the measured one:
the plugin README and its
onTapKDoc, the demo README and About tab,llms.txtwith both mirrors, and the MCP Flutter snippet. The guidance on those surfaces is unchanged — Flutter's iOSonTapstill does not fire. -
The React Native demo now builds and runs on iOS for the first time. Its
podspecdeclaresSceneViewSwift(a pod cannot see the host app's SwiftPM packages), the demoPodfileresolves it from the repo root and re-pinsIPHONEOS_DEPLOYMENT_TARGETto 18.0 afterreact_native_post_installlowers it to React Native's 13.4 floor. The module'sREADMEiOS section documented the Swift Package Manager route as the supported one — it never worked, since the module compiles insidePods.xcodeproj, which cannot see the host project's packages — and now gives thePodfilecoordinate the unpublishedSceneViewSwiftpod needs — pointed atmainrather than a tag, because no released tag carriesSceneViewSwift.podspecyet and a tagged raw URL 404s. That closes the React Native half of #3072. Three@react-native/*dev dependencies Metro needs were missing. Akhronos_fox.usdzis bundled so the Animation tab renders on iOS at all: the demo passed remote.glbURLs on both platforms, which RealityKit cannot read — the same failure, and the same fix, as the Flutter demo's viewer page in #3048. -
Play Store: the 7" and 10" tablet listings now show the same three screenshots as the phone listing (
model-viewer,dynamic-sky,multi-model) instead of only the first two, andmodel-vieweris framed for the tablet portrait aspect rather than at the phone distance (#3106). - The weekly community-metrics PR no longer carries
[skip ci]in its commit subject.CI Gateis the single required context onmainand a skipped run never reports it, sogh pr merge --autowaited on a check that could never arrive — #3075 sat open in that state with a body promising it would "merge itself once CI Gate reports green", and needed an admin merge. check-workflow-scripts.shnow fails any workflow that commits with a CI-skip marker and then asks forgh pr merge --auto. The pair is the bug; either half alone is legitimate. Matched case-insensitively and including theskip-checks: truetrailer, since GitHub honours those identically.- A fork PR could show a green review check nobody had read (#3117). A run without reviewer credentials — every pull request from a fork — reported its inability to review as a warning and exited zero, so
Agent reviewwent green on a PR no reviewer had opened (measured on #3109). The step now fails, which is honest rather than blocking:Agent reviewis advisory, so a red mark there does not stop the merge; it only stops the run from being mistaken for a clean review.review-fanoutno longer recommends MERGE when an ERROR finding got no verdict. A verifier agent that dies (reachable via an exhausted quota on its pinned model) used to have its finding silently dropped, takingconfirmedErrorsto zero and clearing the auto-merge gate on a change whose blocking findings were never checked. Such a finding is now kept and marked unverified, which routes the run toREVIEW_INCOMPLETE.
Tests¶
DemoRenderingScreenshotTestnow actually guards against visual regressions. Seven of its fourteen baselines had been recorded as empty viewports — four as 320×544 all-black captures, three (fog,lighting,lines-paths) as full-size frames whose SceneView band never rendered — and one (secondary-camera) was missing entirely, so those cases either compared against nothing or compared nothing against nothing. Eleven goldens are re-recorded from settled renders and one (secondary-camera) is added; the two that already depicted a correct settled render (custom-geometry,two-d-in-three-d) are left byte-for-byte untouched. All fourteen cases pass under the new guards over two consecutive full runs (#2323).- The harness refuses the states that produced those baselines: a committed golden with a
flat SceneView band fails as
DEGENERATE, a capture whose viewport never rendered fails instead of being recorded, and the run waits for theqa_modebadge so a splash screen can never be captured as the demo. The content probe no longer exempts unexpected viewport geometries, and its band sits inside the viewport rather than overlapping the app bar — that overlap is what let three all-black baselines score as "has content". - Screenshot captures are pinned to light mode, so a device left in dark mode no longer reddens the whole suite with a ~50 % pixel diff that says nothing about rendering.
- Demos that load a
.glbnow settle for 14 s: a loaded skybox reads as "rendered" while the model is still missing, which the content probe cannot detect. - Debug-artifact writes can no longer mask a verdict.
saveToDeviceForReviewthrewFileNotFoundException: EACCESfrom inside the failure path, replacing six real assertions with filesystem errors; it is now best-effort and reports where it landed. - The bridges' Android Kotlin now has unit tests, and CI proves they ran (#3062). Neither the Flutter plugin nor the React Native module had a single JVM test:
flutter testcovers only the Dart tree, andtools/rn-android-compileis a compile gate. Both modules gain a test source set pinning the tap payload's node-name derivation — the transform #3071 broke — with a shared case table so a divergence between the two bridges shows up as a diff. Each CI leg then counts the tests the JUnit XML says were executed, so an emptied or moved source set cannot pass as green. DemoRenderingScreenshotTestvalidates each demo slug against a lower-kebab pattern before it reaches the shell command that launches the demo, and waits on the qa_mode pill's full text (QA ×) rather than the bare substringQA, which demo copy and control labels can also contain. Review follow-ups to #3100.pre-push-check.shnow mirrors the blocking CI gates it used to omit: Android ↔ iOS demo-id parity,assets/CREDITS.mddrift, theandroid runcontent gate, workflow shell-block validation, every repo-hygiene gate self-test (list derived fromci.yml, not copied), and the fullquality-gate.shoffline profile. A green "ALL CHECKS PASSED" no longer hides a red CI.- Everything the local gate deliberately does not cover — network, Gradle-bound, device-bound and Checks-API-bound CI steps — is now listed with its reason in a
CI-PARITY LEGScomment block in the script, so "not covered here" is distinguishable from "covered". quality-gate.sh's "Filament calls on background thread" check could only ever fail:grep -cprints0and exits 1 when it matches nothing, so|| echo "0"made the count0\n0, the numeric test died, and the||branch reported a THREADING VIOLATION on every clean local diff. It was also blind to the multi-linewithContext(Dispatchers.IO) { modelLoader.createModel… }shape it exists to catch, since it required both on one line — and it read green in CI, wheregit diff HEADis empty and the whole block is skipped. Detection now lives inlib/detect-filament-bg-thread.pywith a 10-fixture self-test pinning both directions, wired intoci.yml → repo-hygiene.- The same
grep -c … || echodefect had a second, worse symptom in the same script:TOTAL=$((TOTAL + N))on0\n0is a shell syntax error, which underset -ekills the enclosingif [ -n "$CHANGED_KT" ]block. On any diff touching Kotlin without a!!, the gate therefore printed neither the force-unwrap line nor the threading line and still exited 0 — a green gate that had verified nothing, with nothing on screen saying a check went missing. Verified before/after againstorigin/mainwith a.ktprobe. - Because that failure lives in the counting helper rather than in any one detector, the normalisation moved into
lib/as-count.sh(sourced byquality-gate.sh) with its own falsifiable guard,test-as-count.sh: it pins every input shape a failinggrep -ccan produce and both consumption shapes — the comparison (must return PASS, not the false red) and the arithmetic inside aset -esubshell (must not abort the block). Wired intoci.yml → repo-hygiene. - The same defect is fixed where it also fed numeric comparisons:
ANDROID_DEMO_COUNTin the Android ↔ iOS demo-id parity gate, and the!!count plus six diagnostic counts inquality-gate.sh. -
A lost checkout no longer reports 40 false gate failures in
repo-hygiene. Every gate in that CI job carriesif: always()so a PR author sees all hygiene violations in one run — butalways()also fired when the job's ownactions/checkoutnever completed, and 40 gates then "measured" an empty working directory and reportedfailure(run 31516160366: checkout hung, consumed the 10-minute timeout, was cancelled). The gates now carryalways() && steps.checkout.outcome == 'success', so a lost checkout leaves exactly one red step — the one that actually broke — instead of a wall of red whose single real cause was visible only by listing the job's steps. -
check-hygiene-checkout-guard.pyevaluates everyif:expression inrepo-hygieneunder seven simulated job states, so the guard is verified by execution rather than by reading the YAML. It pins both directions: a barealways()is refused, and so is asuccess()-based guard, which would restore the fix-them-one-at-a-time behaviouralways()exists to prevent. It also refuses a guard naming a stepid:that no earlier step declares — that spelling makes every gate skip while the job reports green, a false green worse than the false red being fixed. Driven on synthetic workflows bytest-check-hygiene-checkout-guard.sh, whose mutation pass deletes each contract assertion and requires a near-miss fixture to go green, so "this assertion is load-bearing" is measured rather than asserted. - Fixed two quality-gate legs that could not report what they claimed to check:
quality-gate.sh's Filament background-thread check reported a THREADING VIOLATION (with an empty log) whenevergit diff HEADitself failed, because the failure propagated through the pipeline underpipefail; andcross-platform-check.sh --with-apk's demo-inventory leg counted its Android demos in a file that no longer holds any, and its iOS demos with a pattern that matched doc comments. Both sides now count the ids their collator parses, and a count of zero is reported as a broken probe instead of as "no drift". cross-platform-check.shnow shareslib/as-count.shwithquality-gate.shinstead of carrying its own two counter idioms, only one of which was correct.- A bad invocation of
lib/detect-filament-bg-thread.pyprints its usage text instead of a blank line, pinned by a new assertion in its self-test. automation-mapnow documentslib/as-count.sh,test-as-count.sh,lib/detect-filament-bg-thread.pyandtest-detect-filament-bg-thread.sh.
v4.28.0 — 2026-08-10¶
Added¶
- A release that ships a breaking change can no longer be tagged as a patch.
release.yml'spublish-rnjob derives the npm version straight from the git tag, so taggingv4.26.1would publish a source-breaking@sceneview-sdk/react-nativechange as a semver patch — the one version class a consumer's caret range picks up without review..claude/scripts/check-breaking-change-bump.shrefuses that combination. A fragment declares a breaking change with a<!-- breaking -->line or simply by saying so in its public prose (non-breakingandgroundbreakingdo not count;<!-- breaking: false -->opts out). The check is category-independent — a removed public symbol is as breaking as a changed one — and runs fromcollate-changelog.sh, fromrelease-fast.ymlright after the version input is validated, and fromrelease-checklist.sh§6 — every path that creates a release tag. A tag pushed by hand, bypassing collation, still reachespublish-rnunguarded: the guard reads the fragments, and collation is what consumes them, so there is nothing left to read afterwards.
Changed¶
- Flutter:
SceneView/ARSceneViewnow claim taps on Android too. AddingTapGestureRecognizerto the platform view's gesture set is what lets the native hit test run, and it applies to both platforms — the Android views previously let taps fall through. An existing app that wrapped the widget in aGestureDetector(onTap:)or anInkWellto catch taps around the scene will find those taps now going to the platform view instead. Move that handling toSceneView's ownonTap.
Fixed¶
- glTF/GLB models whose textures are WebP-encoded (
EXT_texture_webp) now load with their textures on Android. Filament's Android prebuilt ships noimage/webpdecoder and offers no seam to register one, soModelLoaderre-encodes embedded WebP textures to PNG — using Android's own decoder — before handing the asset to Filament, instead of letting it render untextured with onlyMissing texture provider for image/webpin Logcat (#2305). A model without WebP textures is passed through untouched. WebP kept in separate.webpfiles beside a.gltfstill cannot be converted, and now logs an actionableSceneViewerror rather than failing silently. - A CI job on the self-hosted runner no longer makes the local pre-push gate accuse your code. The runner and every local session share one
~/.gradle, and a starting job rewrites~/.gradle/init.d/gradle-actions.*. Any concurrent local build whose compiled-script cache still points at the previous copy then dies during initialization — before a source file is read — and the gate printed✗ sceneview FAILED to compilefor all four Gradle legs (measured 2026-08-10, on a run whose only error wasCould not load compiled classes for script '…/init.d/…').gradle_infra_reasonnow recognises both spellings of that failure (with and without a reused configuration cache) and reports it as an unrun gate, which still blocks the push. Deliberately anchored on the~/.gradle/init.d/path: an init script the repo owns and passes with--init-scriptis committed code, so a break in it stays a real failure — asserted by its own fixture. - A killed test task is no longer reported as a test failure (#3029). The screenshot leg of
pre-push-check.shwas the one Gradle step that never went throughgate_gradle_failure: whenever the Roborazzi report showed zero fresh diffs and the build was red, it printed✗ :samples:android-demo tests FAILED — every screenshot matched its goldenand named a culprit the log never named. Measured 2026-08-09 on a host down to 2 Gi of free disk, where the only error in the log wasTimeout has been exceeded— the per-task 25-minute timeout fromsamples/android-demo/build.gradlefiring, which kills the task before it renders any verdict; re-running the same task on a healthy host gaveBUILD SUCCESSFUL in 18s. The leg now uses the same triage as every other Gradle step, andgradle_infra_reason()gained aTimeout has been exceededrow (fixture + mutation case intest-gradle-run.sh, 24 assertions). The step reports ⚠ "did not run to a verdict" instead of ✗, and the gate still exits non-zero — a task that was killed is not a task that passed, whether the host was starved or a test genuinely hung. - Maintainer-only notes can no longer leak into the published release notes (#3037).
collate-changelog.shintercepted exactly one comment shape — the single-line<!-- category: X -->tag — and copied every other line of a fragment intoCHANGELOG.mdverbatim, so a multi-line<!-- RELEASE NOTE: … -->block reached the public page intact. Every HTML comment in a fragment is now stripped, whether it is single-line, multi-line, or trailing on a bullet; the bullet text around it survives untouched. An unterminated<!--is a hard error naming the file rather than a silent truncation, because the collator deletes the fragments it consumes and bullets missing from a release section would have no source left to recover them from. - Flutter demo: the iOS 3D viewer now builds and renders.
samples/flutter-democould not be built for iOS at all — it shipped noPodfile, soflutter build iosgenerated one targeting iOS 13, below what the plugin required. The demo now commits aPodfile, targets iOS 18, and consumesSceneViewSwiftas a pod: a Swift package added to the host app's Xcode project is invisible to the bridge, which compiles inside CocoaPods' own project (Unable to find module dependency: 'SceneViewSwift'). Adds a root-levelSceneViewSwift.podspecfor that path, and corrects the plugin README, which documented the route that does not work. flutter_sceneview's declared iOS minimum moves 17.0 → 18.0. Consumer-visible. The podspec claimed 17.0 whileSceneViewSwift/Package.swifthas always required 18.0, so a host app that believed it got RealityKit availability errors at link time instead of a clear version error. The podspec now also depends onSceneViewSwift(pinned~> 4.27), which is not on the CocoaPods trunk: host apps must add apod 'SceneViewSwift', :podspec => '<raw URL of SceneViewSwift.podspec>'line, documented in the plugin README. Not the:git => …, :tag =>form: CocoaPods reads the podspec from the root of the checked-out tag, and the root podspec is not in any tag yet — verified withgit cat-file -e vX.Y.Z:SceneViewSwift.podspecon v4.26.0 and v4.27.0, the two most recent — so every tag that exists today resolves to "Unable to find a specification". The:podspec =>URL reads the spec frommainwhile the sources still come from the tag the spec names.- React Native stays on the SwiftPM route for now, deliberately. The same pod
treatment would need
samples/react-native-demo'sPodfilechanged in the same breath orrn-ios-compile.yml's realpod installturns red, which is a larger change than this one. Tracked in #3072; the RN podspec and README now say so where they claim no CocoaPods spec exists. - Flutter demo: iOS loads a model instead of an empty viewport. The viewer passed
a remote
.glbURL on every platform, but RealityKit reads only.usdz/.realityandModelNode.load(_:)resolves a bundle resource, not a URL — so every iOS load threw into a swallowedNSLog. Sample models now carry a per-platform source; a bundledkhronos_fox.usdzrenders on iOS, and entries with no USDZ are shown disabled with the reason rather than looking loadable. - Flutter bridge: iOS accepts remote model URLs. An
https://path now becomes a download rather than a lookup for a bundle resource named"https:…", closing a divergence with Android, where Filament'sModelLoadertakes either. The 3D path routes it by settingSceneViewerModel.urlStringinstead ofassetPath— the shared host reads exactly one of the two and checksassetPathfirst — and the AR path, which has no shared host, routes it throughModelNode.load(from:). AR also names an unsupported format with an actionable reason rather than relaying RealityKit's generic error, which is indistinguishable from "file not found". - Flutter bridge: platform views claim tap gestures.
SceneView/ARSceneViewdeclared only pan and scale recognizers, so Flutter kept every tap and the native hit test never ran. (onTapstill does not fire on iOS for a separate, documented reason — see the plugin README.) sync-assets.shaddressed a directory that does not exist. Its Flutter paths pointed atsamples/flutter-demo/example/…, so the demo never received the assets its catalog entries already claimed it used. The Flutter legs now refresh the assets the demo actually bundles rather than pushing the whole shared library at it.- Flutter demo About tab showed
v4.13.0while the SDK had moved on — and the integration test asserted that exact string, so it defended the drift instead of catching it. - The AI-facing surfaces no longer promise a Flutter
onTapthat iOS never delivers (#3045).llms.txt, its generatedgpt/knowledge-*.mdmirror,samples/flutter-demo/README.mdand the demo's own "Bridge Coverage" page all stated that the 3DonTapis delivered on Android and iOS — an inference from the code, landed with #3063. Measurement says otherwise: on an iPhone 17 Pro Max simulator (iOS 26.3) the callback never fires on iOS, re-measured across two different native hosts with the model rendering and the camera orbiting throughout.llms.txtis the file an AI reads to generate Flutter code, so the claim shipped as generatedonTaphandlers that are silently dead on iOS, and the demo's honesty page rendered a green "Android + iOS" badge for the exact feature it exists to be honest about. All four surfaces now say Android-only and point at #3045; the generated mirror was regenerated from the corrected source, never hand-edited. The Flutter plugin README carried the same claim and is corrected in the merge commit that brought it in. - The demo no longer reaches for a
package:collectionextension it never declares.viewer_page.dartcalledIterable.firstOrNull, which resolves only through a transitive re-export — the shapedepend_on_referenced_packagesexists to catch. Replaced with plaindart:core. - The MCP's Flutter setup guide no longer hands out a
pubspec.yamllineflutter pub getcannot resolve.mcp/src/platform-setup.tsinterpolatedLATEST_SCENEVIEW_RELEASE— the in-flight SDK version — intoflutter_sceneview: ^X.Y.Z, emitting^4.26.0while pub.dev's newest was 4.24.0. The Flutter plugin is a separate release track and its caret range must name a version that already exists on the registry; this is the same bug llms.txt carried until it was corrected.generate-version.jsnow also emitsLATEST_FLUTTER_PUB_RELEASE, read from the plugin's own README (the coordinate a human updates after a successful publish) and fatal if that line is missing — a silent fallback toVERSION_NAMEis exactly how the wrong version shipped. Guarded by a test that asserts the guide does not name the SDK version. - The React Native surfaces no longer state as fact that the 3D
onTapreaches iOS. That bridge routes its iOS 3D tap through the samehostView.onTapEntityhook whose Flutter counterpart was measured never to fire (#3045), so the claim was an untested inference. It was first walked back to "unverified" — deliberately not flipped to "Android-only", because asserting the opposite without measuring would repeat the original mistake — and #3086 then measured it in this same release: the iOS 3DonTapdoes fire, and every RN surface now states that measured result. - The React Native README no longer says
SceneViewSwiftships as SwiftPM only three lines above a callout announcing that a root podspec exists; the podspec exists, it is simply unpublished on the CocoaPods trunk. - The Flutter demo's viewer uses
defaultTargetPlatforminstead ofdart:io'sPlatform, which made the file uncompilable on Flutter web. sync-versions.shno longer bumpsllms.txt'sflutter_sceneview: ^X.Y.ZtoVERSION_NAMEat release time, and the row is report-only rather than critical. This is the same defect as the MCP one above, in the surface that feeds it — and the4.27.0release (fe4d30b42) proved it is not theoretical: the--fixsweep rewrote the caret to^4.27.0while pub.dev'sflutter_sceneviewhad exactly one published version,4.24.0(checked against the registry API, not inferred). A guard that repairs a value it has no view of does not prevent drift, it manufactures it; what keeps this line honest is the absence of an autofix.- Flutter iOS setup in the MCP server now ships a Podfile that actually
resolves. Both Flutter guides stopped at
platform :ios, '18.0', so a generated project failed withUnable to find a specification for 'SceneViewSwift'; they now carry thepod 'SceneViewSwift', :podspec => …line and say why a Swift package cannot replace it. - Fixed the Desktop setup guide, which named four APIs that do not exist
(
DesktopScene,WireframeCube,WireframeSphere,Float3) and leaked a TypeScriptimportinto a Kotlin block. It now showsWireframeCubeViewer(), the only public entry point, and states plainly that noio.github.sceneview:sceneview-desktopartifact is published. - The Flutter plugin's podspec floor on
SceneViewSwiftis now enforced. It sat at~> 4.26through the 4.27.0 release becausesync-versions.shwatched onlys.version; a stale floor lets an older SceneViewSwift satisfy the dependency, so the bridge can link against a runtime predating the APIs it calls. Bumped to~> 4.27and registered as a checked, autofixable row. - React Native's quickstart stopped implying the iOS 3D
onTapworks from source alone. #3086 then measured it in this same release and the quickstart now states the measured result; only the sibling Flutter bridge stays dead on iOS (#3045). changelog.d/3041-flutter-platformview-tap-arena.mdnow carries an explicit<!-- breaking -->marker. It describes a behaviour break in prose without ever using the tokenbreaking, so the patch-level guard would have let it ship in a patch release.- The Flutter demo's About tab read
v4.26.0while the SDK shipped 4.27.0.sync-versions.shonly checked that its two slots agreed with each other, so a pair that drifted together stayed green. Both now trackVERSION_NAMEand the row reads OK rather than WARN. llms.txtand its two mirrors asserted the React Native iOSonTapfrom source alone. All three AI-facing copies were walked back to "unverified" pending RN's own measurement, #3086 — which lands in this same release and confirms it does fire, so all three now carry the measured result.llms.txttaught a Flutter install that cannotpod install. The mandatorypod 'SceneViewSwift', :podspec => …line existed in the quickstart, the plugin README and the MCP server but not in the file AI assistants actually read. Added, with the Swift-package dead end spelled out.llms.txttaughtmodelPath: 'models/helmet.glb'with no platform caveat — RealityKit cannot read glTF at all, so that line renders nothing on iOS while compiling fine.- Both bridge guides in the MCP server invented props —
modelUrl,onModelLoaded,tapToPlace,onAnchorCreated,PlaneDetection.horizontal. Rewritten against the real surface (initialModels/ModelNode(modelPath:)in Dart,modelNodes={[{ src }]}in TSX) and guarded by a test. flutter_sceneview.podspec'sswift_versionlagged at 5.9 while the root podspec that declares the sync invariant sets 5.10.- Three
--fixhandlers insync-versions.shread a version through an unguardedgrep | grep | headpipeline. Underset -euo pipefaila non-matching inner grep aborts the entire sweep before the emptiness guard runs, silently skipping every later autofix. - Two internal contradictions this PR introduced:
SceneViewSwift.podspec's own comment called:git =>"the one form that works" while every install document in the same PR says only:podspec =>resolves today, and the React Native README'sonTaptopic sentence still said "both platforms" three lines above the callout walking iOS back to probably broken. -
The MCP React Native AR guide no longer sets
depthOcclusion={true}. The prop is declared on the bridge but configured nowhere native (#909), so the example promised LiDAR occlusion the runtime never delivers — and said the opposite of the RN README in the same repo. Real-but-inert props are invisible to the invented-symbol test (the identifier exists), so a dedicated guard now forbids enabling this one in either RN guide. -
sync-versions.sh's new SceneViewSwift floor row mis-handled a pre-releaseVERSION_NAME. Both the check ($FLOOR.${SOURCE_VERSION##*.}) and its autofix (${SOURCE_VERSION%.*}) took the last dot-segment as the patch, so4.27.0-rc.1produced the expected value4.27.1— a blocking MISMATCH on a floor that was correct — and--fixwould then have written4.27.0-rcas the floor. Both now strip the pre-release suffix before slicing; table-tested acrossX.Y.Z,X.Y.Z-rc.NandX.Y.Z-SNAPSHOT, matching and mismatching. - The React Native bridge's own TypeScript source — the fifth copy of the same
AI-facing claim, and the one consumers read as an IDE tooltip — asserted the
3D
onTappayload arrives "on both Android and iOS" from source alone. Both theonTapandTapEvent.nodeNamedoc comments were walked back pending measurement, then restored to the measured Android+iOS result by #3086 in this same release, insrc/index.tsxand the packagedlib/typescript/**/*.d.ts. - The MCP Flutter 3D guide branched its asset path on
dart:io'sPlatform.isIOS— the exact import this PR removed fromviewer_page.dart, because it makes a Flutter file uncompilable on web. It now usesdefaultTargetPlatform, matching both the demo and the guidance this PR added tollms.txt. Guarded and mutation-tested for both Flutter guides. -
impact-check.sh's SPM version gate now measures the repository instead of the disk (#3068). It reported[FAIL] SPM version refs stale — 15 file(s)on a clean tree, and no PR could fix those 15:grep -r .walked the working directory, so every hit was an untracked local file. The count drifted with the disk (15, then 17) because it never described the repository. Worse, the pattern targeted only the SPM mirror archived in PR #1215, leaving the gate a tracked population of zero — green in CI while verifying nothing, and blind to the 17 tracked files (llms.txt,gpt/knowledge-*.md,docs/docs/quickstart-ios.md, …) that carry the canonicalsceneview/sceneviewsnippet. On the tree that reported the blocker:[FAIL] 15 file(s)→[PASS] 17 tracked file(s) scanned. -
Discovery runs on
git ls-filesand targets the canonical coordinate. What isn't committed can't be a merge blocker. - Discovery and verdict share one line. At file granularity a stale snippet passed whenever any other line in the file quoted the current version — the gate confirming a version no reader resolves.
- Every offending line is named (
llms.txt:1), one per line, not counted and not space-joined — a bare count is what made the original unactionable, andspm guide.md:1is indistinguishable from two entries once a space is also the separator. - An empty population is FAIL (
pattern is broken, not the tree), except on a lean/sparse clone with no doc surface, where it stays SKIP — false-FAILing those is the #2370 scar this script already carries. - All SPM constraint forms count (
upToNextMajor,upToNextMinor,exact), andCHANGELOG.md/MIGRATION.mdare excluded at any depth — as ischangelog.d/, which inherits its destination's exemption: a fragment that was a blocker untilcollate-changelog.shmoved it is the same text judged twice by nothing but timing.mcp/stays out: independent release track, fixture stale by design. - A keyword counts as a constraint only in the SYNTAX that carries one —
from:,.upToNextMajor(from:),exact:. Without that,`…/sceneview.git, from v3 onwardis discovered as a pin and then judged stale for carrying no version: a release note describing history becomes a merge blocker, which is the "only says no" failure this PR removes, reintroduced in the half that decides what a pin IS. Both halves of a pin end on a boundary for the same reason:exactis also the first five letters ofexactly, and4.26.0is a prefix of4.26.0-beta` — without them the gate reads an English word as a constraint and blesses a version nobody checked. The keyword boundary lives in the constraint SHARED by discovery and verdict, so neither half can define a pin the other does not. -
Seventeen cases in
test-impact-check.shpin the contract, each mutation-proven — including tracked pathological filenames (-i.md,spm guide.md), which are dropped silently without grep's-e … --guard. -
check-sceneview-swift-urls.shno longer blocks a release note for describing the retired SPM mirror — and no longer lets one ship a pin to it (#3068). The allowlist coveredCHANGELOG.mdbut notchangelog.d/, even thoughcollate-changelog.shmerges each fragment intoCHANGELOG.md: the same sentence was blocked as a fragment and allowed once collated. Caught by this PR's own fragment, which failed three CI jobs on that one root cause. Both changelog surfaces are allowed wholesale, so they get a second, narrower pass — the archived mirror may be named, never pinned. A version constraint sitting next to that URL is a copy-pasteable install line that does not resolve, and release-note prose around it changes nothing. Everywhere else the gate is unchanged: anysceneview-swiftURL outside the allowlist still fails and is named — and now*.shis scanned at all. It was in no glob, which quietly made the two.shentries in the allowlist dead surface: the comment documented a protection no pass applied, and a setup script cloning the archived mirror — the one place a dead URL is a failing command rather than a bad paste — would have shipped. And a pin is no longer only a version constraint:git clone …/sceneview-swift.gitcarries no version at all and still fails the moment anyone pastes it, so the fetch verbs count too — targeting the org-qualified repo path rather than the bare token, because this detector's own filename contains that token and the first draft duly failed the automation-map row documenting it. A keyword-less SPM range ("4.0.0"..<"5.0.0") names no constraint at all and counts as well. Twelve cases intest-check-sceneview-swift-urls.shpin both passes in both directions — including the org-qualified requirement, whose fixture puts the mirror-bearing filename in the argument slot the verb-to-URL gap allows: an earlier fixture separated the verb from the name by prose, which the bare-token regression could not have matched either, so it passed against the very defect it named and proved nothing. - The release guard now reads a
<!-- breaking -->marker wherever it appears on a line, including trailing a bullet. Anchored to a whole line, a marker written next to its bullet was silently discarded and the fragment shipped unflagged. - The published
/llms-full.txtAI-context file is now served fromdocs/docs/llms-full.txtinstead of a hand-maintained duplicate underwebsite-static/. The duplicate sat outside every version sweep and shadowed the canonical file on the deployed site, so LLMs reading it were told SceneView 3.6.2 / Filament 1.70.0 / ARCore 1.53.0 — five minors behind. A structural check (check-llms-drift.sh) now fails if the committed copy ever returns. release-fast.ymlno longer dies right after collating the changelog. Staging the release commit with exclude pathspecs (git add -A ':!device-qa-report.json' …) makes git treat a gitignored match as explicitly named and exit 1 — onlydevice-qa-report.jsonis actually gitignored, which is one too many — and underbash -ethat killed the run before the release branch was ever pushed. The artifacts are now unstaged withgit resetinstead.
Tests¶
test-collate-changelog.shgains the confidentiality contract the collator never had a test for: internal notes in three comment shapes must not reachCHANGELOG.md, the bullets around them must, a category tag quoted inside a note must stay inert, and an unterminated comment must fail loudly without consuming a fragment. A second mutation test neutralises the stripper and asserts all six fixture note lines come back.test-check-breaking-change-bump.shpins the new guard in both directions on fixtures taken verbatim from real fragments — #3037's prose must refuse a patch tag,changelog.d/3008-contentid.md's "non-breaking" must not — with one mutation test per direction, plus the post-collation path where the previous version must be read past aCHANGELOG.mdsection that already names the target.
Docs¶
- Continued the provenance cleanup started in #2827: the branding audit, the branding README favicon entry and the MkDocs stylesheet now credit the SceneView design system (
DESIGN.md) for palette and token values instead of the tool that once produced them. Colors and tokens are unchanged. References describing theDESIGN.mdfile format are intentionally kept. - The React Native
onTap"iOS is unverified" caveat pointed readers at #3072, which tracks moving the module from SwiftPM to the root podspec — a different problem. The measurement got its own issue, #3086, and the caveat cited it on every surface that carried it before that measurement landed in this same release and replaced the caveat with the result:llms.txt, itswebsite-static/.well-known/mirror, the regeneratedgpt/knowledge-*, the React Native quickstart, the plugin README,src/index.tsx(with thebob-generated.d.ts), the MCP server's RN setup guide, and the demo app's AR-tab "AR Bridge Coverage" card and README bridge-status table. The #3072 citations in the plugin README's iOS section and inreact-native-sceneview.podspecare about the podspec gap and are correct; they stay.
v4.27.0 — 2026-08-10 — Compose Multiplatform, a shared iOS host, and one tap contract across the bridges¶
sceneview-compose arrives: one SceneViewer composable from commonMain, viewer
subset only, Android delegating to the existing Filament renderer. On Apple platforms
SceneViewerHostView — the reusable @objc UIView around SceneViewSwift — is the
missing half of that bridge, and the Flutter and React Native iOS bridges now render
through it instead of each carrying its own host. The bridges' tap contract is unified
in the same movement: a tap reports the model, not a mesh inside it, on every
platform, and nodeName == null is now the single "hit nothing" test in React Native.
Added¶
SceneView.contentID(_:)on Apple platforms — swap a model without re-creating the renderer. The content closure used to run only when the scene was created, so every demo that shows a different model re-keyed the whole view with SwiftUI's.id(_:). That destroys theRealityViewand builds a new one, and a re-createdRealityViewon iOS 26 Simulator intermittently renders nothing at all — no model, and no skybox either — permanently (#3008)..contentID(_:)keeps one renderer for the scene's lifetime: it removes the previous content (unregistering its gesture handlers first, so it deallocates instead of leaking), re-runs the closure, re-applies the render-quality preset, and re-arms the auto-framing pass so the new subject is fitted to the viewport instead of inheriting the previous one's camera distance. Additive and non-breaking — a scene without the modifier builds its content exactly once, as before. Android needs no equivalent: its DSL content is already re-read on recomposition.SceneViewergains anonErrorcallback, plus theSceneViewerErrortype it reports. A failed load has no pixels of its own — the viewport keeps showing the environment, which is indistinguishable from a load still in progress — so a failure was previously observable only in the platform log. Handling it stays optional and the log line is unchanged. Both shapes of failure are reported: an exception, and a loader answeringnullwithout throwing — the second matters because the threading fix above changed which one a malformed model produces (createModelInstancethrew, the suspendingloadModelInstancereturnsnull), so handling only exceptions would have made unparseable models fail silently. Added now rather than deferred because the module is unreleased, so it costs no compatibility; after publication it would.check-vendored-download-safety.sh— refuses to build a vendored tree whose build-logic downloads archives without verifying them and creates symlinks from an unvalidatedentry.linkName. Both defects are real in thefilament-kmp 0.3.0build-logic, and both are build-time code execution the moment something compiles it. The tree was removed frommainby #3015 while this change was in flight, so the gate is dormant today; it arms itself when the desktop spike (#2540) restores the copy and asettings.gradleinclude lands, and fails from that moment naming both fixes. The remediation is also written intodocs/docs/desktop-filament.md§ Re-vendoring the binding as item 4 of the obligations that must ship in the same PR as a restored tree — the requirement lands before the build chain, not after. Wired intorepo-hygieneandpre-push-check.sh, and its failing path is driven on synthetic trees bytest-check-vendored-download-safety.sh— a gate dormant on the real tree is a gate whose breakage would otherwise surface only in the PR it must stop. That self-test already caught one: the wiring probe matchedinclude("<path>")and was blind to theprojectDir = file(...)form Gradle actually uses, so wiring the tree left the gate green.- Compose Multiplatform support — new
sceneview-composemodule exposing a singleSceneViewercomposable fromcommonMain, answering #558 and #486. Scope is the viewer subset (model, orbit camera, key light, environment, tap hit-testing); AR, custom materials and post-processing stay platform-native by design. Android is implemented and delegates to the existing FilamentSceneView { }; iOS (RealityKit) and Desktop render an explicit placeholder until their renderers are wired. Purely additive — no existing published surface changes. See docs/docs/compose-multiplatform.md. - iOS bridge for
sceneview-compose—SceneViewerBridgelets an iOS app supply the RealityKit renderer, since a KMP module cannot depend on a Swift Package. Gestures are written back intoCameraState, so reads stay truthful about what the user did. The reusable@objc UIViewwrapper aroundSceneViewSwiftis not written yet; without a registered factorySceneViewerdraws a visible notice rather than an empty viewport. ModelSource.Urlnow rejects non-http/https URLs incommonMain, so the documented invariant holds on every platform instead of only inside the Android downloader.- The vendored
third_party/filament-kmp/copy was removed again before shipping. It was 31 700 lines that nosettings.gradlereferenced, so nothing compiled it, and its Apache-2.0 §4(b) guard cloned a single-maintainer GitHub repo on every CI run — making an unrelated upstream outage able to redden every PR in the monorepo. The desktop track still plans to vendor; the execution moves to the P1 spike, where the copy can be taken at a current upstream tag instead of ageing onmain. Restoring it is one command, documented in docs/docs/desktop-filament.md. SceneViewerHostView— the reusable@objc UIViewaroundSceneViewSwift. This is the missing half of thesceneview-composeiOS bridge shipped in #3009: the Kotlin side declared it needed aUIViewfactory, and every app had to write thatUIViewitself. It now ships inSceneViewSwift, driven entirely by primitives onSceneViewerConfiguration, so aSceneViewerViewFactoryis a field-by-field copy plus two callbacks. The Flutter and React Native bridges render their 3D path through this same wrapper — each keeps a platform-view class only for its method channel or prop bag and for the AR path. Seesceneview-compose/README.md.- Four additive
SceneViewmodifiers the wrapper needed, all opt-in and none changing existing behaviour:cameraPose(_:)(continuous camera write-through, applied only when the value changes so it does not fight a live drag),onCameraChanged(_:)(the camera read-back — fired for drag, pinch, auto-rotate and re-framing alike),cameraGesturesEnabled(_:)(freeze the gestures without handing the camera to Apple'srealityViewCameraControls, whichCameraControlMode.nonedoes), andonEntityTapHit(_:)(tap plus a world-space position). The distinct base name is deliberate and was arrived at the hard way: an overload distinguished only by ahit:label does not protect existing call sites, because an unlabelled trailing closure ignores the label — measured, every published.onEntityTapped { entity in }snippet stopped compiling. CameraStateis now genuinely two-way on iOS. Gestures write into it and writes drive the camera, verified on the iOS 26.3 simulator: a 180-point drag moved the camera to the arithmetically expected −51.6° and reported exactly that back. A pose the renderer has to clamp is reported back clamped, so the clamp is visible in your state instead of a silent disagreement with the screen.
Changed¶
- React Native (iOS): the module's minimum iOS version is now 18.0, up from a declared 17.0. The 17.0 figure was never real —
SceneViewSwifthas required iOS 18.0 since #719, so an iOS 17 host app resolved the pod and then failed later at build time with a confusing error. Declaring the true floor moves that failure topod install, where it names its own cause. Host apps must setplatform :ios, '18.0'in theirPodfileand build with Xcode 16+. - A React Native Android model tap now carries a name — on
SceneViewand onARSceneView.nodeNamewent from alwaysnullto the model's file base name. Both Android views dispatch through the same path, so tapping a model placed in an AR scene now reports it too. An app that readnodeName == nullas "the tap missed every model" (for instance to place an object at that point) will now see model taps stop matching that test — in AR, that means a tap landing on an already-placed model no longer looks like a bare surface hit. Only Android changes: on iOS,ARSceneViewstill reportsnullfor every tap, becauseSceneViewSwift'sARSceneViewexposes no entity hit-test hook (#2051). The type change that accompanies all this is a separate entry. TapEvent.nodeNameis now typedstring | null— no longer optional (React Native). It wasstring | undefinedin the type andnullat runtime (Android has always usedputNull), andARSceneViewon iOS built its payload fromonTapOnPlaneand left the key out entirely:nodeName === nullmeant "the tap hit no model" on three dispatch paths andundefinedmeant the same thing on the fourth, so every consumer needed a two-sentinel guard to be correct. The iOS payloads are now built by a singlernTapPayloadseeded with"nodeName": NSNull(), matching Android'sputNull, and all four paths — AndroidSceneView/ARSceneView(one sharedTapEvent.getEventData), iOSSceneView, iOSARSceneView— always emit the key. OnenodeName == nullcheck is now correct everywhere. Droppingundefinedfrom the type is source-breaking understrictNullChecksfor code that narrowed with=== undefinedor assignednodeNameinto astring | undefinedbinding;== null, truthy checks and?.are unaffected. Because it is source-breaking, it ships in a minor release, never a patch.- Flutter's
onTapis unchanged, and its "no model" value is still'', notnull. The Dart callback staysvoid Function(String nodeName): unlike React Native's, it only fires because something was hit, so it has no "the tap missed everything" dispatch to carry anullthrough. The one near-miss it can reach — an iOS tap that resolved outside every model the bridge loaded — reports the empty string, on both platforms. Flutter code should keep testingnodeName.isEmpty; React Native code should usenodeName == null. OnARSceneViewunder iOS the Flutter callback does not fire at all, becauseSceneViewSwift.ARSceneViewexposes no entity hit-test hook (#2051). - The Flutter and React Native iOS bridges now render through the shared
SceneViewerHostView. Both carried their own copy of "host a SwiftUISceneViewinside a UIKit view, then load models into it imperatively" — two independentUIHostingControllerwrappers, two content roots, two model reconcilers, drifting apart. The 3D path of each now builds aSceneViewerConfigurationand hands it to the same host thatsceneview-composeuses; each bridge keeps only what is genuinely its own, its method channel or its prop bag. Their AR paths are untouched:ARSceneViewis anchor-driven and shares nothing with the 3D viewer. Every method-channel name and every prop name is unchanged. The one payload that did change is the tapped node's name, and deliberately: both bridges were reporting a mesh from inside the asset, so the definitions were unified rather than preserved — see thenodeNameentries in this release. SceneViewerConfigurationgained the four things a bridge cannot do without.models(a list — Flutter appends one at a time, React Native replaces the lot; a per-entryidentityis what keeps two copies of one path as two models),cameraControlModeandautoCenterContent(both bridges expose them publicly), andcameraPoseAuthored(neither bridge has a camera at all — without it every method call would re-assert the default pose and snap the camera out of its framing and away from wherever the user had orbited to).cameraPoseAuthored: falsedetaches the pose rather than merely stopping it from being updated:SceneViewapplies the first non-nil request it sees, so handing it a default pose still frames the scene, at elevation 15° whereCameraControls' own default is 30°. Auto-centering re-fits distance and target and hides all of that except the angle — a camera-less bridge would have come out of this migration looking down on the model from somewhere else. Caught by the agent review on this PR. Every pre-existing member keeps its name, type and default, sosceneview-composeis unaffected: a configuration with nomodelsis resolved into a one-element list built from the single-model fields, through the same reconciliation path.SceneViewerHostView.onTapEntity: ((SceneTapHit, Entity?) -> Void)?, a Swift-only companion to the@objconTapthat hands over theSceneTapHitrather than five primitives, plus the model root the hit entity sits inside — the direct child of the content root, which is the entitySceneViewerModel.nodeNamewas written on, andnilwhen the tap resolved outside every configured model. Both bridges were re-deriving that from the hit entity and both got it wrong (see the tap fix below), so the resolution lives in the host, which is the one place that knows what a model is. This member has never shipped in a release, so its arity is free to be what it should have been.- Entities are now eligible for entity-targeted SwiftUI gestures by default on Apple
platforms. This is the other face of the
InputTargetComponentfix, and it is a behaviour change to code that did not ask for it:NodeGesturehandlers (onTap/onDrag/onScale/onRotate/onLongPress) that were registered and silently never fired will now fire. If your app registered one, saw nothing, and worked around it, re-check that wiring — the workaround and the handler will now both run. Camera orbit and pinch are unaffected: the entity gestures are attached with.simultaneousGesture, and a drag over a model was verified on the iOS 26.3 simulator to still orbit the camera by the expected amount. SceneCameraPosewrite-through clamps to RealityKit's dolly envelope (1…50scene units) and to ±85° of elevation, and reports the clamped value back throughonCameraChanged. A pose that cannot be honoured verbatim now says so instead of leaving your state and the screen disagreeing.
Fixed¶
- The release device-QA gate no longer grades every release against one frozen
run.
device-qa-report.jsonis harness output, but it was committed by accident in #3050 and never gitignored — andrelease-checklist.shtakes its fast path whenever that file exists. So the deterministic gate that dispatches its own uncancellable Device QA run (#1683) became unreachable at release time, and every release since was graded against a single 2026-07-12 report whoseiosleg was red — a permanent hard block built out of a stale artifact, which is the failure mode #1683 existed to prevent. The file is now untracked and ignored, so the gate dispatches again. - Quality gate (#3065):
pre-push-check.shno longer announces a cause it did not establish. A Gradle step that dies because the host is not set up (nolocal.properties/sdk.dir/ANDROID_HOME, missing SDK package or NDK, unusable JDK) now reports⚠ … did NOT run, prints the exact one-line fix and counts as an incomplete gate — instead of claiming the public API "drifted" and prescribing./gradlew apiDump, a remedy that would have committed a bogus.apidiff.apiCheckadditionally requires a positive comparison cue from the Kotlin binary-compatibility validator, so a build that dies insideapiBuildis reported as "not compared", never as a drift. The same rule now covers the non-Gradle checkers (demo assets, skill drift, gpt knowledge, vendored chain, runner routing) viascript_report_failure. - Agent review (#3076): the PR reviewers could be handed a two-dot diff — everything
maingained since the branch point, reversed — and report it as the author's work. A--depth=1fetch inside the job grafted.git/shallowonto its ownfetch-depth: 0checkout,origin/main...HEADstopped resolving, and the fallback turned "I cannot compute this PR's diff" into two blocking errors about files the PR never touched. No fetch in that workflow is depth-limited any more, a shallow graft is now repaired rather than worked around, and an unresolvable merge base refuses the review instead of substituting a different one. The computation moved to.claude/scripts/pr-diff.sh, pinned to the default branch like the grader and covered bytest-pr-diff.sh(hermetic git repos, plus a mutant carrying the old fallback so the assertions have to discriminate). RerunBridgeno longer drops the first event after a reconnect (#2777). The bridge shared a singleCONFLATEDchannel across connections, so adisconnect()→connect()cycle could hand the next connection's first event to the writer it had just cancelled: a writer parked inreceiveis still a registered receiver until its cancellation is actually processed, and with noonUndeliveredElementhook the channel drops such an element on the floor — never buffered, so the incoming writer never sees it. Each connection now gets its own outbox, installed byconnect()before the writer starts, which makes the hand-off structurally impossible. Measured on the pre-fix bridge, a reconnect lost the event 48% of the time (72/150); the fixed bridge scored 0/450. This was surfacing as the long-standingRerunBridgeTest > bridge can be disconnected and reconnectedflake (SocketTimeoutException: Read timed out) — a real product bug, not a tight test timeout: a Rerun session that reconnected silently swallowed its first frame. The single-shot test was too insensitive to hold the line (it passed 12/12 locally while the bug was live), so the regression guard is a 20-round loop that fails deterministically against the old bridge.- The App Store screenshots now upload during the one window a release opens (#2899).
app-store.ymlcreates the App Store version, syncs the listing text, then submits for review — and Apple locks the metadata on submission. That leftapp-store-screenshots.ymlwith no reliable moment to run: dispatched after a release it skips honestly (no editable iOS version), dispatched before one there is nothing to write to. So v4.26.0 shipped with a correctpromotionalTextand a screenshot set four releases stale — the window was real, and nothing was writing in it. The release now callsasc_listing.apply_screenshots()between the listing sync and the submission, reusing the same uploader the manual workflow runs so the two paths cannot drift. It re-mints the API token first: the one minted at the top of the step is good for 1200 s and the build poll alone can burn 900 s of that, and a token expiring mid-upload is the one way this could leave the listing worse than it found it. The step is deliberately never fatal — a screenshot that fails to upload must not stop a release from reaching App Review — but it is loud, because a quiet skip is exactly what let the drift survive four releases. agent-cost-report.shnow sees subagent transcripts. They live at<slug>/<sessionId>/subagents/agent-*.jsonl, not<slug>/*.jsonl, so the report globbed past them and printed no subagent line at all — measured 2026-08-03, 643 subagent transcripts on disk, 22% of all requests, invisible.agent-cost-report.shreports a weighted cost (cache read x0.1, cache write x1.25-2, output x5) instead of headlining raw output tokens. The old headline called output "the quota-binding number"; measured over 7 days across all projects, output is 11.7% of the bill and cache reads are 60.8%. The report now also prints the average context re-read per request — the quantity the cost actually scales with.context-budget.shreported the standing session context at ~4 chars/token, a plain-English default that understated it by ~35% for markdown full of tables, paths and emoji. The ratio is now ~2.7, derived from two natural experiments in the local transcripts, and the report gained the two items it never counted: the user-levelCLAUDE.mdand the one-line skill/command/workflow descriptions that ship in every preamble whether or not a body is ever opened.STATE.mdandworkflows/README.mdmoved to a separate "read at bootstrap" block — they are not in the preamble, and counting them as standing cost is what kept sending each pass back to cut the same file (#3001).- iOS demo:
Animation,Scene GalleryandModel Viewerno longer go permanently black when you change the model. All three now keep theirSceneViewmounted — spinner as an overlay rather than anif letthat unmounts the scene — and swap subjects through.contentID(_:). Measured onQA-iPhone16-c(iOS 26.3): a subject change used to build two freshRealityViewinstances and now builds zero. - iOS demo: the asset-source pill no longer tears the scene down the first
time it appears.
assetSourcePill(_:)branched betweenoverlay(…)andself, which are structurally different views, so the first transition from no-pill to pill discarded the modified subtree —RealityViewincluded. It now applies the overlay unconditionally and drops only the pill. This was measured re-creatingAnimationDemo's scene on exactly the first subject change and no other. - iOS demo:
Model Viewer's "Surprise me" no longer skips a model when two rolls share a title. Its scene key was the model's display name, so two consecutive picks with the same title left the key unchanged and the swap silently did not happen. It is keyed on a monotonic load counter now. The same collision existed with the previous.id(_:). - Docs: the iOS model-viewer recipe now renders its model.
samples/recipes/model-viewer.mdloaded a model asynchronously into a scene with no.contentID(_:), so the content closure — which runs once, at scene creation, while the model is stillnil— never ran again and the viewer stayed empty. It now carries the key plus a model-swap section. flutter_sceneviewpublishes to pub.dev again, and cannot fail silently (#3011).flutter pub publish --forcedoes not fail when the OIDC credential is missing: it falls back to interactive OAuth, prints anaccounts.google.comURL and blocks onWaiting for your authorization…until the job timeout kills it. The job then lands ascancelled, which reads as "someone stopped it" rather than "the publish failed", so nothing was ever red — and the plugin silently missed both v4.25.0 and v4.26.0 while every other target shipped. pub.dev still serves 4.24.0. The step now closes stdin so a prompt dies instead of waiting, bounds the call withtimeoutso a hang is attributable to the step instead of surfacing as a job cancellation, and treats the interactive banner as a hard failure whateverpubexits with afterwards. A new step then verifies the registry actually serves the tag's version, so a future failure mode that ends0without uploading cannot hide in the same way. This is the workflow's only post-publish re-verify: npm and Maven Central query their registries as a pre-publish skip guard and then trust a non-zero exit, which holds for them because their CLIs fail loudly on an auth error instead of dropping to an interactive prompt. Extending the check to them is tracked separately.- A large release no longer ships notes cut off mid-sentence (#3012). A GitHub release body is capped at 125,000 characters and the API truncates rather than rejecting, so the step stays green and nothing anywhere says the notes are incomplete. v4.26.0 extracted 132,833 characters from
CHANGELOG.mdand published 124,999 — the cap minus one — ending mid-sentence inside### Fixed, with all of### Testsand### Docsgone. It is a function of fragment count, not of anything unusual about that release:### Fixedalone was 73,275 characters.create-releasenow measures the extracted section and, when it overflows, drops whole trailing###subsections until it fits and appends a pointer naming what was omitted and linkingCHANGELOG.mdat the tag. Whole subsections keep the body valid markdown and keep the loss legible; a warning records which ones went, because a silent cap reads as "these are the complete notes" precisely because nothing says otherwise. - The PR review workflow no longer reports its own edits as defects, and can no
longer make them. Its four reviewers shared one working tree with a
process-wide
Writegrant, and the deny list stopped them from moving the branch but not from reverting a file, while the prompt told the orchestrator to treat uncommitted changes as part of the review surface. A reviewer that touched the checkout therefore produced aDO_NOT_MERGEnaming an "uncommitted revert" nobody had made — three times across #3009 and #3015.git restore,git applyandgit cleanare now denied, the prompt states that CI checkouts are clean by construction so uncommitted work can only be the review's own damage, and an assertion fails the job outright if the tree is dirty rather than letting a poisoned verdict reach the pull request. That assertion was itself fail-open at first — a failedgit statusleft its output variable empty and the step announced a pristine checkout it had never managed to look at, the same "absent is not zero" trap this workflow already carries two steps below — so a failed probe is now treated as contamination rather than as a clean result. Above all, the reviewers are now fivesv-ci-*agent types whosetools:frontmatter grantsRead, Glob, Grepand no shell, so contamination is impossible rather than forbidden: the diff and the verdict file moved out of the repository intoRUNNER_TEMP, and the clean-tree assertion demands a checkout byte-identical toHEAD(refined in #3057, which carves out — and asserts — the eight config pathsclaude-code-actionitself restores from the base branch). Measured — droppingWritealone would have changed nothing, since a subagent that still hasBashoverwrites a tracked file with oneecho. Closes #3016. CI Gateno longer passes green over a check that vanished from one Checks API read (#3018). The gate took every decision — what is still pending, whether the core-check guard is armed, and the final pass/fail — from a single instant's response toGET /commits/{sha}/check-runs. That read is not stable: while GitHub rebuilds a run attempt (gh run rerun, "Re-run failed jobs"), an entire check suite can be absent from one response and back in the next. On #3015 that window landed on the last poll, and all three consequences pushed the same way — the 11 missing checks leftpendingso the loop broke, leftobserved_namesso the core-check guard read its docs-only signature and disarmed, and left the aggregated set so thecancellednever reachedci-gate-aggregate.sh. The single check that branch protection requires went green over aCompile KMP corethat had concludedcancelled, and its conclusions list held one entry where a dozen jobs had just finished — not a display bug, the aggregation genuinely saw one check. A check observed once for a head SHA is now carried across polls and kept asstatus: vanished, which the existingpendingselector treats as not-completed, so the gate waits instead of concluding; when the check returns its fresh record replaces the remembered one, so a genuinecancelled→ re-run →successstill goes green, and if it never returns the gate times out red naming it. Both branches are fail-closed where the old behaviour was fail-open. The merge is monotone in check-run id rather than "the live read always wins": a response that drops the fresh run while still listing the superseded one no longer retires the fresh one, which was measured passing green over a check that had never concluded. This narrows the window from "any one read is partial" to "every read up to the decision is partial" — it does not close the class, because this workflow's owncancel-in-progressrestart gives the new gate run an empty ledger; that residual is tracked in #3024. This is orthogonal to the #2492 latest-run-per-name collapse — that resolves two check runs sharing a name within one read, this carries names across reads — and the collapse still runs first, so a genuinely supersededcancelledis resolved before the ledger ever sees it.sceneview-composeno longer reads model assets on the main thread.ModelSource.Assetwent throughModelLoader.createModelInstance(assetFileLocation), which is@MainThreadand reads the file on the calling thread — and the caller here isproduceState, whose producer runs in the composition's context. The whole asset landed on the main thread. It now uses the suspendingloadModelInstance, which reads throughDispatchers.IOand hops back to Main for the Filament JNI call alone. Sibling resolution is preserved, so a multi-file.gltfstill loads its external.binand textures.SceneViewerSpec(iOS) now compares by value, and its model bytes by content. It is the recomposition key the iOSSceneViewerpublishes throughrememberUpdatedState, which only notifies on an unequal value — but it was a plain class with identity equality, rebuilt on every composition. Every recomposition, including the one each touch-move triggers throughCameraState, therefore handed the Swift renderer a new spec carrying the same model and asked it to apply it again.ModelSource.Bytesalready compared its array by content precisely to avoid this; the guarantee was lost the moment the array was unpacked into aByteArrayfield, whose ownequalsis reference equality. The callbacks stay out of the comparison — they are permanent forwarders that already read the app's current lambdas.-
The neutral fallback environment is no longer built for scenes that cannot use it. It was hoisted above the
when, so everyEnvironmentSource.Colorscene paid a synchronousneutral_ibl.ktxasset read and a cubemap upload for a value that branch can never reach — a colour background has no image-based light. It is now built inside the two branches that use it. -
pre-push-check.shnow checks the generated GPT knowledge base.gpt/knowledge-*.mdis generated fromllms.txtand gated inci.yml→repo-hygiene, but no local gate ran it — notpre-push-check.sh, notquality-gate.sh, notimpact-check.sh. Editingllms.txttherefore passed every local check and only turned red on CI, which is exactly what happened to this PR. Added as a twelfth leg (a sub-second regenerate-and-compare), and mutation-tested: appending a line tollms.txtturns it red, restoring it turns it green. -
ModelSource.Assetnow rejects any URI scheme, and this closes a hole the threading fix above had just opened.loadModelInstancedispatches on URI scheme, where the replacedcreateModelInstance(assetFileLocation)went straight toAssetManager.open. So for one commit an app resolving a deep link or a server-supplied id intoModelSource.Assetcould be handedcontent://(reading a private ContentProvider under its own uid),file://(an arbitrary local read) orhttps://(bypassing the timeouts and the 64 MB cap thatModelSource.Urlenforces).Url's KDoc already argued this case — "afile://slipped into a deep link would otherwise turn into a local-file read on whichever platform happened not to re-check" — and the fix is its mirror: the check lives incommonMain, so every platform refuses identically. Found by review, not by a gate; no test covered the widening because the threading fix looked like a pure substitution. onErroris now always called on the main thread.runCatchingsat insidewithContext(Dispatchers.IO)on the download path only, so a handler that worked for a failed asset crashed for a failed download withCan't create handler inside thread that has not called Looper.prepare()— and the failure most likely to happen in production was the one delivered on the wrong thread. The thread is now documented on the parameter and inllms.txt, alongside the fact that it is raised on Android only today.- The pre-push gate no longer blames your code for a dead Gradle daemon (#3029). Five steps of
pre-push-check.shran./gradlew <task> --quiet 2>/dev/nulland translated any non-zero exit into one hard-coded diagnosis — so aGradle build daemon disappeared unexpectedly(daemon contention on the host) was reported as "Android screenshot regression detected", and2>/dev/nullhad deleted the one line that said otherwise. Measured 2026-08-06 and reproduced identically on a pristine clone ofmain, with no golden and no source change involved; re-running the task alone returnedBUILD SUCCESSFUL. Gradle output is now written to a log under$TMPDIR/sceneview-pre-push/and quoted, and a specific diagnosis is only pronounced when the log carries no infrastructure signature — otherwise the step reports "did not run to a verdict" and the summary counts it separately. The gate still exits non-zero: a check that could not run is not a check that passed. - The same gate could also pass while comparing no screenshot at all (#3029). The goldens under
samples/android-demo/src/test/snapshots/are not declared inputs of any Gradle task, so a second run came backverifyRoborazziDebug UP-TO-DATE/BUILD SUCCESSFUL in 1s— and the step printed "✓ Android screenshots match goldens" having read none of them (measured on a golden mutated by 8000 red pixels). The step now forces the comparison and takes its verdict from Roborazzi'sresults-summary.json, which must be newer than a marker taken just before the run; the diff count comes from the report, so "regression" names how many goldens differ and points at the*_compare.pngimages. - The CI leg that actually gates merge had the same false green (#3029).
ci.yml'sunit-testjob invokedverifyRoborazziDebugbare, with a restored Gradle cache, so a PR whose only change was a golden PNG could go green having compared nothing. It now forces the demo module's test task to re-run, like the local gate. release-checklist.shnames a Gradle infrastructure failure instead of calling it a failed build, for the same reason — "fixing" code that was never broken costs a whole cycle. It stays a blocker: the checklist exits 0 whenever there are no blockers, so recording it as a warning would have let a release be tagged withassembleDebugnever having run.- React Native (iOS): the
pod installof a host app no longer fails on this module's podspec.s.homepagewas fedpackage["repository"]— an object, which CocoaPods rejects outright (Unacceptable type 'Hash' for 'homepage') — ands.platformsclaimed iOS 17.0 whileSceneViewSwiftrequires iOS 18.0, so CocoaPods could not resolve the module at all. Both are corrected, andsamples/react-native-demo'sPodfile, Xcode deployment target and READMEs now state the real iOS 18.0 floor. - A tap on a model now reports the model on iOS, not a mesh inside it (#3037). Tapping
black_dragon.usdzin the Flutter demo on an iOS 26 simulator reportedskin0— the name of an internal mesh — while the same tap on Android reportsblack_dragon.SpatialTapGesturehands back the deepest hit entity, and USDZ assets name their meshes, so every derivation that started from that entity stopped inside the asset: the Flutter bridge walked up to the first named ancestor and found one immediately, and the React Native bridge reportedhit.entity.nameraw, with no walk at all. Android cannot reproduce it, so it was never the reference: the only collider a loaded model owns there is theModelNoderoot (glTF child renderables get no collision shape), so its hit-test can only ever resolve to the model. The resolution now lives inSceneViewerHostView, which is the one place that knows what a model is — it climbs to the model root, the direct child of the content root and the only entity a bridge names — and both bridges report that entity's file base name without extension. The React Native Android side, which reportednodeName: nullfor every model tap because nothing ever named theModelNode, now names each model after its file, so both platforms emit the identical string. nodeNameno longer leaks a URL's query string. A model source may be a URL —ModelLoaderloadshttps://on Android andSceneViewerModel.urlStringtakes a remote.usdz— and cutting at the last.only strips the extension when it is the last dot in the whole string:https://cdn/robot.glb?sig=SIG&v=1.2derivedrobot.glb?sig=SIG&v=1, putting a CDN signature into a payload apps routinely show in a label or send to analytics. Query and fragment are now stripped first, on both platforms.- The React Native tap payload's
x, y, zis the tapped model's world position on both platforms, matching Android'snode.worldPosition. On iOS it was the origin of whichever entity RealityKit reported as hit — a mesh deep inside the asset, offset from the model itself — so the same tap on the same model gave different coordinates on the two platforms. - The React Native README's SwiftPM install version is now swept like the other 30+ version locations.
sync-versions.shtracked the bridge's machine-readable slots —package.json,package-lock.json— but not the version a host app types into Xcode's Add Package Dependencies… dialog, which sat at4.14.0whileVERSION_NAMEreached 4.26.0. It is anchored on its own- Version: \X.Y.Z`line shape so thev4.3.0feature notes in the same file are never swept, and a hermetic self-test (test-sync-versions-bridge-readmes.sh, wired intoci.yml'srepo-hygienejob) pins both the rewrite and that non-rewrite. That test earns its place: the handler only fires on drifted prose, so the normal in-tree run never executes it and a brokensed` would stay green until the next release bump — the failure mode that bit the Kotlin rewriter twice (#2790, #2876). Its fixture gives the drifted slot the same version as the dated notes on purpose: with a non-colliding version, an anchored sed and a de-anchored one emit byte-identical output, and the guard passes against a broken handler. - The RN README's SwiftPM instructions pointed at a repository that does not exist.
https://github.com/sceneview/SceneViewSwiftreturns Repository not found;SceneViewSwiftis a product of the monorepo's rootPackage.swift, which is what the root README andSceneViewSwift/README.mdhave always said. Bumping only the stale version beside it would have produced a fresh-looking instruction that still fails in Xcode. - The RN README's "not yet published to npm" status note was long dead. It claimed
@sceneview-sdk/react-native@3.6.1waslatestand the 4.0.x line unpublished, pending #924 and #962 — both closed, with npmlatestnow tracking the release train at 4.26.0. It is replaced by the actual publishing rule. The GitHub-install fallback it justified is removed rather than re-pinned: this is a monorepo with no rootpackage.json, sonpm install github:sceneview/sceneviewcould never resolve the module underreact-native/react-native-sceneview/— the pin was stale and the command was broken. The README now documents the clone-and-install-by-path route that actually works. - The Flutter README's pub.dev install snippet is checked but deliberately never bumped.
flutter_sceneview: ^X.Y.Zis a caret range against a version that must already be live on pub.dev, so it belongs to the same lagging track as the plugins' consumed Maven coordinate (#1494), not toVERSION_NAME. pub.dev's newest is 4.24.0 against a 4.26.0VERSION_NAME, and^4.26.0there matches nothing and failsflutter pub getoutright — a release-time sweep would have converted a working install line into a broken one every single release. It is now reported as a WARN with no--fixhandler, and the self-test's regression guard asserts the absence of that sweep. - The Flutter README's naming note called this project's own old package a third-party upload. It warned that both
sceneviewandsceneview_flutteron pub.dev were "unrelated third-party uploads". Only the second is: pub.dev'ssceneviewcarries this repo's ownrepositoryURL and a byte-identical description — it is the project's pre-rename package, abandoned at 3.6.1. A reader who checks the first name finds the note obviously wrong and discounts the half that is true and actually matters. The note now separates the two cases. The RN module's podspec comment pointed at the same non-existentsceneview/SceneViewSwiftURL as the README did, while telling readers to follow that README — corrected to the monorepo URL alongside it. - The Flutter README's rename note named the wrong tag. It read "at tags
v4.23.0and earlier the package name wassceneview_flutter", but the rename commit (#2735) is first contained in v4.25.0:v4.24.0's pubspec still readsname: sceneview_flutter. Since "the dependency key must match the name at the ref", a git-pin consumer atv4.24.0following that sentence got a failingpub get. Corrected to "at tagsv4.24.0and earlier", verified against the tags' own pubspec contents rather than the release prose —CHANGELOG.md's own "consumers at tags ≤ v4.22.0" line describes the rename as landing in 4.23.0 and is wrong for the same reason (left alone here as released history; the README is the surface people follow). -
The React Native Android bridge is compiled by CI, for the first time (#3042).
react-native/react-native-sceneview/android/— ~1130 lines of Kotlin acrossSceneViewManager.kt,ARSceneViewManager.kt,SceneViewEvents.kt,SceneViewModule.ktandARRecorderModule.kt— was in no CI job and not in the rootsettings.gradle. It is the same exposurern-ios-compile.ymlclosed on the iOS side (#2067), made worse by the fact that the module builds againstio.github.sceneview:sceneview:4.7.0, the last published release, which lagsVERSION_NAMEon purpose (#1494): an API that exists in repo source can be absent from the artifact the bridge really compiles against, so reading the matching tag's source proves nothing the compiler agrees with. A newrn-android-compile.ymlcompiles the module through a standalone Gradle build (tools/rn-android-compile/) that includes it as the single project of a throwaway build — deliberately NOT the root build, which would resolveio.github.sceneview:*against local source and prove the wrong thing. The gate does not trust a green Gradle exit either: it assertscompileReleaseKotlingenuinely executed (notNO-SOURCE,UP-TO-DATEorFROM-CACHE) and that class files came out, because a moved source directory would otherwise turn the job into a no-op that reports success for life. -
The React Native Android module could not be built standalone at all. It declared no JVM target, so
compileReleaseJavaWithJavac(1.8) andcompileReleaseKotlin(the toolchain default) disagreed and Gradle refused the build — and React Native's Gradle plugin does not fix this for a library module, it only supplies plugin versions.compileOptions/jvmTargetare now pinned to 17, matching the Flutter plugin and React Native's own JDK requirement. This was the first thing the new gate caught, on its first run. - The React Native bridge's TypeScript is now actually linted, type-checked and tested — by CI, not by a script that could never run (#3049).
react-native/react-native-sceneview/package.jsondeclared"lint": "eslint \"src/**/*.{ts,tsx}\""whileeslintwas in neither its devDependencies nor anywhere else in the repo: after a cleannpm ci,npm run lintfailed withsh: eslint: command not found, exit 127. No workflow invoked it either — the only RN npm script any job ever called wasnpm run build, insiderelease.yml'spublish-rn, at publish time on a tag.npm run typescriptandnpm testworked but were equally unreached, so the bridge's TypeScript shipped to npm having been checked only on a contributor's laptop. Rather than install a second linter to satisfy a stale string, the package joins the one the repo already has:src/**,__tests__/**andexample/src/**are now listed in the rootbiome.json'sfiles.includes, andlint/lint:fixrun Biome from the repo root the same waymcp/does. The newrn-ts-check.ymlruns lint on all three directories,tsc --noEmitonsrc(that is whattsconfig.jsonincludes) and jest on__tests__, for every PR touching the package's TypeScript. - That new CI job cannot report coverage it did not compute. Biome's exit code alone would not have been enough, and the first draft of this job wrongly assumed it was. Measured on Biome 2.5.7: a path argument excluded by
biome.jsonis dropped silently, and the run still exits 0 as long as any other argument matched — so deleting just thesrc/**line fromfiles.includesleft the job green while the actually-shipped source went unlinted. The job now counts the.ts/.tsxfiles on disk and requires Biome to report exactly that many. Mutation-tested in both directions: dropping any one of the threeincludeslines fails the job, and all three passed green without the assertion. Same defect class the Kotlin-sidern-android-compile.ymlguards against by asserting its compile task really executed. - Kept
Reacta value import in the RN bridge and its example. Clearing the new lint baseline surfaced Biome offering safe fixes that would have broken both files —useImportTypeonsrc/index.tsx,noUnusedImportsonexample/src/App.tsx. Neither is safe here:tsconfig.jsonsets"jsx": "react", the classic runtime, so every JSX element lowers toReact.createElement(...)— verified in the publishedlib/commonjs/index.js, and independently against both thetscpath (TS1361: 'React' cannot be used as a value) and the babel path (which emits a bare undefinedReactwith no import, a silent runtimeReferenceError). Biome sees theReact.FCannotations, not the JSX lowering. Suppressed inline in both files, with the reason and the evidence next to it. Note that these rules are warning severity underbiome.json, so the suppressions document a real hazard rather than unblock a red gate. - Fork pull requests no longer route to the self-hosted macOS runner. The three jobs opted into
sceneview-mac(ci.yml→kmp-native-test,bridge-ios-compile.yml,device-qa.yml→ios) selected it purely on the heartbeat variable, so a pull request from any fork could have run its build steps on a persistent machine that carries the previous job's filesystem,~/.gradle, and the login user's reach. They now additionally require the PR head repository to be this repository, and fall back to the disposablemacos-15runner otherwise — forpull_request_targetas well aspull_request, since that event carries a fully populated fork payload under a different event name and would otherwise short-circuit straight to the self-hosted runner. Thegithub.event_nameterms are equally load-bearing in the other direction:github.event.pull_requestis null onpush,workflow_dispatch,scheduleandworkflow_call, so without it every non-PR run would have quietly lost the fast runner. This is defence in depth, not a trust boundary — a fork PR executes the workflow file from the merge ref, i.e. its own copy, so the boundary remains the repository's fork-PR approval policy. - CI:
pr-review.yml's clean-tree assertion no longer fails every PR that touches.claude/**.claude-code-actionreverts eight config paths (.claude/,.mcp.json,CLAUDE.md, …) to the base branch before the CLI starts, because the CLI reads settings and hooks from cwd and a PR head is untrusted — sogit statuswas dirty before a reviewer had read a line, and the error blamed the reviewers for it (#3057). The guard is not weakened and gains no path exclusion:assert-review-tree-clean.shforgives a restored path only when its bytes and mode equalorigin/<base>exactly, so a reviewer editing.claude/still blocks the job. Self-tested against real git fixtures, with a mutation test and a wiring check. flutter_lintsnow actually runs on the published Flutter plugin, and a warning in it reddens CI (#3064).flutter/sceneview_flutter— the package published to pub.dev asflutter_sceneview— declaredflutter_lints: ^3.0.0in its dev_dependencies but shipped noanalysis_options.yaml. Dart only applies the lints an options file includes, so the dependency was inert and not one of those rules ran on the artefact we ship. Adding the file (mirroringsamples/flutter-demo) took the package from 3 to 8 issues; all 8 are fixed, so it lands clean at 0. The +5 being small is a real result rather than a blind spot: aprefer_const_constructorsviolation injected intolib/,test/andexample/lib/was reported from all three, so the options file reaches the whole tree — the package is simply small (1 lib file, 2 test files, 1 example file). The two pre-existing warnings wereexample/pubspec.yamldeclaringmodels/andenvironments/asset directories that do not exist, which is a hard build failure and not a style nit (flutter build bundleexits 1 with "unable to find directory entry in pubspec.yaml"). They are removed rather than backfilled, because that example is source-only — it has noandroid/orios/runner, soflutter build apkthere stops earlier still at "unsupported Gradle project", and vendoring a GLB and an HDR into a package that cannot run them would only bloat the pub.dev tarball. With the package clean, theflutter analyze (published plugin)step in theFlutter plugin + demo APKjob drops--no-fatal-warnings. That tightening was mutation-tested rather than assumed: an injectedasset_directory_does_not_existwarning now exits 1, and the same warning under the old flags exited 0 — the gate really was blind to the class it now catches.--no-fatal-infosis deliberately kept, since infos churn with every Flutter SDK bump and a green build must not depend on the runner's SDK minor. Measuring this also exposed an adjacent hole: onlyflutter/**/.dart_tool/was gitignored whileflutter pub publishships every non-ignored file, so a single example build putexample/build/flutter_assets/*into the publish dry-run tarball and took it from ~1 MB to 16 MB —.gitignorenow covers the build output,.flutter-plugins-dependenciesand the example'spubspec.lock.- CI:
pr-review.ymlnow restores.claude/,.mcp.json,CLAUDE.mdand the other five sensitive config paths from the base branch when it runs onworkflow_dispatch.claude-code-actionperforms that restore only under a pull-request context, so the dispatch path — the documented way to review a fork PR — previously ran the CLI against the checked-out head's own settings and hooks. Covered by a new self-test (test-dispatch-config-restore.sh) wired into the repo-hygiene job. bytesFileExtensionis validated before it reaches the filesystem. The value is public@objconSceneViewerConfigurationand on the newSceneViewerModel, and it was appended to a temp file name unvalidated. Anything that is not a short ASCII alphanumeric run is now refused back tousdzrather than sanitised — a caller that sent something else asked for something this API does not offer. No shipped bridge is affected: Flutter and React Native only ever send an asset path.setEnvironmenton the Flutter plugin andenvironmenton the React Native component were silently inert on iOS. Both stored the HDR path in their scene state and no view ever read it, so the call succeeded and nothing changed. Routed through the shared host, both now apply the environment. The surface is unchanged; what changed is that it does something. React Native'scameraOrbitprop stays deliberately inert —cameraControlModesupersedes it and wiring both would make them contradict each other — and is now documented as deprecated rather than left looking functional.samples/flutter-democould not runpod installat all. Its Xcode project targeted iOS 13 while the plugin's podspec requires 17, so CocoaPods refused before reaching any Swift. Bumped to 17. Note this unblockspod installonly: the demo still cannot complete an iOS build, because the plugin's Swift is compiled inside the Pods project, which does not see theSceneViewSwiftSwift package — the structural gapbridge-ios-compile.ymlalready documents and works around with a type-check.- Entity tap and every
NodeGesturehandler never fired on iOS. Nodes generated collision shapes —ModelNode.load'senableCollisionparameter is documented "for hit testing" — but SwiftUI'stargetedToAnyEntity()gestures additionally require anInputTargetComponent, which nothing in the package ever set. The failure was completely silent: no error, no warning, a scene that looked correct until someone tapped it. The repo's ownCollisionHitTestDemohad never been tappable.SceneViewnow applies it to the whole content subtree (soGeometryNode,MeshNode,TextNode,ImageNode,ShapeNode,ViewNodeandPhysicsNodeare covered, not just loaded models),ModelNode.loadapplies it underenableCollision, andNodeGestureregistration applies it to the entity it registers on. Measured on the iOS 26.3 simulator: a tap on a loaded.usdzand on an inlineGeometryNode.cubeproduced no callback before and fired on the first try after. This also repairs the Flutter bridge'sonTap(#2051). ModelNode.load(from:)accepted any URL scheme. Its documentation says "remote HTTP/HTTPS URL", butURLSessionhonoursfile://— measured: it returns the bytes of a local path, with a response that is not anHTTPURLResponseand therefore skipped the status check entirely. A caller forwarding a user- or network-supplied string turned it into an in-sandbox file read handed to RealityKit's USD parser. The scheme is now enforced, the response check rejects rather than skips a non-HTTP response, and the temporary files are cleaned up on the failure paths too. Useload(contentsOf:)for a local file.ModelNode.load(from:)had no size ceiling, where the Android downloader has capped at 64 MB since the compose façade shipped.timeoutis an inactivity timeout, so a host trickling an endless body kept the connection alive and filled the device's storage. Now capped at 64 MB by default (maxBytes:), enforced by a download delegate that cancels the transfer mid-flight rather than measuring it after the fact, with an early refusal when the server announces an oversizedContent-Length.ModelSource's format documentation was wrong about iOS. It claimed every platform accepts glTF and GLB; RealityKit reads neither. There is no format all platforms accept, and the KDoc now says so instead of letting it be discovered as a load that fails invisibly.
Tests¶
- iOS demo: an opt-in measurement rig for the intermittent black viewport of
#3008. A
SceneViewre-created by.id()sometimes renders nothing at all — no model, no skybox — and it does so on roughly a quarter to three-quarters of subject switches depending on the session, which makes any fix impossible to sign off by eye.testBlackViewportProbedrives theAnimationDemosubject row and attaches two samples per switch, so a viewport counts as black only when it is still black on the second one — a frame that has not rendered yet is not a black viewport, and the first calibration run caught exactly that case (black at +12 s, rendered at +20 s) which a single-sample method scores as a failure. It skips unlessSV_BLACK_PROBE=1is set, so it never runs in CI; it also deliberately does not pass-qa_mode 1, because a zero auto-rotate speed short-circuitsSceneView's auto-rotate task (#2896) and would exercise a different render path from the one the defect lives on. CI Gate's aggregation now has a regression suite for the observation ledger (#3018)..github/scripts/test-ci-gate-observations.shpins the disappearance case, the returning-check replacement, an unsupersededcancelledend-to-end throughci-gate-aggregate.sh, the #2492 collapse, the docs-only edges, and two hostile-input cases — a fork-controlled check name containing a newline (which forged all threeREQUIRED_CHECKSintoobserved_namesand reached column 0 of the Actions log as a workflow command) and one starting with-(which blanked the whole "still running" diagnostic throughgrep). The suite EXTRACTS thependingselector,REQUIRED_CHECKSand the name-normalisation filter out ofci-gate.ymlrather than copying them, so it cannot keep passing against a workflow it no longer matches.- Kotlin/Native unit tests now actually run in CI.
iosSimulatorArm64Testwas never invoked by any job — the KMP job compiles iOS targets to klibs on Linux, which cannot link or run a native test binary — so aniosTestsource set was unexecuted code. A macOS job (self-hosted when awake,macos-15otherwise) now runs them, gated on a new narrowcomposepath filter rather than the broadkmpone.SceneViewerSpecTestis its first occupant, pinning the value-equality above; a compile-only check would have passed on exactly the identity equality that was the defect.
The job also selects Xcode 26.x explicitly, as every other macOS job in this repo
already did. Kotlin/Native links against whatever SDK DEVELOPER_DIR points at, and
the macos-15 image still defaults to Xcode 16.4 — whose iOS 18.5 SDK has no
UIViewLayoutRegion, a class the 2.4.10 platform klibs reference. Without the
selection the link fails with Undefined symbols for architecture arm64, which is
exactly how this job's first real run ended. It passed locally throughout because the
development machine runs Xcode 26.3 (SDK 26.2), where the class exists — a divergence
no local gate could have surfaced.
- The four demo_list_* screenshot goldens are compared by a test again (#3031). samples/android-demo/src/test/snapshots/ held 15 committed goldens but verifyRoborazziDebug reported total: 11 — demo_list_light, demo_list_dark, demo_list_large_font and demo_list_tablet were compared by nothing, and mutating one by 8000 red pixels still gave BUILD SUCCESSFUL with changed: 0. They were not the residue of a deleted test: git log -S'demo_list_light' finds no non-binary file in any commit that ever referenced them, and 425618a48 added them while ScreenshotTest.kt was already an @Ignored stub. So they had never been compared, while reading as dark-mode / large-font / tablet coverage of the Samples grid. Restored rather than deleted, because DemoListScreen is a screen the app ships and those three axes are where its fixed-height cards actually break. The new DemoListScreenSnapshotTest forces LocalInspectionMode on, which ParticleBackground now honours by short-circuiting to a static backdrop — the live one calls rememberEngine() (UnsatisfiedLinkError: no filament-jni on the JVM) and seeds its particle field from an unseeded Random, so a pixel-exact golden could never have matched it. That fixes @Preview for the Samples tab as a side effect. The dark golden carries a night qualifier because DemoListScreen and ParticleBackground branch on isSystemInDarkTheme(), which reads the device configuration and ignores the darkTheme argument passed to the theme — without it the "dark" golden recorded dark cards on a white backdrop with the light-mode accents, a combination the app never renders. All four goldens were re-recorded from the current UI, since the committed bytes predated three months of unchecked drift. verifyRoborazziDebug now reports total: 15, and the same 8000-pixel mutation applied to all four now fails all four.
These four compare with a per-pixel tolerance (SimpleImageComparator(maxDistance = 0.02), changeThreshold = 0) where the other 11 stay byte-exact. Byte-exact comparison failed them on CI: goldens recorded on macOS and verified on the Linux runner drift by at most 2 of 255 per channel across 0.06–0.67 % of pixels, confined to the cards' Brush.linearGradient icon tiles — gradient rasterisation rounds differently per host, and the other goldens are flat control panels with no gradient. The tolerance is per-pixel rather than a share-of-pixels threshold on purpose: a percentage would silently absorb a real, small, localised regression such as a clipped label. The value is measured with the real comparator, not guessed — the drift disappears between 0.012 and 0.014 on all four (0.010 still leaves 25–70 differing pixels), so 0.02 keeps ~40 % headroom while staying ~29× below a one-pixel text shift. Goldens stay as recorded on a developer machine so the Linux CI run exercises the tolerance on every build; committing CI-recorded goldens instead would make CI byte-exact and blind to the drift growing.
- The React Native iOS bridge is now type-checked against the real React API, not a hand-written stub. rn-ios-compile.yml used to synthesise a Swift shim redeclaring the four React symbols the bridge touches; a stub like that silently drifts from the API it stands in for. The job now runs npm ci + pod install on the demo and imports CocoaPods' own generated React-Core.modulemap over React Native's real headers. Two negative controls run before the real check on every invocation — the same swiftc command without the SceneViewSwift module, and without the React modulemap — and each must fail with no such module, so the job can never report green on a check it did not actually perform.
- New gate .claude/scripts/check-self-hosted-runner-routing.py (pre-push leg 13/14, and a blocking repo-hygiene step in CI) evaluates the real runs-on expression under 11 simulated event payloads instead of comparing strings, and finds the jobs by scanning .github/workflows/ rather than from a hardcoded list — so a fourth workflow opted in by pasting the old two-term expression is caught. Discovery is itself falsifiable: any non-comment line naming sceneview-mac that no job's runs-on was attributed to fails the gate, because the regexes are a claim about formatting and a folded scalar, a block-sequence label list, or a ${{ matrix.runner }} indirection would otherwise find nothing and exit 0 — reporting green over a job pinned to the persistent Mac with no fallback and no fork clause. .claude/scripts/test-check-self-hosted-runner-routing.sh drives the failing path across 15 synthetic trees so a loosened probe cannot report green on a repo that merely happens to be correct.
- CI now runs flutter analyze and flutter test against the published
flutter_sceneview package itself. Previously every check in the
flutter-demo job except the pub.dev publish dry-run ran in
samples/flutter-demo, so the package's lib/ and test/ trees were
analyzed by nothing and its 18 Dart unit tests were run by nothing — an
analyzer error in the code shipped to pub.dev could reach main unnoticed.
The job is renamed Flutter plugin + demo APK to match what it now covers.
Docs¶
sceneview-composeis now documented in thesceneviewagent skill, with the scope boundary (viewer subset, no AR), theModelSourcerules and the per-platform status — a published module absent from the skills is a module future AI sessions do not know exists.- The
sceneview-composedetekt reports are now uploaded as CI artifacts alongside the other three library modules; the step already ran the module but discarded its reports. - The React Native docs now say that the
0, 0, 0"hit nothing" tap is Android-only. iOS resolves a 3D tap through RealityKit's entity-targetedSpatialTapGesture, which fires only when an entity is hit, so a tap on empty space dispatches noonTapevent at all rather than a{0, 0, 0, nodeName: null}one.nodeName == nullis still the correct "no model was hit" test — but the two platforms do not deliver the same number of tap events, which matters to anything counting them. Stated onsrc/index.tsx,llms.txt, the module README and the React Native quickstart. - The Flutter plugin README now documents its SwiftPM tag coupling, the note React Native already carried. The plugin's
ios/Classes/*.swiftbuilds onSceneViewerHostView, which landed afterv4.26.0— noSceneViewer*type exists at that tag or earlier — so a host app must pinv4.27.0or newer. Both READMEs now also state that no CI job here catches a stale pin:bridge-ios-compile.ymlandrn-ios-compile.ymltype-check the bridges against theSceneViewSwiftsources in this repo, never against the tag the host app resolves, so the mismatch surfaces as a Swift compile error in the app's own build. The React Native note's stated reason was corrected at the same time — it read as thoughonTapEntityhad merely gained a parameter atv4.26.0. sceneViewerModelFileNameno longer presents its Kotlin↔Swift divergences as a closed set of two. Measured, at least five inputs derive differently (models/,.hidden,/,..,robot.— the last becausedeletingPathExtensiondoes not treat a trailing dot as an extension). None is a loadable model path; the comment now says so without claiming an exhaustiveness it cannot prove.
v4.26.0 — 2026-08-04¶
Added¶
- App Store listing drift is now visible read-only, and the assumption the
screenshot diff rests on is measured rather than assumed. A daily
asc-listing-driftjob runsstore-sync/asc_listing.py --dry-run— the first CI caller of the ASC read-only path — and prints asourceFileChecksumprovenance verdict (confirmed/unattested-match/md5-shaped/absent/ …) before the diff it justifies. It writes nothing to the store, skips honestly with no credential, and is never blocking. This is #2612 Phase C step 0: it turns the "issourceFileChecksumreally the source MD5?" question — which the upload path can never answer, since Apple only echoes what we send — into an observable measurement, and blocks the Phase C drift gate from being wired until the verdict isconfirmed. A repo-MD5 match alone readsunattested-match, reported with the display type it was found in; promoting it toconfirmedrequires attesting console provenance (--screenshots-are-console-sourced), so uploading our own screenshots can never confirm the assumption by echo (#2612). - Point & Ask demo: answers are now anchored in world space — a tap that lands on a tracked horizontal surface pins its answer card there (
frame.hitTest→createAnchor()→AnchorNode+ViewNode), so it stays on the object it describes while the camera moves around it. Up to 8 panels stay pinned (oldest retired past the cap) until Reset; a tap that hits nothing trackable — or that lands on a wall — keeps the screen-space card. Anchored cards are hidden during the composited capture, so the model never re-reads its own earlier answers as part of the next question (#2648 P2) - Device-QA: opt-in Rosetta x86_64 AR rig —
setup-ar-emulator.sh --rosettaprovisions and boots a separatePixel_7a_x86AVD (Intel emulator bundle + x86_64 system image) on a reserved port outside the QA emulator pool, disk-gated, with every guest probe time-bounded. It was built to test whether an x86_64 guest could host a live-camera ARCore session on Apple Silicon, which the arm64 AVD cannot (#2754). Measured answer: it cannot either. On a quiet host the guest does boot (ActivityManager registered at ~42 min), but (a) it exposes the same camera topology as arm64 — HAL ids"1"and"10", no id0— so that numbering comes from the emulator's camera HAL, not from the guest ABI; (b) installing the 82 MB ARCore APK killssystem_server(Broken pipe), reproduced via both streamed and--no-streaminginstalls, so ARCore cannot be installed at all; and (c) nothing renders under software GL. Real AR tracking QA still requires a physical device. The flag ships as a reproducible probe — and as the evidence that stops this being re-attempted a fourth time (#2758) - iOS port: Cloud Anchors (
ar-cloud-anchor) (#2836). The full four-step Cloud Anchor loop from Android'sARCloudAnchorDemonow runs on iOS: tap a detected plane to drop a singleARAnchorcarrying the bundledkhronos_lantern, Host it throughCloudAnchorNode.host(ttlDays:), copy the returned id out of the settings sheet, and Resolve a pasted id back onto the same real-world pose. Both futures are cancelled inonDisappear, the direct analogue of Android'sDisposableEffect { onDispose { future.cancel() } }billing hygiene (#1768).ArCloudAnchorsSceneflips from stub to@available true/@status knownIssue— matching Android, which isKnownIssuefor this id too. - Honest unavailable state, no fake success.
SceneViewSwiftdeliberately does not vendor Google'sarcore-ios-sdk, and neither does the demo app, so there is noGARSessionto reach the ARCore Cloud service with. The demo says so in a red on-screen banner and disables Host/Resolve — mirroring Android's missing-API_KEYpath — instead of inventing a cloud anchor id. Placement, plane detection and the anchor lifecycle underneath are real; the hosted round-trip is documented as unexercised. - iOS port: Pose Placement (
ar-pose) (#2837). Free pose placement now works on iOS, matching Android'sARPoseDemo: an ARKit world-tracking session captures a base pose 1 m in front of the camera the moment tracking starts, a red/green/blue axes gizmo (LineNode.axisGizmo, the RealityKit mirror of Android'sAxes3DNode) marks it, and three X/Y/Z sliders nudge the bundledkhronos_lanternmodel relative to that anchor with a live coordinate readout.ArPoseSceneflips from stub toworking. - iOS port of the
ar-depth-colliderdemo (#2838) — drops small bouncy balls (5 cm spheres, SceneView brand blue) in front of the live camera pose and lets them bounce off the real floor / table / wall viaSceneReconstructionNode.enablePhysics(ARKit scene reconstruction / LiDAR), the RealityKit analogue of Android'sDepthCollider. Mirrors Android's own fallback behaviour exactly: when the depth subsystem can't run — no LiDAR on the device, or the Simulator, which has no camera at all — the demo does not gate itself off. It falls back to a static, collidable floor (floorY = -1, matching Android's own fallback value) so a bounce is still visible in every case, on-device or in the Simulator. Lands with@status knownIssue, mirroring Android's ownKnownIssuestatus for this id — the depth-driven collision path compiles and the static-floor fallback is exercised in CI, but real LiDAR-mesh collision has not yet been verified on physical LiDAR hardware. - iOS demo: ported Android's
placement-scenedemo — the "one-line tap-to-place AR" showcase forPlacementScene's batteries-included bundle (coaching overlay, a placement reticle, an instant-placement-style raycast, and a contact shadow under each model).PlacementSceneScene.swiftwiresARSceneView's equivalent flags (showCoachingOverlay,showPlacementReticle,groundingShadows) and drops a single bundledkhronos_damaged_helmetmodel per tap, with a "models placed" counter and a "Clear All" control — distinct from the existing low-levelar-placementdemo, not an alias of it. Honest gap noted in-app and in code: unlike Android, the plane-detection grid does not fade out after the first placement, sinceARSceneView's plane overlay isn't reactive after scene setup (#2839). - iOS port of the
wall-placementdemo (#2840) — the iOS demo app now mounts a procedural TV on a real wall instead of showing a coming-soon card. Mirrors Android's four-phase Amazon "AR View" flow: the FINDING_FLOOR → FINDING_WALL → ALIGNING_EDGE → PLACED coaching banner, the fixed orange guide line the user aligns with the floor↔wall seam before tapping, the post-placement D-pad (2 cm nudges along the wall, 2° yaw steps), and the asset-free TV built from two boxes (matte body + glossy screen). - The placement math is a direct port of
arsceneview/.../WallPlacement.kt: orientation is a pure yaw derived from the wall normal (never inheriting the hit pose's pitch/roll noise), and the height is floor-relative (floorY + mountHeight), so the panel does not drift while ARKit refines the wall plane. Wall detection uses ARKit's nativeARPlaneAnchor.classification == .wall— the primitive ARCore lacks — falling back to plane alignment on devices whose classifier never resolves, which is the Android behaviour. - Honest gap, stated in the demo's own settings sheet: Android's procedural
ContactShadowContext.Wallpool is not mirrored. RealityKit'sGroundingShadowComponentonly projects downward onto a surface below an entity, andSceneViewSwifthas noContactShadowequivalent yet, so the iOS panel ships with no shadow behind it rather than a faked one. LandsinReview, matching Android's own status for this demo. - iOS port: AR Placement Reticle Preview (#2841). Ports Android's
placement-reticle-previewdemo to iOS — a non-ARSceneView(RealityKit, noARSession, no camera permission) that previews the production placement visuals on a static synthetic floor: the searching/ready reticle (ring-with-centre-dot or the legacy disc, both mirroring Android's exact radii, lift, and phase alpha) and, toggled from the settings sheet, a placedkhronos_damaged_helmetmodel grounded with a RealityKitGroundingShadowComponentcontact shadow. Fully verifiable on the simulator. iOS/RealityKit deltas from the Android original (camera framing, contact-shadow technique, studio HDR asset, and a darkened placed-mode floor color to avoid clipping to white under RealityKit's default exposure) are documented in the Scene file and the PR description. SceneView.framingMargin(_:)(iOS/macOS/visionOS) — scales the distance the auto-fit pass picks.1.15(default) keeps existing framing;1.0puts the content's bounding sphere exactly tangent to the frustum; below1.0the subject fills more of a tall portrait viewport. Stay at or above ~0.95on anautoRotatescene, where the visible azimuth is arbitrary (#2896).SceneView.cameraOrbit(azimuth:elevation:)(iOS/macOS/visionOS) — seeds the initial orbit pose. Elevation matters more than it looks: at the 60° vertical FOV, the 30° default pitch puts the horizon exactly on the top edge of the frame, so a scene with ashowSkyboxenvironment showed none of its sky at any framing (#2896).- Blocking CI gate on
assets/CREDITS.md..claude/scripts/generate-credits.py --checkregenerates the credits in memory and compares them against the committed file;ci.yml→repo-hygienenow fails when acatalog.jsonedit lands without regenerating them, so a catalog entry can no longer reach a release uncredited inassets/CREDITS.md. Deterministic regenerate-and-compare, same class as the existinggpt/knowledge-*.mdgate. The APK-bundledsamples/android-demo/src/main/assets/CREDITS.mdstays hand-maintained and outside this gate (#2941). .claude/scripts/context-budget.shreports the standing context a session pays before doing any work, per file, against each file's documented spec. It complementsagent-cost-report.sh— that one measures what was spent, this one measures what will be. Bytes are measured; the token column is an explicit estimate.test-context-budget.shgates the committed half inrepo-hygiene: aCLAUDE.mdceiling, skill frontmatter, and — in both directions — that the skills index and.claude/skills/agree. A skill missing from the index is a file no session will think to open, which is strictly worse than the inline text it replaced. Mutation-tested, including on the trap that caught the first version of the check: a file-widegrepfor the skill name still passes after its index row is deleted, because the name also appears in the hard-rules pointers.- The
automation-mapskill no longer carries a 7-row subset of the version location map: a partial copy of a completeness-critical list is worse than no copy, and one of its rows had already gone stale. It points atversioning, which holds the canonical 30+ location table. - Agent review now runs in CI, not only inside a live session. The four
reviewer mandates (
sv-code-reviewer,sv-security-reviewer,sv-impact-reviewer,sv-doc-freshness) fan out on every non-draft PR viapr-review.yml, every ERROR is adversarially verified before it counts, and the verdict is posted as one comment updated in place. Until now those reviewers only ran through thereview-fanoutsaved workflow, which coupled every merge to someone having a Claude Code session open — measured 2026-08-01, the five most recently merged PRs carried zero review recorded on GitHub. The reviewers FIND;grade-pr-review.shDECIDES, deterministically, mirroringreview-fanout.jsso both paths reach the same verdict. It fails closed: a missing verdict file, unparsable JSON, or a dropped reviewer isREVIEW_INCOMPLETE(blocking), because a crashed fan-out produces no findings and would otherwise be indistinguishable from a clean review. A confirmedsv-impact-reviewererror remains the maintainer gate. Fork PRs cannot be reviewed (GitHub withholds secrets, andpull_request_targetis deliberately unused) and say so loudly instead of reporting a silent green. - Agent token use is now measured —
agent-cost-report.shaggregates the local session transcripts by day / model / session / branch. The repo had no instrumentation at all (no OTel, no analytics, no counter), so the step-3 bottleneck of "are tokens used efficiently" was managed by feel. It reports tokens and never dollars — this is a flat Max plan, so a dollar figure would be an invented number wearing a measurement's clothes — and groups--by model, which is the actionable view because the quota is per-model. Everything is keyed onrequestId: a transcript writes several records per API call carrying the sameusage, and summing records overstates output tokens by ~95% (measured: 980 usage records for 658 real requests). - Claude now starts some work without being asked.
issue-intake.ymlgains atriagejob that runs after the deterministic labeller (never replacing it) and comments duplicate/reproducibility/location/cross-platform findings on newly opened issues;maintenance.ymlgainsdigest-to-tasks, turning the daily digest from a report into individually actionable, de-duplicated issues. The issue body is treated as untrusted data in both directions — it is never interpolated into arun:step or into the prompt, the agent fetches it withghand is told explicitly that what it reads is data, not instructions.digest-to-tasksis capped at 3 new issues per run, and the cap is verified by a deterministic step that reddens the run when exceeded rather than trusting the prompt; a healthy repo files zero.
Changed¶
- Daily maintenance (
maintenance.yml) now opens and refreshes a de-duplicated tracking issue — one per store — when the live Play Store or App Store listing has drifted from the repo. Both read-only drift jobs run their diff with--fail-on-drift, and the issue is filed only on a measured drift (exit 3), never on a credential-less skip or a mid-read crash. Advisory-only: a drifted listing surfaces as an actionable tracking issue (refreshed daily while the drift persists; closing it once reconciled is a manual step) instead of an unread step summary, and never fails CI (#2612 Phase C). - The pre-release checklist (
release-checklist.sh) now surfaces Play Store and App Store listing drift before tagging: section 17 runs the store-as-code read-only diff (play_listing.py/asc_listing.py --dry-run --fail-on-drift) and WARNs when the live store listing has diverged from the repo, so a silently-drifted listing is caught at release time rather than after the next blind sync overwrites it. Advisory-first — a drifted (or, without credentials, unmeasured) listing is a warning, never a release blocker (#2612 Phase C). - CI: iOS App Store review submission failed on 4.24.0 and 4.25.0 with HTTP 409
ENTITY_ERROR.RELATIONSHIP.INVALID("The specified build has a different platform than the version"). Thedeploy-iosjob's submit step selected "the latest VALID build" with no platform filter, so — because the iOS and macOS deploy jobs run in parallel against the same App Store record (shared bundleId → shared app_id) — it could attach the macOS build to the iOS version. The build lookup now resolves each build's platform via the includedpreReleaseVersionand selects the iOS build (the build-side twin of the #2731 version-hijack fix, which only filtered the version lookup). (#2731) - The
contact-shadow-previewdemo (Android) now gives the wall-mounted TV its own on-screen beat: theFloor/Wall/TableToppreset picker moved out of the settings sheet and onto the scene, in the TV's half of the frame, with a one-line verdict naming what each preset costs on a wall ("Floor on a wall: too dark, too round — reads as a sticker"). The sheet's scrim used to dim the scene, so the wall pool could never be watched while being changed, and a control sitting among the global ones read as global while it only ever drove the TV's pool — a mismatch previously patched over by renaming the label. The A/B is now live, the control's scope is self-evident, and the settings sheet drops from four controls to three (#2740). ContactShadowControlsand the newWallShadowBeatare covered byContactShadowControlsSnapshotTest(Robolectric + Roborazzi, pure JVM, no emulator) — including one golden per wall preset, so a regression collapsing the per-preset verdicts back into a single shared caption cannot merge silently (#880 pattern, #2740).- CI: unbreak
main. The:snippets-checkmodule added by #2808 compiles every```kotlinblock inllms.txt, but theDemoScaffoldsignature listing added by #2780 referencesDemoBottomOverlayScope— a type that lives insamples/android-demo, which is deliberately not on:snippets-check's classpath (the module depends on the libraries, not on the sample app). Every PR opened since has been red onBuild libraries & samplesthrough no fault of its own. The block is now tagged```kotlin notest <reason>, the escape hatch the extractor documents for exactly this case. (#2808) - Demo app: numbers on the English UI no longer render with the device's decimal
separator. 61
String.formatcall sites across 21 demo files formatted against the device default locale, so a French phone showedCamera distance: 1,5 m,Density: 0,25andTrajectory 1,80 mon an otherwise English screen. Every format string carrying a locale-sensitive conversion (%f,%e,%g,%d) is now pinned toLocale.US, matching the locale the app'sSimpleDateFormatsites already used. Purely textual%sformats are left alone — they have no locale sensitivity. (#2819) - Picking & Collision demo: the "Tapped N times" counter no longer increments on a tap
anywhere in the scene. It now counts only taps whose ray-cast actually hits the 3D card —
which is what a picking demo is meant to show. A scene-level
onSingleTapUpwas bumping the counter without checking the hit node, so empty-space taps counted too. The embedded Compose button cannot count them itself: aViewNodenever receives touch events (#2845). (#2819) - iOS demo: two PBR demos now render through the wrapper's studio IBL for catalog + Android parity (#2842).
TextureStreamingDemoandOcclusionMaterialDemobuilt their PBR entities on a rawRealityView, outside the.environment(.studio)path thatSceneViewSwift.SceneViewinstalls. They were not unlit — a non-ARRealityViewreceives RealityKit's default environment lighting, and both demos rendered lit (verified on the simulator, 2026-07-23). But they were the only PBR views in the iOS catalog outside the wrapper's studio HDRI, and out of step with Android, where these material variants live inMaterialsDemowithstudio_2k.hdr+ skybox. Both now build their entities inside the wrapper's content closure and carry.environment(.studio)— the same environment asModelViewerDemo/MaterialsDemo(#2114) and Android. Because that closure runs once (RealityView'smake:), the reactive material swaps (the preset picker, the occluder toggle) now mutate a stashed entity reference fromonChange, the pattern already used byMultiModelDemo/MovableLightDemo. Also removes a structural oddity inTextureStreamingDemo(aRealityViewoverlay stacked on an emptySceneView). Follow-up to the L1.1 IBL sweep (#2805). The post-change on-device look is not yet graded — the Simulator under-renders the wrapper skybox — so device before/after stays tracked under the L1.1 device-confirmation follow-up. - Docs:
ViewNodenow documents that its rendered view is not interactive — the hosting window isFLAG_NOT_TOUCHABLEand no touch is dispatched into it, so an embeddedButton.onClicknever fires. KDoc andllms.txtboth show the supported alternative (pick the node from the scene viaonSingleTapUp), so an AI reading the docs stops generating clickable-button-in-3D samples that silently do nothing. (#2845) - Docs: removed the three surfaces that asserted the opposite.
README.mddescribedViewNodeas "buttons, lists, animations, all interactive" in two feature tables, anddocs/docs/nodes.mdrecommended it for "interactive panels" — all three now state the render-only reality and point at the hit-test alternative.llms.txt's demo index no longer calls the picking sample an "interactive ViewNode overlay". (#2845) - The
contact-shadow-previewdemo (Android) is now a grounded-vs-floating comparison rather than a single on/off toggle: two boxes side by side with deliberately different motion — the left one bounces and STRIKES the floor, anchored by a height-responsive contact pool that slides out from under the box along the key light as it lifts (the "ball-in-a-box" depth cue) and snaps back tight and dark on landing; the right one hovers high and never touches down, shadowless. The floating box's own motion carries the "airborne" read, so its missing shadow reads as "it's in the air" instead of as a rendering bug — the earlier revision hopped both boxes identically and conveyed floating only by the absence of a shadow, which does not read. Plus a wall-mounted TV with switchable per-surface presets and labelled overlay chips. Still non-AR, so the shader stays reviewable on any emulator with no ARCore session and no physical AR device (#2740, #2754). - The
contact-shadow-previewdemo moved from the Augmented Reality category to Lighting & Environment: the feature lives insceneview(notarsceneview) and the demo is a non-AR studio scene, so filing it under AR set the wrong expectation (#2851). - The comparison's lifted-shadow opacity floor was raised (0.28 → 0.45) after on-device QA measured the pool near-invisible at the top of each hop on the demo's light floor — the grounded-vs-floating contrast now reads at every phase of the motion (#2851).
- The iOS demo app's
contact-shadow-previewplaceholder moved out of the AR tab too (@category ar→lighting) and its subtitle now matches Android's: the same "this is not a camera experience" reasoning applies on both platforms, so the two catalogs stay mirrored (#2851). - Reworked the common store-screenshot set shared by the Play Store and App Store capture scripts to a tight three —
model-viewer · dynamic-sky · multi-model— chosen by judging the ACTUAL captured mosaic, not by picking ids a-priori. model-viewer is the load-any-GLB hero;dynamic-skyis the strongest frame (a lit drone against a procedural sky, a theme no other slot carries);multi-modelis the only non-helmet, non-sky frame, a photoreal-foliage fidelity shot. Five candidates were captured then dropped after inspection:double-pendulumrenders as a tiny linkage in a ~95%-black frame and ignores reframing (its own auto-fit);fogstayed a low-contrast grey helmet even pulled fully in to 1.6 m (centre-variance ~3.6k, under the 4k ship bar); plus the earliermaterials(non-reproducible random HDRI, #2874),geometry(its primitives clipped a phone-portrait frame at the time — #2873 has since fixed that, and the id stays out of the set for a different, capture-side reason) andanimation(a static frame is just a posed model, duplicating slot 1). Fewer strong frames beat more mixed ones. Each surviving id was re-verified in source to resolve to a DISTINCT on-screen demo on both platforms — a standalone iOS generated scene, and on Android a distinct umbrella tab viaALIAS_INITIAL_TAB(multi-model→ the Multi-Model tab, never the Single Model tab that would collapse onto slot 1) — so no two slots duplicate the way they did before #2773 (#2854). capture-play-store-screenshots.shframes its hero-orbit slots through thecamera_distanceextra (#2652) rather than their interactive default:model-viewergoes from a helmet occupying ~2% of an otherwise black frame — centre-patch variance 98.3, close enough to the blank-capture guard's threshold of 100 that the run passed or failed on where the auto-orbit happened to be — to a full-frame subject at 4.5 m, andmulti-modelis pulled back to 6.0 m for the fullest scene its fixed camera angle allows. This lever is Android-only (iOS has no equivalent, #2785), so the App Store captures render each scene at its default framing; the decision shared between the two stores is the SET and ORDER, not the per-slot distance (#2854).- Only the Play Store (Android) screenshots are regenerated in this change; the App Store (iOS) screenshots are left untouched and deferred to #2896. Captured with the same three ids, the iOS RealityKit scenes render too weak for the store — dim, far-framed subjects on black, and
dynamic-skyshows no sky — with nocamera_distancelever to reframe them (#2785). Both capture scripts now define the same three-id set, so the two stores regain screenshot parity once the iOS scene-side fixes tracked in #2896 land and the App Store set is re-captured (#2854). - CI: close a latent false-green hole in the doc-snippet guard.
:snippets-checkcompiles every```kotlinblock ofllms.txt, but it only ran transitively inside ci.yml'sBuild libraries & samplesjob — which ispaths-ignored forllms*.txt, so a PR editing onlyllms.txtnever compiled its snippets and a broken block could reachmain(this is how #2871's own fix went un-verified by CI). A standalonesnippets-check.ymlnow compiles the snippets whenever their real inputs change (llms.txt,agents/sceneview/references/**, the extractor, the guard module), and its check run is gated by CI Gate. (#2875) - CI: the iOS App Store submit step now sources the required "What's New"
(
whatsNew) field from a user-facingsamples/ios-demo/distribution/app-store/en-US/release_notes.txtinstead of deriving it from the technical, cross-platformCHANGELOG.md(which left the field near-empty for Web/Flutter-heavy releases). An empty requiredwhatsNewis rejected by App Store Connect with HTTP 409ENTITY_STATE_INVALID("not in valid state") at review submission — the second blocker that stopped 4.25.0 even after the #2885 build-platform fix. Falls back to the previousCHANGELOG.mdextraction when the file is absent. (#2893) - The iOS App Store screenshot set is refreshed to
model-viewer·dynamic-sky, replacing the five pre-v2 images captured back when no environment loaded. The scenes were retuned for capture: the model viewer uses the.warmphoto studio as a backdrop instead of.studio's living room and frames tighter underqa_mode, and the dynamic-sky skyline sits on a footprint-sized ground plane at a 12° camera pitch (.pi / 15) so its sky is in frame at all — at the previous 30° pitch none of it was (#2896, #2854). multi-modelis deliberately NOT in the iOS set, a documented divergence from Android's phone set. An App Store capture build has no Sketchfab key, so the resolver substitutes the registered bundled stand-ins, and the frame measured on the 6.9" simulator shows an upright wooden piano with a blossoming-tree diorama growing through it and a coloured bird mid-frame — not the park diorama the demo documents, and not something a keyless user can ever see. Every mechanical check passes on that frame, so only looking at it catches the problem. Same call as Android's tablet set — and, like it, the exclusion is structural rather than gated on an issue: restore it only against a fresh frame you have looked at (#2896, #2913, #2915).qa_modenow actually freezes auto-rotation.DeepLinkRouterhas advertised-qa_mode 1/?qa_mode=1as the deterministic-screenshot switch since it was added, but no demo read it — so every store capture shot whatever azimuth the sweep had reached, giving a different pose and a different slice of the HDRI backdrop each run.ModelViewerDemoandMultiModelDemonow honour it; two independent capture runs are byte-identical (measured: 0 differing pixels) (#2896).autoRotate(speed: 0)no longer starts a rotation loop. It setenableAutoRotate = trueregardless of the speed, so freezing a scene left a 60 Hz task waking every 16.7 ms to advance the azimuth by zero and re-apply an unchanged camera transform (#2896).capture-appstore-screenshots.shrefuses to keep a frame with a system banner in it.simctlhas no notification-suppression API, and simply waiting does not work — a freshly-erased device posted "Ready for Apple Intelligence" about a minute in, i.e. during a capture, which is how it leaked into an iPad frame. The script now re-shoots each demo after a pause and compares a hash of the frame's top band across three samples; a band that changed means something transient was drawn over it, so the set is discarded and retried, and exhausting the retries deletes the frame and fails the run. It proves the band did not change, which is not the same as proving it is clean — an overlay that outlives the whole sampling window still passes, so looking at every PNG stays mandatory (#2896, #917).- Known consequence — #2897 becomes live.
SceneEnvironment.intensityis applied as a2^xexponent (intensityExponent:), while the presets are authored as linear multipliers (.night0.4,.nightSky0.5,.sunset0.8,.outdoor1.2) and Android'sEnvironmentintensity is linear. That defect pre-exists this change, but it was latent while the IBL never loaded at all; now that it does,.nightbrightens ×1.32 instead of dimming ×0.4 — a ~3.3× divergence from Android under the same preset name. Tracked in #2897 and fixed in this same release — see the2897-fragment — so the condition this bullet set ("land it in the same release, or the two platforms ship different lighting for identical code") is met. CLAUDE.mdis now 217 lines instead of 1126: the nine sections only some sessions need moved into lazy.claude/skills/entries, which load on demand. The file is re-sent on every turn of every session, so its size was a cost every agent in the repo paid forever — 72.7 Ko of it, growing monotonically because nothing ever reported it. Nothing was rewritten and nothing was lost: the move was mechanical and verified line-by-line. The rules whose cost of being forgotten is high (never QA on a personal device, never calladbdirectly, never drive a leased emulator, never hand-edit a generated file) stay in the always-loaded file; only their detail moved.- android-demo — the Scene Gallery and Multi-Model asset-source pills now route through
the same
AssetSourceProbethe two AR demos use, finishing the de-duplication started in #2953. All four call sites had held their own copy of the rule in three different shapes, and it had been fixed once per site (#2934, #2938, #2953) because each re-derived it. No behaviour change — the two remaining copies were already correct, and both directions were re-verified on the emulator (#2989). CLAUDE.md's "Before EVERY push" list addsimpact-check.shand says out loud that it is a floor, not the full set. A session ran impact-check from agent memory alone — it is not in that list — and surfaced 10 pre-existing failures (#2987) plus #2988. Shortening the file to 217 lines made its lists read as authoritative: at 1126 lines nobody believed they held the whole picture.- The
device-qaandandroid-toolingskills both claimed "QA on an emulator" and neither said which was which.device-qais the scripted harness and the release gate;android-toolingis driving a device by hand. Both descriptions and both index rows now say so.
Fixed¶
- Demo: the Materials demo no longer leaks a streamed model per chip switch
(#2459 class). The PBR section's
rememberFileModelInstanceproduced aModelInstancethroughproduceState, which cancels its producer on a key change but never destroys what it already produced — so every chip switch left the previous streamedModelGPU-resident inModelLoader.modelsuntil the section's engine was torn down. It now mirrors the library'srememberModelInstancedisposal contract (DisposableEffect(instance)→destroyModel), registered before the consumingModelNodeso the node detaches before the buffers are freed (#2424 ordering). Found by the adversarial review of #2926. - Docs: the world-anchored Point & Ask snippet gated its hit-test on an
isTrackingflag that was never assigned, so every tap silently hit-tested nothing.llms.txtandsamples/recipes/point-and-ask.mdnow set it fromframe.camera.trackingStateinonSessionUpdated; the recipe also declareslatestFrame,isTrackingandnextId, which it used without ever declaring. contact-shadow-previewpeek header no longer contradicts the scene (#2740). The banner tested the shadow toggle alone, so pulling the intensity slider to 0 — which makes the pool fully transparent and leaves both boxes floating identically — still announced "Grounded vs floating" while the overlay legend correctly read "Shadows off". Both labels now read oneshadowVisiblevalue (toggle ON and intensity above zero), so no label can drift from what is actually drawn.- Device-QA: the rig's boot-wait loop reported
init.svc.bootanimas a progress signal while its own boot command passes-no-boot-anim, which pins that property tostoppedfor the whole boot — the harness disabled the thing whose absence it then read as evidence, and "boots to ~90% but never finishes" was the false conclusion it produced. Replaced withpidof system_server+service check activity, and a registered ActivityManager is now accepted as boot success alongsidesys.boot_completed=1(measured: a usable guest with the property still unset, so waiting on it alone burned the full timeout and failed a healthy boot) (#2758) - Nightly CI health (#2775): the two web Playwright legs no longer time out at
night — their job budgets were outgrown by the suite itself (measured green
wall-clocks 13–17 min vs a 20-min cap in
render-tests.yml, 18–24 min vs a 25-min cap indevice-qa.yml); both caps raised (+10 min) while Playwright's per-test timeout keeps bounding real hangs. - Device-QA ios leg — two macOS bash 3.2 empty-array crashes (
set -urejects expanding an empty array before bash 4.4):device-qa.shdied withLEGS[@]: unbound variablewhenever the disk gate skipped every leg (turning the honest advisory skip into a bogus exit 1 on the self-hosted Mac), andlib/maestro.shdied ondevice_args[@]on the iOS path — worse, that abort exited 0 (bash 3.2||-guarded abort with an EXIT trap set), so the leg graded PASSED with zero Maestro steps run. Both expansions are now guarded, andrun_iosadditionally requires the positive[ios-qa] PASSmarker — an exit-0 harness abort can never grade green again. - Demo bottom overlays no longer collide with the Settings FAB (#2779).
DemoScaffoldgains abottomOverlayslot that lays a demo's floating banner / status pill / answer card out against the bottom-end Settings FAB, with the reserved band (SETTINGS_FAB_RESERVED_SPACE= 104 dp) resolved scaffold-side from the samecontrols != nullcondition that composes the FAB — so a demo whose controls are conditional gets the right inset without duplicating the condition. Migrates the three demos Pixel 9 device QA caught masking text: AR Body Tracker, Point & Ask and AR Streetscape. Follow-up device QA measured the band off the wrong element — it was sized from the 56 dp FAB when the widest thing in that corner is the ~79 dp "Settings" peek chip, leaving a 1 px gap on AR Streetscape's four-line status pill — so the reserve is now derived from the chip (79 dp chip + 16 dp gutter + 8 dp breathing room = 104 dp). - Release pipeline —
sceneview-webnpm publish no longer fails on a missing npm auth token.actions/setup-nodewrites an.npmrccontaining_authToken=${NODE_AUTH_TOKEN}; the Kotlin/JS:kotlinNpmInstalltask shells out to yarn, which expands that file and aborts withFailed to replace env in configwhen the variable is unset. Thepublish-webjob is the only one combiningregistry-urlwith a Gradle task, so its build step now exportsNODE_AUTH_TOKENtoo. Surfaced by thesetup-nodev6 → v7 bump (#2787): it broke the v4.25.0 release after Maven Central had already published, which also skipped the GitHub Release job. Invisible to PR CI, since no pull-request job publishes to npm. capture-play-store-screenshots.shgained a--form-factor phone|tablet7|tablet10path, so the Play Store's 7"/10" screenshot slots are reproducible instead of hand-uploaded. The 12 committed tablet PNGs it replaces were byte-identical across the two slots — the 10" capture had simply been re-uploaded into the 7" one — light-mode, and two of six showed no 3D at all. Tablets keep their native post-crop height rather than being padded to the phone's 9:19.2 (padding a landscape frame to a portrait ratio is the #917 letterbox defect), and the mosaic preview now preserves each capture's aspect ratio and is written outside the listing directory — that directory mirrors the Play listing byte-for-byte, andplay_listing.py's test suite rejects any file there that noimageTypeclaims (#2796).- Hardened the same script against four failure modes found while capturing, each of which produced a plausible-looking result that was wrong (#2796):
- The
--es demo <id>deep link is silently ignored once the app has saved state — it restores the last-viewed demo instead, so--es demo model-viewerre-opened Picking & Collision. The script now does a one-shotpm clear+ cache warm-up before the run. - The variance check only rejects a uniform frame, so it accepted an Android launcher screenshot (variance 679, Play Store icons and all) after the demo app died mid-series. Each capture now asserts the demo package actually owns the screen first.
- A stale
wm sizeoverride (Override size: 1080x2424on a 2560x1600 tablet) shrinks every tablet capture to a phone-shaped viewport; the script resets any display override before reading the physical size. android runcan no-op the install and still exit 0, so the existing|| adb installfallback never fired and the run died on the firstam startwith no output at all (set -e). The script now verifiespm path <pkg>actually resolves after installing, retries withadb install -r, and fails loudly if the package still is not there.- Tablet screenshots are captured in portrait: the demos frame their scene for a portrait viewport, and in a tablet's natural landscape orientation the subject collapses to roughly 5% of the frame width —
double-pendulumcame out uniform enough that the variance guard rejected it outright. The rotation is derived fromwm sizerather than hardcoded, because a 10" tablet is landscape-native while a 7" one is portrait-native (#2796). - Play Store 10" tablet "Materials" screenshot (slot 3): the run committed by #2858 captured the app bar and IBL skybox before the 3D model finished loading — the "no 3D at all" defect #2796 set out to fix (the 7" counterpart caught the model). Re-captured on a 10" AVD with a longer settle so the PBR model renders, restoring the 10" set to the full five unified-showcase demos (Models, Lighting, Materials, Geometry, Double Pendulum) in canonical order, matching phone/7"/iOS (#2796).
parity-manifest.yml's section banners can no longer lie. The ledger's# ─── working (N) ───headers and their tallies are COMMENTS, andcheck-demo-id-parity.shloads the file withyaml.safe_load— which drops comments entirely — so every count in the header was unverified prose that drifted freely behind a green CI. It had drifted three times in a single wave of iOS ports: four rows were flipped toiosStatus: workingin place, without moving them out of thestubsection or touching a banner, leaving the file advertising 30 working / 23 stub against a real 34 / 19. The gate now recounts the rows itself and fails on any disagreement, in three ways: a banner whose declared tally differs from that bucket's real row count, a row filed under a section that is not its owniosStatus(the in-place flip that makes both tallies wrong at once), and the preamble's ownOf the N Android ids: …summary line. The check is purely textual and deterministic — no heuristic, so unlike the advisory doc-drift checks it is blocking — and a manifest with no section banners at all opts out, keeping it strictly additive. The manifest's own counts were recounted with a parser and reconciled in the same change, and its header no longer claims the #2798 audit found a strictandroidStatus→iosStatuscorrelation "with zero exceptions": genuine ports have since landed non-WorkingAndroid demos in iOS'sworkingbucket, so that line described a snapshot, never an invariant (#2801, follow-up to #2857).- Auto-filed maintenance issues now close themselves when the condition they describe clears (#2835). Every auto-filer in
maintenance.ymlwas one-directional — five of them open or refresh a tracking issue daily while their condition holds, andgh issue closeappeared nowhere in the workflow — so an auto-filed issue stayed open forever, including after the problem was fixed. #2835 ("sceneview-mcp npm is stale (4.0.14 < 4.0.15)") sat open for 13 days after 4.0.15 was published. Each of the five now derives a positive measured-and-clear signal and closes its issue through a sharedclose-maintenance-issue.sh, which will only ever touch an issue that is open, filed byapp/github-actions, labelledmaintenanceand title-matched. The clear signal is deliberately not the inverse of the open signal: for the two store-drift jobs a0exit also means "credentials absent", and a failednpm viewyields the same "no lag" as a genuine match, so closing on a bare0would silently retract a finding that is still true. - The App Store drift issue no longer tells a maintainer to publish known-wrong screenshots. Its body recommended reconciling via
app-store-screenshots.yml, whilesamples/ios-demo/appstore-screenshots/README.mdexplicitly forbids dispatching it until the frames are re-captured — they predate #2897 and were shot whileSceneEnvironment.intensitywas fed to RealityKit as a2^xexponent. Nothing enforced that (the dispatch is manual andasc_listing.pycompares checksums, not pixels), so the warning now travels in the issue body itself. - The Play Store graphics README stated the wrong iOS screenshot count — it advertised 5 + 5 as a "pre-v2 five" awaiting refresh, when #2896 had already curated the set down to the deliberate 2 + 2 (
model-viewer · dynamic-sky, withmulti-modelexcluded because a keyless capture build substitutes bundled stand-ins). capture-play-store-screenshots.shno longer captures a stale build.android runwas observed printingNo matching components found for type ACTIVITYand still exiting 0, so the script'sadb install -rfallback never fired and the whole capture ran against a build 16 hours old (device 4.23.0 vs freshly-built 4.24.0) while producing entirely plausible screenshots. The install is now verified against the device's packagelastUpdateTimerather than trusted from an exit code, falls back when it did not land, and prints the on-device build for every run (#2854).Node.destroy()now returns the entity id to Filament'sEntityManager, instead of only destroying the entity's components. Every node ever created used to burn one id for the lifetime of the process — invisible in single-teardown tests, and measured by the #2762 leak-churn harness on its first run (#2859).- The release is gated on ownership, so a borrowed entity is left to its real owner: a node
recycles its id only when it allocated the entity itself (the constructor's
entityargument omitted).ModelNodewrapsmodelInstance.rootand its children wrapgltfionode entities, all owned by theAssetLoader— recycling those would let Filament reissue an id a live asset still uses. SplatNodealso recycles the per-batch renderable entities it allocates.Node.destroy()now removes its entities from the FilamentSceneit is attached to before recycling the id, so an imperative caller that destroys a node without detaching it first cannot leave a reissued id behind in the scene.- New:
NULL_ENTITY(the "no entity" sentinel, and the new default of every optionalentityconstructor parameter) andEngine.safeRecycleEntity(entity). Both are additive — no existing signature changed, andNode(engine)/Node(engine, entity)still compile as before. - Device-QA emulator pool: a provisioned emulator no longer looks free to every
other session.
setup-ar-emulator.shleased by pid and dropped the lease in its EXIT trap, but the emulator it provisions deliberately outlives the script — so the nextdevice-qa.sh/qa-android-demos.shrun was handed an AVD another session was actively driving. Leases are now reserved per session and survive the provisioning script (--releasehands one back, with a bounded TTL so a dead session can never wedge the pool). (#2862) - The pool also refuses to lease an emulator that is not the pool AVD: a stray
device sitting on a pool port used to be leased and driven as if it were the
ARCore-ready
Pixel_7a, producing a QA verdict about a device nobody meant to test. (#2862) - Device-QA emulator pool (follow-up to #2862): the scripts that actually DRIVE
a pool emulator now HOLD a lease for their whole run, closing the two-sessions
-on-one-AVD gap for the harness's own scripts.
qa-android-demos.shandar-replay-qa.shused to pick a running emulator without acquiring it, so a second standalone run drove the same one; they nowemu_lease_acquireit (or adopt this session's sticky reservation), refuse one a peer reserved, and release it on exit.ar-replay-qa.shalso refuses a pool-port emulator it cannot identify (wrong AVD, or a console that does not answer — most likely precisely when a peer is driving it) instead of falling through and driving it unleased. The lease file governs allocation, not exclusion:CLAUDE.mdtells agents to drive the emulator withadb install/input tapdirectly, and no amount of leasing inside the scripts stops that — measured during this work, a sibling session'sadb installkilled a leased run's app mid-sweep (Killing <pid>:<pkg> (adj 0): stop <pkg> due to installPackageLI, which without that logcat line reads as a native crash). Rawadbis now blocked by a separate mechanism, the #2924PreToolUsehook, for commands a session issues — not by this change. (#2862) device-qa.shnow grades its android leg on the positive[qa] PASSmarker in addition to the exit code, as the iOS leg already did. Holding the pool lease meansqa-android-demos.shinstalls an EXIT trap, and on macOS bash 3.2 (measured: 3.2.57) a script that aborts inside a||-guarded list with a trap installed exits 0 — which would have graded a crashed sweep aspassed. Preserving$?inside the trap does not help: the||has already reset it. (#2862)setup-ar-emulator.shnow publishes its session token to the handoff file only when it minted the token itself. A caller that already exported one (device-qa.sh) no longer has its reservation inherited — and the emulator stolen — by a concurrent session inside the handoff window, and the ad-hoc "next steps" hint leads with the token export that makes the reservation exclusive. (#2862)samples/android-demo: thegeometrydemo no longer clips its primitives in a phone-portrait viewport (#2873). Two independent faults stacked. The four primitives were laid out on a row ~1.45 m wide — wider than a portrait frame at any sane distance — and the camera was ~2× closer than the code believed:rememberCameraManipulator'sorbitHomePositionwas documented as the camera's world position "to return to on double-tap", which reads as "distance =|orbitHomePosition − targetPosition|", but the resulting orbit distance measures as|orbitHomePosition|— because Filament takes the value as the eye verbatim whileautoCenterContent = truehas already translated the content onto the world origin, sotargetPositionnever enters the distance (documented onmainin #2930).(0, 0.2, 1.2)against a target atz = -1.5therefore framed the row from 1.22 m, not the "comfortable 2.7 m" its comment claimed, so the group was ~2.7× wider than the frame and a primitive was cut off at an edge no matter what. The primitives now sit in a 2 × 2 cluster and the distance is passed as a vector whose length is the distance. Measured on the QA emulator at the default framing: the cluster clears the frame with ≥ 184 px of margin per side on a 1080-wide viewport (model predicted the cube's left edge at 187.2 px, pixels measured 187).samples/android-demo: thegeometrydemo now honours thecamera_distancelaunch lever (#2652). The extra is read byrememberHeroOrbitCameraManipulator, which this demo does not use, so--ef camera_distance <f>was a silent no-op on it — the reason #2873 reports the clipping as reproducing "at every camera distance": the distances tried never reached the camera. Verified on-device at 4 / 6 / 10 m, each producing a distinctly reframed scene. The same silent no-op on every other non-hero-orbit demo remains #2785's scope.samples/android-demo: newGeometryLayout+GeometryLayoutTestpin the framing as arithmetic instead of eyeballed constants. Positions, sizes, the default distance and the frustum relation (halfHeight = distance · 12 / focalLength, Filament's 24 mm full-frame sensor model) live in one internal object, and the JVM test asserts the cluster clears both a real phone-portrait viewport and the narrowest frame it could meet — including a regression case proving the old row measures as clipped. This defect was invisible to every existing gate: the demo compiled, rendered correctly, and passed the store-capture blank-frame guard while a primitive hung off the edge.- Demo: the
materialsdemo now shows the same subject on the same backdrop on every launch (#2874). The idle orbit still varies the camera yaw, so a pixel-stable capture needs--ez qa_mode true. Two things made it non-reproducible, and both are fixed. (1) The subject was streamed. The PBR Materials section opened on a Sketchfab slug, so what the first frame showed depended on the API key, the network and the disk cache — two captures of the same demo id from the same build showed a different model. It now opens on a bundled subject: Khronos'ToyCar, already in the APK, whose GLB declaresKHR_materials_clearcoat,KHR_materials_sheenandKHR_materials_transmission— the three extension families the section is about, on the car body, the seat fabric and the windows. That is strictly more than the old offline path showed, since every slug fell back tokhronos_damaged_helmet.glb, which declares noKHR_materials_*extension at all. The streamed catalogue is unchanged and stays one chip tap away, so variety survives as an explicit user action. (2) The backdrop was a photograph swept by the camera. The sections drew thestudio_2kskybox, which — despite itsneutral / studio / producttags inassets/catalog.json— decodes to a domestic living-room interior; drawn behind a camera that orbits 360° every 18 s, one environment shows a different room feature in every capture, which is what #2874 saw as "a different HDRI each launch". Measured: swapping to a genuine photo-studio HDRI did not fix it (two cold launches came back with the same subject against the studio's dark side and its bright sweep), so the material sections now share one constant,MATERIALS_SHOWCASE_HDR = environments/studio_warm_2k.hdr, used as IBL only — the materials still read the environment through their reflections, the backdrop is the demo's own surface at every orbit angle. Framing is subject-independent too: every chip is normalised to the same size and viewed from the same orbit radius instead of each model's ownscaleToUnits(0.15 m for the beetle, 0.90 m for the sofa), so the subject no longer reads as a speck — measured on the phone capture, its base now spans 98–100% of the frame width. The cold-launch contract (default subject is bundled, never streamed) is asserted byMaterialsSubjectsTeston the JVM, because this defect is invisible to a per-frame check: every capture looked fine, they just differed from each other. - iOS CI no longer swallows build/test failures (#2878). Three Swift build/test steps in
ios.yml, plus theSceneViewSwiftbuild inrn-ios-compile.ymlandbridge-ios-compile.yml, ended in| xcpretty … || cat, which defeatedset -o pipefail:catreads CI's empty stdin and exits 0, so the steps stayed green through real failures. They now end in|| exit ${PIPESTATUS[0]}, propagating xcodebuild's exit code while still tolerating a missing xcpretty. Extends #2865, which fixed the same idiom in the one new step it added. AnchorNode.removeAll()/AugmentedImageNode.removeAll()now remove every child (#2878). Both iterated a live RealityKit children view while removing from it, which re-indexed the collection mid-loop and left every other child attached (2 children → 1 stranded). They now snapshot into anArrayfirst. Surfaced once iOS CI stopped masking the failing tests.CameraNodefar clip plane now defaults to a deterministic 1000 m (#2878).PerspectiveCameraComponent()ships withfar = .infinity, so thefarClipgetter's?? 1000fallback was unreachable and the documented 1000 m default was silently infinite — this now matches Android (CameraNode.far = 1000.0f) and the web viewer.CameraNode.init()setsnear/farexplicitly. Note: geometry beyond 1000 m is now clipped by default onCameraNode; call.clipPlanes(far:)for larger scenes.- CI: the iOS App Store submit step no longer attaches the previous release's
binary (#2893 W1). It selected the newest VALID iOS build, which — while
Apple was still processing the upload from the running job — is the PREVIOUS
release's build. The archive step now exports its
CFBundleVersionand the submit step pins the selection to it: no match yet means our build is still processing (keep polling), and exhausting the window is a loud red naming the build it waited for. The#2885platform-resolution fallback is preserved. - CI: an authentication failure on the App Store Connect builds query is no longer misreported as an Apple processing delay (#2893 W2). The status code was ignored, so a 401 read exactly like "still processing" and burned the full ~10-minute poll before failing with a message blaming Apple. 401/403 now fail immediately naming auth, 429/5xx stay retryable, other 4xx fail fast, and a non-JSON 200 is retried instead of raising out of the step.
- CI: a failed submission no longer leaves an orphan
reviewSubmissionin App Store Connect (#2893 W5). Every failure path after the submission was created exited without deleting it, accruing an empty, open, never-submitted record per run — the exact signaturestore-preflight.shreports as a release blocker, cleared by hand after run 30269459288. The submission this run created is now cancelled on any post-create failure, never on success, and a failing cleanup can no longer mask the error that triggered it. The one ambiguous case is handled explicitly: a submit request that gets no usable answer — no response at all, or a5xx/408a gateway can return after the write was already committed — may still have reached Apple, so the submission's state is read back and a live one is left alone rather than withdrawn. - CI: a submission that dies on a transport error now says so in the log. The
step's fatal handler caught only
SystemExit, so aConnectionErroron the submission POST or PATCH went red without ever printing the "did NOT reach App Review" banner — the operator saw a stack trace and no verdict. The orphan cleanup already ran in that case; only the diagnostic was missing. - CI: the empty-
whatsNewwarning names the real state ofrelease_notes.txt— "No release_notes.txt" sent a reader hunting for a missing file that was present but blank (review nit from #2908). - iOS environments never lit anything. Every bundled
SceneEnvironmentpreset is a Radiance.hdr, andEnvironmentResource(named:)cannot load one — it threwresourceLoadFailureonstudio.hdr/outdoor_cloudy.hdr/ every other preset, andSceneEnvironment.load()swallowed that into "scene continues with default lighting". So every iOS scene carrying.environment(…)ran with no custom IBL and no skybox: theImageBasedLightComponentwas never set, so the scene fell back to RealityView's own default environment lighting (dim, not unlit — see #2842/#2868), andshowSkyboxhad no visible effect at all. Visual change on upgrade: an app already on 4.25.0 that tuned its look around the broken state will render differently once the IBL and the skybox appear.load()now falls back to decoding the file through ImageIO (which readspublic.radiancenatively) and building the resource from the equirectangularCGImage. Thenamed:path is still tried first, so.exr, asset-catalog and Reality Composer Pro resources are unaffected (#2896). - iOS/macOS/visionOS:
SceneEnvironment.intensityis applied as the linear multiplier it is documented to be. It was passed straight to RealityKit'sImageBasedLightComponent(intensityExponent:), which scales the IBL by2^x, so every bundled preset rendered at the wrong exposure:.studio1.0 at ×2.0, and.night0.4 at ×1.32 — brightening where its authored value asks it to dim to ×0.4. The defect pre-dated #2896 but was latent, becauseEnvironmentResource(named:)could not load the Radiance.hdrpresets and noImageBasedLightComponentwas ever set; #2896 made the IBL load, and with it the wrong unit. The value is now converted withlog2at apply time, so1.0is a true no-op and the presets keep their linear authoring. The result is clamped finite for everyFloat, includingNaNand±infinity, which RealityKit rejects. The KDoc andllms.txtstate the unit explicitly (#2897). - Note for anyone reading this as a parity fix — it is not one. Android's
Environmenthas no intensity member; its IBL level is Filament'sIndirectLight.intensityin absolute lux (DEFAULT_IBL_INTENSITY = 10_000), so the two knobs are not interchangeable and never were. This change moves iOS onto the exponent-0 baseline thatSceneFactories.kt's cross-platform note already assumes it uses (≈1000 lux equivalent); the platforms stay matched on the key-to-IBL ratio, not on absolute values (#2897). - The committed App Store screenshots predate this fix.
appstore-screenshots/was captured while the exponent was live:01-model-viewer.pngon.warm(intensity 1.0 → ×2.00, now ×1.00) and02-dynamic-sky.pngon.outdoor(1.2 → ×2.30, now ×1.20). Only the IBL contribution changes — the direct lights and the skybox are untouched — so the frames are not uniformly twice as bright, but they no longer match what the app renders. Re-capture and re-judge the mosaic before dispatchingapp-store-screenshots.yml(#2897). dynamic-skyon iOS now demonstrates the sun with a subject that can show it (#3003). The demo built a stylised skyline from fivesystemGraycubes — working exactly as written, but a matte grey box reads the same at noon and at dusk apart from its shadow, so the one thing a time-of-day demo exists to show was invisible. It now loadskhronos_damaged_helmet, the subject Android's Lighting Lab puts under this same demo id, whose metal and rough-dielectric regions render the environment change directly in their reflections. The ground plane went with the cubes: it existed so the auto-framing pass (which fits the union bounding sphere) would not pull back to contain an oversized slab, and with a single hero subject it earned nothing while leaving the helmet at a sixth of the frame height and visibly intersecting it. The demo also gained theframingMarginsplitmodel-vieweralready had — looser at 0.75, because a helmet is nearly as tall as it is wide and the 13" iPad frame clips it atmodel-viewer's 0.62.- The iOS App Store screenshots are re-captured from a post-#2897 build, clearing the ⛔ caveat that blocked reconciling the App Store listing drift (#2899). The previous frames were shot while
SceneEnvironment.intensitywas still applied as a2^xexponent. The visible change from that fix alone is nil, exactly as the caveat's own measurement predicted — which is the point: the frames are now provably what the app renders instead of probably close enough. - Recorded a capture defect the mosaic surfaced: the iPad frames leak their capture date (
09:41 Tue 28 Julvs09:41 Mon 3 Aug), becausesimctl status_bar override --timedoes not cover the date iPadOS draws beside the clock — which both dates a public listing and defeats the script's byte-reproducibility (#3004). - MCP:
sceneview-mcpno longer advertises a one-release-old SDK pin to AI agents.mcp/src/generated/version.tsis auto-generated but, unlike its gitignoredllms-txt.ts/symbols.tssiblings, committed — and the v4.25.0 release bumpedgradle.propertieswithout regenerating it, soLATEST_SCENEVIEW_RELEASE(and theanalyze-projectandroid-oktest fixture's SDK pin) stayed at4.24.0, the version the MCP hands out in its install snippets. Both are regenerated to4.25.0. The MCP's own npm version (PACKAGE_VERSION) is on an independent track and is left untouched (#1705, #2906). - Tooling:
sync-versions.shnow verifiesLATEST_SCENEVIEW_RELEASEagainstVERSION_NAME(CRITICAL) and regeneratesversion.ts+ the fixture in--fix. A future SDK bump that forgets the MCP regeneration is now caught by the release pipeline (release-fast.ymlre-runs the check for zero residuals) instead of silently shipping a stale pin.PACKAGE_VERSIONstays deliberately out of the check (#1705, #2906). - Store-screenshot docs now describe what the repo actually ships instead of a parity that no longer holds. #2855 moved the phone class to set v2 (
model-viewer · dynamic-sky · multi-model) without re-shooting the tablets, so four surfaces had drifted:PLAY_STORE_SETUP.mdstill advertised five phone screenshots and "the same five demos" across all classes; the Play graphics README still presented the retired pre-v2 five as the shipped set; the capture script's own usage example still offered the retired ids as its--demossample, next to the banner warning against re-adding them; and the App Store README claimed parity with Android while its images are the pre-v2 five. Each class is now documented with the set it really carries, and the tablet gap is tracked in #2907 (#2907). capture-play-store-screenshots.shresolves its demo set per form factor and dropsmulti-modelfrom tablet runs. Measured on both tablet AVDs against a 4.25.0 build: at a tablet's wider aspect (~0.64 w/h vs the phone's ~0.47) that demo's fixed camera angle frames a wooden support post against the backdrop wall, with none of the foliage the slot exists for. It is not a settle defect — the frame renders fully — and the framing lever cannot correct it (probed at 2.5 / 3.5 / 4.5 m: essentially the same frame, becausecamera_distancemoves the camera along an angle it cannot change). The variance guard passes the bad frame (2227 on 10", 2827 on 7"), so only a mosaic eyeball catches it; the guard is forward-looking and rewrites no committed screenshot. Demo-side fix tracked in #2913 (#2907).- Play Store: both tablet classes are now on screenshot set v2
(
model-viewer · dynamic-sky). The three retired slots per class —materials(#2874),geometry(#2873) anddouble-pendulum, all shot from a 4.23.0 build — are removed, so the next listing sync stops uploading them:play_listing.pyselects screenshots by glob, not by count. capture-play-store-screenshots.shnow prunes higher-numbered leftover slots after a completed run, so a shrinking set can no longer leave stale frames in the Play mirror where neither the mosaic nor the run summary can show them.- The demo app's Multi-Model ("park") scene now frames itself from the live viewport aspect instead of a hardcoded camera pose, so it composes correctly on a tablet instead of filling the frame with one model's bare flank against the backdrop wall. The scene aimed a fixed camera at
(0, 0, -1.5)— the formation centre it authored — while the library'sautoCenterContentpass had already translated that formation onto the world origin, leaving the lens ~0.6 m from the content centroid, effectively inside the subject. The section now places its own models around the origin (autoCenterContent = false), bottom-aligns every one of them onto a shared ground plane (centerOrigin) instead of inheriting each GLB's authored pivot, and derives the camera distance from the formation's own size and the measured viewport aspect viaDemoMath.coverDistance— cover framing, not fit: the models fill the frame on both axes and the excess is cropped, so a wider viewport lands on more models rather than on the backdrop. Filament fixes the vertical FOV, so a phone (~0.47 w/h) and a tablet (~0.64) resolve to the same distance and a landscape / foldable viewport pulls the camera in. Covered by 11 new pure-JVMDemoMathTestcases, including one that pins the formation layout so the derived framing bounds cannot drift away from it (#2913). - The Multi-Model section honours the
camera_distance/?cameraDistance=framing override, which it previously ignored: it built a stockrememberCameraManipulator, which reads noDemoSettings, so the store script's--ef camera_distance 6.0never reached the scene. That is why probing 2.5 / 3.5 / 4.5 m produced three identical frames and looked like a camera angle that distance could not change. The store capture script drops that no-op 6.0 m value and lets the scene's own per-viewport framing stand (#2913). - Switching between the Model Viewer's sections no longer carries the camera-distance override across. The Single-Model slider writes to the process-global
DemoSettings.cameraDistance— that is how it drives the live camera — and now that the Multi-Model section honours the same override, dragging the slider down and then switching put the camera inside the formation with no control in that section to undo it. Changing section clears the override; a cold launch never passes through that path, so the--ef camera_distance/?cameraDistance=deep link is unaffected (#2913). multi-modelis captured on tablets again —capture-play-store-screenshots.shhad dropped it from tablet runs while the framing was broken (#2915). The committed tablet PNGs still hold two slots until they are re-captured; Play accepts 2–8 per type (#2913).capture-play-store-screenshots.shwarns whenmulti-modelis captured without a Sketchfab API key. With a key the demo streams the photorealparkoaks; without one the resolver substitutes per-slug bundled models (lantern / lantern / shiba / soldier), so the same demo id captures a completely different scene — and the frame still renders fully, still passes the foreground guard, and still clears centre-variance, so nothing downstream can tell. #2913 was diagnosed against a keyless tablet capture next to a committed phone screenshot shot with a key, and the asset swap read as a framing defect (#2913).- Point & Ask: anchored answer cards no longer drift and jump. All panels share one
ViewNodeWindowManager, whose single wrap-content host sizes itself to its largest child and re-measures every sibling to that size — so a long streaming answer silently resized and shifted every other pinned card, continuously, while it typed. Each card now has a fixed width and height, with the answer scrolling inside it. - Docs: the
ViewNodeKDoc (mirrored inllms.txt) claimed there is "no parent to measure against", sofillMaxWidth()has "nothing to fill". The window isWRAP_CONTENT, so the content is measuredAT_MOST(display)—fillMaxWidth()resolves to the full display width and puts a metres-wide quad in the scene. The advice (give an explicit size) was right; the stated reason and failure mode were not. The shared-WindowManagersizing rule is now documented alongside it. - Device-QA emulator pool: the hold-the-lease guard now also runs on the path
setup-ar-emulator.shitself documents. Both drivers treated a pre-setANDROID_SERIALas proof that the caller held the lease, and the script's own printed next-steps tell you toexport ANDROID_SERIAL=…— so in the exact workflow it advertises, the guard never ran.qa-android-demos.shandar-replay-qa.shnow verify it instead, via a newemu_lease_ensure. (#2921) - The obvious fix here — "just call
emu_lease_acquire" — is a regression, and the hermetic self-test now pins that. Measured withdevice-qa.shas parent: when both share a session token, an acquire in the child adopts the parent's lease and rewrites the owner to the child's pid, so the child's EXIT trap deletes a lease the parent is still relying on and the emulator goes back to looking free to every peer — the collision the lease exists to prevent, reintroduced on the nominal path. Without a shared token it is worse: the child refuses to run at all.emu_lease_ensuretherefore verifies without taking ownership — a strict no-op when the lease is already ours, a real acquire when the emulator is unleased, and a refusal when a live peer holds it. (#2921) - The ar leg is no longer graded on its exit code alone. It shared the
bash 3.2 false-green the android and iOS legs already defend against (an abort
inside a
||-guarded list under an EXIT trap exits 0), but unlike them it had no positive marker to require — it graded an absence.device-qa.shnow requires one, and keeps the two green paths distinguishable: a real[ar-replay-qa] PASSversus the newGREEN-NO-OPa sparse checkout emits when there was nothing to replay, which is reported as such instead of implying demos ran. An exit 0 with neither marker is now a failure, not a pass. (#2921) qa-android-demos.sh/ar-replay-qa.shstop sendingemu_lease_release_allto/dev/null— that discarded the only evidence a release did the right thing, and|| truealone already makes the trap safe. A mis-release is now diagnosable from the artifact bundle. (#2921)- Still open, stated plainly:
device-qa.shacquires its own emulator on a best-effort basis (emu_lease_acquire … || true), so the orchestrator itself can still proceed unleased. Rawadbtyped by a session is blocked separately by the #2924PreToolUsehook, which sees only commands that pass through a Claude Code session — a plain terminal or a wrapper script is invisible to it. - Known, unchanged: the handoff token is inheritable at most once, so a chain of
setup-ar-emulator.sh→qa-android-demos.sh→ar-replay-qa.shin a shell that never exportedEMU_LEASE_SESSIONends at the third step. Export the token the provisioning script prints — the scripts say so on the refusal path. (#2862) - Demo: the
materialsdemo no longer goes black after a chip round-trip (follow-up to #2874 / #2926). Tapping Toy Car → any streamed chip → Toy Car rendered an empty viewport with no loading scrim and no way back short of leaving the demo. The section feeds oneModelNodecall site an instance that swaps when the chip changes; that re-keysremember(engine, modelInstance)inSceneScope.ModelNode, and the outgoing node'sDisposableEffectrunsnode.destroy(), which walkschildNodesand callsengine.safeDestroyEntityon the entities theModelInstanceonly borrows.ownsEntityisfalse, so the entity ids survive but their renderable components do not — and the bundled instance is retained for the whole session and never reloaded, so it came back renderable-less. Both subjects now stay mounted and the inactive one is hidden withisVisible. - Docs: the demo's reproducibility claim now matches what is coded. The
changelog fragment and
DemoEnvironment's KDoc said the demo "produces a reproducible frame"; the subject and the backdrop are indeed identical on every launch, but the section's idle orbit is time-driven and starts when the model finishes loading, so two captures still differ in camera yaw unless the app is launched with--ez qa_mode true. Both surfaces now say so. - iOS demo: the Multi-Model "Park" Tree slot renders again, and five other demos stop stalling (#2928).
Models/tree_scene.usdzcontained 2 712 mesh prims — 2 665 of them individual grass tufts — and RealityKit's USD import cost scales with prim count, not file size or triangle count. Measured on an iPhone 17 Pro Max simulator (iOS 26.3): the asset took 91.71 s inEntity(contentsOf:), while a 25.3 MB / 1.3 M-triangle bundled model parses in 0.85 s. The slot was therefore still awaiting its parse long past any settle window, and becauseloadSlotnever threw, nothing was logged and the model simply read as absent. The grass prims are stripped (47 mesh prims remain), taking the parse to 2.58 s. The strip is purely subtractive — no mesh was re-authored, and all 47 survivors keep byte-identicalextentarrays — so framing and composition are unchanged. Becauseassets/is the source of truth that platform copies are derived from, and the shared original still carries the 2 712 prims,sync-assets.shwould have copied it straight back over the fix; the optimised copy is now a checksum-pinned divergence there, so it is skipped by the sync and still verified on every run. The same file backs the "Tree Scene" entry in the Explore and AR tabs, and was also the keyless fallback of the Potted Monstera, Wooden End Table and Floor Lampar_placementslugs — all of which stalled the same way in a keyless build, which is both the default local build and the App Store build. Those three slugs have since been repointed at distinct bundled stand-ins (#2940): un-stalling them would otherwise have rendered a tree scene under a plant/table/lamp label. A deterministic prim-count budget test guards the regression (a wall-clock assertion would flake on a loaded CI host). - iOS demo: a corrected bundled asset now actually reaches an already-installed app (#2928).
SketchfabAssetResolver.fallbackBundle(for:)staged each keyless fallback into the cache root under a path keyed only on the slug uid and returned any file already there without comparing it to the bundle. That staged copy lives in the app's data container, which survives an App Store update — so for every existing install, an update shipping a fixed asset was inert and the old bytes were served forever. It is how thetree_scene.usdzfix above silently failed to take effect on an already-installed build. The staged copy is now re-made whenever its byte size no longer matches the bundled asset. - The Multi-Model demo's per-model visibility chips are labelled from the resolved Sketchfab slug's
displayNameinstead of four hardcoded nouns, on Android and iOS. They read "Tree" / "Bench" / "Dog" / "Bird" while theparkregistry has held four oak trees since the streamed-asset migration — nothing named a bench, a dog or a bird has been in that scene for releases, so the toggles were effectively unlabelled. They now read "Oak Trees" / "Stylized Tree" / "Mighty Oak Trees" / "Skovfogedegen Oak" and follow any registry edit, falling back to a positional "Model N" only while a slot has no slug. The chip row scrolls horizontally, as the Gallery row already does, because catalogue names do not fit four-across on a phone (verified on a Pixel 7a AVD) (#2933). - The Multi-Model section now shows the scaffold's asset-source pill, and it is measured from the resolved file rather than inferred from
SketchfabConfig.apiKey. Every failure path inSketchfabAssetResolver.resolve— no network, a stale key, a bounds-drifted asset, exhausted retries — ends at the bundled fallback, so a build with a key can render four offline stand-ins; the config-based inference used elsewhere labels that "Streamed (cached)". Reproduced on the QA emulator with a valid key while the Sketchfab download endpoint returned HTTP 429: all four slots staged out ofcache/sketchfab/fallback/while the config-based inference labelled that scene "Streamed (cached)". The file-based pill was captured reading "Offline model" on the keyless leg; the keyed-with-429 case follows the same code path but was not re-captured. NewSketchfabAssetResolver.isBundledFallback(file)is the shared signal (#2933). - Toggling a Multi-Model visibility chip no longer blanks the models that were meant to stay on screen. The scene skipped a hidden slot's
ModelNodecall site entirely, which shifted every later node onto the preceding composition group with a differentModelInstance, re-keyedremember(engine, modelInstance)and rannode.destroy()— destroying the Filament renderable components that the instances only borrow, with no reload possible because they come from aproduceStatewhose keys never change again. Every slot now stays mounted and is hidden withisVisible, keyed by slot index. Same defect, same remedy as the Materials section in #2939 (#2933). - android-demo — the Scene Gallery asset-source pill now reports the origin it
actually rendered. It was inferred from
SketchfabConfig.apiKey, so a build with a key configured but a failed download — no network, a stale key, a bounds-drifted asset, exhausted retries, all of which end at the bundled fallback — showed "Streamed (cached)" over the offline stand-in. The pill now asks the resolved file viaSketchfabAssetResolver.isBundledFallback, and only falls back to the key-based guess while nothing has resolved yet (#2936). - iOS demo: the AR placement picker no longer renders a tree scene labelled "Potted Monstera" (#2940). The Potted Monstera, Wooden End Table and Floor Lamp
ar_placementslugs inSampleAssets.swiftall declaredfallbackBundledPath: "Models/tree_scene.usdz", so a keyless build — the default local build and the App Store build — dropped the same tree-and-terrain island under each of those three labels. Before #2928 that asset never finished parsing and the slot merely looked broken; un-stalling it turned the same mapping into a confident, wrong scene, which is the failure shape #2913 named. It also violated the rule documented in place a little above those entries: when several slugs share a fallback the fallbacks must stay distinct, precisely so a fallback can never be mistaken for the real asset (#2355) — and the AR placement demo accumulates taps, so three identical islands could stack in one scene. The three now fall back to three distinct bundled Khronos reference models:khronos_lantern.usdzfor Floor Lamp, which is the one bundled asset that genuinely is what its label says; and, because no plant and no table exist in the bundled set,khronos_toy_car.usdzfor Wooden End Table andkhronos_damaged_helmet.usdzfor Potted Monstera — reference objects that read as stand-ins rather than as mislabelled real furniture. All three are already shipped in the IPA, so nothing is added to the bundle, and all three carry 1–3 mesh prims against the 100-prim budget #2928 introduced. The keyed path is untouched — it still streams the real Sketchfab models. Thepark"Oak Trees" slug, whosetree_scene.usdzfallback is correct, is unchanged. - iOS demo: a mistyped or repointed keyless fallback is now caught by a test instead of at runtime (#2940). The registry's only fallback invariant was that
fallbackBundledPathis non-empty; nothing verified that a declared path resolves to an asset actually present in the app bundle, so a typo — or an asset added to the repo but never to the Resources build phase — surfaced only as a failed resolve on a keyless device.BundledAssetPrimBudgetTestsnow walks every distinctfallbackBundledPathread from the registry itself, asserting each one resolves, parses to non-empty bounds, and stays inside the #2928 prim budget. Reading the paths from the registry rather than from a literal list means repointing a fallback re-aims the guard, instead of leaving it watching the assets that used to be declared. - Credit three Khronos models that shipped uncredited.
assets/CREDITS.mdhad drifted from its source of truthassets/catalog.json: Toy Car (android-demo,ios-demo), and Sheen Chair + Iridescence Dish With Olives (android-tv-demo) were in the catalog and shipping in a sample app, but absent from the attribution list each model's licence (CC-BY 4.0 §3a) requires. Regenerated with.claude/scripts/generate-credits.py; the header tally goes 70 → 75 catalog records — five, because Sheen Chair and Chronograph Watch each have a secondweb-demorecord in the catalog (Chronograph Watch itself was already credited under its twin).toy_caralso leaves the "Missing metadata" section now that its metadata is filled in. - Demo (Android): a corrected bundled model now reaches installs that already
ran the app.
SketchfabAssetResolverstaged the offline fallback under a path keyed onuidalone and trusted any complete GLB found there, so the app's data dir — which survives a Play Store update — kept serving the previous version's bytes forever. An APK shipping a fixed asset stayed inert on every existing install. The staged copy is now compared against the byte length of the asset currently in the APK and re-staged when they diverge, mirroring the iOS fix from #2929. This closes the parity half of #2943 that #2947 left open; the KDoc contract "keep both in sync when adding behaviour" was pointing at exactly this gap. - Demo: a bundled asset that has gone missing degrades instead of throwing.
When the bundled resource is unreadable — renamed or pruned from the app
while the registry still points at the old path — both resolvers now serve an
existing staged copy as a last resort. On iOS the freshness check had moved
the bundle lookup ahead of the staged-copy early return, turning "renders the
previous model" into a throw across all eight
fallbackBundlecall sites;Bundlecaches resource lookups, so the guard stats the file rather than trusting the URL it hands back. - The AR camera no longer loses ARCore's projection to Filament's generic 28 mm lens default (#2950).
ARSceneView's surface-resize callback rebuilt the camera projection fromCameraNode.focalLength, discarding the projection ARCore derives from the physical camera's intrinsics — a 46.4° vertical field of view where the device has 73.7°, so virtual content was drawn ≈1.75× too large and, worse, stopped being registered to the real world, sliding across the room as the phone rotated.ARCameraNodenow re-derives the projection from ARCore instead of from a lens (which also reproduces ARCore's off-centre principal point, something a focal length structurally cannot express), and its projection cache gained a third dirty signal so a rebuild from outside the ARCore path can no longer be frozen in place for the life of the session. The AR5 (#2329) per-frame allocation win is preserved. Reported with a full measured diagnosis by @xmhorsehead. pr-review.ymlnow grants the orchestrator theTaskand git tools it needs. They are not inclaude-code-action's default set, so the four reviewers could never be spawned and the diff could never be computed: every review since the workflow landed ended asREVIEW_INCOMPLETEwith noreview-verdict.json. The git allowlist is per-subcommand — reviewers share one working tree, and a branch switch corrupts it for the others (#2431).- A dispatched review used to review the wrong code entirely.
actions/checkoutdefaults togithub.ref, which on aworkflow_dispatchis whatever--refsaid —main— and not the PR named ininputs.pr. The reviewers would have diffedmain...HEAD, found nothing, and reported a clean PASS on a PR they never read. Unlike the missing-tools failure above, which the grader caught and blocked, this one is a false green, and it lands on the one path that exists to rescue reviews which cannot run automatically (fork PRs). The dispatch path now checks outrefs/pull/N/head, which resolves on the base repo even for fork PRs. Thepull_requestpath is untouched — it already resolved the right ref, and merging the two would have silently switched the review from the merge ref to the head ref. - Relatedly, the self-modification guard no longer fires on a dispatch. What
claude-code-actionvalidates is the workflow file it is running, which on a dispatch comes from--ref, not from the checkout; comparing the checkout would flag every older PR whose copy of the file has merely been superseded, making the documented rescue path unusable as soon as this workflow changes. - A blocking verdict now names its own cause. The failing runs put a red check on
the PR reading
REVIEW_INCOMPLETEand nothing else — correct, and useless: the real reason sat in the action's JSON log, and the natural reading ("a reviewer crashed") was wrong. A newDiagnose a missing verdict filestep reads the run record and distinguishes denied tools — a configuration failure, not a finding about the PR — from ran but wrote nothing, before the grader compresses it to one word. It scans the record recursively rather than at a fixed path, because the action writes either a list or a single object and a wrong path would silently report zero denials, printing the reassuring branch this step exists to prevent. - android-demo — the AR Placement and Orbital AR asset-source pills now report the
origin they actually rendered. Both inferred it from
SketchfabConfig.apiKey, so a build with a key configured but a failed resolve — no network, aeroplane mode, a stale key, a 4xx, the WAF, a bounds-drifted asset, exhausted retries, all of which end at the bundled fallback — showed "Streamed (cached)" over the offline stand-in. Both now ask the resolved file viaSketchfabAssetResolver.isBundledFallback, and consult the key only while nothing has resolved yet and there is no file to ask. Orbital AR takes the whole-scene pessimistic verdict Multi-Model uses: one fallen-back planet reads "Offline model" for the formation. The rule now lives in one testable place (AssetSourceProbe) instead of being re-derived per demo (#2953, follows #2936). contact-shadow-preview: the legend chip is no longer drawn across the grounded box at its landing pose (#2957). Device QA measured the chip row crossing the hero box by 170 × 58 px — 51 % of the box's width — in 3 of 6 sampled frames, precisely on the contact-shadow moment the screen exists to demonstrate. The row used to float over the viewport, lifted clear of the Settings FAB by a vertical gutter; no gutter constant can fix this, because where a 3D object lands on screen is a projection and any value is tuned to one viewport.DemoScaffoldgains an opt-inbottomOverlayReservesScenethat insets the scene by the measured height of thebottomOverlayband, so the viewport and the overlay are disjoint by layout at any screen size, density, font scale or locale. The legend now clears the FAB sideways instead of being lifted over it, keeping the reserved band to the chip's own height.contact-shadow-preview: the Wall preset's verdict line now describes what actually renders (#2957). It promised "a faint, wide halo below the panel"; the measured pool is 18.6/255 darker in the first 70 px under a 314 px-tall panel and has fully decayed by 70 px. The caption names the visible cue instead — a thin band of shade against the panel's lower edge — and states what it buys.- iOS demo: a keyless build now says which model it is actually showing, and
four stand-ins stopped contradicting their own label (#2960). App Store
builds ship no Sketchfab key, so
SketchfabAssetResolversilently substitutes each slug's bundled USDZ — and iOS had no cue at all that a substitution had happened, so "Cushioned Sofa" over a mosquito in amber read as the real model (the #2913 failure mode: a confident wrong scene beats a visible stall). Every demo that streams (Scene Gallery, Materials, Physics, Animation, Multi-Model, Orbital AR, both AR placement demos) now shows anAssetSourcePill— "Streamed (cached)" / "Streaming…" / "Offline model" — driven byAssetSourceProbe, a port of Android's probe (#2989) that measures the file the resolver returned rather than trusting that a configured API key means the download succeeded. Four fallbacks were also re-pointed at bundled assets that match their label, with no new binary: PBR Low-Poly Fox →khronos_fox, Desk Lamp →khronos_lantern(both matching what Android already maps), Walking Robot and Enforcer Mk1 →cyberpunk_character(verified to carry a bakedSkelAnimation, so the playback demo still animates). The remaining mismatches in #2960 need a new bundled asset and stay open — the pill is what makes them honest in the meantime. - Contact-shadow preview (android-demo):
DemoMath.CONTACT_FLOAT_CENTER_Y_METERSno longer documents a face-to-face clearance the constants do not provide. The floating box's lowest bottom face sits at 0.38 m — exactly flush with the grounded box's top face at its landing pose (0.00 m of clearance), and 0.34 m below that box's top face at the peak of the hop. The KDoc now states the measured geometry, the 0.040 m top-face margin that actually carries the "aloft" reading, and why a clearance over the hop peak is impossible in this room (it would need a rest centre above 0.96 m, whose top face punches through the wall TV at 0.93 m). The accompanying test now asserts on box faces with the margins in metres, instead of comparing box centres — a comparison two interpenetrating boxes also satisfy (#2961, #2931). - A changelog fragment carrying several
<!-- category: -->tags no longer files every bullet under the last one.collate-changelog.shreassigned the category on each tag line but accumulated the whole fragment into a single buffer, written once at EOF — so aFixed+Testsfragment shipped its Fixed bullets under### Tests. The parser now flushes at every tag transition, so each tag owns the bullets that follow it (bullets before any tag still default toChanged). Two uncollated fragments already carried the pattern and would have misfiled at the next release. Multi-tag fragments are now documented inchangelog.d/README.md. - The merge grader was being loaded from the code it was grading.
pr-review.ymlchecks out the PR's tree and then ran.claude/scripts/grade-pr-review.shfrom it, so a PR that edited the grader would have had its own verdict computed by its own version of the grader. The generator≠evaluator split this workflow is built on is worth nothing if the generator can rewrite the evaluator — this is thepull_request_targetfootgun in different clothes, and the self-modification guard did not cover it (it watchespr-review.ymlonly). The grader is now read from the default branch withgit show, so a PR improving the grader is graded by the current one, which is the correct semantics regardless of trust. The same bug had a second, louder symptom that is how it was found: a dispatch on a PR branched before the script existed died withNo such file or directoryat the very last step, after the four reviewers had already been paid for (measured, run 30764492028 on #2962). A failure to read the grader now posts an explicit comment saying it is a CI configuration problem and not a finding about the PR, instead of leaving another unexplained red check. pr-review.yml's reviewers get a real shell again. Two fixes for the same denied-tools bug landed minutes apart — one allowlisting five git subcommands, one allowlistingBash— and the merge between them was textually clean, so it kept the narrow form and silently reverted the broad one before it ever ran. Measured on the narrow form (run 30719795972, allowlist echoed back resolved in the SDK options): 26 turns, 10 permission denials, noreview-verdict.json. A reviewer reads a diff with more than five git subcommands, and every other command was refused.Bashis now bare, and the#2431constraint it used to encode moved to where it belongs:--disallowedToolsdeniesgit checkout/switch/reset/stash, so the shared working tree is protected by the permission layer instead of by starving the shell.- The missing-verdict diagnostic now prints the denial messages, not just the count. The count was what made the first diagnosis wrong: it went 7 → 10 across a "fix" while naming no tool, so the next guess was as blind as the last. Bounded to 20 × 300 chars, denial messages only — no diff, no transcript.
pr-review.yml's missing-verdict diagnostic now also prints the orchestrator's closing message. Zero denials and no verdict file is a different failure from refused tools, and the denial count cannot explain it: measured on run 30800617868, the fan-out reported 0 denials, ran 11 turns in 60s — far too few for four reviewers — and wrote nothing. Bounded to 1500 chars of the agent's own one-paragraph summary, which the prompt already requires.pr-review.yml's orchestrator no longer backgrounds its reviewers. Subagents default to running in the background, which is fine interactively — a notification wakes the parent later — but a CI review is headless and the session ends with the turn. The orchestrator spawned all four, ended its turn, and the run died with them unread. Its own closing words on run 30801646272, now printed by the diagnostic: "Now waiting for the four reviewers to report." 0 denials, 11 turns, noreview-verdict.json. The prompt now requiresrun_in_background: falseon every reviewer and every adversarial verifier.- The step written to prevent a false reassurance produced one.
pr-review.yml's diagnostic ended its denial scan with| max // 0. jq'smaxover an empty array isnull, and// 0turns that into0— so a scan that foundpermission_denials_countnowhere returned the same value as a run with genuinely no denials, and took the reassuring branch. Measured on run 30800040485: the step printed "0 denials" while the action's own summary in the same log said 13. Its own comment had stated that a wrong path "would silently read 0 denials and print the reassuring branch" — it was only ever tested against record shapes invented for the test, never a real one. Absence now reportsunknown, the count is extracted by regex over the raw bytes (the record's shape is not a promise — already seen both as a list and as a single object), and the branch structure is three-way sounknowncan no longer fall through to "the reviewers ran fine". pr-review.ymlnow uploads the fan-out's run record as an artifact (7 days) when a review produced noreview-verdict.json. #2971's description announced this upload; its diff did not contain it, and the claim reached the merge commit — implementing it is the honest way to settle that. The argument it was merged on holds: the record is the only place the refused tool names and the full turn sequence live, the diagnostic step can print only a bounded excerpt, and the file dies with the runner. It is uploaded only on a failed review — a healthy one has nothing to explain, and the record carries the whole reviewer conversation.- The Android demo's staged-fallback guard now enforces the same 12-byte floor iOS does.
stagedLooksCompletegated onlength() > 0plus theglTFmagic, so a 4-byte file whose entire content is that magic counted as a complete GLB and was served once the bundled asset vanished from the APK. iOS gates the same last-resort path onboundsAreSane, which carries the floor — the two platforms disagreed about the same file while both comments claimed parity. Reaching it needs a racy truncated write, and it degrades to the wrong model rather than crashing; the reason to fix it is the false parity claim, which is the shape this repo keeps paying for (#2961, #2943). - The new test is mutation-tested: restoring
length() > 0Lmakes it fail with the 4-byte file served instead of refused. - android-demo: the six
ar_placementslugs no longer share bundled fallbacks —khronos_lantern.glbwas claimed by three of them andkhronos_damaged_helmet.glbby two, so on a keyless buildARPlacementDemo/ARInstantPlacementDemo(which accumulate placed models) rendered several differently-labelled chips as the identical asset in one frame. Potted Monstera, Wooden End Table and Picture Frame now point at distinct already-bundled GLBs, making the six-slug → six-GLB mapping a bijection with no new binary. Guarded by a newSampleAssetsTestcase that derives the slug set from the registry by category, mirroring the iOS guard (#2940, #2355, #2973). android_cli_install_and_launchcan no longer report success without installing anything. It used toreturn $?fromandroid run; measured on a real emulator, that command printedApp loaded:andDebuggable: true, then rejected an activity the platform resolves fine — and installed nothing, leaving a build eight hours old on the device while a QA run measured it. The helper now proves the install by checking that the device'slastUpdateTimemoved, falls back toadb install -rwhen the CLI path leaves it untouched, and refuses to launch when neither path can be proven, naming the danger (the device still holds the previous build). Covered bytest-android-cli-install.shagainst stub binaries — no emulator, no lease — with a mutation test on the stamp check (#2990).impact-check.shno longer skips its Android build leg inside a git worktree. It tested[[ -d .git ]], but in a linked worktree — how.claude/worktrees/*and every agent-isolated session runs —.gitis a regular file, so the leg that catches a sample app which no longer configures was skipped exactly where most work happens, and announced itself as "not a git repository" so the skip read as an environment limitation rather than a bug (#2988).context-budget.shnow sorts by size and names the over-spec file. It previously printed rows in authoring order with a one-character flag, which is how the largest item in the budget sat unnoticed through three passes while the smaller one got optimised.context-budget.shstops describing the skills as bytes "NOT in the standing cost". They are deferred, not free: opening one 15 Ko skill is ~19% of the whole standing budget, measured on the first real use (#2986). It now prints the three most expensive skills with their price if opened.- The event-driven agent jobs now carry daily budgets, because a public repo's
issues and comments spend the maintainer's quota. Fork
pull_requestruns get no secrets, sopr-review.ymlstructurally cannot spend anything on an outside contributor's PR — butissues: openedandissue_commentfire in the base repo, where secrets are available, andconcurrencyis keyed per thread so distinct issues never queue behind each other. Both budgets were calibrated against measured traffic rather than a guess, and both measurements were counter-intuitive: mostclaude.ymlruns areskippedtriggers that cost nothing (15 runs on 2026-08-01, all skipped; zero real executions across the last 200), so counting raw runs would have capped the bot on a day with 46 triggers and no spend; and of the last 200 issues, 186 were opened by the maintainer against 6 by outside reporters, so triaging every issue would have spent ~93% of the budget explaining an issue back to the person who had just written it.OWNER/MEMBER/COLLABORATORare now excluded from triage — the opposite of gating on "is this person a collaborator", which would have disabled it exactly where it earns its keep. - The iOS bridge compile-check workflows (
bridge-ios-compile.yml,rn-ios-compile.yml) no longer fail — or, worse, pass while skipping their.swiftmoduleguard — whenxcprettyis absent or crashes. Piping xcodebuild into a missingxcprettygave the producer a SIGPIPE (exit 141), and the inline|| exit ${PIPESTATUS[0]}ran inside the pipe, short-circuiting the post-build module check. Both workflows now detectxcprettyfirst, run xcodebuild raw when it is missing, capture xcodebuild's real exit code in a variable, and test it — neverexitinline — so a good build stays green (even on the self-hostedsceneview-macrunner without the gem) and a broken one fails loudly. Same hardening #2878/#2865 gaveios.yml, extended to the two bridge workflows an advisory cross-vendor review flagged.
Tests¶
- CI now compiles every Kotlin snippet embedded in
llms.txtand the agent-skill references (tools/extract-doc-snippets.js+ the new:snippets-checkmodule): an API change that breaks documented code is a deterministic CI red instead of a silently stale doc (#2759) - Compiling the docs immediately caught and fixed 9 real drifts in
llms.txt: gesture move/rotate/scale callbacks documented with 2 params instead of 3,PlacementScene/WallPlacementSceneusage examples passing the placement lambda in thecontentslot, a nullablerememberModelInstancepassed straight toModelNode, non-genericTrackableNode, v3arSceneView.framephrasing, and more (#2759) - Resurrect the #2317 allocation-counting harness as a committed instrumented suite
(
AllocationBudgetTest): hard allocs/call ceilings on the #2263 hot-path wins —slerppre-decomposed TRS ≤ 7,Mat4.copyColumnsInto= 0, Ray↔mesh ≤ 3 per triangle — with a permanent +1-alloc sensitivity canary so the budgets can never pass on a dead instrument (#2761) - Leak-churn guard (#2762) — a committed instrumented suite that builds and tears down node trees 40 times per test and asserts engine state returns to baseline, so the repo's most recurrent bug class (leaks) is measured on every run instead of only after a user reports it. Probes, each actually exercised:
LightManagercomponent count back to baseline (light churn), no survivingTransformcomponent (node-tree + reparenting churn) orRenderablecomponent (geometry churn viaCubeNode), correct hold-then-release behaviour of the deferred-destroy queue across its grace period, and eviction from the previous parent on re-parenting (the #2458/#2459 stale-reference shape). Runs headless (no SwapChain, noreadPixels), so unlike therenderpackage it genuinely executes on the SwiftShader CI emulator instead of skipping. Advisory, not a hard gate: it lands inrender-tests.yml's existingcontinue-on-errorjob, which is not a required status check — a red run is a signal to a human, not a merge block, matching how the repo grades its other emulator legs. - A permanent, differential mutation test (leaked vs properly-destroyed control, for both the
LightManagerandTransformManagerprobes) fails loudly if a Filament upgrade ever turns the probes into no-ops — so the suite cannot pass vacuously on a dead instrument.CONTRIBUTING.mdgains a per-probe guide to reading a red run. - The harness found a real pre-existing leak on its first run:
Node.destroy()frees an entity's components but never returns the id toEntityManager, so every node burns a Filament entity id for the process lifetime — filed as #2859 and pinned (not asserted) by the suite, so a fix there turns the pin red on purpose. - CI now compiles the iOS device code path on every PR. Every AR demo wraps its
real ARKit/RealityKit logic in
#if !targetEnvironment(simulator), so the existing Simulator-destination build stripped that code before the compiler saw it — a type error inside an AR demo could pass CI green and land onmain, with the first real compile happening only during an App Store archive.ios.ymlnow adds a build-onlygeneric/platform=iOSstep withCODE_SIGNING_ALLOWED=NO, which needs no certificates and so runs on forks too (#2852). - Realigned stale
SceneViewSwifttests exposed by the iOS CI fix (#2878). With failures no longer swallowed,CameraControlsposition tests were updated to the v4.4.0orbitRadius = 2.0default (they still asserted the old radius-5 values), and theSceneEnvironmentpreset tests to the current 7-preset set including "Night Sky". - The App Store submit step now has a hermetic self-test
(
.claude/scripts/test-app-store-submit.py, inrepo-hygiene). A large Python program inside a YAML heredoc stands between a green tag build and an App Store submission, and it had no test seam: exercising it meant dispatchingapp-store.yml, which archives, signs and uploads a real TestFlight build — so every fix landed in production, on a release, after it broke one (#2731, #2885, #2893). The test extracts the real heredoc (never a copy, so it cannot drift) and runs it against a stubbed App Store Connect. Stdlib only: no network, no secrets, no Apple call. - Every guard added here is mutation-tested individually. Dropping the
byte-length comparison makes the re-stage test return the stale bytes;
dropping the last-resort branch makes the degradation test throw
FallbackUnavailable; and a staged copy corrupted after the bundled asset vanishes must still throw rather than be served. Each mutation was run on its own, after a first attempt that mutated two guards at once turned only one test red — the first mutation masked the second. - The assumption under the freshness check is tested against the real
AssetManager, not a fake. Everything else here injects bytes, which cannot prove thatAssetInputStream.available()equals what a copy of the same asset writes to disk. If those disagree nothing fails — the fast path simply never matches and everyresolvere-copies megabytes on a hot demo path. Mutation-tested too:+1on the expected length turns it red, which is what proves it measured rather than skipped. test-collate-changelog.shpins the fragment→category contract inrepo-hygiene: single-tag, multi-tag, untagged, unknown category name, and a tag with odd spacing/casing, plus--dry-runimmutability. The collator runs once per release and deletes the fragments it consumed, so a misfiled bullet is otherwise found only after the notes are public, with the source already gone. Mutation-tested on the per-tag flush.test-grade-pr-review.shpins the above: it fails ifpr-review.ymlever runs the grader straight out of the checkout again, or stops reading it fromorigin/$DEFAULT_BRANCH. Mutation-tested — restoring the checkout-relative invocation takes the suite from 14 passed to 13 passed / 1 failed.test-grade-pr-review.shfails if the denial scan ever collapses "field absent" into0again, or stops reporting an unreadable count asunknown. Mutation-tested: restoringmax // 0takes the suite from 15 passed to 14 passed / 1 failed.- iOS demo: repointing an
ar_placementfallback back onto a shared asset is now caught by a test (#2940). #2962 split threear_placementslugs off the sharedtree_scene.usdz, but nothing held that fix in place: the registry-driven guard inBundledAssetPrimBudgetTestsde-duplicatesfallbackBundledPathinto aSet— deliberately, so it never parses the same asset twice — which means collapsing every slug in the category back onto one fallback shrinks its workload and still passes green. That is how #2940 shipped.testARPlacementFallbacksArePairwiseDistinctnow asserts the category's fallbacks are pairwise distinct, reading the slug set fromSampleAssets.byCategory["ar_placement"]rather than from a literal list of names, so a seventh slug is covered the day it lands; the failure message names both colliding slugs and the shared path. Scope is deliberatelyar_placementonly — the one category whose demos put several different slugs on screen at once — because other categories share fallbacks legitimately (the foursolarbutterflies genuinely stand in for one another) or carry label mismatches tracked under #2960, so a registry-wide assertion would be red on arrival and would guard nothing. - iOS demo: the AR placement picker no longer renders one Game Boy under two different labels (#2940). Writing the guard above surfaced a live instance of the defect #2962 had missed, because the category holds six slugs rather than the three that were repointed: Crates & Barrels and Picture Frame both declared
Models/game_boy_classic.usdz. Both demos in this category accumulate placed anchors —placedAnchorsis cleared only by "Clear all placed models" — so on a keyless build a user could arm one chip, tap, arm the other, tap, and watch two differently-labelled objects render the identical Game Boy side by side in a single frame, which is the #2355 rule documented in place directly above those entries. Picture Frame now falls back toModels/khronos_fox.usdz, the last unclaimed Khronos reference object, following the stand-in convention #2962 established for labels with no matching bundled asset. It already ships in the IPA and is already in the Resources build phase, so nothing is added to the bundle and it carries a single mesh against the 100-prim budget. The keyed path is untouched — it still streams the real Sketchfab model. test-grade-pr-review.shandtest-agent-cost-report.shpin the two new guards inci.yml→repo-hygiene, both with a mutation test. Removing the reviewer-count check makes a 3-of-4 review gradeMERGE; re-keying the cost dedup fromrequestIdtouuidinflates the fixture total from 350 to- Both mutations turn the suite red, so neither guard can regress into a
silently-green no-op (the #2947 failure mode). Writing them also corrected
two wrong assumptions: the missing-file branch in the grader is redundant
with its own
try/except, and the cost report's dedup comes from the choice of key, not from thecontinuethat feeds the duplicate counter.
Docs¶
ViewNode: documented the gotchas its off-screen window creates — the content inherits noCompositionLocals (re-apply your theme inside, or Material 3 defaults silently win) and has no parent to measure against (give it an explicit size). KDoc +llms.txt(#2648)- Point & Ask recipe:
llms.txt,samples/recipes/point-and-ask.mdand thesceneviewagent skill gain the world-anchored variant (hit-test → anchor →ViewNodecard, explicit content width, anchor detach contract), including why the "no facing rotation" rule holds only for horizontal-plane andPointhits — on a vertical plane the same code pins the card edge-on (#2648) - Documented honestly that the Web renderer cannot recycle Filament entity ids: the pinned
filament.js1.52.3 usably binds onlyEntityManager.get()/create()— its runtimedestroy()is a no-op on the id pool (verified by an in-browser probe: 2000 create/destroy/create yields zero id reuse) andisAlive()is unbound. So the id-recycling thatNode.destroy()gains on Android (#2859) has no working Web equivalent until thefilament.jspin is bumped. Corrected a comment in the WebSceneView.destroy()that wrongly claimed the camera entity's id was reclaimed, and added a guard note on theEntityManagerbinding so no future change naively calls the no-opdestroy(). - Fixed the
MeshNode/GeometryNodeKDoc example, which referenced an undefinedrenderablevariable and showed low-level manual entity creation; it now shows real node usage and notes that letting the node own its entity is preferred (#2859). - iOS Cloud Anchor docs made skim-safe so AI-generated code stays correct (#2864). The SceneViewSwift
CloudAnchorNode/CloudAnchorFuturewrapper is real, but Host/Resolve require the app to add Google'sarcore-ios-sdkand supplyGARSessionthrough theoperation:closure — theios-demoapp deliberately does not link it, so itsar-cloud-anchorscreen shows a "Preview" badge with Host/Resolve disabled (plane detection + tap-to-place stay live). Replaced a misleading bold "Available" label incheatsheet-ios.mdwith "Wrapper only (app suppliesGARSession)", added a Cloud Anchor rule to the iOS agent skill, and recorded a capability-caveatreason:on thear-cloud-anchorparity-manifest.ymlrow — the first row where "an iOS screen exists" and "the capability can run" diverge. No code change; vendoring arcore-ios-sdk into the demo is not planned, and the manifest row documents the reopen conditions. rememberCameraManipulator:orbitHomePositionis now documented truthfully — the old KDoc made an AI generate a camera framing that is ~2× wrong whenever the target is not the origin (#2873). It described the parameter as "Camera's world position to return to on double-tap", which reads as "distance =|orbitHomePosition − targetPosition|". Two things are wrong with that. Filament'sOrbitManipulatorassigns the value verbatim as the eye (mEye = mProps.orbitHomePosition, default(0, 0, 1)) and never re-bases it ontargetPosition; andSceneView's defaultautoCenterContent = truetranslates the DSL content so its bounding-box centre lands on the world origin, so the distance the subject is framed from is|orbitHomePosition|— the coordinates you gave your nodes do not survive, andtargetPosition(the orbit pivot / initial look-at point) does not enter into it.(0, 0.2, 1.2)against a target atz = -1.5frames from 1.22 m, not 2.7 m — measured on the QA emulator on two demos at four camera distances in #2923. Every example in the reference docs targets the origin, where both readings coincide, which is why the discrepancy stayed invisible. The docs also no longer imply that omitting the parameter yields Filament's(0, 0, 1):SceneView's own default manipulator passescameraNode.worldPosition, so the effective default is(0, 0.4, 2.75)≈ 2.78 m.rememberCameraManipulator: the "returns on double-tap" claim is removed — no such gesture exists.SceneViewnever calls Filament's home/bookmark API, andonDoubleTapis a plain callback forwarded to user code.orbitHomePositionis the initial eye position only.autoCenterContent: the docs said the content centroid "lands at the orbit pivot" — it lands on the world origin.contentRoot.position = -bounds.center, andcontentRoot's parent is the scene root. The two coincide only whentargetPositionis the default origin, and that coincidence is precisely what hid the bug above: it is because the centroid goes to the origin rather than to the pivot that the framing distance is|orbitHomePosition|. iOS already worded this correctly, so the Swift line inllms.txtwas wrong about its own platform too. Corrected in bothSceneView.ktKDoc blocks,llms.txt,docs/docs/migration.mdand the generatedgpt/knowledge-*.md.- Fixed on every surface an AI reads: the KDoc in
SceneView.kt,llms.txt(helper table + a new "How farorbitHomePositionactually puts the camera" section), the generatedgpt/knowledge-*.md,website-static/.well-known/llms.txt, and the camera recipe indocs/docs/recipes.md. - The CC-BY indicate-changes note names the artefact that actually changed.
assets/catalog.jsonattached the modification record tomodels/usdz/tree_scene.usdz— the untracked, unmodified original — while the stripped derivative lives in the iOS demo bundle. The note now says which copy is which and points at the checksum pin that protects it, and it drops the "bit-identical bounding box" claim two reviewers could not reproduce, keeping only what is independently checkable (a purely subtractive strip whose 47 surviving meshes keep byte-identicalextentarrays). - docs — node-count claims aligned to reality across every checked-in surface (16 now
verified by the gate):
44+/42+/41+/30+/29+node types →46+(ContactShadowNodefrom #2817 andSplatNodejoined the inventory after the last alignment in #2594), the doc site, the website, the MCP docs and the checked-inmarketing/copy included..cursorrulesand.windsurfrulesalso named node types that do not exist —GeospatialNode,DepthNode,InstantPlacementNode, absent from the sources and from both public.apidumps — while omitting the real additions; both lists are now generated from the node sources and are exhaustive (26 3D + 20 AR). The website's unqualified26+ Node typesstat is relabelled3D node types: 26 is the genuine 3D-only subset, and it sat on the same page as the 46+ card (#2987). - build —
impact-check.sh's node-count gate no longer passes by being blind. Its regex matched only a bareN+ node type, so41+ built-in node typesand42+ composable node typesevaded it for two alignments running, and split-markup stat cards (number and label in separate elements) were structurally invisible — the gate reported clean while the repo contradicted itself in seven places. It now accepts a generic qualifier, reads split stat cards pair-aware, still ignores platform-qualified subsets (26+ 3D,15+ SceneViewSwift), and watches.windsurfrules,docs/docs/index.mdand the threemarketing/files, none of which were in its list. Every branch mutation-tested against the files' real content (#2987). - The
android-toolingskill no longer recommends callingandroid rundirectly; it documents the measured misbehaviour and points at the helper. Measured on CLI1.0.15498356:--no-metricsis accepted in the global position (android --no-metrics run …, what the helper uses) and rejected in the sub-command position — so that flag, which the report flagged as suspicious, is not the cause. The silent non-install remains unexplained upstream, which is exactly why the helper verifies instead of trusting. - The places that taught the disproven command are fixed — the public
agents/sceneview/SKILL.md(installed for any AI agent on the host), the flagshipdocs/docs/try.mdquickstart,samples/README.md,samples/android-demo/README.md,samples/android-demo/AR_TESTING.md, the advicesetup-ar-emulator.shprints after provisioning, and stale comments intry-demo.sh,render-tests.ymlandmaintain.md. A first pass claimed completeness after finding three — it had grepped forandroid run --apks, and five docs writeandroid run \with a line continuation, so the probe was too narrow and reported an all-clear — and a second completeness claim was wrong too, because prose can sell the command without naming it ("atomic install + launch"). No exhaustiveness is claimed here; the gate is what enforces it.check-android-run-not-taught.shnow matches the subcommand — at end-of-line and inside inline code too, after a first version missed 15 of the 22 files that mention it — and fails when any file teaches the command without naming the defect. qa-android-demos.shno longer retries a bareadb install -rwhen the helper refuses. The helper already tries that itself and only fails when it could not prove the install landed; retrying it and continuing unverified downgraded the guarantee back to the exit code the fix exists to stop trusting. It now aborts.check-workflow-scripts.shwas invisible to shellcheck. Two prose comments began with the wordshellcheck, which the tool parses as a malformed DIRECTIVE (SC1073) and then stops analysing the rest of the file — so the script that lints every workflowrun:block was itself never linted. Both reworded; the file now parses clean (2 diagnostics → 0, and the remaining body is actually analysed). Pre-existing, unrelated to this fix, taken because the message literally reads "Fix to allow more checks".- The repo-wide content gate moved out of
test-android-cli-install.shinto its owncheck-android-run-not-taught.sh. The unit test is hermetic (stub binaries, no repo state); coupling its verdict to unrelated docs meant an unrelated edit could redden it and point at the wrong thing. docs/docs/try.md,samples/README.md,samples/android-demo/README.mdandAR_TESTING.mdhad headings promising Google'sandroidCLI and an "atomic install + launch" directly above the plainadbcommands the first pass substituted — the code changed, the prose around it did not. Fixed, along with a leftoverAR_TESTING.mdnote framing the command as merely missing--es"until v0.8+", which contradicted the warning immediately above it.- The helper now returns 2 when the install was proven but the activity would
not start, and 1 when the install could not be proven. It used to return
am start's status, so a genuine launch failure printed "install could not be proven … the device may not be running" — a true failure described by a false cause, which sends the reader after the wrong bug.qa-android-demos.shsays which half broke. check-android-run-not-taught.shenumerates tracked files withgit ls-filesinstead of recursing the working tree.--includefilters names but does not stopgrep -rdescending intonode_modules/orbuild/, so a vendored file containing the token would have false-failed the gate in CI. It looked clean locally for a reason that is not one: the author'sgrepisugrep, which skips ignored paths, while CI runs GNU grep, which does not — measured with a probe file seen by one and not the other.android_cli_install_stampcan no longer abort its caller. Its pipeline ran under the lib'sset -o pipefailplus a caller's inheritedset -e, so anadbfailure while READING the stamp aborted the whole helper with adb's raw exit code and an empty stderr — measuredrc=3, no diagnostic at all, so a reader would debug the wrong layer. "No stamp" is a legitimate answer and is now returned as one; the helper then refuses with its own explanation (rc=1,INSTALL NOT PROVEN).- The content gate no longer matches
gcloud firebase test android run— an unrelated Firebase Test Lab command ending in the same two words. Harmless today (it appears only in a.ktfile, outside the gated set), but the day someone documents Test Lab in a.mdthe only escape would have been citing an unrelated issue number, and a gate whose escape hatch is a lie teaches people to lie to it. - The content gate scans all tracked files, with no extension list. An
earlier version listed
*.md *.sh *.ymland so could not see a*.yaml— the third too-narrow probe in a script whose entire subject is too-narrow probes. The list was never a performance decision: measured, the full sweep of 3122 tracked files takes 0.7 s. - Its Firebase exclusion is anchored on the adjacent
test android runphrasing instead of the wordfirebaseappearing anywhere on the line. A line that genuinely recommended the install and happened to mention Firebase would otherwise have been excluded — the exclusion would have become the hole. - The content gate's enumerator passes
--before the file list and-rtoxargs. Without--, a tracked path starting with-is read bygrepas an option: measured, one such file aborts the whole batch withunknown --directories optionand silently drops every file in it — a gate that evades itself. Mutation-tested with exactly such a file. qa-android-demos.shalways goes through the helper now. Itsif android_cli_locate … else adb install -rshape meant that on a host without the CLI it installed with no verification at all — the same unproven-install class, one branch over. The helper does that check itself and its fallback carries the proof, so the branch was both unverified and redundant.tools/try-demo.sh's file header still marketed theandroidCLI as "preferred — atomic install+launch" and said the script "usesandroid run". An earlier pass reported that comment fixed; it had used a conditional replacement with no assertion, the pattern did not match, and the edit silently did nothing. The header now states what the script actually does.tools/try-demo.shno longer gates the verified helper on theandroidCLI being installed. Both install sites didif <CLI present> then <helper> else adb install -r, so a developer with onlyadbtook an unproven path — the same shape fixed inqa-android-demos.sh, in the other caller, and it contradicted the header added one commit earlier. The helper performs that check itself and its adb fallback carries the proof.- Its
check_deviceclaimed "eitherandroidoradbis fine, the CLI being preferred", then requiredadbten lines below — so the friendly first error could never fire, and the advice it gave (install the CLI) unblocked nobody.adbis stated as required; the CLI is optional and explicitly not an installer. - The helper's launch contract is symmetric. The
android runbranch returned 0 as soon as the install was proven, but that command launches silently — so a failed launch on the CLI path reported full success while the adb path returned 2 for the identical outcome. Both branches converge on one launch check now; verified with a stub whoseam startfails on the CLI path (0 → 2). - The content gate also matches the phrase "atomic install", because the drift
it kept missing never wrote
android runat all —docs/docs/try.md's Requirements tip sold the command in prose, on the same page as the warning callout contradicting it. - The suite pins return-code 2 (install proven, launch failed) on both
branches. It had been verified by hand three times while building the fix and
never committed as a test — the coverage a refactor eats silently, and this
function was restructured. Mutation-tested: restore the CLI branch's early
return 0and thecli pathcase goes red while theadb pathone stays green, which is precisely the asymmetry that existed before. capture-play-store-screenshots.sh's section-3 header still opened with "Useandroid run(atomic install+launch) when available" — the branch mechanics below it were already correct. Third stale line the gate's file-level exemption let through. The alternative (exempt only within ±4 lines of an issue reference) was measured and is worse: 21 files flagged, including the gate itself, its test suite and the changelog, because prose legitimately discusses the command across paragraphs. The measurement is recorded in the gate's header so it is not re-proposed blind.
v4.25.0 — 2026-07-21¶
Added¶
- Web XR:
XRAnchorNode.drive(node)bridges a tracked anchor to the retained scene graph — the bound rootNode'sworldTransformfollows the anchor's per-frame pose, so AR-placed content is real graph content with children composing beneath it.stopDriving()releases the node; a destroyed node is auto-released; parented nodes are rejected (world-space poses must not double-compose). Proven with synthetic poses injsTest— no new embind binding (the write path is the #2024-P1-probedTransformManager.setTransform) (#2024 P5a). - Web:
Node.smoothTransform/Node.smoothTransformSpeed— smooth transform animation on the retained web node tree, with the Android core semantics and the same5fdefault speed (noisSmoothTransformEnabledgate, noonSmoothEndon web). Setting a target localTransformstarts a per-frame speed-scaled slerp/lerp on the scene's frame loop (the pre-decomposed TRS core path — zero matrix decompositions per tick); on convergence the node snaps and the property resets tonull; settingnullcancels in place. The repaint hook (onInvalidate) moved up fromSplatNodetoNode, is wired subtree-wide byaddNode(and inherited on attach) and released byremoveNode, so animations keep the on-demand render gate awake from idle scenes.CameraNode/SplatNodeonFrameoverrides callsuper, so camera and splat nodes smooth-animate too (#2024 P5b). - Web:
sv.hitTest(x, y)— screen-point picking on the retained node tree (#2024 P5c). The point is unprojected through the live camera (projection + model matrix reads proven by a new in-browser embind probe) into a world ray and tested against real per-node bounds: model/geometry nodes get their asset AABB (analytic for primitives — pickable immediately), splat nodes their cloud bounds, each transformed by the node's current world transform at hit time. Returns the sameNodeHandleinstances theadd*Nodefactories handed out (===-comparable), nearest-first. Kotlin/JS gainsSceneView.hitTest(x, y)/hitTest(ray)(→List<HitResult>) and the Android-mirrorNode.collisionShapeoverride. The unprojection samples its second point mid-volume because Filament renders with an infinite-far projection (NDC z = +1 is a point at infinity). - Automated pub.dev publishing for the Flutter plugin: OIDC
pub-publishjob in the release workflow (idempotent, honest-red until pub.dev-side activation) + PR-timeflutter pub publish --dry-runpreflight in CI (#2735) ContactShadow/ContactShadowNode— a procedural contact shadow that grounds an object on any surface, at any light angle (#2740). UnlikeShadowReceiverPlane, it does not depend on Filament's shadow map: it draws its own elliptical gradient in the shader. That is what makes wall placement read as mounted rather than floating — indoor light comes from the ceiling, so it merely grazes a wall and a real shadow map casts almost nothing onto it. Same trade Amazon "AR View" makes with its baked per-context shadow textures, done procedurally so no texture ships.ContactShadowContext—Floor/Wall/TableToppresets carrying the gradient shape each situation calls for (a wall pool is fainter, wider than tall, and pushed below the object). The lift off the host surface is a vector (ContactShadowNode.surfaceOffsetFor), not a hardcoded+Y:Planedoes not rotate its geometry to match itsnormal, so a wall quad is built in the XY plane and apos.y +=offset would slide it up its own face instead of off the wall.- New
contact-shadow-previewdemo — a non-AR scene grounding a wall-mounted TV and a floor box, with an on/off toggle for the A/B. Like the plane-grid and reticle previews, it makes a shader effect reviewable on any emulator, with no ARCore session and no physical AR device (#2754). - The
wall-placementAR demo now grounds its mounted TV with aContactShadowContext.Wallpool, so the panel reads as mounted rather than floating on-device. - MCP
validate_codev2 — symbol-existence checking against the real public API (#2760). Asymbols.json-style index is generated at build time from the committed binary-compatibility.apidumps (sceneview,arsceneview,sceneview-core— zero Gradle in the chain, kept honest by the blockingapiCheckCI gate) and embedded insceneview-mcp. Four new rules reject the #1 AI failure mode — APIs that do not exist: unknownio.github.sceneview.*imports, made-up*Node/*Scenetypes, nonexistent loader members (modelLoader.createModelInstanceAsync→ did you meanloadModelInstanceAsync?), and inventedremember*helpers. Suggestions use a hybrid edit-distance + camelCase-token metric so structural hallucinations get corrected, not just typos. Android/KMP surface only — Swift and Web have no committed dump and are stated as unchecked. maintenance.yml: daily read-only Play listing drift check —play_listing.py --dry-rundiffs the live listing (text + per-image SHA-256) against the repo and reports in the step summary. The apply path only runs on a minor bump and writes blind, which is how #2794 stayed hidden; the drift is now visible before a release overwrites the store. Advisory-only, SKIPs honestly without a service-account credential, and reports a failed check as a failure rather than letting an empty log read as "no drift". (#2794)- iOS demo:
DemoStatusgrows from 2 states to 4 —.working/.knownIssue/.inReview/.comingSoon— mirroring Android'sDemoStatus(Working/KnownIssue/ComingSoon/InReview). Before this, iOS could not express a known bug on an already-implemented demo or a newly-shipped demo awaiting review sign-off, even though Android uses both states today (5KnownIssue+ 2InReviewdemos, verified by grep). The collator (collate-ios-demos.sh) gains an optional@statusdirective alongside@sceneId/@available, defaulting sensibly when omitted (workingfor an@available truescene,comingSoonfor one that isn't) so none of the 51 existing*Scene.swiftfiles needed an edit, and cross-validates@statusagainst@availableso the two can't contradict each other.SamplesTabrenders a smallStatusBadgecapsule per status ("Preview" / "In review" / "Soon";.workingshows no badge) — the iOS mirror of Android'sDemoListScreen.ktstatus chip. L0.4 of the iOS/Android catalog-ISO effort (#2798); depends on the generated registry (#2800). - GLB→USDZ conversion pipeline + 4 bundled Khronos reference models for iOS (#2806). New
tools/convert-usdz.shwraps headless Blender (already installed, ships a glTF 2.0 importer and a native USD/USDZ exporter — zero extra install versus Apple'susdzconvert, which needs a multi-hundred-MB download) into an idempotent GLB→USDZ pipeline: every conversion runs under/tmpand the script never writes into the repo working tree, so a failed run can't leave anything dirty. Used it to convert the four well-knownKhronosGroup/glTF-Sample-Assetsreference models already bundled on Android (Lantern, Toy Car, Fox, Damaged Helmet) and bundled the results into the iOS demo (samples/ios-demo/SceneViewDemo/Models/, registered inproject.pbxproj, declared inassets/catalog.json). Feeds the Phase 2/3 AR demo ports that need bundled non-Sketchfab reference models. Note: Toy Car's USDZ is ~4× its GLB size (8.8 MB vs 2.1 MB) because the source GLB usesKHR_draco_mesh_compressionon ~109k triangles and USD/USDZ has no equivalent mesh-compression scheme, so the geometry inherently grows once decompressed — not a pipeline defect, and still within the size range of models already bundled in the same folder.
Changed¶
- The Flutter plugin's package name is now
flutter_sceneview(wassceneview_flutter) for its pub.dev debut — both natural names on pub.dev turned out to be unrelated third-party uploads (#2735). Git-pin consumers at tags ≤ v4.22.0 keep the old dependency key; the repo directoryflutter/sceneview_flutter/is unchanged - Unified the store-screenshot capture across Android and iOS: both
capture-play-store-screenshots.shandcapture-appstore-screenshots.shnow shoot the same five showcase demos in the same order (model-viewer, lighting, materials, geometry, double-pendulum— all standalone on both platforms) in dark appearance with a cleaned status bar, so the Play Store and App Store listings show identical screens. Refreshedbranding/ICON_AUDIT.md(stale iOS status + pre-Stitch#1A73E8palette → current#005BC1) and documented the store-icon vs on-device-adaptive-icon gradient decision (#2773). - Documented the Play Store listing artwork in a new
graphics/README.md, mirroring the iOSappstore-screenshots/README.md: the unified demo set, which files the capture script can and cannot regenerate, and the pinned-ANDROID_SERIALrule. Auditing it surfaced that the 12 committed tablet PNGs are byte-identical duplicates across the 7"/10" slots, light-mode, advertise a stalev4.14.0, and two of six show no 3D at all — filed as #2796 rather than papered over (#2773). - Re-captured the five Play Store phone screenshots on the unified showcase set (
model-viewer, lighting, materials, geometry, double-pendulum) in dark appearance, replacing a stale four-shot light-mode set, so the Play and App Store phone listings finally show the same demos in the same order. Fixed the capture script's per-demo settle: model-heavy demos load their GLB asynchronously and 8s let the variance guard (correctly) reject a not-yet-loaded blank frame — the default is now 15s with a new--settle SECONDSoverride (#2773). - App Store listing tooling: symmetric offline guard for
screenshotDisplayType(#2794 follow-up) — the Play-side fix pinned Google Play'sAppImageTypeenum so a bogusimageTypeis caught offline instead of 400-ing against the live store;asc_listing.pyhad the identical exposure with no equivalent guard — itsDISPLAY_TYPE_MAPvalues were correct, but only a dir→row coverage test protected them, so a future row with an inventedscreenshotDisplayTypewould have surfaced only on the first real App Store Connect call (set-creation on the--apply-screenshotswrite path, after an earlier display type's live set may already have been replaced). AddedVALID_DISPLAY_TYPES, transcribed verbatim from Apple's App Store Connect API OpenAPI spec (v4.3ScreenshotDisplayType, cross-checked against fastlane spaceship'sAppScreenshotSet::DisplayType— 33 values), plusunknown_display_types()enforced before any network call inmain()and again at the write boundary inapply_screenshots(), and unit tests pinning the set (test-store-sync.sh,repo-hygiene). No behaviour change on the shipped map — 6.9" iPhone captures still route toAPP_IPHONE_67and 13" iPad captures toAPP_IPAD_PRO_3GEN_129(Apple never mintedAPP_IPHONE_69/APP_IPAD_13). (#2794) - iOS demo: the deep-link registry is now generated.
collate-ios-demos.shemitsGeneratedScenes.allowedIdsandGeneratedScenes.destination(for:)from the same@sceneIddirectives that already drive the Samples tab, so the three deep-link surfaces (list,allowedIdsgate, id→view resolver) can no longer drift apart — the root cause that silently dropped 12 ids (#2769).DemoDeepLinkRegistryshrinks from a hand-maintained 66-idallowedIds+ 43-caseswitchto a generated union plus a ~15-id residual (AR ids without a Scene file yet, and legacy aliases). Adding a demo is now one Scene file. All 66 pre-existing deep-link ids still resolve identically. A well-formedsceneview://demo/<id>whose id is unknown now surfaces a placeholder instead of being silently dropped (#2800).
Fixed¶
- CI now actually verifies the committed Roborazzi golden screenshots for
samples:android-demo— theUnit testsjob swaps:samples:android-demo:testDebugUnitTestfor:samples:android-demo:verifyRoborazziDebug, so a layout regression in a covered composable fails the PR instead of silently passing (the goldens were previously only checked locally viapre-push-check.sh). A failed verify now also uploads aroborazzi-diff-reportartifact with the actual/diff PNGs. sync-versions.sh --fixnow auto-prepends the missing## X.Y.Zstub entry to the Flutter plugin'sCHANGELOG.mdwhen it lagsVERSION_NAME— a bumped pubspec without a matching CHANGELOG entry made the pub.dev publish preflight (#2735) fail theBuild flutter-demo APKjob on every non-path-gated PR and nightly (bit twice, for 4.23.0 and 4.24.0 — #2775). The handler runs outside the MISMATCH-gated fix block on purpose: the CHANGELOG check is WARN-only, so it must fire even when every numeric version is already aligned.- Play Store listing sync has never applied anything — the Play listing kept an old violet app icon while the repo, the App Store listing and the in-app icon all carried the current blue one. The committed
icon-512.pngwas assumed to need a manual Play Console upload; in fact the automated sync ran on every release and failed.play_listing.pydeclared twoimageTypevalues Google Play'sAppImageTypeenum does not contain (tabletScreenshots/tabletScreenshots10instead ofsevenInchScreenshots/tenInchScreenshots), so the tablet upload 400'd — and because the whole listing is pushed inside one atomic edit, that 400 abandoned the edit and rolled back everything already staged in it, icon and store copy included. Two things kept it invisible:sync-listingiscontinue-on-error, so the job's red never marked the run red, and only a minor bump triggers it. Wrong since the graphics sync landed (#1710); load-bearing once the tablet PNGs did. Fixed, plusunknown_image_types()— an offline guard transcribed from the v3 API discovery document and enforced both before any network call and again at the write boundary, so an imageType typo can no longer wait for a release to surface against the live store — and a::warning::annotation on non-403 failures so an aborted sync is visible despitecontinue-on-error. (#2794) - iOS demo ids that diverged from Android's canonical
DemoRegistryslugs are now aligned:ar-cloud-anchors→ar-cloud-anchor,ar-rooftop-anchors→ar-rooftop,ar-terrain-anchors→ar-terrain,ar-recording→ar-record-playback. The 4 old ids are kept as documented deep-link aliases inDemoDeepLinkRegistry.allowedIdsso existing QR codes and bookmarks keep resolving. First lot (#2799) of the iOS/Android catalog-ISO effort (#2798) — required before the generated-registry union (#2800) can land without silently duplicating ids. - iOS demo: every one of Android's 53 canonical demo ids now resolves to
something honest — a real screen, an alias to an existing equivalent
screen, or a clearly-labeled coming-soon/Android-only card — never a
silent no-op. Closes the real 12-id scope of #2769 (not just the 6 in its
title): the 6 ids Android consolidated via the #2239 catalog regroup
(
custom-geometry,camera-gestures,picking-collision,animation-physics,lighting-lab,two-d-in-three-d) now route via aDemoDeepLinkRegistry.legacyAliasesentry straight to the single most-representative pre-regroup granular scene — real, already-shipped content, not a new coming-soon card — chosen from Android's own default segmented-button tab for each umbrella (DemoSettings.initialDemoMode). 7 ids with no iOS equivalent at all (ar-plane-renderer-v2,contact-shadow-preview,placement-reticle-preview,point-and-ask,splat-preview,video-recording,wall-placement) and the 11 ids previously hand-listed inDemoDeepLinkRegistry.residualIdswith no backing scene file (ar-collaborative,ar-depth-collider,ar-depth-of-field,ar-depth-visualization,ar-fog,ar-hand-tracking,ar-ml-object-label,ar-raw-depth-point-cloud,ar-scene-semantics,ar-xr-face,placement-scene) each get a dedicated stub*Scene.swiftwith an honestcomingSoonTitle—residualIdsis now[]. The 3 permanently platform-locked ids (ar-rooftop,ar-streetscape,ar-image-stabilization— ARCore Geospatial/VPS and EIS, no ARKit equivalent) get a new optional@androidOnlyReasonScene directive so their card reads "Android-only:" instead of "Coming soon", which would dishonestly imply a future port ( ComingSoonScreen+DemoItemgain the matching optional field,nilby default — zero behavior change for every other demo).parity-manifest.ymlmoves from 22 working / 18 stub / 13 android-only to 28 / 25 / 0 —check-demo-id-parity.sh(#2801) is green. Part of the iOS catalog-parity effort (#2798, L0.6). - ios-demo: 6 more demo views now render with an image-based light
(
.environment(.studio)), same preset and pattern as theModelViewerDemo(#2114),MaterialsDemoand Scene Gallery/Multi-Model (#2805 predecessors):AnimationDemo(bundledcyberpunk_character.usdz+ streamed Sketchfab characters),GestureEditingDemo(Ferrari F40),AllShapesDemo/GeometryDemo(PBR cube + sphere — its own on-screen caption already claimed "PBR materials"),BillboardDemo(the metallic "Treasure" sphere),CameraControlsDemo(the central PBR cube), andCustomMeshDemo(the PBR pyramid + diamond built from raw vertex data). Every one of these renders a metallic/rough PBR surface that had nothing to reflect without an IBL. Re-measured from scratch against the current repo rather than reusing an older estimate — 42 non-registry views live underViews/Demos/*.swift(a 43rd file,GeneratedScenes.swift, is an auto-generated registry, not a view): 12 already carried.environment()before this PR, 14 are AR views (ARSceneViewlights from the real camera feed, out of scope by design), 6 gain the fix here, 3 are confirmed carve-outs (FogDemo,LightTypesDemo,MovableLightDemo— the neutral/single-light background is the demonstrated effect itself), and 4 have no PBR material to reflect anything with (TextDemo,ImagePlaneDemo/ImageDemo,LinesPathsDemo,VideoTextureDemo) so are left deliberately untouched. The remaining 3 are structural findings, not judgment calls:.environment()is only defined onSceneView, so it cannot reach a rawRealityView.TextureStreamingDemo's visible PBR sphere (the demo's entire point — Gold/Silver/Copper/Ceramic/ Plastic/Rubber presets) lives in aRealityViewoverlay entirely separate from its own (empty)SceneView;OcclusionMaterialDemo's metallic reference sphere is also built directly onRealityView;DebugOverlayDemohas the same structural block but isn't a PBR showcase either way (its spheres are non-metallic stress-test filler). Fixing the first two for real needs more than this mechanical sweep, so all three are left for a follow-up rather than shipping a.environment()call that would silently do nothing. Part of the iOS/Android catalog-ISO effort (#2798). Verified:xcodebuildcompiles clean; visual QA on the iOS Simulator confirms every changed view still renders without crashing, though — per the 2026-07-18 finding that RealityKit degrades IBL/skybox rendering on the Simulator — the before/after captures read as visually close on this host, so final visual confirmation on a physical device remains an open follow-up.
Tests¶
- iOS: added a registry/deep-link guard suite (
DemoRegistryGuardTests, 19 tests) assertingGeneratedScenes/DemoDeepLinkRegistryinvariants — id uniqueness across the three sources, kebab-case format, every legacy alias resolving to a live scene id, and the central check a human used to verify by hand: ids that should show a real demo do, everything else honestly falls through to the placeholder. Also registered the orphanedSketchfabAssetResolver+Tests.swift(17 tests, dead since May — never compiled) in theSceneViewDemoTeststarget. iOS test count: 20 -> 56 (#2801, part of #2798). - Added
parity-manifest.yml(repo root) — one row per Android canonical demo id (53) declaring its current iOS status (working / stub / android-only) with a reason for every non-working entry — plus.claude/scripts/check-demo-id-parity.sh, wired intoci.yml->repo-hygiene(ubuntu, blocking, zero macOS cost). Fails the moment a new Android demo ships without a matching iOS registry entry or manifest row — the silent-drift class behind #2769 (#2801, part of #2798). - CI: the iOS device-QA leg is now real.
device-qa.ymlgained aniosjob (Maestro on an iOS Simulator viaios-device-qa.sh) that routes to the self-hosted Mac when online and falls back tomacos-15. It runs nightly and on manual dispatch only — never per-push (a macOS runner is ~10x the ubuntu cost) — and is advisory (a red iOS leg is a release WARN, never a hard block), matching the android/ar posture.device-qa.shtagsiosadvisory indevice-qa-report.json/releaseGate(#2803). - CI:
render-tests.yml's "iOS screenshot tests" job now produces real PNGs. A dedicatedSceneViewDemoUITestsUI-testing target (XCUITest) launches the demo in a simulator and captures anXCTAttachmentscreenshot of the launch screen, every tab, and a representative subset of working 3D demos; the job exports the attachments from the.xcresultas PNG artifacts. It uses its own scheme so the per-PR iOS unit-test check stays fast and simulator-free (#2803).
Docs¶
- Flutter:
flutter_sceneviewis now live on pub.dev — quickstart,llms.txt, platforms doc and MCP setup snippets flipped from the git-pin fallback to the pub.dev install form, and thepub-publishrelease job is promoted intocreate-release's needs-gate (#2735). - Doc truth pass — stale demo/deep-link counts and two flatly-false CI claims corrected (#2807, part of #2798). "51 demos" → 52 (recounted at the source: 53
*Fragment.ktfiles undersamples/android-demo/.../fragments/minus theDemoFragment.ktbase class; breakdown corrected to 18 non-AR + 34 AR) acrossCLAUDE.md,.maestro/README.md,docs/docs/samples.md,docs/docs/llms-full.txt,docs/docs/try.md,samples/README.md, andwebsite-static/index.html. "24 deep-linkable" → 63 (DemoDeepLinkRegistry.allowedIdscounted directly) inCLAUDE.mdand.maestro/README.md; the "subset of Android's 42-demo catalog" framing is corrected too — cross-checked against source, the set isn't a clean subset either way (2 iOS ids match no current Android id; 12 Android ids aren't yet reachable on iOS). Two doc claims were outright false, not just stale, and are now corrected to describe today's real state rather than nuanced: 0 iOS Maestro runs have ever executed in CI (device-qa.ymldefines noiosjob — the leg is local-only today) andrender-tests.yml's "iOS screenshot tests" job captures no PNGs (it runs the existing logic-onlySceneViewDemoTeststarget; no UI-testing target, noXCTAttachmentanywhere in the iOS demo). Both corrections cite #2803, which tracks wiring them up for real.docs/docs/cheatsheet-ios.mdgains the missing Android-only parity rows forSplatNode(#2768), Collaborative AR (CollaborativeTransport/CollaborativeSession, noting the ARKit-nativeARSession.collaborationDataadvantage once a port lands), and thear-ml-object-labeldemo, plus a demo-id cross-reference on the existingar-scene-semanticsrow — and surfaces the honest SSAO/Bloom/MSAA render-quality gap table that was previously only inRenderQuality.swift's KDoc. Docs-only — no library, test, or CI workflow code changed.
v4.24.0 — 2026-07-20¶
Added¶
- App Store screenshots can now be published from the repo instead of by hand:
.claude/scripts/store-sync/asc_listing.py --apply-screenshotsuploadssamples/ios-demo/appstore-screenshots/to the editable App Store version (reserve → chunked upload → commit). It skips display types whose live set already matches, replaces the others, and skips honestly when no version is editable — it never creates one. CI entry point is its own dispatch-only workflow,app-store-screenshots.yml, which runs on ubuntu and starts no build. Closes the loop left open by the capture script, whose output had never actually reached the store (#2612, #2384). WallPlacementScene— a one-call AR flow for placing products on a vertical surface (TV, framed art, mirror, shelf), the vertical-surface sibling ofPlacementScene. Inspired by Amazon "AR View" / IKEA Place: orientation is taken from the wall (object flush + upright, no hit-pose tilt) and height from the floor (floorY + mountHeight), so a placement stays put while the vertical plane jitters. Tracks the live floor↔wall seam (onSeamChanged) and the onboarding phase (onPhaseChanged). The placement geometry is exposed as pure, unit-tested functions —wallFacingRotation,roomFacingNormal(flips an ARCore plane normal toward the camera; its sign is not guaranteed),floorWallSeam,wallAnchorPose— for custom flows.- New
wall-placementdemo in the Android demo app — mounts a procedural TV on a wall throughWallPlacementScene: phase-driven onboarding banner, Amazon-style orange floor↔wall alignment guide line, and a D-pad fine-adjust (2 cm nudges + 2° yaw) after placement (#2740). - Demo catalog: new
In reviewstatus chip (DemoStatus.InReview) marking freshly shipped demos awaiting on-device review sign-off (#2740). First increment of #2740; an in-scene 3D seam guide line and a gizmo/D-pad fine-adjust UI are tracked follow-ups.
Changed¶
- Store publishing as code, Phase A (#2612 P2): the Play Store listing sync logic moved verbatim from
play-store.yml's inline heredoc into.claude/scripts/store-sync/play_listing.py— one code path for CI (--apply, unchanged behaviour: #1710 delete-then-upload, #1386 403-tolerance, caps + truncation) and local runs (--dry-rundefault: read-only live-vs-repo diff of listing text and per-image SHA-256s, probe edit abandoned). A newasc_listing.pydiffs the live App Store listing (text fields + screenshotsourceFileChecksumMD5s) againstsamples/ios-demo/distribution/app-store/andappstore-screenshots/. Both scripts SKIP honestly without credentials and are pinned bytest-store-sync.sh's offline unit-test suite inrepo-hygiene.
Fixed¶
play_listing.pyaccepted abbreviated flags:--applexpanded to--applyvia argparse prefix matching and reached the Play Console write path. Both store-sync scripts now require exact flag names, which also keeps the new--apply-screenshotsupload unreachable by a near-miss (#2612).- ios-demo: the Materials, Scene Gallery and Multi-Model demos now render with an image-based light (
.environment(.studio)), matchingModelViewerDemo's #2114 IBL fix. All three display curated PBR models, and a PBR surface is defined by what it reflects — Materials in particular showcasesKHR_materials_transmission/_iridescence/_sheen, which only exist through the light around the model (transmission refracts the environment, iridescence and sheen shift with the reflected view angle). With no IBL these demos fell back to flat shading and undersold the very models they exist to show; a model also looked different alone versus inside the multi-model scene. Verified on the simulator: specular highlights now appear on the iridescent-beetle model where the pre-fix capture had none. The demos that deliberately keep a neutral background (Light Types, Movable Light, Fog) are untouched — there the absence of an IBL is the point. - Web: models with a bright albedo no longer render as a clipped white blob.
sceneview-webpaired a relative camera exposure (Camera.setExposureDirect(1.1), model-viewer style) with photometric light intensities in lux (a 50 000 lux directional key light + the neutral IBL). Filament's camera is physically based, so mixing the two unit systems blew every light albedo out to white with a bloom halo — the Khronos Duck rendered as a featureless white blob in the shared/viewpage. The default camera now uses the photometricsetExposure(f/12, 1/200 s, ISO 200), mirroring Android'sSceneFactoriesdefault camera, and the default light intensities were rebalanced to match Android (createViewer*key light 50 000 → 15 000 lux; the no-light{}3-point setup 50 000/25 000/30 000 → 10 000/3 000/3 000 lux;LightConfig/LightNodedefault 100 000 → 10 000 lux). Verified on the Khronos Duck against the published 4.21.0 bundle: mean model RGB goes from (245, 240, 218) — B/R 0.89, white — to (241, 229, 141) — B/R 0.58, yellow. - Web:
Camera.setExposureDirectis documented as a trap and is no longer called. In Filament.js it over-exposes for any value tested (1.1, 2.6e-5 and 5.79e-5 all blow the Duck out to white), so the exposure — not the light intensity — was the dominant factor: lowering the key light to 15 000 lux under the old exposure produced a byte-identical white blob, while keeping 50 000 lux under the photometric exposure renders correctly.
Removed¶
- Web (breaking, Kotlin/JS DSL):
CameraConfig.exposure(value: Double)— the relative / model-viewer-style exposure overload — is gone, along with thedirectExposure/useDirectExposureproperties. It mapped onto Filament.js'Camera.setExposureDirect, which over-exposes bright albedos to a clipped white blob for every value tested, so the overload could not be given correct semantics.camera { }now offers only the photometric form,exposure(aperture, shutterSpeed, sensitivity)(default f/12, 1/200 s, ISO 200). Callers passing a relative value — e.g.exposure(1.1)— should drop the call to take the default, or express the intent photometrically (open the aperture / slow the shutter / raise the ISO to brighten). The JSsceneviewnamespace and theSceneViewernpm surface are unchanged.
v4.23.0 — Gaussian Splatting on Android & Web, on-device AI with Point & Ask (2026-07-18)¶
Added¶
- samples/android-demo: new Gaussian Splatting demo (
splat-preview, 3D Basics → Advanced) — decodes a bundled synthetic rainbow-sphere.ply(8 000 gaussians) with the sharedSplatParserand renders it throughSplatNode, with a gesture orbit that feeds the node's back-to-front painter's sort and a reveal slider drivingSplatNode.splatCount. First on-device render of the SplatNode pipeline (P1c of #2646). Emulator QA confirmed the within-batch alpha-blend order is stable — no splat popping across a slow camera orbit (#2646). - sceneview-web: 3D Gaussian Splatting on the web (
SplatNode, P2 of #2646) — the same radiance-field rendering as Android, now on Kotlin/JS + Filament.js (WebGL2).viewer.addSplatNode(url)(plain JS) /SceneView.addSplatNode(url)/addSplatNode(splatCloud)(Kotlin/JS) fetch and render a.ply(INRIA) or.spz(Niantic) capture through the shared KMPsceneview-coreparsers: camera-facing gaussian discs (hardware-instanced quads, per-splat data in RGBA16F textures, premultiplied-alpha blend) with a back-to-front painter's sort that re-runs on camera motion. Thesplat_web.filamatmaterial is compiled against the web Filament runtime (newfilamentWebpin ingradle/libs.versions.toml, MATERIAL_VERSION 52) and embedded in the bundle. In-browser gate (tests/splat-bundle.spec.ts) confirms the cloud renders and the blend stays stable across a full camera orbit — the web port of the P1b device gate. Scope matches Android P1 (isotropic billboards, SH degree-0 colour); iOS tracked separately under #2646. - samples/android-demo: new Point & Ask AR demo (
point-and-ask, Augmented Reality) — tap anything in the camera view and Gemini Nano explains it fully on-device via ML Kit's GenAI Prompt API (genai-promptbeta): the tapped AR camera frame is captured with the existingFrame.cameraImage()helpers, sent as an image+text prompt, and answered in an overlay card. Honest AICore gating (download CTA with progress onDOWNLOADABLEdevices, explanatory banner on unsupported ones, no cloud fallback by design) and a deterministic canned engine under QA mode so the flow stays emulator-testable (P1 of #2648). - samples/android-demo: Point & Ask P3 — free-form questions and streamed answers. The controls sheet gains an "Ask anything about what you see" field (blank falls back to the default prompt; Reset restores it), and answers now stream progressively into the card with a live typing cursor via the GenAI Prompt API's
generateContentStream(a mid-stream failure keeps the text already received). The QA-mode canned engine streams word-by-word and echoes the question, so the free-form plumbing and the progressive-display state stay emulator-provable end-to-end (#2648). - iOS demo: the Explore tab is now multi-source (Sketchfab | Icosa Gallery | Poly Haven) via a
ModelSourceabstraction, in parity with the Android port (#2685). A source-picker chip row switches catalogs, each feed loads with per-feed resilience, and the search placeholder is source-aware. The Creative-Commons sources (Icosa, Poly Haven) are always available and need no API key; because RealityKit renders only USDZ, their glTF-only models are fully browsable + searchable but their in-app 3D render is an honest "coming soon" (Sketchfab, which serves USDZ, still renders live in SceneView). Carries forward the Android hardening: path-segment sanitization on cache filenames, bounded JSON reads, and a per-model download size ceiling. (#2700) - Web demo: multi-source Explore parity (#2722) — the Models tab's catalog layer is now source-agnostic (Sketchfab | Icosa Gallery | Poly Haven), closing the platform trio after Android (#2685) and iOS (#2721). Source-picker chips with persisted selection (localStorage), strict behavioural parity (switching sources resets browse + search; Sketchfab hidden without an API key; keyless CC catalogs always available; one degraded source never blanks the tab), streamed bounded downloads with sanitized cache keys — and unlike iOS, the CC catalogs render in-app (Filament.js loads glTF natively, multi-file glTF resources resolved from memory). Deterministic Playwright coverage mocks every network catalog and exercises one real download→render path end-to-end.
- Public API surface tracking (#2723). Wired Kotlin's
binary-compatibility-validatorinto the root build for the three published library modules —sceneview,arsceneviewandsceneview-core. Each module's public ABI is now dumped to a committed<module>/api/<module>.apifile, and a blockingapiCheckCI job (same tier as the unit tests) fails any PR whose compiled public surface no longer matches its committed dump. Intentional API changes re-run./gradlew apiDumpand commit the.apidiff, making the public-API delta a first-class part of PR review — the binary-level guard for the exact signaturesllms.txtpromises. JVM surface only (sceneview-core'sjvm("android")dump already covers the sharedcommonMainAPI); native/JS klib validation and the npm-publishedsceneview-webJS module are documented exclusions. See CONTRIBUTING.md → "Public API changes". - Automated pub.dev publishing for
sceneview_flutter: OIDCpub-publishjob in the release workflow (idempotent, honest-red until pub.dev-side activation) + PR-timeflutter pub publish --dry-runpreflight in CI (#2735)
Changed¶
- Filament runtime bumped 1.71.5 → 1.72.1 (#2590). Updated
filament-android,gltfio-android, andfilament-utils-android, and recompiled all 26 committed.filamatblobs with the matchingmatc1.72.1 toolchain (MATERIAL_VERSION 72) in the same change to keep the runtime ↔ blob ABI invariant intact. No public API change. - Point & Ask demo — film-mode pass (#2648): the capture is now the composited
AR frame (window
PixelCopy— camera + placed virtual objects, overlays hidden during capture), so the on-device model sees the augmented scene; long-press places a 3D prop (hitTest→AnchorNode+ModelNode); tap ping animation; answer card shows the asked question and a "Gemini Nano · on-device · no network" badge (ConnectivityManageractive-network check) and auto-dismisses after 12 s; single auto-hiding instruction pill. QA-mode path (canned engine + synthetic frame) unchanged. - GPT knowledge base is now generated from
llms.txt(#2724). The fourgpt/knowledge-*.mdfiles uploaded to the "SceneView 3D & AR Assistant" Custom GPT were hand-maintained copies ofllms.txtthat nothing regenerated, so they rotted (the platform table sat at 3.6.2 and the sample index claimed "39 samples" while the SDK shipped 4.22.0 with 49 demos). They are now derived deterministically fromllms.txtbytools/generate-gpt-knowledge.js, and a blocking CI drift check (ci.yml→repo-hygiene) fails the build if they fall out of sync — so the AI-facing GPT surface can never silently drift again.llms.txtis the single source of truth; runnode tools/generate-gpt-knowledge.jsafter editing it. - Sponsorship: GitHub Sponsors is now the single sponsorship channel — removed Open Collective and third-party pricing links from the README, FUNDING.yml, SPONSORS.md, GOVERNANCE.md, website navigation, and docs.
Fixed¶
- Demo-catalog docs drift: every counter surface said 50 demos (17 non-AR + 33 AR) while the registry actually ships 51 (18 non-AR + 33 AR) — fixed in
docs/docs/samples.md,try.md,llms-full.txt,samples/README.md, andCLAUDE.md(found by the #2239 Phase-0 audit). - Maestro device-QA coverage gap: 5 registered demos were driven by no flow entry —
splat-preview,ar-hand-tracking,ar-plane-renderer-v2,ar-xr-face,placement-reticle-previewnow have their own legs in.maestro/android/advanced.yaml/ar.yaml, and the stalecatalog.yamlheader (58) now documents the real arithmetic: 65 flow entries covering 51 registered demos (retired-alias entries QA the merged demos' tabs). - Externalized the last hard-coded English strings in the unified tap-to-place engine (
TapToPlaceArSession.kt) to string resources — the gesture pill labels ("Moving" / "Rotating" / "Scaling" →ar_gesture_*) and the "Aim at a surface…" aiming hint (ar_aim_at_surface), completing #2482 plan §3.5. Shared by both AR entries; no visual or behavioural change in the default locale. - Sketchfab model viewer: the ground shadow under the model is now visible. Two independent causes were fixed. (1) Geometry: the shadow-receiver quad was built in the XY plane (
Size(x, y)), butplane_renderer_shadow's vertex shader forcespos.y = 0.005, flattening that quad to a zero-area line — no shadow was caught. It is now an XZ (horizontal) quad (Size(x, y = 0, z)), matching the shader contract and theShadowReceiverPlaneNodeconvention. (2) Compositing:plane_renderer_shadowonly darkens what is behind it, but the viewer rendered onto an opaque black framebuffer (createSkybox = falseleaves no skybox to fill it), so the shadow multiplied black-on-black and stayed invisible. The viewer now renders withisOpaque = false, so the translucentTextureViewcomposites the shadow over the sheet's light Compose surface — the sheet-surface backdrop this viewer was always documented to want (#2581). - sceneview-core:
SmoothTransform.updateSmoothTransformgained a pre-decomposed TRS-tuple overload —SmoothTransformTRSState/SmoothTransformTRSTarget— that calls the pre-decomposedslerp(startPosition, startQuaternion, startScale, …)overload directly instead of round-tripping throughTransform(Mat4), avoiding 6 matrix decompositions per interpolation tick for callers on a per-frame path that already hold decomposed TRS components (matching theNodepattern from #2187). The existingTransform-basedSmoothTransformStateAPI is unchanged (#2668 MED-1). - sceneview-web: a hit-test source resolving after teardown no longer overwrites the intentional
hitTestSource = null— it is cancelled immediately instead, fixing anXRHitTestSourceleak on a quick tap-to-cancel or early error path. Applied to bothWebXRSession.setupHitTesting(guarded onisRunning) andARSceneView.startSession(guarded on teardown, so a source resolving betweenonReady()and the caller'sstart()is still adopted) (#2668 MED-2). EnvironmentLoader: the three suspend loaders (loadHDREnvironment, bothloadKTX1Environmentoverloads) now build the environment insidewithContext(Dispatchers.Main)instead of on the loader's IO scope — Filament asserts (native abort) on JNI thread mismatch. Buffer loading (network/disk) stays off the main thread; only thecreate*Environmentbuilder call moves, mirroringMaterialLoader.loadMaterial/ModelLoader.loadModel(#2669, #2670, #2671, part of #2668).ARSceneScope.PoseNode: the composable no longer re-applies the declaredposeon every recomposition. The bareSideEffect { node.pose = pose; … }clobbered a pose a drag gesture had just written (PoseNode.onMovewithisPositionEditable) — the #2639 defect class.node.poseis now pushed from aDisposableEffectkeyed on the pose's scalar components (translation + rotation quaternion, sincecom.google.ar.core.Posehas noequals()); thevisibleCameraTrackingStates/onPoseChangedreference updates stay unkeyed (#2672, part of #2668).- sceneview-web:
loadEnvironment/loadDefaultEnvironmentno longer use-after-free a destroyed engine whendestroy()runs while a KTX fetch is in flight — both the IBL and skybox.thencallbacks now bail out on a newdestroyedflag (set first indestroy()), still settlingpendingLoadsso the render-gate counter never leaks. Same #1597 Tier-2 guard asloadModel'ssupersededflag (#2673, part of #2668). - sceneview-web:
loadModelno longer use-after-frees a destroyed engine whendestroy()runs while the initial GLB fetch is in flight — the fetch.thencontinuation now bails on thedestroyedflag beforecreateAsset/addEntitiestouch the freed WASM engine/scene, still settlingpendingLoadsso the render-gate counter never leaks. The existingsupersededguard only covered the lateloadResources/onDonestep, not this initial continuation; this mirrors theloadEnvironmentKTX guard (#2691, sibling of #2673, part of #2668). - Fixed two more MCP tool-count truthfulness drifts (follow-up to the mcpize.yaml "35 Pro tools" fix in #2689), all counts derived programmatically by importing the real modules: the gaming/interior/rerun package READMEs claimed the shared gateway exposes "63 tools total" but the registry on
mainmounts 67 (sceneview-mcp 31 + automotive 9 + gaming 7 + healthcare 7 + interior 7 + rerun 5 + the gateway widget tool); removed the phantomget_startedentry fromFREE_TOOLSinmcp/src/tiers.ts(it exists in no tool library) and documented in place why the gateway-onlyview_3d_modelentry must stay (the gateway tier gate defaults unknown tools topro); correctedmcp/mcpize.yaml's free-tool count 30 → 28 (the stdio package's real free surface). New truth tests (mcp/src/tool-count-claims.test.ts,mcp-gateway/test/tool-count-claims.test.ts) re-derive every advertised number from the registry and fail with actionable messages on the next drift (#2696). - Mapped the 11 gateway-mounted tools that silently rode the default-to-pro fallback into an explicit
PRO_TOOLSentry (mcp/src/tiers.ts): the 5 rerun tools,get_ev_charging_station_viewer,get_car_paint_shader, and the 4validate_*_codetools — behaviour unchanged (they were already Pro via the fallback), but a forgotten mapping is now distinguishable from a deliberate Pro tool. Refreshed the stale Pro copy accordingly:PRO_UPGRADE_MESSAGEnow says 5 vertical packages / 35 specialized tools (was "4 / 24"),mcp/mcpize.yamladvertises 38 Pro tools (was 27), and the gateway truth test gained the reverse assertion — every mounted tool must have an explicit tier entry (#2697). - Harden the Sketchfab/gallery model-viewer ground-shadow receiver against the FL2+ flat-quad crash (#2699). The invisible shadow-catcher
PlaneNodein the gallery model viewer was a raw flat (zero-Y) quad with noisShadowCaster = false,isShadowReceiver = true,setCulling(false), or non-degenerate bounding box — the exact latent Filament Level-2+ cascaded-shadow crash theShadowReceiverPlaneNode(#2620) recipe guards against (the FL1 SwiftShader emulator never hits it). It now applies the full device-proven hardening combo. Also exposes the pattern as a reusable non-AR recipe (samples/recipes/ground-shadow-catcher.md+llms.txt) so 3D-scene shadow catchers no longer have to hand-roll the flat-quad guard. - Fix NPE in Filament
DisplayHelper.updateDisplayInfowhen closing aSceneViewscreen (#2709).SceneRenderer.onDetachedFromSurfacenow detaches theDisplayHelper(unregistering its display-changed listener) before destroying the swap chain andflushAndWait(), restoring the SceneView 2.3.0 teardown ordering. Previously, destroying the surface on an adaptive-refresh display posted a refresh-rate-change event onto the main-thread queue that was delivered only afterdetach()had nulled the helper's renderer, crashing inside Filament's unfixed 1.71.5DisplayHelper(google/filament#9352). - Pose Placement AR demo: the live X/Y/Z coordinate readout no longer clips off the right screen edge. It was a world-space label floating above the lantern, so moving the X slider pushed it (and the lantern) past the screen edge and cut the numbers off. It is now a screen-anchored Compose overlay that stays fully on-screen and readable for any slider value. (#2727)
- Fixed the iOS App Store review submission that had been silently failing since 4.19.0: the deploy's version lookup now filters
platform=IOS(it used to hijack the macOS listing's permanently-editable draft, 409-ing every downstream call), build-attach / submission errors are now fatal instead of a swallowed warning, andstore-preflight.shgained an open-reviewSubmissions probe that WARNs on assembled-but-never-submitted releases (#2731). - Fixed the nightly-CI failure reporter that was blind to
cancelledruns and to the render-tests/device-qa legs (a week of silently-dying nightly runs produced zero reports): the reporter now grades the last two completed scheduled nights at the START of each run — immune to its own run being cancelled — and opens/updates a single deduplicated tracking issue only after 2 consecutive bad nights (#2732). - Deterministic issue-form auto-labeling and stale-bot label integrity (#2734). New
.github/workflows/issue-intake.ymlparses the### Platform/### Modulesections a newly opened issue-form submission renders and applies the matching existingplatform:*/module:*label — no LLM, no external calls, deterministic, safe against body injection (parsed viaactions/github-script, never a shell). Created thepinnedandroadmaplabels thatmaintenance.yml's stale job already referenced but that did not exist, so itsexempt-issue-labelsprotection is now real instead of partially phantom. Fixedbug_report.yml's relative../MIGRATION.mdlink (404 on the issue-form render) to an absolute GitHub URL. - Saved workflows: guard against JSON-stringified
args. All 8 remaining.claude/workflows/*.jsscripts now parse a stringifiedargs(or fail loudly on non-JSON) instead of silently ignoring it — a stringified{"issues":[…]}madefix-issue-batchfall back to auto-selecting issues, twice on 2026-07-16, picking maintainer-gated work. Same guardreview-fanout.jsandparity-audit.jsalready had. sceneview.haptic(light/medium/heavy taps, notification patterns, continuous, pattern) was live at runtime but missing from the npm TypeScript declarations — now typed insceneview-web.d.ts(found by the new #2736 drift gate)- device-QA harness:
setup-ar-emulator.shnow says loudly what was previously a silent black viewport — on arm64 AVDs, live-camera AR sessions cannot start because ARCore ships no arm64 emulator build (device APK requires the back camera at HAL id0, which arm64 AVDs never expose).--checkgains a camera-id-topology probe (dumpsys media.camera) that reports the HAL ids and the ARCore verdict, and the provisioning flow prints an honest arm64 limitation notice pointing at the qa_mode fallback pattern and the Rosetta/physical-device alternatives (#2754). - Explore gallery (Android): flat models (e.g. a Poly Haven grass/terrain slab) were orbited at the ~3° hero tilt and shown edge-on, "by the slice". The hero camera elevation is now adaptive to flatness —
he[1] / max(he[0], he[2]): models below a0.15threshold ramp up toward a ~23° top-down view, while normal 3-D objects (characters, cars — Scifi Girl, Porsche) keep the exact calibrated tilt unchanged. - Augmented Faces demo (Android): a slow-to-open front camera was falsely reported as "Front camera unavailable on this device" and the black-viewport scrim lifted after only 5 s. The countdown is now anchored on the real ARCore session resume (not composition), non-latching, and widened to a 12 s grace window; the scrim no longer lifts on that advisory timeout (only a real frame or a genuine session failure lifts it). A genuine ARCore session failure gets its own distinct message, the slow-start hint is now honest and advisory ("Still starting the front camera…"), and all status strings are externalized to resources.
- AR Body Tracker demo (Android): the
PoseLandmarkerinit was wrapped inrunCatching { … }.getOrNull(), silently swallowing any failure. Added anonFailurethat logs the exception so a corrupt/missing pose model is diagnosable instead of vanishing. - AR Body Tracker demo (Android): the "no pose model" status showed a developer-facing message ("add pose_landmarker_lite.task to assets/mediapipe/") to end users — replaced with an honest, user-facing line ("Body tracking is unavailable right now — the rest of the AR scene still works.").
- Explore gallery (Android): multi-file model downloads (Icosa, Poly Haven) reported progress for the root
.gltfonly, so the bar spun forever (Icosa) or froze at "0.0 MB" (Poly Haven) while the dominant.bin/texture payload streamed silently. Progress is now cumulative across every file in the bundle, and the viewer shows the downloaded size even when the server omitsContent-Length, so the counter always advances.downloadSingle(GLB) is unchanged. - Geometry Primitives demo (Android): the catalog subtitle listed "cone", a shape the demo does not render. Corrected to "Cube, sphere, cylinder, plane".
- AR Body Tracker demo (Android): added
taskto the demo APK'snoCompressset. The MediaPipe.taskbundle is a ZIP that MediaPipe memory-maps at runtime; re-compressing it in the APK madePoseLandmarker.createFromOptionsfail to load the pose model. - Point & Ask demo (Android): the streamed answer rendered Markdown emphasis literally (users saw
**bold**with the asterisks). Added a tiny dependency-freerenderMarkdownLite(bold**..**, italic*..*/_.._, single left-to-right pass) that is streaming-safe — an unclosed marker mid-stream is rendered as a literal character. - Explore gallery (Android): the model footer showed a misleading "Rendered by SceneView · 0 polys" for sources that expose no face count (e.g. Poly Haven). The "· N polys" suffix is now hidden when
faceCount == 0, mirroring the StatsRow poly chip. (The face-count recompute itself is a separate SDK-side follow-up.) - Scene Mesh demo (Android): the catalog subtitle claimed "ARKit ARMeshAnchor parity" on an Android/ARCore demo — corrected to "Color-coded real-world geometry via ARCore Streetscape (terrain + buildings)", which is what the demo actually renders.
- Patched the
wsmemory-exhaustion DoS (GHSA-96hv-2xvq-fx4p / CVE-2026-48779) across all 4 open Dependabot HIGH alerts via version-scoped npmoverrides—mcp-gatewayandtelemetry-workerlockfiles move to ws 8.21.1,react-native-sceneviewpins its transitive 7.x line to 7.5.12 and 6.x line to 6.2.5 (no major bumps, metro/devtools untouched). Test suites green with the patched resolutions (gateway 187, telemetry 58).
Removed¶
- Decommission the orphaned
feedback-worker/Cloudflare Worker (#2618). Both demo apps migrated to the zero-permission direct-GitHub bug reporter (#2597), leaving the media-upload/Whisper-transcription worker unused. Removed thefeedback-worker/directory and itsquality-gate.shtest block, and reworded the Play StoreDATA_SAFETY.md+PLAY_STORE_SETUP.mdto declare no user-data collection (the on-device reporter only shares via a user-initiated share sheet or a GitHub issue the user submits). The audio/media-feedback capability is intentionally retired; the Cloudflare infra teardown is a maintainer-side action.
Tests¶
- arsceneview:
RerunBridgeTestcan no longer hang indefinitely. Its socket helpers wrapped blockingServerSocket.accept()/BufferedReader.readLine()calls inwithTimeout, but a blocking JVM socket read is not a coroutine suspension point, so cooperative cancellation could never interrupt it — a missing or misrouted line wedged the whole:arsceneview:testDebugUnitTestjob until the outer CI timeout (observed >30 min, then passed on retry). Every accept/read now goes through wire-levelSocket.setSoTimeout(acceptWithin/lineReaderhelpers), so a missing line surfaces as aSocketTimeoutExceptionwithin the test budget instead of blocking forever;withTimeoutis kept as a coarse backstop (#2688, found during #2668 audit-batch verification). - CI unit-test hangs now name the culprit and fail fast instead of silently eating the 30-min job (#2692). Every JVM
Testtask now emits astartedevent per test method (so an intermittent hang points at the exact running test instead of going dark) and self-cancels with a Gradle thread dump at a 15-min per-task timeout — well under theUnit testsjob's 30-min ceiling. Converts an unattributable force-cancel into a named, stack-traced failure. - device-QA android: launchability gate in
qa-android-demos.sh— a stale/partial install residue (package listed but launcher activity unresolvable) is now detected before the Maestro flow, remediated by one clean uninstall+reinstall, and otherwise fails fast with a diagnostic instead of burning the whole 49-demo catalog (#2725). - The ~55-file
sceneview-corecommonTest suite (shared KMP math/collision/animation logic) now gates every PR via the blockingunit-testCI job — previously only the informationaljsTestran it (#2733) sceneview-web.d.tsis now machine-guarded:check-web-dts.sh(quality-gate + repo-hygiene CI) fails on any bidirectional drift between the npm typings and the actual Kotlin/JS surface, with a 6-scenario mutation self-test (#2736)- Device-QA: Maestro runs are now pinned to the leased QA emulator (
--device "$ANDROID_SERIAL"). Maestro does not honorANDROID_SERIAL; on a host with several adb devices connected (e.g. a personal phone on wireless debugging next to the pool emulator) it silently drove the wrong device, producing an invalid QA verdict against whatever app was on that device.maestro_runnow forwards the leased serial explicitly, keeping the emulator-first rule true on multi-device hosts.
Docs¶
- Refreshed
docs/docs/desktop-filament.mdinto the durable decision record for #2540: corrected the stale claim that upstream Filament still ships a desktop Java build (FilamentCanvas/FilamentPanelwere removed in 2021, google/filament#4263), documented the communityfilament-kmpFFM bindings as the S1 supply, and summarized the adopted offscreen architecture with its phased plan and integration notes. - AI-first docs: new Point & Ask recipe — "build an AR app that explains what the camera sees" — across all three AI-facing surfaces:
samples/recipes/point-and-ask.md(full pattern: AICore availability gating, current-frame CPU-image capture, off-main YUV→Bitmap, multimodalgenerateContent, emulator QA note), anllms.txt"Recipes" entry with the condensed working code and its gotchas, and agent-skill reference #14 pointing at the shippedPointAndAskDemo.kt. Completes item 3 of #2648 (P1 follow-up).
v4.22.0 — 2026-07-12¶
Added¶
- Store preflight — detect human-only store blockers before a deploy 403s (#2612 P1). New read-only
.claude/scripts/store-preflight.shprobes App Store Connect for the account-side blockers that used to stall releases silently: an expired Apple Program License Agreement (REQUIRED_AGREEMENTS_MISSING_OR_EXPIREDcanary), an App Review rejection (latestappStoreVersionstate), and a distribution certificate / provisioning profile inside 30 days of expiry. It signs the ES256 ASC JWT with openssl only (no PyJWT), reusesapp-store.yml's existing ASC secrets (no new scope), and SKIPs honestly — never a fake green — when run without credentials. Advisory-first (mirrors the Android Vitals gate #1691): a real blocker is graded but only hard-blocks underGATE_HARD=1. Wired intorelease-checklist.sh(new §16), the/store-statusknown-gap note, and a dailymaintenance.ymlstore-preflightjob that posts to the run's step summary. Self-tested offline bytest-store-preflight.sh(runs inrepo-hygiene). Detection only — agreements, tax forms, and Resolution Center replies stay human-only; the script detects and deep-links, it never clears a blocker. - Public Surface mirroring — clean in-app video recording without MediaProjection (#2626).
SurfaceMirrorer(io.github.sceneview.utils) is now public and wired into both composables via the newsurfaceMirrorerparameter onSceneViewandARSceneView(+rememberSurfaceMirrorer()). Attach aMediaRecorderinput surface withstartMirroring(surface)/stopMirroring(surface)and get an MP4 of exactly what the scene renders — in AR, camera feed + virtual content composited. No system consent dialog, nomediaProjectionforeground service, no overlay UI in the frame. Multi-surface capable, letterboxed,startMirroringthread-safe/JNI-free, both calls idempotent. Newvideo-recordingdemo + llms.txt "Record the scene to MP4" section. - Binary-compatibility note — recompile required: adding the
surfaceMirrorerparameter changes the JVM method descriptor of the@Composable SceneViewandARSceneViewfunctions, so this release is binary-incompatible — code compiled against an earlier SceneView keeps calling the old descriptor and must be recompiled against this version. It is source-compatible (the parameter defaults tonull), so no call site needs to change — a recompile is enough. Shipped under a minor version bump. - iOS
ModelNode.centerOrigin(normalized:)— Android normalized-origin parity (#2632). SceneViewSwift gains a normalized-origin overload alongside the existing absolutecenterOrigin(_:).centerOrigin(normalized:)takes a bounding-box point in normalized AABB coordinates (-1...1per axis,0= box centre,±1= box faces) and aligns it with the node origin via-(center + origin * extents/2)— identical semantics to Android'sModelNode.centerOrigin(Position).centerOrigin(normalized: SIMD3(0, -1, 0))now bottom-aligns exactly like Android'sPosition(0, -1, 0)(the model sits on the origin), so an Android snippet ports verbatim. The absolutecenterOrigin(_:)overload is unchanged (source-compatible). Replaces the former manual grounding workaroundcenterOrigin(SIMD3(0, bounds.extents.y / 2, 0))in llms.txt and the iOS agent skill's migration table. - Explore tab multi-source resilience — browse Sketchfab, Icosa Gallery & Poly Haven (#2645, demo-app only, no SDK API change). The Android demo's Explore tab is now backed by a source-agnostic
ModelSourceabstraction (search / feeds / streaming download + attribution & license metadata) with three implementations: the existing Sketchfab client wrapped asSketchfabSource, plus new keylessIcosaGalleryService(the open-source Google Poly successor, glTF-native CC assets) andPolyHavenService(CC0 PBR models). A source-picker chip row lets the user switch catalogs, the choice is remembered across launches, and per-feed failures are isolated (supervisorScope) so one degraded source never blanks the tab — the samples row, the picker, and the surviving sources stay usable. Every model still renders through SceneView (never an external web viewer), and each card/viewer surfaces the creator + license regardless of origin. Keeps the flagship "browse real 3D models" demo alive independent of Sketchfab/Epic's platform trajectory (see #2644). The two CC sources need no API key, so this feature is fully functional in the public Play Store build even when the Sketchfab key is absent — Sketchfab simply drops out of the picker. sceneview-core: portable Kotlin Multiplatform parsers for 3D Gaussian Splatting files.SplatParser.fromPly,SplatParser.fromSpzand the auto-sniffingSplatParser.parsedecode INRIA-style PLY (binary_little_endian, property-order-agnostic — normals and higher-orderf_rest_*SH bands tolerated) and Niantic SPZ (gzip container, versions 2first-threeand 3smallest-three) into a sharedSplatCloud(count,positions,scales,rotations, SH degree-0colors,opacities) with activations applied. Includes a dependency-free pure-Kotlin gzip + DEFLATE inflater, so SPZ decodes on every target (Android, Apple, Web) with noexpect/actualand no native zlib. Malformed, truncated, ASCII/big-endian PLY, and unsupported SPZ v1/v4 inputs are rejected with aSplatParseException(#2646)SplatNode— 3D Gaussian Splatting rendering on Android (#2646, P1). NewSceneScope.SplatNodecomposable (+rememberSplatCloudand the underlyingio.github.sceneview.node.SplatNode) renders aSplatCloud— the flat-array 3DGS data model shared insceneview-corecommonMain— as hardware-instanced camera-facing gaussian discs: per-splat centre/scale/colour/opacity fetched in the vertex shader from two RGBA16F data textures, isotropic gaussian falloff, premultiplied-alpha blending (newsplat.filamat, compiled with the pinned matc 1.71.5 toolchain). Clouds above Filament's 65535 instances/draw cap are split into batches transparently; an optionalcameraPositionProviderenables an off-main-thread back-to-front painter's sort keyed on camera motion, andsplatCounttruncates the draw for LOD/reveal effects. P1 scope: isotropic billboards + SH0 colour — anisotropic 2D-covariance ellipses and the.ply/.spzfile loaders land in the follow-up #2646 workstreams.sceneview-mcp: newgenerate_3d_modeltool — Tripo BYOK text/image→GLB (#2647). Closes the agentic asset loop next tosearch_models: when no existing asset fits, the assistant generates a brand-new GLB from a text prompt (text→3D) or a source image (image→3D) via the Tripo AI API and gets back a direct GLB download URL (expires ~5 min — the result tells the assistant to download and self-host immediately) plus license/attribution metadata, ready forrememberModelInstanceand AR placement. Two quality tiers:"fast"(default, Tripo P1 low-poly — AR-ready, ~25–30 s) and"hd"(Tripo H3.1 quad topology + detailed geometry/textures, up to ~100 s). BYOK viaTRIPO_API_KEY, mirroring theSKETCHFAB_API_KEYpattern — no server-side key custody, generations are billed to the user's own Tripo account; a missing key returns actionable setup instructions. Bounded polling (2 min fast / 4 min hd) with structured errors for task failure, rate limiting, and timeout. Ships with a new llms.txt recipe ("generate →rememberModelInstance→ place in AR"). The npm publish is a separate follow-up on the MCP's independent version track (#1705).
Changed¶
samples/android-demo(AR View tab + ar-placement):TapToPlaceArSessionnow runs the full #2241 Sprint-1 stack —PlaneDiscoveryGuideonboarding replaces the static "Scanning…" affordance, the reticle is the smoothedPlacementReticle(samePlacementHitPolicyacceptance via its newpredicateparameter), and every tracked plane hosts an invisibleShadowReceiverPlaneso placed models ground with a real contact shadow.PlacementReticle/PlacementReticleNodegain thepredicateacceptance hook, andsnapToPlane = falsenow means free placement (feature-point hits accepted, planes stay in-polygon) instead of accepting nothing.- Binary compatibility: the new
predicateparameter added to the already-released publicPlacementReticlecomposable andopen class PlacementReticleNodeis source-compatible but binary-incompatible (it changes the shipped JVM signatures / Kotlin default-args synthetic). It therefore rides a MINOR release (e.g. 4.22.0), never a patch, and binary consumers ofarsceneviewmust recompile against the new artifact — a stale.classbound to the old signature wouldNoSuchMethodErrorat runtime. - Reworded the
AssetSourceChip"Bundled fallback" label to plain-language "Offline model" (demo apps only — tap-to-place unification plan item 4, #2482).
Fixed¶
samples/ios-demo(Sketchfab streaming): the live Sketchfab → USDZ → RealityKit stream never actually reached the user.SketchfabService'sURLSessionDownloadDelegateresumed its continuation with the delegate's temporarylocationURL, but only moved the file into the cache after the delegate method returned — by which point URLSession had already deleted that temp file (Apple's documented contract). Every streamed model download therefore fully transferred from the S3 CDN and was then discarded, soSketchfabAssetResolversilently fell back to the bundled asset and the Explore / streamed demos (SceneGallery, Materials, ModelViewer "Surprise me", etc.) only ever rendered offline copies — never the live-streamed model. The temp file is now moved to a caller-owned staged URL inside the delegate callback, before it returns. Uncovered by the #2356 keyed-QA mechanism (keyed sim run: 0 streamed / 4 fallback before, 4 streamed / 0 fallback after).- sceneview-web:
Node.parentsetter now guards the engine write afterdestroy()— re-parenting a retained, destroyed node no longer reachesTransformManager.setParenton a freed instance (a WASM use-after-free abort), closing the last unguarded hierarchy-write path (#2611 review, symmetric to the existing transform-write guard). The#2024P1 browser probe also asserts the detach-sentinel invariant: the sentinel entity never gains a transform component, sonullParentInstance()stays native instance 0. - Restored the Maestro 3D zoom-QA coverage that had silently stopped running (#2633). The reusable
flows/demo.yamlsubflow declared its ownenv: CAMERA_DISTANCE: ""default which — contrary to its comment — masked the value the caller passes viarunFlow → env:(verified on-emulator under Maestro 2.6.1/GraalJS), so the optional near/far zoom screenshots (#1571) were always skipped while the flow stayed green. The masking default is removed and the gate is nowtypeof-guarded, so the zoom section runs when a caller setsCAMERA_DISTANCE(proven:demo-model-viewer-zoom-near/-far.pngproduced) and skips cleanly — with no undefined-variable error — when it does not. ModelNodeno longer re-applies its declaredrotation(andposition/scale) on every recomposition — a gesture-rotated model is no longer silently reset to its declared transform when an unrelated state change triggers a recomposition (#2639).- The advisory
ar/androiddevice-QA leg no longer hard-fails a CI job when the runner is low on disk (#2640).device-qa.sh's CI disk gate used a blanket 15 GB threshold, so the AR leg — which reuses the prebuilt APK and only adds an emulator + the ~300 MB ARCore sideload — tripped it and aborted withexit 2before any demo ran, turning the job red and writing no report. The gate now scales per leg (emulator legs need ~8 GB, not 15) and, when it still trips for an advisory-only--ciselection, degrades to an honestskipped(WARN, exit 0 — the #1645/#1670 path) instead of aborting. A blocking leg (web/all) still hard-stops, and because no demo launches during the gate no real crash is ever masked. - The advisory
ardevice-QA leg now completes on CI with a per-demo verdict instead of dying silently (#2643). On the x86_64 + SwiftShader CI emulator the AR replay harness ran all ~32 AR demos in a single ~4-minuteam instrumentprocess; the sustained Filament/GL pressure severed theam instrument -wadb connection mid-sweep (rc=255), and #2620's incremental summary was never recovered — so the leg failed with no verdict and could not even name the in-flight demo. Two root causes were fixed: (1)ARReplayHarnessTest.writeSummary()wrote the summary with a rawjava.io.Fileto/sdcard/Download/, which scoped storage silently discards for atargetSdk 36app — it now publishes via the MediaStore API (the same pattern the QA-screenshot tests use), soadb pullcan actually retrieve it; (2)ar-replay-qa.shnow shards the sweep intoAR_SHARD_COUNT(default 6) separateam instrumentruns with anam force-stopbetween them, so no one process replays every demo, a severed shard costs only its own demos, and the emulator is calmed before each summary pull. The per-shard verdicts are merged; any demo a shard planned but never reached (severed early) is recordedskippedwith an environmental reason — accounted for, never lost, never folded into a fake pass. A shard that produced no summary at all (killed before its first incremental write) makes the leg exit non-zero with the lost shard indices named (missingShardsin the merged summary) — unaccounted demos can never grade as a pass; and a severed shard'sinProgressdemo is surfaced in the merged summary with a "prime suspect" reason instead of being folded into the generic environmental bucket. The emulator also gets-memory 6144(was 4096) for lmkd headroom and the Gradle daemon is stopped before the sweep to free host RAM. The leg stays advisory (WARN, never a release blocker). - Explore search no longer "loses the connection" when typing fast (#2644). Three stacked fixes in the demo app's Sketchfab client: (1) network calls now go through OkHttp's cancellable
executeAsync()— re-typing genuinely aborts the superseded request (socket included) instead of stacking zombie calls behind the debounce, the burst signature that tripped CloudFront's WAF; (2) a transient upstream failure is retried exactly once — live probing showed Sketchfab's degraded search backend timing out (HTTP 408) on a majority of burst queries while an immediate retry succeeds, so a 408 no longer renders as a bogus "0 results"; (3) a transient WAF challenge during search no longer latches the permanent "Sketchfab unavailable" banner (that stays reserved for a genuinely rejected key), and JSON calls carry an explicit 20 s overall timeout. GLB downloads keep streaming past the ceiling but now abort promptly on cancellation and never leave orphaned temp files. - Geometry/media node composables (
CubeNode,CylinderNode,ConeNode,TorusNode,CapsuleNode,PlaneNode,ImageNode,BillboardNode,TextNode,VideoNode,LineNode,PathNode,ShapeNode, plusLightNode's position) no longer re-apply their declaredposition/rotation/scaleon every recomposition — a gesture- or frame-driver-mutated transform is no longer silently reset to the declared value when an unrelated state change triggers a recomposition. Same component-keyedDisposableEffectidiom as theModelNodefix in #2639 (#2653). - The bug reporter no longer silently attaches a blank-viewport screenshot (#2654). Some compositors (the emulator's gfxstream today, driver quirks tomorrow) return
PixelCopy.SUCCESSwhile leaving the FilamentSurfaceViewout of the read-back — analpha == 0hole where the 3D viewport should be — so the "3D viewport may appear black" warning (keyed on the fallback path only) never showed. Every capture is now sampled for such a transparent hole and the warning flips on when one is found; a legitimately dark scene is opaque black and can never false-positive the probe. PlacementScene: thegroundShadowscontact-shadow catchers are now mutually exclusive with the plane-detection grid, closing the latent #2657 double-receiver footgun at the library level.planeRenderer = true+fadePlaneOnFirstPlacement = false+groundShadows = trueused to stack the V1 plane renderer's built-in shadow receiver and aShadowReceiverPlane— two exactly coplanarshadowMultiplierquads that z-fight and darken the contact shadow twice (0.4 × 0.4 ≈ 0.16, near-black) — for the whole session.PlacementScenenow enforces the exclusion itself: while the grid renders, its own receiver serves the contact shadows; once the grid is gone (disabled or faded after the first placement), the dedicated catcher takes over. Exactly one shadow receiver is ever live on a plane, in every flag combination (#2657 follow-up).- Tap-to-place AR demo: a detected floor plane no longer renders as a dark, double-darkened polygon under placed models.
TapToPlaceArSessionkept the V1 plane renderer's shadow receiver and aShadowReceiverPlanestacked on the same plane — two coplanarshadowMultiplierquads that z-fight and darken the contact shadow twice (0.4 × 0.4 ≈ 0.16, near-black). The plane grid (and its built-in shadow receiver) now recede once the first model is placed, so exactly one shadow receiver is ever live on a plane — mirroringPlacementScene'sfadePlaneOnFirstPlacementcontract (#2657). samples/android-demo(AR Rerun Debug demo): "Save & Share recording" is now disabled until the Rerun bridge is actually connected, and its label states why inline ("Save & Share (sidecar offline)"), so the primary CTA no longer leads straight to a failure dialog when no desktop sidecar is reachable (e.g. over wireless debugging). If a save does fail, the dialog now shows actionable setup copy (run the sidecar overadb reverse tcp:9876 tcp:9876) instead of leaking the bridge's raw internal message "bridge not connected — call connect() first" (#2658).- CI: the release-fast bump step no longer aborts when
sync-versions.sh --fixexits 1 after applying fixes — the fast-release pipeline completes the bump instead of failing spuriously (#2661) - Explore/Sketchfab: a search cancelled mid-body-read now reliably surfaces
CancellationExceptioninstead of the socket-abortSocketException, so a query superseded by fast typing can no longer flash the "Sketchfab unavailable" error banner. Fixes the flakySketchfabServiceTestcancellation test. (#2665)
Tests¶
samples/android-demo: thecamera_distanceQA extra is now honoured regardless of the sender's Bundle type (#2652).MainActivityonly readgetFloatExtra, but Maestro'slaunchAppdelivers env-interpolated launch arguments as String extras — so the #1571 zoom-QA near/far relaunches silently fell back to the auto-fit framing and never actually reframed (verified on-emulator:adb --efreframed, the Maestro-style string was ignored). The raw extra is now coerced type-agnostically via the newDeepLinkRouter.coerceCameraDistanceExtra(Float/Double/Int/Long/String all funnel through the samevalidateCameraDistanceclamp; garbage still resolves tonull= auto-fit), with unit tests covering every encoding.- Maestro zoom-QA near/far screenshots now capture the actually-rendered model instead of the black cold-start viewport (#2652). Each zoom relaunch screenshotted ~6 s after a cold start — before engine warm-up + model + IBL load completed, and
qa_mode-frozen scenes letwaitForAnimationToEndreturn early on the settled black viewport — so-zoom-near/-zoom-farcame out byte-identical black frames and the near-vs-far diff proved nothing. Each zoom relaunch now waits for the demo's model-load scrim text to clear (the "model instance is in the scene" signal), then holds a fixed 9 s render warm-up (IBL + shader compilation + first lit frame; Maestro-sleep via anoptionalextendedWaitUntilon a never-present marker, immune to qa_mode freezing), then settles. Deliberately no swipes in the zoom section: a drag hands the camera to the ORBIT fallback manipulator for ~3 s, racing the screenshot with a non-canonical angle — zero interaction keeps the frozen-QA framing (static 45° yaw) so the near/far diff isolates exactly one variable, the orbit radius. Verified on the ARCore emulator: near (0.6 m) fills the viewport, far (40 m) shrinks the model to a dot, both fully rendered. samples/ios-demo: automated regression pin for the Sketchfab streamed-download persistence path (#2663). The exact bug class fixed in #2662 — network succeeds, bytes are silently discarded, the feature falls back to the bundled asset with no error — was invisible to every existing gate (compile, unit tests, screenshot QA all stayed green while the streamed path was dead from #2252).SketchfabService+Tests.swiftnow stubs the CDN transfer with aURLProtocolthat serves a deterministic 2 MB payload to a download task and asserts:downloadModel(uid:)returns a cache URL whose bytes byte-for-byte match the source (the assertion that fails on pre-#2662 code — the temp file is gone, so the cache file is missing); re-downloading over an existing cache entry replaces it (theremoveItembranch); and a mid-stream failure surfaces as a thrown error, never a bogus partial cache file. No live key or network — runs on keyless CI. The test file (and its sibling offline URL-builder / gated live-search tests) was also wired into theSceneViewDemoTeststarget, which it was silently missing from — so it actually executes now.
Docs¶
- WebP-textured glTF models — documented the Android/upstream limitation more prominently (#2305).
docs/docs/nodes.md's "Common mistakes" table now has a direct entry forEXT_texture_webpglTF textures (LogcatMissing texture provider for image/webp, renders untextured), linking to the existing full writeup indocs/docs/troubleshooting.mdand the workaround (re-encode to PNG/JPEG/KTX2). Re-verified upstream as of Filamentv1.73.0(2026-07-07, current latest release, newer than the pinned1.71.5):FILAMENT_SUPPORTS_WEBP_TEXTURESstill defaultsOFFinandroid/gltfio-android/CMakeLists.txt, so the published Androidgltfio-androidAAR still ships withisWebpSupported() == falseand noimage/webpprovider — no released Filament version has flipped this on. No SceneView code change; no Filament version bump. - Fixed stale Pro-tool count in
mcp/mcpize.yaml: the manifest claimed "35 Pro tools" in two places, butPRO_TOOLSinmcp/src/tiers.tshas 27 entries (counted programmatically by importing the module). Also dropped "multi-platform setup" from the Pro description — setup guides moved to Free in MCP 4.0.5 (follow-up to the "26 free tools" sibling drift fixed in #2675).
v4.21.2 — AR drag gestures unfrozen, groundShadows NPE + centerOrigin correctness (2026-07-10)¶
Fixed¶
GestureDetector: move/rotate/scale listeners dispatched the gesture-BEGINMotionEvent(a stale, framework-recycled reference) to nodes on every mid-gesture callback — a destructured binding shadowed the live event. In AR this froze drag entirely:PoseNode.onMovere-hit-tested the finger-DOWN pixel every frame, so a draggedAnchorNode/model never moved. Gestures now pin the begin node and forward the live event (#2629).ShadowReceiverPlaneNode: the invisible shadow-catcher quad was touchable and won first-hit touch resolution over anything behind it — taps and drags starting on a floor covered by a shadow catcher were silently swallowed (placed models undraggable, floor taps dead). The node and its mesh child now opt out of touch (isTouchable = false) (#2630).- arsceneview: fixed a
NullPointerExceptioncrash on real devices the instant an ARCore plane was detected withPlacementScene(groundShadows = true)(#2621, regression in 4.21.0, root-caused via the AR device-QA leg failure #2620). The basePlaneNode'sinit { trackable = plane }invokesupdate(trackable)— dispatched toShadowReceiverPlaneNode's override — before the subclass'smeshNodefield is initialized, so the override dereferenced a nullmeshNodeand crashed on the first detected plane (the emulator never converges a plane, so it slipped through emulator QA).update()now no-ops until construction finishes. The receiver additionally mirrorsPlaneVisualizer's device-proven flat-receiver recipe (culling(false)+ a non-degenerate bounding box). - sceneview:
ModelNode.centerOriginnow actually aligns the model's bounding box with the node origin (#2622). The old formula (position += origin * size) was sign-inverted (bottom-aligning shifted the model down), double magnitude (full extent instead of half extent), and ignored the AABB center — soorigin = (0,0,0)("center the model") was a silent no-op andorigin = (0,-1,0)left the model a full scaled height below the origin instead of sitting on it. The new formula (position -= (center + origin * halfExtent) * scale) lands the bounding-box point selected by the normalized origin (-1..1per axis) exactly on the node origin, whatever the asset's authored pivot — matching what the KDoc always promised. This lines up with iOScenterOrigin(_:)only for the centering case (AndroidPosition(0,0,0)↔ iOS.zero); for a non-zero origin the platforms deliberately diverge — Android's origin is a normalized-1..1bounding-box coordinate, whereas iOS's target is an absolute point in metres, so an Android snippet ported verbatim mis-places the model (llms.txtdocuments the divergence and the iOSSIMD3(0, bounds.extents.y/2, 0)grounding workaround). Formula extracted as a pure, JVM-tested helper (ModelNodeCenterOriginFormulaTest, non-centered-AABB fixtures). Migration (behavior change): if you compensated the old offset manually (e.g. addedpositioncorrections or ascaleToUnits / 2lift on top ofcenterOrigin), remove the compensation —centerOrigin = Position(0,-1,0)alone now grounds the model exactly. If you passedcenterOrigin = Position(0,0,0)relying on it doing nothing, passnull(or drop the parameter) to keep the asset's authored pivot;(0,0,0)now genuinely centers the bounding box on the node origin. The in-repo demos passedPosition(0,0,0)as a no-op everywhere; those arguments are removed in this change so every demo renders byte-for-byte identically. - arsceneview: hardened the four remaining carriers of the init-time open-dispatch bug class behind the 4.21.0
groundShadowscrash (#2624, audit of #2621).PlaneNode,AugmentedImageNode,AugmentedFaceNodeandStreetscapeGeometryNodeall runinit { trackable = … }, whose setter virtually dispatches the openupdate()— so any subclass override executed before the subclass's fields were initialized (exactly howShadowReceiverPlaneNodeNPE'd on-device in 4.21.0). Each carrier now gates its class-specificupdate()tail behind aconstructedflag (the provenShadowReceiverPlaneNodepattern) and re-applies the initial trackable state at the end ofinit, so the construction end-state is byte-for-byte unchanged — a user callback likeonTrackingMethodChangednow simply observes a fully-constructed node.TrackableNode.update's KDoc documents the hazard + guard recipe for user subclasses (which cannot be protected library-side), and a source-level contract test (TrackableNodeConstructionGuardContractTest) pins the guard structure in all four files. Audit table with per-node verdicts: #2624. - device-qa (CI): the pre-flight disk gate aborted
--platform=webCI runs at random — it required 15 GB free (a threshold sized for a full local multi-platform pass) while GitHub ubuntu runners float between ~14-21 GB depending on the image, and the web leg is the BLOCKING release gate. The threshold now scales with the platform selection (web-only: 5 GB). - web-demo (tests): the WebXR Playwright specs false-failed from the day
iwer2.3.0 shipped (2026-07-09) — 2.3.0 added a guard that silently skipsinstallRuntime()when a nativenavigator.xrexists, and headless Chromium ships one (answeringisSessionSupported=false), so the shim never took on CI and the AR/VR buttons stayeddisplay:none. The test helper now passes the official{ forceInstall: true }escape hatch — honored by 2.3.0, accepted-and-ignored by 2.2.x, no version pin needed. Verified 3/3 specs pass under both 2.2.1 and 2.3.0.
Tests¶
- AR device-QA leg (
ar-replay-qa.sh+ARReplayHarnessTest): the harness now writes its machine-readable summary incrementally with aninProgressmarker, and the script streamslogcat+ tees the instrumentation output into the artifact bundle. When the instrumentation host process dies mid-sweep — a demo crashing the sharedMainActivityprocess, or an lmkd OOM-kill, as happened silently on the CI x86_64/swiftshader emulator for over a week — the leg now leaves an honest partial verdict that names the crashing demo and captures the crash signature, instead of a barerc=1with no summary at all (#2620). - device-qa (iOS leg): root-caused the
3d-basicsflow failure ondemo-settings.yaml— not an app regression: Maestro 2.6.1 on the iOS 26.3 runtime does not traverse a presented SwiftUI sheet's content at all (with the sheet visibly open and screenshot-verified, the accessibility hierarchy contains only the gear FAB and the status bar). The sheet-contentASSERT_TEXTassertion is nowoptional: true(advisory) with the real crash gates unchanged (FAB re-assert + the simulator-log sweep); documented in.maestro/README.mdknown limitations. - device-qa (Maestro pin): bumped the pinned Maestro from 1.39.0 to 2.6.1
in
lib/maestro.shand — new — actually pinned the CI install indevice-qa.yml, which had been silently floating on latest all along (so android CI was already green on 2.6.x while local runs pinned 1.39). No.maestro/flow usesrunScript/evalScript, so the 2.x Rhino→GraalJS removal is a non-event; the iOS catalog was re-validated on 2.6.1 locally. - device-qa (iOS leg):
ios-device-qa.shnow keeps the simulator's unified log as a run artifact instead of a discarded mktemp, and fixes the crash-gate predicate — it filtered onprocess == "SceneViewDemo", which never matched anything (the built bundle'sCFBundleExecutableisSceneView), so the post-run crash sweep had been silently blind. The stream now filters onprocess == "SceneView" OR subsystem == "io.github.sceneview.demo"(verified against the installed demo app: the process filter carries the runtime + crash markers, the subsystem filter the app's structuredLoggercalls), anddevice-qa.shcopies the log into the artifacts dir and attaches its path as a newlogfield on the iOS platform record indevice-qa-report.json. Inspired by XcodeBuildMCP's automatic per-app os_log capture (QA-efficiency spike, 2026-07-09).
v4.21.0 — Consumer-grade AR placement + Web node scene-graph (2026-07-07)¶
Highlights: PlacementScene gets a Scene-Viewer/IKEA-grade placement UX — a ring
reticle that brightens when a surface is ready, opt-in onboarding coaching, plane-grid
fade, and contact shadows under placed models (#2241) — and sceneview-web lands the
retained-mode Node transform graph reachable from plain JS via NodeHandle (#2024).
Added¶
- sceneview-web: retained-mode
Nodetransform graph (Kotlin/JS API, slice 1 of #2024) —Nodebase class implementing the sharedsceneview-coreSceneNodecontract (pristine TRS state, parent/child hierarchy via FilamentTransformManager.setParent, world-space getters/setters,lookAt/lookTowards, recursive idempotentdestroy()), plusSceneView.sceneGraph/addNode/removeNode. Not yet reachable from plain JS — the@JsExportNodeHandlesurface arrives in a later slice; the published JS API and the builder DSL are unchanged. - sceneview-web:
ModelNodeandGeometryNode(+CubeNode/SphereNode/CylinderNode/PlaneNode) — slice 2a of the #2024 node graph.SceneView.addModelNode(url)/addGeometryNode(config)/ typed primitive factories run today's pipelines (render-gate #2332, supersede #1597, auto-center intact) and re-parent the asset root under a node pivot, so content is now addressable and transformable through the retained tree; themodel { }/geometry { }builder DSL delegates to nodes (identical visual result, unchanged Kotlin shape).addGeometrynow returns the createdFilamentAsset?(additive).Nodetransform writes afterdestroy()are guarded (no engine call on a freed entity). Still Kotlin-only — the@JsExportsurface is untouched. - sceneview-web: in-browser
TransformManager.setParentproof (#2024 P1) — a newkotlin-bundle.spec.tsprobe asserts 2-entity world-transform composition AND detach against the real pinned Filament.js WASM, closing the slice-1 review caveat. The probe caught a real bug: embind rejects a JSnullparent (BindingError), so detaching/destroying any slice-1Nodewould have crashed at runtime —FilamentNodeBackendnow detaches through the null instance (a component-less sentinel entity), the JS analog of Android'ssetParent(i, 0). - Web:
LightNodeandCameraNodeland the #2024 node scene-graph slice 2b.SceneView.addLightNode(config)wraps a Filament light in an addressable, transformable node (runtimeintensity/color/direction/positionmutators push through theLightManagerinstance bindings);SceneView.addCameraNode()drives the camera from a node's world transform viaCamera.setModelMatrixeach frame (AndroidCameraNodeparity). Thelight { }DSL block now delegates to a retainedLightNode(visually byte-identical to the flat path, addressable viasceneView.sceneGraphafterwards). AllLightManager/Cameraembind bindings the nodes depend on are proven in-browser by thekotlin-bundle.spec.ts#2024-P1 slice-2b probes. - Web: first exported plain-JS scene-graph surface —
NodeHandle(#2024, slice 3 / P4).window.sceneviewviewers now exposeaddNode(),addModelNode(url),addCubeNode(size),addSphereNode(radius),addLightNode(type)andremoveNode(handle), returning an opaqueNodeHandleyou can address aftercreate():setPosition/setRotation(Euler degrees)/setScale/setScaleUniform/setVisible/addChild/removeChild/getWorldPosition/destroy. The publishedsceneview-web.d.tsdeclares the new surface. Library auto-centering is now routed through a single real content-rootNodetranslation (the iOScontentRootapproach) instead of per-asset root-entity offsets — visually identical; node-created content is framed via its own node transform.
Changed¶
- arsceneview:
PlacementScenegains a consumer-grade placement UX (#2241). The built-in reticle is now a thin ring (reticleStyle = PlacementReticleStyle.RING, the Scene Viewer / IKEA / Houzz idiom) that shows a centre dot and brightens the moment a surface is ready, instead of a solid cyan disc (PlacementReticleStyle.DISCkeeps the old look). Three opt-in refinements match Google's AR design guidance:fadePlaneOnFirstPlacement = true(default) recedes the plane grid once the first model is placed so the floor stops being highlighted;coaching = trueoverlays the animatedPlaneDiscoveryGuideonboarding while the user finds a surface;groundShadows = truedrops an invisible shadow catcher on each detected plane so placed models cast a contact shadow instead of floating. ThePlacementSceneDemoinsamples/android-demoturns coaching + ground shadows on to showcase it. Existing calls are unchanged (ring is a visual upgrade; the other three are opt-in or default-safe). sync-versions.sh --fixnow covers the 7 formerly-manual version locations (llms.txt prose/CDN/flutter/package labels, demo build.gradle versionName ternary, web.html JSON-LD, playground prompts, andsceneview.js?v=cache-busters across ALL website pages incl. embed/preview) — a release version bump is now a single command, verified by a 9.9.9 blank-bump round-trip reaching 0 MISMATCH.- One-click releases:
release-fast.yml(dispatch with a version → BLOCKING web device-QA gate → completesync-versions --fixbump → changelog collate → auto-merge release PR) +tag-release.yml(tags the merged release commit and dispatchesrelease.ymlon the tag — working around GITHUB_TOKEN event suppression). Main stays protected; the bump rides a reviewable PR (M7c).
Docs¶
- sceneview-web: documented the exported plain-JS node surface (#2024 P6). The
SceneViewer instance methodsblock in the web agent skill (SKILL.md,references/cheatsheet.md) now lists the sixsv.*node factories (addNode/addModelNode/addCubeNode/addSphereNode/addLightNode/removeNode) and the tenNodeHandlemethods, closing the drift the #2615 doc reviewer flagged; the "Kotlin-only incubating node factories" note is clarified to show which factories are now reachable from plain JS.docs/docs/quickstart-web.mdgains an "Imperative node API" section;llms.txtdocumentssv.addNode/sv.removeNode;references/recipes.mdrecords the new Web parity line. Two stale code comments referencing the removedtransformScratchfield are reworded (comment-only, no logic change). - Node-count claims aligned to reality: "42+ node types" → "44+" across README, website, MCP docs and doc site (PlacementReticleNode + ShadowReceiverPlaneNode joined the inventory in #2241) — impact-check.sh runs clean again (#2594).
v4.20.0 — 2026-07-05¶
Added¶
- iOS
ARSceneView(showPlacementReticle:)— opt-in placement reticle: the tap-to-place raycast now runs every AR frame and drives a surface-snapped translucent disc at the screen centre (orientation slerp0.75, Depth Lab / AndroidPlacementReticleparity; hidden while the ray misses). Android'sPlacementReticleiOS counterpart from the Sprint-1 design (#894). - iOS
ARSceneView(groundingShadows:)— entities placed synchronously inonTapOnPlanenow automatically get RealityKit'sGroundingShadowComponent(castsShadow: true), projecting a contact shadow onto the detected surface — the RealityKit analogue of Android'sShadowReceiverPlane(#2580). Opt out withgroundingShadows: false(#894).
Changed¶
- Demo app feedback rebuilt permission-free — the MediaProjection screen + microphone recorder (foreground service,
RECORD_AUDIO/FOREGROUND_SERVICE/FOREGROUND_SERVICE_MEDIA_PROJECTION/POST_NOTIFICATIONS, worker upload) is removed and replaced by a lightweight "Report a bug" bottom sheet: an optionalPixelCopyscreenshot of the app (include/exclude toggle, honest fallback note when the 3D viewport can't be captured), the app's own logcat tail, and device/app context — shared via the system share sheet or a pre-filled GitHub issue. Zero sensitive permissions, zero foreground service, and the Play Console foreground-service declaration (#2120 / #2188) is no longer required; the FGS-declaration CI steps were removed fromplay-store.yml/main-internal-deploy.yml. - CI: every push to
mainnow deploys the Android demo to the Play Store internal-testing track (#2596). New paths-filteredmain-internal-deploy.yml(cancel-in-progress, internal track only, versionNameX.Y.Z-main.<sha>) gives the maintainer a minutes-fast real-device test loop; production remains tag-only viaplay-store.yml. Both Play-uploading workflows now share a single strictly-increasing epoch-minutesversionCodescheme (epoch_seconds / 60) — required because Play versionCodes must increase across all tracks, so the release workflow migrated offgithub.run_numberin the same change.
Fixed¶
- AR: placed models rendered too dark / green-tinted under
ENVIRONMENTAL_HDRlight estimation (theARSceneViewdefault) (#2483).LightEstimatorfed ARCore's raw main-light radiance ×1/ev100(≈ 0.067) into the light color — the max-component normalization from the 0.9.x/SceneformMaintained lineage was dropped in the v2 rewrite (the computedmaxIntensityhad been dead code) — whilemainLightIntensitycarried the same magnitude again, so the estimate was applied ~squared and the main directional light collapsed to ~1e-4 of its baseline (effectively black). AR models were lit only by the dim estimated cubemap/SH: dark, glossy, tinted by the camera feed (green/teal indoors). The estimate is now decomposed into hue (max-normalizedmainLightColor) × magnitude (mainLightIntensity= max component), applying the radiance exactly once through the baseline-multiply contract —(c / max) × max == c. Pinned byLightEstimatorTest; on-device validation (Pixel 9) due at the next device-QA pass. - Demo app: added a
-dontwarn com.google.android.gms.nearby.**ProGuard rule so the release AAB's R8 minification no longer aborts on thecompileOnlyNearby Connections types referenced by arsceneview'sNearbyCollaborativeTransportreference implementation. This was silently blocking the Play Store deploy of the demo (the missing-class check only runs duringminifyReleaseWithR8, not on the CI compile/unit-test gates).
Tests¶
DemoRenderingScreenshotTestnow FAILS (instead of silently assume-skipping) when a slug listed inBASELINED_GOLDENShas no committed golden — a deleted/renamed baseline can no longer disable its own regression guard unnoticed (#2323 suggestion 2). New slugs keep the quiet first-run capture flow.
v4.19.0 — 2026-07-04¶
Added¶
- iOS Cloud Anchor lifecycle parity.
SceneViewSwift.CloudAnchorNode.host(ttlDays:completion:operation:)/.resolve(cloudAnchorId:completion:operation:)return a cancellableCloudAnchorFuture— callfuture.cancel()from SwiftUI.onDisappear(the analogue of Android'sDisposableEffect { onDispose { future.cancel() } }, #1768) to short-circuit billed ARCore Cloud round-trips when the view goes away. Completion fires at most once and never aftercancel()or after the handle is deallocated. To keep the core library dependency-free, the actualGARSession.hostCloudAnchorcall (Google'sarcore-ios-sdkSwift Package) is supplied by the app through theoperationclosure;CloudAnchorFutureowns only the portable, fully unit-tested cancellation gate. Mirrors AndroidCloudAnchorNode.host/.resolvereturningHostCloudAnchorFuture/ResolveCloudAnchorFuture. Closes #1859 (tracked from the cross-platform parity umbrella #1813). NearbyCollaborativeTransport— a reference [CollaborativeTransport] implementation backed by Google Nearby Connections for offline, same-room collaborative AR (no backend). Theplay-services-nearbydependency iscompileOnly, so it adds zero footprint and no permissions to AR apps that don't use collaboration; consumers that opt in declare the dependency and request the surfacedREQUIRED_PERMISSIONS_*themselves. Android-only for now; theCollaborativeTransportabstraction stays platform-neutral so iOS can map it onto RealityKit'sMultipeerConnectivityService(#2008).arsceneview:PlacementReticlecomposable +PlacementReticleNode(#2241 Sprint-1, PR 4/6) — the Depth LabOrientedReticleport: an AR placement cursor that slerps its orientation toward the hit surface normal each frame (default 0.75) so the disc no longer jitters as ARCore refines the normal, with optional depth-hit acceptance (depthPoint = true, lands on arbitrary geometry when the session depth mode is enabled — default off, #1891 plane-only contract preserved). Ships a built-in thin cyan disc visual when no customcontentis passed; anullhit auto-hides the marker and resets the smoothing.PlaneDiscoveryGuide— AR plane-discovery onboarding overlay (#2241). Newio.github.sceneview.ar.PlaneDiscoveryGuidecomposable, a Compose port of Google ARCore Elements' user-tested onboarding state machine: silent 0–3 s, animated hand-sweep hint + "Move your phone to find a surface" pill at 3 s, "Need help?" affordance with a built-in tip card at 8 s, 750 ms fade-out once the first plane tracks (latched — never re-onboards within a session), and contextual tracking-lost messages reusing the existingsceneview_*_messagecopy. Pure UI overlay — consumescameraReady/isTracking/anyPlaneTracked/trackingFailureReasonsignals the host already produces; no ARCore or Filament dependency. Ships with a headless-testablePlaneDiscoveryGuideState(injectable clock), a statelessPlaneDiscoveryGuideOverlayfor custom hosts/previews, and the Canvas-drawnPlaneDiscoveryHandHintanimation (no Lottie dependency).ShadowReceiverPlane— invisible AR shadow-catcher ground (#2241 Sprint-1, PR 3/6). NewShadowReceiverPlaneNode+ARSceneScope.ShadowReceiverPlane { }composable inarsceneview: an invisible surface bound to a detected ARCorePlanethat only darkens the camera feed where a virtual object casts a shadow onto it, so placed models read as grounded on the real floor. Port of ARCore Depth Lab'sShadowReceiverMeshShader(Blend Zero SrcColor) using Filament's dedicatedshadowMultipliershadow-catcher feature, via a newshadow_receiver.matmaterial with a runtime-tunableshadowIntensityparameter (default 0.6, the Depth Lab value). The quad follows the plane's center pose and refined extents, receives shadows and never casts them, and is compiled intoshadow_receiver.filamatwith the pinned matc toolchain (Filament 1.71.5, profile C) enrolled in theGenerateFilamat.shdrift gate.- sceneview-web: wired the
model { scale(...); autoAnimate(...) }DSL builder options that were silent no-ops (#2432).SceneViewBuilder.apply()now threads both throughloadModel, matching AndroidModelNodesemantics.autoAnimate(false)renders a model static (the render loop no longer unconditionally plays glTF animation 0, and a static model no longer holds the on-demand render gate live);scale(value)applies a raw uniform local scale to the model's root entity (like AndroidModelNode(scale = Scale(value)), notscaleToUnitsnormalisation). The auto-centre / auto-dolly pass scales each model's asset-space bounding box by the same factor, so a scaled model stays centred and correctly framed. Follow-up to the #2429 doc correction. ARSceneViewgains arenderQuality: RenderQuality?parameter, closing the 3D/AR API asymmetry the docs previously advertised as symmetric (#2524, #2519 audit). It is nullable and defaults tonull— existing AR scenes are unchanged, keeping the camera-feed-tunedcreateARViewdefaults (no SSAO/bloom). Pass a preset to opt in, e.g.ARSceneView(renderQuality = RenderQuality.Performance) { }for battery-sensitive overlays orRenderQuality.Cinematicfor a hero placement showcase. The Filmic tone mapper that round-trips the AR camera background (#1434) is preserved across every preset, sinceapplyRenderQualitynever writesview.colorGrading.
Changed¶
- Documented
Node.rotation(Euler getter) as deliberately un-cached — no per-frame render/animation path reads it (they readquaternion), so caching would only add invalidation cost to the hotquaternionwrite path. Closes the last open item (N2) of the Phase-2 hot-path tracker (#2328); the transform/parent JNI and gesture/light/animation allocation items landed earlier in #2366, #2417, and #2423. - Flutter & React Native demos: the Environment demo now toggles between two
distinct HDRs (Studio ↔ Night) at runtime instead of reloading a single HDR
(Flutter) or switching HDR↔none (RN). This honestly demonstrates IBL/skybox
switching and exercises the keyed-
rememberEnvironmentswap path that proves the #2361 fix (the skybox actually rebuilds on a new HDR). The second HDR reuses the existing in-reporooftop_night_2k.hdrasset. (#2365) - Flutter & React Native Android bridges: tidied the mutually-exclusive
rememberEnvironmentcall sites into a single stable call site (one keyedrememberEnvironmentwhose factory falls back to the default environment when the HDR path is null), instead of a keyed call plus a separateenvironment ?: rememberEnvironment(...)fallback at theSceneViewargument. Behavior-preserving; version-independent (still uses Composekey {}, not the unreleasedkey=param). (#2365) - Build toolchain: bump Kotlin
2.3.21→2.4.0(the 2026-06-03 stable language release) ingradle/libs.versions.toml. The Compose compiler plugin (org.jetbrains.kotlin.plugin.compose) and the serialization plugin are pinned to the Kotlin version and bump in lockstep; Compose Multiplatform stays at1.11.1(compatible). No public-API or runtime-library changes. Doc references (llms.txt,docs/docs/llms-full.txt) updated to the new Kotlin version. Verified::sceneview+:arsceneviewrelease compile clean,:sceneview-coreKMP metadata + JS compile clean, and the Android unit suites pass (:sceneview494 tests,:arsceneview709 tests — 0 failures). (#2391) samples/android-demo: thear-placementdemo now renders through the sharedTapToPlaceArSessionengine instead of its own inlineARSceneView, removing ~330 lines of duplicated session code (centre reticle, texture-settle gating, PAUSED-surviving anchors, per-asset rotation correction, the gesture pill, the camera-init scrim and the plane-gated status vocabulary all move into the shared engine). The demo keeps all of its developer-facing chrome — streamed/bundled chip pickers, Snap-to-plane / Show-reticle toggles, Clear All, "Next tap places:" preview and the force-tracking-failure QA menu — and its behaviour is unchanged (#2482, PR 2/4).- The AR View tab now renders via the shared
TapToPlaceArSessionengine (#2482, PR 3/4) — it gains the centre placement reticle, texture-settle gating (no black flash on placement), helmet rotation correction (the Damaged Helmet now lands upright instead of face-down) and PAUSED-surviving anchors. The top-end X close becomes a top-start back arrow, the toast-only Share stub is dropped, and both entry points now speak one status vocabulary. - Added a shared, demo-app-level
TapToPlaceArSessionengine (common/placement/) — the foundational shared session behind the AR View tab and thear-placementdemo (#2482 Option A, #2518). It carries the centre placement reticle (#1882), texture-settle gating and PAUSED-surviving anchors (#1435), per-asset rotation correction (#1477), tap-time model resolution as an API invariant (#2476), the camera-init scrim (#2484) and the #2234 plane-gated status vocabulary, with the tap/reticle acceptance test single-sourced as a JVM-testedPlacementHitPolicy. This is PR 1/4 of the unification — purely additive; the two hosts are re-pointed onto the shared engine in the follow-up PRs. - Collaborative AR hardening (
NearbyCollaborativeTransport/CollaborativeSession, #2569): inbound messages are now bound to the connection-bound transport peer id — a message whose body claims anotherpeeris rejected as spoofed, and a second live connection claiming an already-connected peer id is rejected at initiation. (Per-connection integrity: absent theshouldAcceptConnectionout-of-band check, peer ids remain self-advertised names.)CollaborativeStaterosters are bounded (MAX_PARTICIPANTS= 64,MAX_NODES= 1024, overridable via constructor) against forged-key memory-amplification DoS.NearbyCollaborativeTransportgains an optionalshouldAcceptConnectiontrust gate exposing the NearbyauthenticationDigestfor out-of-band pairing, guards theConnectionsClient.MAX_BYTES_DATA_SIZEBYTES payload limit, fail-closes a throwingshouldAcceptConnectiongate, observessendPayloadfailures, and documents the same-serviceIdauto-accept trust boundary. Wire-format vector parsing early-bails on oversized[...]bodies. Adds the promisedNearbyPayloadFramingTest/NearbyPeerRegistryTestplus impersonation-rejection and roster-cap tests. - Renamed the Android source files
Scene.kt→SceneView.ktandARScene.kt→ARSceneView.ktso each file matches the primary composable it defines (SceneView/ARSceneView); the bareScene/ARSceneare deprecated backward-compat aliases. This aligns Android with the Web (SceneView.kt) and iOS (SceneView.swift/ARSceneView.swift) source layout — Android was the only platform still named after the deprecated symbol — and makes the primary entry point discoverable by file name (an AI-first concern). Source-only, no binary break:@file:JvmName("SceneKt")/@file:JvmName("ARSceneKt")pin the published JVM facade class names, so Kotlin consumers compiled against an earlier release keep resolving the facade in their bytecode without a recompile. No public API, signature, or behavior change.
Fixed¶
samples/android-demo:ARDepthColliderDemonow drops balls so they are always visible in the camera view. The previous fix anchored the spawn to the live camera but bundled the drop height into the camera transform, so aiming at the floor (as the on-screen hint instructs) rotated the height by the phone's pitch and the balls landed off in a screen corner. The forward offset is now projected through the camera pose while the horizontal scatter and drop height are applied in world space (world +Y up), so balls spawn straight ahead of the camera and fall straight down into view regardless of how the device is tilted (#1874, #2466).- Android demo — in-app screen recording restored on Android 14+. Re-added
FOREGROUND_SERVICE_MEDIA_PROJECTIONpermission andandroid:foregroundServiceType="mediaProjection"onFeedbackRecordingService(temporarily removed in #2120 to unblock a Play Console catch-22). The Play Console foreground service type declaration must be completed before the next Play release — see PR body for the console step. (#2188) - iOS demo: the published AR-recording deep link (
sceneview://demo/ar-record-playback) now resolves to the recorder demo instead of the "Open in app" placeholder. The iOSDemoDeepLinkRegistryonly wired the unpublishedar-recordingid; the canonicalar-record-playback(used by the website QR landing page,llms.txt, and the Android catalog) fell through to the placeholder. Both ids now route toARRecorderDemo(#2370). Node.worldQuaternion(andworldRotation) now round-trips again on parented nodes (#2392). A 4.15.2 → 4.17.0 regression: setting a child's world-space rotation vianode.worldQuaternion = Xunder a parent with a non-identity world rotation silently producedparentWorldRotation ⊗ Xon read-back instead ofX, scattering per-frame billboards and mis-placing rotated child meshes. The world-space TRS cache (#2280/#2264) combined with the cached-quaternion fast path ingetLocalQuaternion(#2294/#2267) made the world→local conversion that backs the setter trust the parent's cachedworldQuaternion, which could be stale relative to Filament's live world matrix. The conversion helpers (getLocalQuaternion/getWorldQuaternionplus the position/scale/transform siblings) now re-validate against the liveTransformManagerworld transform — exactly the read 4.15.2 did — so a stale cache can no longer corrupt the result. The per-frame world-space getters stay fully cache-served (the hot read path is unchanged). Engine-backed regression coverage added inNodeWorldQuaternionRoundTripTest.- sceneview-web: Guard the on-demand render gate (#2332) against a frozen canvas after a failed model/IBL/skybox load. A failed
fetchnow settles its in-flight load and requests a repaint so the viewer reflects the error state instead of freezing at the last successful frame, and the success and error paths signal the gate identically. AddedLoadModelErrorSignalTestregression coverage. (#2409) release-device-qa-gate.shno longer false-FAILs the release on the advisoryarleg. Per the CLAUDE.md "Release-gate policy for continue-on-error legs (#1651)", onlywebis BLOCKING;androidandarare ADVISORY (flaky emulator / CI assumeTrue-SKIP when the bundled recording or Play Services for AR is absent). The gate's default graded sets are nowREQUIRED=web/ADVISORY=android,ar(matchingdevice-qa.sh's pre-computedreleaseGate.verdict), it honours--advisory=/--required=CLI overrides, and atest-release-device-qa-gate.shself-test guards the policy. (#2433)- macOS App Store demo-app upload no longer rejected for a duplicate
CFBundleVersion(#2443). The iOS-demo Xcode project pinnedCURRENT_PROJECT_VERSIONas a static literal (build366), so every store-affecting deploy re-archived the same build number; App Store Connect refused the duplicate on the Mac App Store stream ("CFBundleVersion [366]must contain a higher version than the previously uploaded [366]").app-store.ymlnow computesBUILD_NUMBER=$(( github.run_number + 1000 ))in bash and passesCURRENT_PROJECT_VERSION="$BUILD_NUMBER"into thexcodebuild archiveinvocation for both the iOS and macOS legs — a command-line build setting overrides the project-file literal at archive time, andgithub.run_numberis strictly increasing, so every upload now carries a fresh, monotonicCFBundleVersion. The+1000offset clears the historical high-water mark (this workflow's bare run_number was ~288, below the already-uploaded 366), so the build number is both monotonic and safely above every previously-uploaded build. (The arithmetic is done in bash, not a${{ }}expression — GitHub Actions expressions do not support the+operator.) No change toproject.pbxproj(the static literal is now irrelevant at archive time); the marketing versionMARKETING_VERSION = 4.18.0and thesync-versions.shcheck that guards it were already correct. rememberHDREnvironment/rememberKTXEnvironmentno longer leak the previously loadedEnvironment(itsIndirectLight+SkyboxGPU textures) when the asset path changes. The factories now dispose the prior environment on a key swap via aDisposableEffect, matching the siblingrememberEnvironment(key = …)— previouslyproduceStateonly cancelled the loader coroutine and the old IBL/skybox stayed GPU-resident until the wholeSceneViewleft composition (e.g. a time-of-day HDR slider leaked one set per swap). (#2458)rememberModelInstance(both overloads) no longer leaks the previously loadedModel(its Filament textures, vertex/index buffers and materials) when the model path changes. The factory now destroys the priorModel(modelLoader.destroyModel(it.model)) on a key swap and on leave-composition via aDisposableEffect— previouslyproduceStateonly cancelled the loader coroutine and the oldModelstayed inModelLoader.models, GPU-resident until the wholeSceneViewleft composition (e.g. the Sketchfab gallery swap leaked one model per swap). Disposal is ordered after the consumingModelNodedetaches its renderables, so the entities are off the scene before the buffers are freed. (#2459)sceneview-web:SceneView.destroy()no longer leaks the camera entity handle. Teardown destroyed the camera component (engine.destroyCameraComponent) but never freed the camera entity, leaking oneEntityManagerslot perSceneViewcreate→destroy cycle — the exact inverse of the #1700 light-component leak.destroy()now also callsengine.destroyEntity(cameraEntity)(component first, then entity, mirroring the light teardown), so the handle is reclaimed. This accumulated on every WebXR enter→exit and SPA dispose/recreate, sinceWebXRSession/ARSceneView/VRSceneViewcreate and destroy aSceneViewper session (#2045). (#2461)- Fixed
generateCapsule(sharedsceneview-core, all platforms) emitting a malformed mesh: the three independently-wound vertex blocks (two hemispheres, the cylinder) were stitched with one continuous-grid index loop, producing an inverted cap funnel over the top hemisphere, a degenerate zero-area band at the cylinder/hemisphere seam, and an inverted bottom cap. The blocks are now stitched independently with per-block winding, pole-row triangle caps, and a zero-length-cylinder guard (so aheight == 2 * radiuscapsule is a clean sphere). Vertex layout is unchanged — only the triangle connectivity is corrected. Added a connectivity regression test (no degenerate triangles, consistent outward winding, no cross-block bridges, no positional holes) that the previous topology failed. - Fixed
Torusrendering inside-out on default parameters — its triangles were wound clockwise (inward), so the default single-sided material culled the visible outer surface. The donut now winds outward and renders solid. (#2469) - Fixed the Android
Capsulegeometry rendering inside-out on default parameters — same inverted (clockwise) winding as the torus; the capsule now winds outward and renders solid. (#2470) - Fixed
setMorphWeights(weights)being a silent no-op: theoffsetparameter defaulted toweights.size, writing the weights past the end of the morph-target buffer instead of at the start. It now defaults to0(matching Filament), sosetMorphWeights(floatArrayOf(1f))correctly drives the first morph target. (#2471) - Fixed the shared
sceneview-coreTorusGeometrygenerator winding every triangle inward (clockwise), which rendered the torus inside-out under a single-sided material on the web (Filament.js) and the iOS reference path. The triangle index order is now counter-clockwise (outward-facing), mirroring the AndroidTorusfix (#2469) and matching the convention of the core Sphere/Cylinder/Cone generators. Vertices are unchanged — index/winding only. (#2475) - android-demo: Fix the AR View "Start AR Camera" experience always placing the default Damaged Helmet regardless of the model picked. The remembered tap-gesture lambda captured the derived
selectedModelval from first composition, so picking Fox/Soldier/etc. updated the pill but never the placement. The tap handler now readsarModels[selectedModelIndex]through state at tap time (mirroringARPlacementDemo), so each placement uses the currently-selected model. (#2476) - ML Kit Object Labels demo: label billboards no longer render oversized, warped or
mirrored/upside-down. The
BillboardNodes were created without acameraPositionProvider, so they kept the AR anchor's plane-aligned pose and were drawn edge-on or back-faced (mirrored UVs) instead of facing the viewer; they now billboard toward the live camera position every frame. The detector's classification confidence is surfaced as a "NN%" subtitle on each label, and the "Aim at a recognisable object" hint is dismissed once at least one object is labeled. (#2478) - AR Depth of Field demo: lowered the default blur strength from
2.0×to1.0×(Filament's stock cinematic strength). The old2.0×over-scaled the circle-of-confusion and crushed out-of-focus regions to black bands and colour smears, making the demo look broken;1.0×shows a legible shallow depth-of-field instead. The blur slider still ranges up to6×for a stronger bokeh. (#2480) - Orbital AR demo: the "Turn around — N models orbiting" banner and the directional edge-arrow now dismiss once the user has turned toward a model (the chase target enters the camera frustum) or after a short onboarding window, instead of staying up for the whole session and cluttering the view. The dismiss is sticky across device rotation (#2481, from the #2466 device review).
- AR demos no longer open on a raw black viewport (#2484). The shared "Starting camera…" scrim (
ARCameraInitScrim) is now wired into the 11 AR demos that still showed jet black for the ~1–3 s ARCore camera warm-up on entry (Tap-to-Place, Depth Collider, Depth Occlusion, Streetscape, Scene Mesh, AR Fog, Orbital AR, Cloud Anchors, Augmented Faces, Augmented Images, Camera Pose), dismissing on the firstonSessionUpdatedframe. The scrim also gained a defensive 8 s self-dismiss so a stuck session can never hide a demo's own error messaging. The camera is not faster — the warm-up gap is simply covered by an honest loading affordance. - AR Body Tracker: replaced silent black screen with a camera-init scrim (spinner while
ARCore starts) and a persistent in-viewport hint pill ("Point camera at a person — full body
visible") that fades out once a skeleton is detected. Error states (model missing, ARCore
tracking failure) now surface as a red pill directly in the viewport, matching the
ARFaceDemoUX pattern. The live skeleton detection path is device-gated and unchanged. - Cloud Anchors demo: made the host→resolve flow discoverable (#2486). The on-screen
Host/Resolve buttons no longer render as a faint, greyed-out ghost over the camera
feed — both stay solid and tappable, guiding the next step on-screen (place an anchor,
enter an ID) instead of being disabled and reading as "there are no buttons". The
one-line instruction and the Cloud Anchor ID field are now on the main screen rather
than buried in the Settings sheet, and the status/error banner moved to the top so the
long
ERROR_NOT_AUTHORIZEDmessage is no longer clipped behind the buttons. (The underlying provisioning failure is tracked in #1436.) - android-demo: The Explore tab's live 3D model viewer no longer flips the model fully upside-down when an orbit drag is carried past the top or bottom pole (#2487, Pixel 9 device review). The hero viewer's user-drag path delegates to Filament's
ORBIT-modeManipulator, which does not clamp its polar angle; once the eye crossed directly over/under the model the fixed world-uplookAtcollapsed and the model snapped inverted. The orbit eye's pitch is now clamped just shy of the poles ([1°, 179°]) — re-derived from the manipulator's transform and re-aimed at the unchanged orbit target — so a near-top-down / near-bottom-up view is still reachable but the gimbal flip can never happen. The idle auto-orbit path is untouched. Covered byOrbitEyePitchClampTest. (The remaining viewer-chrome items in #2487 — in-viewer control bar, detail-sheet sizing, Feedback FAB overlap, decorative-dot legibility, clipped sample card — are tracked there for follow-up; model fit/zoom/shadow are in #2348/#2233/#2235.) - Flutter (Android): the AR plane-discovery bridge now dedupes detected planes by reference identity (
IdentityHashMap-backed set) instead ofSystem.identityHashCode, which is not collision-free — a new plane whose hash collided with an already-reported one could silently drop itsonPlaneDetectedcallback (#2488). sceneview-core:worldToLocalScale/localToWorldScaleno longer transform a scale through theMat4 * Float3point operator, which leaked the parent transform's translation (and rotation) into the result. Scale conversions now compose the transform's basis-vector lengths, so a translated parent no longer corrupts the converted scale (#2489).sceneview-core:LatheGeometry's documentedclosedparameter now has an effect. Previouslyclosed = falseproduced byte-for-byte the same fully-closed surface asclosed = true; it now leaves the final angular seam unstitched, producing the open lathe the parameter promises (#2490).rememberOnGestureListenerno longer freezes its callbacks at first composition. The previousremember(creator)captured every callback lambda once, so any handler that closed over a derivedval(rather than reading ComposeStateinside its body) silently kept stale behaviour across recompositions — the root-cause footgun behind the "AR placement always uses the first model" report. Callbacks now route throughrememberUpdatedState, keeping the listener instance stable while always invoking the latest lambda (#2506, #2476).- Website showcase viewer (
website-static/js/sceneview.js): the render loop now pauses when its canvas scrolls off-screen (IntersectionObserver) or the browser tab is hidden (visibilitychange), and resumes cleanly when it returns — no more 5 concurrent Filament/WebGL loops running forever onplatforms-showcase.html/claude-3d.html.dispose()now cancels the pending animation frame, disconnects the observer, and removes every tracked event listener (the canvas controls + the visibility listener), so a disposed viewer no longer leaks its engine. The public API is unchanged — pages get the gating for free. (#2508) - Website showcase viewer: a failed model load (network error, 404, or corrupt GLB) now paints a subtle "3D preview unavailable" placeholder over the canvas — themed from the site's design tokens, light + dark — and logs
console.warn, instead of leaving a permanently blank canvas. The promise still rejects, so existing.catch()callers (hero, lazy-loader, playground, web) keep their current behaviour. (#2509) - Fixed the two-finger camera pan gesture being wildly over-sensitive — the slightest drag could throw the model off-screen. When the camera manipulator was swapped at runtime (e.g. an auto-fit viewer rebuilding it once the model loads),
SceneViewnever re-pushed the surface viewport to the new manipulator, so Filament's ORBIT pan divided the touch pixel by a stale1×1viewport and the pan delta exploded by ~1000×.SceneViewnow caches the last surface size and re-seeds it whenever the manipulator instance changes. One-finger orbit and pinch-zoom are unaffected. (#2514) - AR docs & KDoc: the canonical plane tap-to-place snippet called
frame.createAnchorOrNull(plane.centerPose), but noFrame.createAnchorOrNullextension exists (ARCore'sFrame.sessionfield is package-private, so the extension is infeasible). Swept all occurrences to the realTrackable.createAnchorOrNull(pose)form (plane.createAnchorOrNull(plane.centerPose)) acrossARSceneViewKDoc, the AR codelab, migration/showcase docs, andsamples/README.md, so AI-reproduced code compiles (#2525, #2519). - iOS demo: removed the "Sponsor" / GitHub Sponsors card from the About tab to comply with App Store Guideline 3.1.1 (no external payment or donation links). The Android demo's equivalent Sponsor card is intentionally unchanged — Google Play allows donation links.
- Website: fixed the garbled ("forky") GitHub icon in the site header. The nav + dev-tools GitHub mark in
index.htmlcarried a malformed SVGdpath (…24.18.0-6.63…) whose elliptical-arc segment was truncated, so the octocat rendered distorted on every browser. Restored the canonical path used by all other pages. (#2546) - Website: hero layout no longer collapses at ≥769px —
minmax(0,1fr)grid tracks +min-width:0children, and the fallback visual's conflicting fixedheight:500px(which imposed an ~889px intrinsic width viaaspect-ratio:16/9) now derives from the aspect ratio (#2560). - Website: repaired 5 SVG icon paths in
index.htmlcorrupted by a historical version find-replace (4.18.0injected into arc commands), and added async-versions.shguard that fails when a version string appears inside anyd="…"path data (#2562). - Website: aligned stale version strings on 4.18.0 (iOS snippet 4.3.4, web JSON-LD 4.4.0, playground prompt 4.3.1,
?v=3.6.2/4.4.0cache-busters) and pinned each surface insync-versions.shso they can't drift again (#2564). - Website: 3D was dead site-wide — the pages' CSP
script-srcwas missing'unsafe-eval', which Filament's Emscripten WASM glue requires, soFilament()rejected and every viewer spun forever. Added'unsafe-eval'to all 26 HTML pages (#2561). - Website: Filament engine init now has a 15s watchdog and a graceful "3D preview unavailable" placeholder — an init failure (blocked WASM, asset 404, OOM) degrades visibly instead of an infinite "Loading 3D engine…" spinner (#2563).
- Website: "Pricing" nav item now appears on every page (was only on the homepage), same position and markup, desktop and mobile menus (#2565).
- Website: added
:focus-visiblestyles — keyboard users get a visible, token-themed focus ring on links, buttons and form controls in both themes (WCAG 2.4.7) (#2566). - Website: hygiene — routine
SceneView:info logs gated behindwindow.SCENEVIEW_DEBUG, dead CSS grids removed, the permanently-hidden duplicate#hamburgerbutton removed from all 9 pages, scroll-reveal consolidated intoscript.js(single implementation, now honoringprefers-reduced-motioneverywhere) (#2568). arsceneview:ARSceneView's reactiveLaunchedEffectforflashModeand every typedConfig.*Modeparam (depthMode,planeFindingMode,instantPlacementMode,geospatialMode,streetscapeGeometryMode,cloudAnchorMode,augmentedFaceMode,imageStabilizationMode,semanticMode,updateMode,focusMode) no longer reverts asessionConfigurationcallback override right after session creation.LaunchedEffect(param)always runs once on the composable's initial composition — using the parameter's untouched default — not only on genuine later changes; that first run compared the live session config against the default and, on any mismatch, silently pushed the default back into the session without re-invokingsessionConfiguration. Any app that configured one of these modes exclusively through the callback (the library's own documented escape-hatch pattern) — e.g.config.depthMode = Config.DepthMode.AUTOMATICwith thedepthModeparam left at itsDISABLEDdefault — had that mode permanently reset moments after creation, with no exception or log. A newChangeGatetracks each param's last-applied value so only a real, later change triggers reconfiguration (#2573). ForfocusModespecifically, the redundant force-reapply inonSessionResumed(which re-clobbered callback overrides on every resume — including the initial one) is also removed: session config persists across pause/resume and the creation path + gated effect cover every legitimate case.
Performance¶
PlaneRenderer/PlaneRendererV2: O(1) updated-plane membership inRENDER_CENTERmode (#2504, audit row AR10). The per-frameplane !in updatedPlanesvisibility check ran against ARCore's JNI-backed list, an O(M) linear scan repeated for every active plane visualizer — O(N×M) every frame.updatedPlanesis now hoisted into aHashSetonce per frame so the membership test is O(1) (O(N+M) total). Behaviour is byte-identical: ARCore returns the samePlaneinstance per trackable across frames, so identity-based set membership matches the previous listcontainsexactly. No public API change.
Tests¶
DemoRenderingScreenshotTestnow asserts (not silently skips) for 3 more unified demos (#2323). Captured + committed render-goldens fortwo-d-in-three-d,materials, andcustom-geometry, so their per-tab@Testmethods now compare against a checked-in baseline instead of taking the first-runassumeTrue-skip path (which asserted nothing). Each golden is a clean, settled frame and was re-run to confirm determinism within its per-test tolerance (custom-geometry holds the tight 2 % default). Capture note: the goldens must be captured/run one method per instrumentation invocation — running all 14 methods in a single session churns the Filament/GLES context enough to blank later captures on the emulator backend, which is what left these baselines uncaptured. 5 demos remain on the first-run skip path pending baselines and are tracked under #2323:animation-physics,picking-collision,camera-gestures, andsecondary-camerarender an empty SceneView underqa_modeon the capture profile (no clean settled frame to bake in), andlighting-lab's procedural dynamic-sky frame is non-deterministic well beyond its 15 % tolerance (~31 % run-to-run), so committing it would bake a flaky baseline.- 4 render-goldens committed from the pinned CI profile (
animationphysics,cameragestures,lightinglab,pickingcollision_default) — harvested from the newdemo-render-goldensjob (#2587);DemoRenderingScreenshotTestnow compares instead of silently skipping for them (#2323). render-tests.ymlgains ademo-render-goldensjob:DemoRenderingScreenshotTest(previously run by NO workflow) now executes on the pinned emulator profile and uploads first-run captures as an artifact — the #2323 silent-skip gap becomes a harvestable baseline source. Non-blocking until the 8 missing goldens are reviewed and committed.- Added a Maestro flow (
.maestro/android/flows/ar-view-live.yaml) that drives the AR View tab's live session end-to-end — launcher → Start AR Camera → unified tap-to-place status overlay → top-start back-arrow exit → launcher restored — and wired it intoar.yaml. Until now no flow exercised the live AR View session (only deep-linked demos), the blind spot that let #2476 ship. Final piece of the #2482 tap-to-place unification (PR 4/4).
Docs¶
- llms.txt, agent skill (cheatsheet + recipes) and llms-full.txt now document the #2241 Sprint-1 placement UX kit —
PlaneDiscoveryGuide,PlacementReticle,ShadowReceiverPlane— with the honest platform matrix (iOS: native coaching overlay ships today, grounded shadows/continuous reticle tracked under #894; Web: coming soon). - AI-first docs: corrected remaining cross-platform doc↔API parity divergences so an AI no longer emits non-compiling / no-op code (#2429). Each fix was verified against the real Swift / Kotlin symbol. iOS (phantom signatures → real symbols):
GeometryNode.plane(width:height:)→plane(width:depth:)in thellms.txtmapping table and thewebsite-staticmirror;VideoNode(url:size:)→VideoNode.load(...)andLineNode(start:end:color:)→LineNode(from:to:color:)incheatsheet-ios.md(and the iOS agent-skill cheatsheet, for consistency). iOSViewNode: added an honest "⚠️ Coming soon (deferred)" note to the primaryllms.txtsurface — ViewNode currently renders a blank white plane (SwiftUI content stored but not displayed, tracked by #1035) — so an AI does not generate a non-working ViewNode. Web: themodel(url) { scale(); autoAnimate() }builder options were documented as functional butSceneViewBuilder.apply()never reads them (silent no-ops); thellms.txtweb-builder example (and mirror) now note that per-modelscale()and disablingautoAnimate()are not yet wired on web (glTF animation 0 auto-plays by default), with wiring tracked by #2432. Docs-only — no library code changed. (TheImageNode/BillboardNodecheatsheet signatures were corrected separately in the v4.18.0 release docs pass.) - Whole-repo AI-first contract audit (#2519): fixed every verified doc↔code inconsistency across
llms.txt,docs/docs/llms-full.txt, both cheatsheets, the 3 agent skills, andsamples/recipes/— AndroidcameraExposureis documented as Filament's absolute exposure scale everywhere (the EV-stops recipe told users to pass negative values, which render a black frame, #1179); removed phantom symbols (GeospatialNode/DepthNode/InstantPlacementNode/ArrowNode,ARSessionFailure.UnavailableArcoreNotInstalled/CameraPermissionNotGranted,createKTXEnvironment→createKTX1Environment,CollisionNode,ARSceneView(renderQuality=)); corrected wrong defaults (Sphere/Cylinder.DEFAULT_RADIUSare1.0f,Sizeis aFloat3); documented missing public API (onPlaybackFailed,onConfigDowngraded/ARConfigDowngrade,playbackDatasetUri,autoFitContent,LightNode(color=),PhysicsNode(floorProvider=), iOSGeometryNode.torus/.capsule/unlit:,faceTracking:,.gimbal, core geometry generators); and rewrote the recipes' iOS snippets that used invented APIs (ModelNode(named:),.autoAnimate(),.editable(),SceneView(environment:)init,content.add,ARSceneView(onTapGesture:)) to the real SceneViewSwift surface. - Post-merge Tier-2 review follow-up for the new
ARSceneView(renderQuality:)(#2524): fixed a stalellms.txttroubleshooting line that still calledrememberARView's tone mapper "Linear" (it is Filmic, #1434 — the same correction #2524 applied to the capabilities row), and documented that the ARrenderQualitypreset is Android-only (the iOS.renderQuality(_:)modifier is 3D-SceneView-only) in both thellms.txt"Android-only" parity table and the iOS agent-skill cheatsheet, so an AI does not generate anARSceneView(...).renderQuality(...)call that fails to compile on iOS. - Weekly doc↔API drift audit: corrected the
SceneViewandARSceneViewparameter order inllms.txtto match the actual Kotlin source.SceneViewnow listsrenderQuality/autoCenterContent/autoFitContentright afterisOpaque(was misplaced afterlifecycle), andARSceneViewlistscameraExposureaftercameraNode(was misplaced afterisOpaque). No API change — reference accuracy only. - Fixed a non-compiling
FogNodeinitializer in the iOS cheatsheets. Bothdocs/docs/cheatsheet-ios.mdand thesceneview-iosagent skill cheatsheet documentedFogNode(density:color:), an initializer that does not exist on the Swift API (the init is private; only the static factories are public). They now use the real factory formFogNode.linear(start:end:color:)/FogNode.exponential(density:color:), matchingllms.txt, so an AI reading the cheatsheets emits compiling Swift.
v4.18.0 — Cross-platform hot-path perf, Android render & CI hardening, demo-quality polish (2026-06-06)¶
Added¶
- Demo app: consolidated demos can now open directly on a specific tab via the launching alias or a
?tab=deep-link /--es tabparam (#2315). The #2239 demo consolidations merged several demos into one segmented-button demo each (e.g.custom-mesh+shape→custom-geometry), but every old alias deep link landed on the demo's default first tab — sosceneview://demo/shapeopened the Custom Mesh tab instead of Shape. A consolidated demo opened through a retired alias now pre-selects the matching tab (shape→ Shape,physics→ Physics,multi-model→ Multi-Model,movable-light→ Movable, …), and an explicit?tab=<index|alias>deep-link query /--es tab <v>intent extra overrides it (e.g.sceneview://demo/custom-geometry?tab=1). The no-alias / no-param path is unchanged — demos still open on their default first tab — and an out-of-range or unparseable tab value falls back to the default rather than crashing. Pure resolution logic (DeepLinkRouter.resolveInitialTab) is unit-tested; the alias→tab table is asserted to stay in sync with the alias map. - Evidence-Stamped Claim Gate — a false "it works / QA complete / live" success-claim can no longer reach the remote (#2346). The AI repeatedly told the maintainer something was done/working/live when it was not (iOS sat on 4.0.3 for three weeks while CI was green; demo QA reported complete on KEYLESS builds where Sketchfab/ARCore were never exercised). A new deterministic gate,
.claude/scripts/claim-gate.sh, wires onto the existingBash(git push*)pre-push hook and BLOCKS the push when the canonicalSTATE.mdasserts an affirmative ✅-stamped success-claim that lacks fresh, agreeing evidence on disk. Verifying tools now stamp that evidence:device-qa.shalready writesdevice-qa-report.json, and/store-status(store-status.js) now writes.claude/data/last-store-probe.json({expected, iosLive, mavenHttp, npm, verdict, ts}, timestamp stamped viadate -u, neverDate.now()). The gate FAILS a QA-complete claim when the report is missing/stale, a key-gated sub-leg (sketchfab/arcore-cloud) isskipped(path NOT tested, #2343), or the release verdict isblocked; it FAILS an all-live / "verified live" claim when the probe is missing/stale orverdict != ALL_LIVE(the exact iOS-stuck-on-4.0.3 trap). It fires ONLY on affirmative claims — never on honest factual lines like "iOS LIVE=4.0.3 (4.17.0 in review)" or "live on Maven Central" — and fails closed (blocks) on an unreadable evidence file rather than waving a claim through. Escape hatch for a genuine false-positive:ESCG_BYPASS=1 git push …. A slow human-in-the-loop loop complements the fast gate:/caught <class> <context>ledgers a miss the gate did not catch (.claude/data/claim-ledger.tsv, gitignored) and, at the 3rd occurrence of a class, promotes it to a durablefeedback_*.mdmemory rule;/handoffruns the gate against the drafted## NOWand backstops the ledger promotion. Verified exhaustively against the realSTATE.md(zero false-positive) plus a fixture matrix (skipped key-leg, missing/stale/unparseable evidence, version mismatch, honest factual lines, evidence-backed pass). SceneViewSwiftgains shared preset-polygon helpers —ShapePresetsplusShapeNode.starPoints(...)/ShapeNode.regularPolygonPoints(...)(#2354). The Shape Extrude gallery's preset outlines (triangle, star, pentagon, hexagon, L-shape, arrow) now live once in the library asShapePresets, and the two point generators behindShapeNode.star(...)/ShapeNode.regularPolygon(...)are exposed so callers can get the raw[SIMD2<Float>]vertices without building a node. The iOS demo'sShapeExtrudeDemoand theSceneViewSwifttriangulation tests now consume this single source of truth instead of hand-copied, byte-identical coordinate lists — so retuning a preset (e.g. the star's inner/outer radius) updates the demo and its guarding test together, and the test can no longer silently drift from the shipped shape. ExistingShapeNode.star(...)/regularPolygon(...)output is byte-identical (a purely internal extraction); no rendered geometry changes.
Changed¶
- perf(sceneview, arsceneview): cache/throttle the remaining MED Filament-JNI & per-frame allocation hot paths (#2328, #2329; audit #2402 MED-1…5 + the V1/V2 plane list churn). All changes are behavior-preserving — identical returned values, just cached/throttled/reused:
RenderableComponent.renderableInstanceandLightComponent.lightInstancedocument the caching contract explicitly (a Kotlin interface property cannot hold a backing field, so the cache must live in the implementer).ARCameraStream— the one productionRenderableComponentimplementer that was paying the uncached interface default — now caches its renderable-instance handle lazily-once, mirroringRenderableNode/LightNode, so per-frame camera-texture swaps and priority/material reads no longer issue agetInstanceJNI thunk each access.ModelInstance.renderableInstances/lightEntityInstancesresolve their handles in a single pass over the entity array (one list instead of the previous filter-then-map two), cutting an intermediate allocation on every material/shadow/visibility update of multi-entity models. Deliberately not cached across calls —ModelInstanceis an externalFilamentInstancetypealias with no invalidation hook, and a stale handle list would be the exact silent native-handle bug this audit targets.HitResultNodegains an opt-inrefreshIntervalMs(default0= run the ARCoreFrame.hitTestevery frame, byte-for-byte as before) that rate-limits the per-pixel raycast the same wayPointCloudNode/DepthMeshNoderate-limit their rebuilds; between hit tests the node keeps its last pose and the smooth-transform interpolation still runs every frame.PlaneVisualizer(V1) andPlaneVisualizerV2reuse a pre-allocated 2-element list forupdateRenderable()'s primitive selection instead of allocating a freshbuildList { }per plane per frame; the sharedselectPlanePrimitiveshelper clears and refills it from the live visibility/shadow-receiver flags each call, so no cached state can go stale.- perf(SceneViewSwift): diff-guard
applyCamera()so an unchanged orbit skips the per-frame RealityKit camera write (#2331).applyCamera()ran on everyRealityView.update:tick, every auto-rotate step (~60 Hz), every framing re-fit, and every drag/pinch tick — and each call unconditionally re-pushed the scene-root identity transform plus the perspective camera'slook(at:from:)/ position+orientation, even when nothing about the orbit had moved. Every per-mode branch is a pure function of the camera's{mode, azimuth, elevation, orbitRadius, target, fov, firstPersonEye}, so the apply now snapshots that state (after the mode-sync that may mutate it) and early-returns when it matches the last-applied snapshot within a float tolerance (1e-5rad / world units — far below one pixel of motion at any realistic scene scale, ~80× smaller than the smallest single-frame auto-rotate step, so a live camera never freezes). Behaviour-preserving: a real orbit/pan drag, an auto-rotate tick, a pinch, or arefreshContentCenteringre-fit (which mutatestarget/orbitRadiusthen re-callsapplyCamera) all change the key and re-apply on the same frame; all RealityKit/entity writes stay on the main actor exactly as before. No public API change. Phase-2 hot-path cleanup under the #2328–#2332 perf umbrella (the Rerun/SceneObserver half landed in #2372). - perf(web):
sceneview-webnow renders on-demand instead of redrawing every frame. A dirty-flag gate (RenderGate) submits a GPU frame only when something actually changed — the camera moved, an animation is playing, an async model/environment load is in flight, the auto-center pass is still running, a resize happened, or a scene/material mutation calledrequestRender()— so an idle static scene no longer runs the full SSAO+bloom+TAA pipeline 60×/second. TherequestAnimationFrameloop itself is never gated (only the draw call is) and the gate over-renders a short settle tail after every change, so the canvas can never freeze and async texture uploads always paint. Also trims per-frame churn: the animation loop no longer allocates a closure/iterator each tick and therequestAnimationFramecallback reference is hoisted. (#2332) - Dropped per-setup micro-allocations in
sceneview-coregeometry/animation:AnimationSequence.currentStepIndex,generateExtrude, andgenerateLathenow use index loops instead ofwithIndex()(no per-step/per-pointIndexedValueboxing), andgenerateIcospherebuilds its index list directly into a pre-sized list instead of allocating aListper face. Behavior-preserving (geometry/animation output is byte-identical, verified by the existing test suites). Part of #2402. - Perf: cache
Nodeparent/local-transform reads and reuse thePose.transformscratch buffer — fewer Filament JNI round-trips and per-frame allocations on hot paths (#2403, #2404, #2405, #2406; audit umbrella #2402). Four behavior-preserving caching changes in the transform/parenting hot paths, mirroring the existingNode._worldTransform/_transformInstancecache pattern:Node.parentEntityno longer callsTransformManager.getParentOrNull()on every read (#2403);Node.parentInstanceno longer callsgetParentOrNull()+getInstance()on every read (#2404) — both are cached behind a validity flag (so a legitimately-null"no parent" is cached, not re-fetched) and invalidated on the single reparent write path.Node.transformno longer callsTransformManager.getTransform()(a JNI round-trip plus aFloatArray(16)+Mat4allocation) on every read; the cache is populated by both local-matrix write paths with the exact matrix pushed to Filament, so the per-framenode.transformread an animated node makes (NodeAnimationDelegate.onFrame) is served without JNI even while the animation writes every tick (#2405).Pose.transformreuses a per-thread scratchFloatArray(16)instead of allocating one on every access, eliminating steady GC churn for nodes that refresh a pose 60–120 Hz (#2406). No public-API, threading, or rendering-semantics change — Filament JNI still runs on the main thread; cache invalidation is proven by an engine-backedandroidTest(equivalence-after-mutation + read-stability across reparent/detach/local writes) and pure-JVM contract tests.
Fixed¶
- iOS Explore: Sketchfab models no longer fail with "We were unable to load the model" (#2252). The Sketchfab download path requested the GLB format, but RealityKit's
Entity(contentsOf:)can only load USDZ/.reality— never GLB/glTF — so tapping any streamed Sketchfab model threw and showed a "Failed to load model" error. This was the App Review Guideline 2.1(a) (App Completeness) rejection, reproduced on an iPad Air 11-inch (M3): bundled USDZ models always loaded, but the live Sketchfab feeds (only present when an API key is configured, i.e. release builds) did not. The service now requests the model's USDZ format and caches it with the correct.usdzextension; when a model offers no USDZ it surfaces an honest "not available in USDZ" message instead of a generic error. The demo target also pinsPRODUCT_MODULE_NAMEso the macOSPRODUCT_NAMErename (sibling fix) keeps the Swift module name stable for the test target. - macOS App Store app now installs as "SceneView", not "SceneViewDemo" (#2252). The demo target's
PRODUCT_NAMEwas left at$(TARGET_NAME), so the built bundle, executable andCFBundleNamewere allSceneViewDemo— the installed name and menu-bar name did not match the "SceneView" App Store name, and the binary carried demo-naming. App Review rejected the macOS build under Guideline 2.3.8 (Accurate Metadata) and 2.2 (Beta Testing — demo language in binary naming).PRODUCT_NAMEis now pinned toSceneViewfor the app target (theio.github.sceneview.demobundle identifier is unchanged, so existing installs upgrade in place). Completes the partial #1688 fix, which had only renamedCFBundleName. - App Store "What's New" notes no longer leak other-platform references (#2252). The iOS/macOS release notes are extracted from the cross-platform
CHANGELOG.md, so Android/Web/Flutter bullets reached App Store Connect and tripped Guideline 2.3.10 (Accurate Metadata). The extractor now drops bullet lines that mention non-Apple ecosystems before they are pushed. - CI: App Store auto-submit no longer 409s on a stale open review submission (#2301). A
workflow_dispatchsubmit run that died betweenPOST /v1/reviewSubmissionsand the finalsubmitted: truePATCH (e.g.reviewSubmissionItemserrored) left an open, unsubmittedreviewSubmissionattached to the app, so the next run's CREATE returned 409 since App Store Connect allows only one open submission per app — the same "stranded resource blocks CREATE" class #1831 fixed on the legacy API.app-store.ymlnow listsGET /v1/apps/{id}/reviewSubmissions?filter[platform]=IOS&filter[state]=READY_FOR_REVIEW,WAITING_FOR_REVIEW,IN_REVIEW,UNRESOLVED_ISSUESand cancels each stale open submission viaPATCH {canceled: true}(there is no DELETE for reviewSubmissions) before creating a fresh one, logging the HTTP status before branching. - Demo app: the PBR Materials (
materials) and Gallery (model-viewer) tabs no longer hang forever on the "Streaming material…" / "Streaming model…" loading scrim offline or on the emulator. The shared root cause was not a network dependency: both tabs fed the resolvedfile://model path to the two-argumentrememberModelInstance(modelLoader, …), which Kotlin binds to the asset-path overload — it tried to open thefile://URI throughAssetManager, failed silently, and left the modelnull. They now load the resolved file (streamed GLB or bundled fallback) throughModelLoader.loadModelInstance("file://…"), mirroring the already-fixed Multi-Model section, so the bundled fallback renders immediately with no network. The Gallery "Nile" chip (and the AR "Coffee Mug" entry) also pointed their offline fallback atkhronos_toy_car.glb, whose Draco mesh buffer Filament cannot decode; they now fall back to a decodable bundled GLB so the default Gallery view renders offline (#2302, #2306). ModelNode(centerOrigin = …)is no longer silently ignored (Android). TheModelNodecomposable appliedcenterOriginin the underlying node's constructor (position += origin * size) but then immediately overwrotenode.positionwith thepositionparameter — on creation and on every recomposition — so any non-zerocenterOrigin(e.g.Position(0, -1, 0)to bottom-align a model) did nothing and the node rendered at the origin.centerOriginnow composes additively withposition: the alignment offset survives whenpositionis left at its default, and the two can be combined (bottom-align and place a model at a point).centerOrigin = null/Position(0,0,0)and the imperativeModelNodeclass are unaffected. Discovered while fixing the Materials → Occlusion demo (#2304).- Demo: the Materials → Occlusion tab now reads at a glance (#2304). The occluder plane sat behind the helmet (and, when in front, was centred on it and covered the whole silhouette), so the depth-occlusion effect the tab exists to show never read. The section is reframed: the helmet sits at the world origin, scaled up and framed close by a static camera on the studio IBL, and the occluder is a vertical wall whose edge sits on the helmet's centre line — so it hides exactly one lateral half of the helmet, giving an obvious vertical occlusion cut down the middle while the other half stays fully visible. Demo-only; no library API change.
- Demo: WebP-textured models no longer render black/untextured (#2305). Four
android-demomodels embedded their textures as WebP (EXT_texture_webp) and rendered black or untextured for weeks, because Filament's Android prebuilt shipsgltfiowith WebP support compiled out (isWebpSupported() == false, noimage/webpprovider — verified at the binary level). They are re-encoded to a Filament-decodable format:khronos_damaged_helmet.glb(drops the redundant WebP variant, keeps its existing JPEG),shiba.glb,threejs_soldier.glb,khronos_lantern.glb(WebP → PNG; Draco geometry preserved). This also fixesandroid-tv-demo, which shares the same bundled assets. (khronos_toy_car.glbwas already re-encoded separately in #2401.) Net APK asset growth ≈ +5.5 MB (PNG is heavier than WebP;khronos_lanternis the driver — KTX2/Basis is a tracked follow-up to reclaim it). General SDK-level WebP support — and the sameEXT_texture_webplimitation on the web build (Filament.js registers noimage/webpprovider either, so thewebsite-staticplatform models are affected too) — remains tracked on #2305. - perf(sceneview): cache the smoothTransform target's TRS in
NodeAnimationDelegate— 3 → 0Mat4decompositions/frame (#2324). The smooth-transform slerp hot path still re-decomposed the targetTransformevery frame (target.position/target.quaternion/target.scale— each a polar decomposition plus column-lengthsqrts), re-deriving the same TRS for the whole animation. The target's decomposed(position, quaternion, scale)is now cached once per target value and reused each frame; the cache is keyed on the target's value (structuralMat4equality), so it invalidates correctly on both a re-assigned target and an in-place mutation of the same target matrix. Combined with #2289 (which took the start-side decompositions from 6 → 3 per frame), the smooth-transform hot path now runs 0 matrix decompositions per frame while the target is stable. The interpolated trajectory is byte-identical — only the redundant per-frame work is removed. Follow-up of #2317/#2289 under the #2263 hot-path umbrella. - Five unlit transparent materials no longer wash out / read as an opaque "blob" over bright backgrounds (#2325).
image_texture,transparent_unlit_colored,view_texture_unlit(AndroidImageNode/TextNode/ViewNode/MaterialLoader) and the ARsemantics_overlay+face_meshmaterials all usedblending: transparent(premultiplied-alpha compositing) but emitted a straight, non-premultipliedbaseColor, so a partial-alpha surface composited ascolor + (1-alpha)*background— the colour was added at full strength and lowering the alpha never reduced it. Each fragment now premultipliesbaseColor.rgb *= baseColor.a, so the surface composites as the intendedlerp(background, color, alpha). This is the same fix class as the #2224 AR plane renderer. Opaque (alpha = 1) rendering is unchanged. All five.filamatblobs were recompiled with the pinned matc 1.71.5 (MATERIAL_VERSION 71). - perf(sceneview): trimmed per-frame and per-gesture-event allocations on Android hot paths (#2328).
LightComponent.colorreads (overridden inLightNode) now reuse a per-instance scratchFloatArrayinstead of allocating a throwaway one every read;NodeGestureDelegate.onRotatereuses a shared world-up axis constant instead of allocating aFloat3per rotate event;CameraGestureDetector'sTouchPairis built directly from theMotionEvent(1–2Float2for the common 1–2-pointer case instead of 3–4); andModelNode.applyAnimationsdrops its per-frameMutableIterator/in-place-removal in favour of a reused scratch list. All changes are behaviour-preserving — render output and gesture math are identical. arsceneviewPhase-2 hot-path cleanups — per-frame AR allocation/JNI wins (#2329). Two behaviour-preserving micro-optimizations from the hot-path audit (umbrella #2263):- AR5 —
ARCameraNodeprojection cache.onCameraUpdatedrebuilt the camera projection every tracked frame, allocating aFloatArray(16)+ aTransformand firing two redundant JNI calls (Camera.getProjectionMatrix+ the FilamentprojectionTransformsetter) for an identical result. The projection is now cached and recomputed only whennear/farchange or the AR display geometry changes (Frame.hasDisplayGeometryChanged()— the same authoritative signalARCameraStreamalready uses), so a device rotation/resize can never freeze a stale projection. Output is identical frame-to-frame; only the allocation/JNI churn is removed. - AR8 —
PointCloudNodeopt-in rate-limit.updaterebuilt the cloud on every tracked frame — allocating a positionsFloatArrayplus two directByteBuffers for the Filament upload — with no rate-limit (unlikeDepthMeshNode/#1810). A newrefreshIntervalMsparameter (on thePointCloudNodeconstructor andrememberPointCloud(...)) gates the rebuild the same wayDepthMeshNodedoes. It defaults to0= rebuild every frame, so existing behaviour is byte-for-byte unchanged; set a positive value (e.g.200= 5 Hz) to cut the per-frame allocation. The Filament upload buffers are still allocated fresh per rebuild (Filament copies asynchronously — pooling them would risk a torn upload, the [#1841] invariant).
AR12 (sharing one frame.hitTest across HitResultNodes at the same screen point) was not done: the hit-test is an opaque user lambda with its filters/screen-point captured inside it, so sharing results across nodes can't be made behaviour-preserving without a public-API redesign and regression risk. #2329 stays open for AR12.
- Collision math allocation hygiene in sceneview-core (#2330). The KMP collision hot paths no longer allocate a per-test swarm of Vector3/Pair/List objects — every intersection result (hit/miss, distance, point, normal) is byte-for-byte unchanged, only the garbage is gone. Box.rayIntersection and Capsule.rayIntersection now inline the slab/cylinder/cap math as scalar reads (was ~7 Vector3 per ray test); Intersections.boxBoxIntersection's SAT test builds its vertices/axes into function-local FloatArray scratch (was ~40 Vector3); the sphere/box test routes through a new allocation-free pointWithinBoxDistance (was ~10 Vector3 in closestPointOnBox); Capsule.capsuleBoxIntersection no longer allocates a Sphere per test point; Capsule.getSegmentEndpoints gained an allocation-free writeSegmentEndpoints(bottom, top) sibling used by the hot paths; MeshCollider.rayTriangleIntersection returns a shared immutable MISS constant instead of allocating a MeshHitResult + two Vector3.zero() on every miss; and Octree.query/queryRay gained caller-supplied-sink overloads that thread one list through the recursion instead of allocating a mutableListOf + addAll per node (the withIndex() triangle loops in MeshCollider/Octree are now index loops, no IndexedValue boxing). All scratch is function-local, so the shared KMP code stays thread-safe for off-thread collision queries. Pure-math change, fully pinned by the collision unit tests (Android + iOS).
- perf(SceneViewSwift): cut per-emit allocation and main-thread churn in the iOS Rerun bridge and scene observer (#2331, partial). RerunBridge now bumps its event total on the I/O queue and publishes eventCount to the UI in a coalesced hop instead of one DispatchQueue.main.async per emitted line (~420/s under a busy ARKit stream) — the published total is unchanged, only the per-line main-thread wake is gone. RerunWireFormat.pointCloud(_:) serializes straight from the ARKit [SIMD3<Float>] buffer (byte-identical JSON, proven by a golden test) instead of reflattening into a temporary [Float] array every emit. SceneObserver.update() gates its @Published entityCount/estimatedFPS writes on a real value change, so a static scene graph no longer re-publishes (and re-wakes every bound SwiftUI view) every frame. The applyCamera() per-frame diff-guard from the same issue is deferred to a dedicated visual-QA pass and #2331 stays open.
- The weekly doc-audit cron now surfaces failures as a de-duplicated tracking issue instead of failing silently (#2340). An expired CLAUDE_CODE_OAUTH_TOKEN (or any failure) previously only showed in the Actions tab, so the audit could silently stop for weeks; an if: failure() step now opens or refreshes one tracking issue per outage.
- Demo device-QA now builds WITH the API keys and honestly SKIPS the key-gated paths when a key is absent (#2343). The QA harness (device-qa.sh / qa-android-demos.sh) used to build the demo assembleDebug with no SKETCHFAB_API_KEY / ARCORE_API_KEY injected, so the Explore/Sketchfab path and the AR Cloud demos (Cloud Anchors / Geospatial / Streetscape) were never exercised — yet the run reported a complete green QA. A new sourced helper (.claude/scripts/lib/qa-keys.sh) resolves both keys (env, else repo-root local.properties) and exports them so the existing build.gradle wiring bakes them into the debug APK. When a key is absent the run now records a dedicated skipped advisory leg (sketchfab / arcore-cloud) in device-qa-report.json with reason key missing — … NOT tested, drives the release gate to warn (never a silent clear), and prints a loud unmissable banner — impossible to misread as complete. When a key IS present, any pre-existing (possibly keyless) demo APK is deleted before the build/install so a stale artifact can never short-circuit the keyed build — an env-sourced buildConfigField is not a tracked Gradle input, so deleting the APK, not trusting UP-TO-DATE, is the robust trigger. The CI android / ar device-QA legs inject the secrets so the keyed paths are exercised in CI too. Presence only is ever logged; no key value is printed or committed.
- Explore "Open in SceneView" viewer now auto-fits any model to the camera (#2348). The Sketchfab viewer rendered the model with scaleToUnits = 1f (normalised to a unit cube) yet computed the orbit radius from the model's raw glTF bounding box — two different scales — so a car authored in large units rendered tiny in a black void while a small-unit character was over-zoomed to its legs. The viewer now mirrors the correctly-framed ModelViewerDemo: it renders at the model's true glTF size, recenters it on its bounding-box centre (off-origin glTF pivots were the prime cause of the "cut off at the legs" framing), and derives the orbit distance from the library helper io.github.sceneview.fitDistanceForBounds (bounding-sphere fit on both axes, fed the live render-surface aspect and the stock 28 mm lens FOV). Tall (Scifi Girl) and wide (Porsche) models now both frame to roughly 85 % of the viewport, centred — verified visually on a Pixel_7a emulator with the keyed build.
- AR Geospatial demos no longer leak the raw FatalException class name into the UI (#2349). When a Geospatial session failed to establish (no VPS coverage / no ARCore Cloud API key — e.g. on an emulator), ARCore throws a FatalException with a null message, and the demos surfaced exception.message ?: exception.javaClass.simpleName directly, so the status banner read the literal "AR session error: FatalException". A new shared mapper friendlyArSessionError(...) translates known ARCore exception classes to honest, actionable copy and degrades the unknown / null-message case to "AR couldn't start — this needs a device with VPS coverage and an ARCore Cloud API key." Applied to ARTerrainAnchorDemo, ARRooftopAnchorDemo, and ARStreetscapeDemo (which shared the identical bug). Verified on a Pixel_7a emulator: a real com.google.ar.core.exceptions.FatalException now renders the friendly message and "FatalException" no longer appears anywhere in the UI.
- Models demo "Surprise me" button is no longer clipped by the Settings FAB (#2350). The extended "Surprise me" FAB and the DemoScaffold Settings FAB / peek chip were both pinned to the bottom-end corner with the same 16 dp padding, so the round Settings control sat on top of the extended FAB and truncated its label to "Surprise…". The "Surprise me" FAB now lives in the bottom-start corner (with system-bar inset padding) so the two controls occupy opposite corners. Verified visually on a Pixel_7a emulator — the full "Surprise me" label is readable with no overlap.
- Lighting Lab's Time-of-Day slider now actually swaps the HDR sky (#2353). Dragging Time of Day from noon to night updated the label and the dynamic sun, but the skybox + IBL stayed frozen on the initial noon outdoor_cloudy_2k.hdr — the marquee day↔night effect of the flagship lighting demo silently did nothing. rememberEnvironment memoised only on (environmentLoader, isOpaque, environment); the factory lambda closed over the time-of-day-derived HDR path but Compose treats the lambda as a stable key, so createHDREnvironment ran once and never re-ran. rememberEnvironment now takes an optional key: Any? = null that participates in its memoisation (rebuilding and disposing the old Environment when the key changes), and the demo passes key = envAsset. Verified visually on a Pixel_7a emulator: noon shows the blue-sky HDR, dragging to night shows the dark rooftop-night HDR. The new key parameter is a public-API addition to rememberEnvironment (both overloads): it is source-compatible — every existing call site is unaffected — but, like any Compose @Composable signature change, the generated JVM method descriptor changes, so consumers must recompile against this release (binary-incompatible; permitted on a minor bump per the project's version policy).
- iOS Shape Extrude: presets now render as real shapes instead of a collapsed edge-on ribbon (#2354). ShapeNode built its polygon in the XZ (horizontal) plane with a +Y normal, while SceneView Android's ShapeGeometry builds it in the XY plane facing the camera (+Z). With the demo's horizontal orbit camera, a horizontal star was seen nearly edge-on — the default "Star" preset rendered as a thin gold ribbon/bowtie rather than a star (and L-Shape/Arrow were unreadable). ShapeNode now builds the polygon in the XY plane facing +Z (flat vertices at (x, y, 0) with a (0,0,1) normal; extrusion is symmetric along Z with front/back faces at ±depth/2 and outward-wound side quads), matching Android. The ear-clipping triangulator was unchanged — it already handled the concave star/L-shape/arrow correctly; the bug was purely the build plane. The polygon is now normalised to a single counter-clockwise winding before meshing, so a clockwise ShapeNode(points:) input (the public API documents no winding requirement) no longer renders its extruded side walls inside-out — previously a clockwise polygon got an outward normal but kept the clockwise face winding, so single-sided materials back-face-culled the real outer wall. ShapeExtrudeDemo was retuned with a gentle compound tilt so the shape reads head-on while the extrusion depth stays visible.
- iOS Multi-Model "Park" demo no longer renders four identical copies of the same island in keyless mode (#2355). The four park slots (tree / bench / dog / bird) in SampleAssets.swift all declared the same fallbackBundledPath: "Models/tree_scene.usdz", so a build without a Sketchfab API key (the default local + App Store build) stacked two-to-four copies of the same 14 MB terrain island at slightly different positions instead of a multi-model diorama — and the "Loading park scene…" scrim never cleared early because every slot loaded the same heavy file, defeating the #1056 progressive-reveal. The bench / dog / bird slots now fall back to distinct, lighter bundled USDZs (retro_piano.usdz 1.8 MB as the foreground prop, animated_butterfly.usdz 3.1 MB as the animated occupant, phoenix_bird.usdz 1.1 MB as the perched bird) so keyless mode shows four distinct silhouettes, and the lightest slot (the 1.1 MB bird) lands first and dismisses the scrim early. The keyed path is untouched — it still streams the real Sketchfab oak-tree models.
- Camera & Gestures "Free Flight" mode no longer opens on a black void (#2357). Switching the camera mode to Free Flight dropped the user into an empty black viewport with nothing to look at, and the void even leaked back into Orbit afterwards. The real cause was the demo wrapping its whole SceneView subtree in key(selectedMode, …): every mode switch tore down and rebuilt the scene, and the rebuilt ModelNode re-attached the already-attached shared modelInstance, leaving the new scene with nothing to render. The demo now keeps a single stable SceneView and swaps only the Filament Manipulator per mode (SceneView already adopts a new cameraManipulator live), so the helmet stays rendered and centred across Orbit → Free Flight → Map and after Reset Camera. The Free Flight start orientation is now also derived from the home → target vector (Filament's eulerZYX(0, yaw, pitch) · (0,0,-1) convention) instead of a hard-coded (0,0), so the camera is correctly aimed at the model for any camera home. Verified visually on a Pixel_7a emulator (helmet centred in Free Flight, after Reset Camera, and back in Orbit).
- The floating Feedback chip no longer overlaps a content card at rest (#2358). The chip is anchored to a fixed bottom-left band, so a full-width card naturally resting in that band was masked even though [#2194] reserved bottom padding for the last item — on Explore the first "Trending models" card ("Scifi Girl v.01"), and on About the Sponsor monetization CTA ("Help keep the project free & active"). The chip now follows the standard Material 3 scroll-aware-FAB behaviour: it is hidden while the list is at its resting (top) position — where the overlap occurred — and slides in from the left the moment the user scrolls, by which point the overlapped card has left the band. Applied consistently across every tab that shows the chip (Explore, About, AR View, Samples). Verified visually on a Pixel emulator: at rest the Sponsor card and the first Trending card are fully visible and tappable; the chip reveals on scroll over empty gutter space.
- Flutter & React Native bridges: switching the HDR environment at runtime now actually swaps the skybox (#2361). Both Android bridges built their Environment via rememberEnvironment(environmentLoader) { createHDREnvironment(path) … }, where path is runtime-mutable (Flutter's setEnvironment method-channel, RN's environment prop). Because the factory lambda is a stable Compose remember key, swapping one non-null HDR for a different non-null HDR left the skybox/IBL frozen on the first one — the same stale-factory class as #2353. Both call sites now wrap the build in a key(path) { … } block so a new path tears down and rebuilds the Environment (disposing the old one). The fix uses Compose's key {} rather than rememberEnvironment's own key= parameter on purpose: the bridges compile against the published Maven artifact (sceneview:4.6.2 / 4.7.0), which predates that parameter, whereas key {} works on every SceneView version. Pre-existing bug, not a regression.
- impact-check.sh now runs correctly in a lean --depth 1 + sparse clone — the standard batch-agent workflow (#2370). Two failure modes are fixed. (1) The node-count consistency check FALSE-FAILed every "N+ node types" doc claim (Claims 42, actual 24) when only one of the two node-source dirs was checked out: the total is the SUM of sceneview/.../node + arsceneview/.../ar/node, so a sparse checkout that omitted arsceneview/ produced a partial count and hard-failed (a blocker under --fail in the quality gate). The check now SKIPs — never FALSE-FAILs — unless BOTH source dirs are present so the total is complete, while still actively flagging a real count mismatch on a full checkout. (2) The sample-build check silently no-oped in a shallow clone: git diff HEAD~1 HEAD can't resolve HEAD~1 without history, so it always reported a misleading "No SDK/sample source changed" PASS. It now picks a diff base that exists (HEAD~1 in a full clone, else origin/main plus uncommitted working-tree edits), SKIPs honestly when samples/android-demo is sparse-excluded or no base is resolvable, and never silently passes. Full-clone behaviour is unchanged. A new test-impact-check.sh self-test (wired into ci.yml → repo-hygiene) pins the contract.
- Bumped vitest 3.x → ^4.1.0 in the three Cloudflare worker projects to clear GHSA-5xrq-8626-4rwp (#2374). telemetry-worker, mcp-gateway, and feedback-worker each declared vitest 3.x as a devDependency, which Dependabot flagged with 3 critical alerts for the Vitest UI server arbitrary-file-read advisory. All three now resolve to vitest 4.1.8 (vite 8). vitest is a test-only devDependency and the three live gateways do not ship it, so this was never a production-runtime risk — but the bump silences the critical alerts. The simple defineConfig Node-pool configs needed no v4 migration; every worker's test suite passes (telemetry 57, gateway 180, feedback 45). Lockfiles regenerated.
- Demo app: fixed two pre-existing issues found while landing the offline-scrim fix (#2390). (1) The same rememberModelInstance(modelLoader, "file://…") overload trap — a two-arg positional call binds to the asset-path overload and silently fails to load file:// URIs — also affected the Animation & Physics carousel (streamed models), the AR Placement / AR Instant Placement demos (streamed placements), and the Model Viewer "Surprise me" stream; they now pass fileLocation = to bind the URL-capable overload, which scheme-detects bundled asset paths and file:// URIs alike. (2) khronos_toy_car.glb was unparseable by Filament's gltfio — a babylon.js export left an out-of-bounds clearcoatTexture index plus webp-only textures (Filament's bundled runtime has no image/webp decoder) — so it rendered black even after parsing. The asset was re-exported (dangling texture dropped, textures transcoded off webp to PNG/JPEG, mesh re-Draco-compressed) and now decodes and renders textured, fixing the AR Image, Depth of Field and AR-View "Toy Car" demos (and the shared Android-TV "Toy Car") that load it as a bundled asset, and it is restored as the distinct Gallery "Nile" / AR "Coffee Mug" fallback (#1433).
- ViewNode.WindowManager.resume() now arms the off-screen retry listener synchronously when the owner View is detached, instead of deferring through View.post(). On a detached View post() queues into the HandlerActionQueue (flushed only on attach, by which point isAttachedToWindow is already true), so the documented #984 retry path (OnAttachStateChangeListener) was unreachable dead code. The end behaviour is preserved (the off-screen window attaches when the owner attaches), but the retry is now explicit and the ViewNodeTest.windowManager_resume_withDetachedOwner_registersAttachListener regression — which deterministically reddened the Render Tests workflow on every push to main — is fixed (#2393).
- A model swapped into a single slot no longer leaves the previous model "stacked" behind the new one (#2400). Switching the model in one slot (the Model-Viewer → Gallery chips, "Surprise me", the Animation & Physics carousel) appeared to render the new model on top of the old one. Device investigation (logcat probe on the Filament Scene) proved the issue's first hypothesis — that ModelNode disposal fails to remove the model's renderable entities — is not the cause: on every swap SceneNodeManager.removeNode correctly removes all of the old model's entities (Scene.getRenderableCount() drops to the new model's count and Scene.hasEntity(...) returns false for the old renderables). The real cause is rendering, not disposal: Filament defaults to Renderer.ClearOptions.clear = false and relies on the skybox to repaint the background every frame, but the model demos use an IBL-only environment (createSkybox = false) so the model can float on the surface background. With no skybox the swap chain is never cleared, so when the rendered footprint shrinks — a large model replaced by a smaller one — the previously-rendered pixels the new model does not cover are left on screen, looking like a stale model stacked behind the new one. SceneView now sets Renderer.ClearOptions.clear = true (clear to opaque black when isOpaque, transparent otherwise), so the color buffer is repainted every frame. When a skybox is present it simply overdraws the clear, so skybox scenes are unaffected. Verified on an ARCore emulator: before, the green toy-car lingered behind the fox / lantern after a Gallery chip switch; after, only the selected model renders. This per-frame-clear gap is Android/Filament-specific: Web (sceneview-web) already sets clear: true at scene setup and iOS (RealityKit) clears its framebuffer in the native render loop, so neither has the stale-pixel bug — only a quick iOS confirm pass is tracked as a parity follow-up.
- iOS AR: ARSceneView no longer leaks RealityKit/ARKit resources when the SwiftUI view is removed (#2407, #2408 — audit #2402). The UIViewRepresentable had no teardown path, so when the AR view left the hierarchy the ARSession kept running — the rear camera, motion sensors, and per-frame tracking pipeline stayed live, draining battery — and every anchor the coordinator had added stayed parented in arView.scene: the translucent detected-plane overlays (#2407) and the dual main/fill light anchors (#2408) were orphaned for the process lifetime. ARSceneView now implements dismantleUIView(_:coordinator:) as the primary, main-actor teardown — it pauses the session, detaches the session delegate, and removes + releases the plane overlays and the cached light anchors (reusing the #2278 cached-anchor references). The coordinator also gains a deinit safety net that breaks the same strong-reference graph if it is ever released without a dismantle, mirroring SceneEntities.deinit's main-thread-guarded teardown (#2068). The teardown is idempotent, so the two paths never double-free. This brings iOS to parity with Android's ARScene DisposableEffect/onDispose teardown, which already paused the session and destroyed the plane renderer + lights. Verified on the iOS simulator with a weak-reference leak test (ARSceneViewTeardownTests): provisioned overlays + light anchors are removed from the scene and their weak references go nil after teardown, and the session delegate is detached.
Tests¶
- iOS gesture-delta tests now drive the production code path instead of a re-implementation (#2313). Extracted the drag cumulative→per-frame-delta conversion + per-entity baseline out of the
privateentityDragGestureinto an internalEntityDragState, soGestureSystemTestsexercises the same code the gesture runs — a regression in the realcurrent − previousdispatch or the.onEndedbaseline reset is now caught (verified by mutation testing). Also added a test for the screen→worldworldTranslationscale + Y-flip that was previously untestable. - Every
ALL_DEMOSid is now guaranteed routable by the debug deep-link host, with a pure-JVM guard (#2320).DemoHostActivity— the debug-only host that instrumentation tests and the--es demo_id <id>QA channel use to launch a single demo composable directly — hand-maintained awhen (id)mapping ids to composables. A demo in the catalog but missing a branch crashed the harness witherror("Unknown demo id"); #2319 found three such demos by interactive QA, and an audit during this fix found seventeen more AR demos in the same state (ar-hand-tracking,ar-scene-mesh,ar-orbital, …) — all of which would have crashed the host.DemoHostActivitynow delegates routing to the collator-generatedGeneratedDemos.Screen(the same router the main-appDemoRouteruses), resolving retired ids throughDeepLinkRouteraliases first, so it covers every catalog id by construction — the hand-written-when()drift class is eliminated. A new:samples:android-demo:testDebugUnitTesttest (DemoHostRoutableTest) asserts the pure, non-composableDemoHostActivity.routableIdresolver covers everyALL_DEMOSid and every retired-id alias — no emulator, no instrumentation. - iOS demo QA can now build WITH the Sketchfab API key, so the Explore/Sketchfab path is exercised instead of shipping untested (#2356). On a fresh checkout
samples/ios-demo/SceneViewDemo/Secrets.xcconfigis absent, so local + QA builds were keyless:SketchfabConfig.apiKeywasnil, the Explore carousels + search were disabled, and the streamed-USDZ demos (Multi-Model, Orbital, Model-Viewer) silently fell back to bundled assets — the exact live path that caused the App Review 2.1(a) rejection in #2252 (RealityKit can only load USDZ, never GLB) went unverified, yet a green QA run looked complete. This mirrors Android #2343 for iOS. A committedSecrets.xcconfig.template(placeholder only) documents the key, the realSecrets.xcconfigstays gitignored (**/Secrets.xcconfig), and a committedConfig.xcconfig#include?s it optionally so a keyless checkout still builds silently.ios-device-qa.shnow sources the sharedqa-keys.shresolver (env → repo-rootlocal.propertiessketchfab.api.key), passes the key toxcodebuildas aSKETCHFAB_API_KEYuser-defined build setting (substituted intoInfo.plist'sSketchfabAPIKey = $(SKETCHFAB_API_KEY)), and adds a--sketchfab-keyoverride flag; a keyless run prints a loud banner and is reported as NOT having tested the Sketchfab path (advisory). Presence only is ever logged — the token value is never printed or committed. -
Web: the published
sceneview-webKotlin/JS bundle could not initialise Filament in a browser —createViewer()hung forever on a blank canvas (#2410). The npm/CDN bundle (sceneview-web@<v>/sceneview-web.js) threw duringSceneView.create()and, because the error was onlyconsole.error-ed, the returned Promise never settled. Root cause was a chain of init-path bugs hidden because thejsTestsuite stubs the Filament externals and the demo/website run the separate hand-authoredsceneview.js: Kotlinas/companion access against a Filamentexternal classcompiled toinstanceof <undefined>(the class binding is captured at module-load, beforeFilament.init()attaches the embind classes) —TypeError: Right-hand side of 'instanceof' is not an object;Camera.setProjectionFovwas called with 4 args where embind enforces 5 (theCamera$Fovdirection);LightManager.Builderresolved toundefined; andcreateAsset/createIblFromKtx1/createSkyFromKtx1were handed a rawArrayBufferinstead of aUint8Array(embindBindingError). External Filament types are now resolved lazily through the runtimeFilamentglobal withunsafeCast, theCamera$Fov/LightManager$Typeenums andUint8Arrayviews are passed explicitly, andSceneView.create()gained anonErrorhook socreateViewer()rejects on failure instead of hanging. -
Web: real in-browser smoke test for the compiled Kotlin/JS bundle (#2410).
samples/web-demo/tests/kotlin-bundle.spec.tsloads the productionsceneview-web.jsbundle next to a version-matchedfilament.js/.wasmand assertscreateViewer().then(...)resolves and renders non-blank — the gap that let the init crash above ship invisibly (the KarmajsTestsuite stubs Filament). Built + staged + run by.claude/scripts/web-bundle-smoke.sh, wired as a blocking step in theweb-desktopCI job. - Unit-tested the #2331 iOS camera diff-guard and the #2332 web render-gate against their production code paths (same rigor as #2313). Extracted
AppliedCameraStateout of theprivateSceneViewRepresentationto a top-levelinternaltype soapproximatelyMatchesis directly testable, and addedAppliedCameraStateTests(identical-state match, per-scalar/target re-apply aboveeps, sub-epsstill-matches, mode change, nil ↔ non-nilfirstPersonEye). Added a webOrbitCameraControlleridle test under the shipped default (enableDamping = true) asserting a settled camera reports not-moved so the render gate idles. DocumentedOrbitCameraController.update()'sBooleanmoved-signal inllms.txt. (#2412)
Docs¶
- CI now guards the AI-facing docs prose against stale demo-class / demo-id references (#2316). When a demo is deleted or merged (the #2239 consolidation merged ~20 Android demos), only the collator-generated demos block in
llms.txtis regenerated — hand-written prose outside the markers keeps naming the deleted Kotlin class (AnimationDemo,MultiModelDemo,PhysicsDemo) or the retired deep-link id, teaching an AI to reference a file/id that no longer exists. A new.claude/scripts/check-demo-class-refs.shgreps the AI-facing surfaces (llms.txtoutside the collator markers,docs/docs/recipes/*,samples/recipes/*, the Android + web agent skills) for*Democlass tokens that exist on no platform, dead*Demo.{kt,swift,ts}source links, and retired ids insceneview://demo/<id>deep-link form. It is precise by design — it does not flag the iOS demo app's legitimately-separate*Demo.swiftfiles that still exist, nor a retired id used as a plain prose phrase (e.g. "the gesture-editing API") — and is wired intoci.yml→repo-hygieneas an advisory (non-blocking) step alongsidecheck-doc-drift.sh, self-tested first bytest-check-demo-class-refs.sh. The two currently-known stale Picker-pattern references (AnimationDemo/PhysicsDemo→AnimationPhysicsDemoinllms.txtanddocs/docs/recipes/demo-settings-sheet.md) are corrected so the guard runs clean. - Documented the new
HitResultNode.refreshIntervalMshit-test throttle inllms.txt(#2328). #2328 added an opt-inrefreshIntervalMsrate-limit toHitResultNode(mirroringPointCloudNode/DepthMeshNode); the AI-first reference now documents it so an assistant can generate code that uses the throttle, closing the doc-coverage gap a review-fanout flagged. - iOS docs: fixed
BillboardNode/ImageNodesignatures that did not exist in the Swift source. The iOS cheatsheets and the cross-platform tables inllms.txtdocumentedBillboardNode(named:width:height:),BillboardNode(text:fontSize:color:),ImageNode(named:size:)and anImageNode.billboard()helper — none of which exist — so an AI reading the docs emitted non-compiling Swift. Corrected to the real API:BillboardNode(child:)/BillboardNode.text(_:fontSize:color:)andImageNode.load("img.png"), acrossdocs/docs/cheatsheet-ios.md,agents/sceneview-ios/references/{cheatsheet,recipes}.md,llms.txtandwebsite-static/.well-known/llms.txt.
v4.17.0 — Performance & correctness: the hot-path audit (2026-05-31)¶
A performance-and-correctness release built around the #2263 hot-path audit — a 5-surface sweep (Android 3D, AR, KMP core, Apple, Web) that found and fixed the per-frame allocation / matrix-decomposition / JNI patterns that quietly burned CPU and GC at 60–120 Hz. Highlights:
- The #2187 transform drift is now fully fixed. The original fix cached the TRS
getters but the per-component setters (
node.quaternion = …) still round-tripped through matrix decomposition and drifted scale; a new Filament-Engine-backed regression harness surfaced it and it's closed (component setters no longer re-decompose; the world-space TRS getters are cached too). - Per-frame work cut across the board: world-space TRS cache, cached Filament
instance handles,
Mat4.copyColumnsInto(zero-alloc matrix upload), a pre-decomposedslerpoverload, collisionRayby-ref, camera-manipulator memoization, batched ARCore updated-set lookups, webrequestAnimationFrameallocation elimination, and SwiftUI auto-rotate off@State. - Correctness fixes: AR
PoseNodepose write, the iOS entity-drag delta bug, and a new Engine-backed test tier (NodeWorldTransformDriftTest/NodeLocalTransformDriftTest/NodeSmoothFollowTest) that pins the transform caches against regression. - Docs: a hot-path / allocation-free API guidance page so generated code avoids the whole class of bug.
No breaking changes; new public APIs are additive (Mat4.copyColumnsInto, the TRS
slerp overload, Pose.toTransform(out)).
Changed¶
- Samples catalog: unified Camera & Gestures demo (#2239). The retired
camera-controlsandgesture-editingdemos consolidated into a singlecamera-gesturesentry with a segmented-button toggle between Camera Modes (orbit / free-flight / map manipulator + distance slider) and Node Gestures (per-node drag / twist / pinch with editability locks, scale sensitivity, and live transform readout) modes. Existingsceneview://demo/camera-controlsandsceneview://demo/gesture-editingdeep links keep working viaDEMO_ID_ALIASES. - Samples catalog: unified Custom Geometry demo (#2239). The retired
custom-meshandshapedemos consolidated into a singlecustom-geometryentry with a segmented-button toggle between Custom Mesh (composite primitives) and Shape Extrude (2D polygon → 3D mesh) modes. Existingsceneview://demo/custom-meshandsceneview://demo/shapedeep links keep working viaDEMO_ID_ALIASES. Batch 1 of the 60 → 23 catalog regrouping (iOS mirror follows). - Samples catalog: unified Picking & Collision demo (#2239). The retired
collisionandview-nodedemos consolidated into a singlepicking-collisionentry with a segmented-button toggle between Ray Hit-Test (tap-driven highlight) and View Node (Compose UI on a textured quad) modes. Existingsceneview://demo/collisionandsceneview://demo/view-nodedeep links keep working viaDEMO_ID_ALIASES. - Samples catalog: unified 2D in 3D demo (#2239). The retired
text,image,video, andbillboarddemos consolidated into a singletwo-d-in-three-dentry with a segmented-button toggle between Text (TextNodelabels), Image (ImageNodephoto gallery), Video (VideoNodestreaming MP4 with surface variants + cinematic camera), and Billboard (BillboardNodevs fixedImageNode) modes. Existingsceneview://demo/text|image|video|billboarddeep links keep working viaDEMO_ID_ALIASES. - Samples catalog: unified Lighting Lab demo (#2239). The retired
dynamic-sky,environment,reflection-probes, andpost-processingdemos consolidated into a singlelighting-labentry with a segmented-button toggle between Sky (DynamicSkyNodetime-of-day sun), Environment (HDR IBL switching), Reflections (ReflectionProbeNodelocal IBL zone), and Post-FX (SSAO / MSAA / FXAA / dithering) modes. Existingsceneview://demo/dynamic-sky|environment|reflection-probes|post-processingdeep links keep working viaDEMO_ID_ALIASES. - Samples catalog: unified Animation & Physics demo (#2239). The retired
animationandphysicsdemos consolidated into a singleanimation-physicsentry with a segmented-button toggle between Animation (skeletal/keyframe playback with a model carousel, cinematic camera shots, and play/pause/speed/loop controls) and Physics (PhysicsNoderigid-body simulation dropping streamed crash-test bodies or bundled spheres). Existingsceneview://demo/animation|physicsdeep links keep working viaDEMO_ID_ALIASES. - Samples catalog: unified Materials demo (#2239). The retired
texture-streamingandocclusion-materialdemos consolidated into the existingmaterialsentry with a segmented-button toggle between PBR Materials (KHR_materials_*extension showcase), Streaming (runtime texture / material swap on a loaded model), and Occlusion (invisible depth-writing surface) modes. Thematerialsid stays a live registered demo; existingsceneview://demo/texture-streaming|occlusion-materialdeep links keep working as aliases viaDEMO_ID_ALIASES. - Samples catalog: unified Models demo (#2239). The retired
multi-modelandscene-gallerydemos consolidated into the existing flagshipmodel-viewerentry with a segmented-button toggle between Single Model (bundled hero viewer with optional Sketchfab "Surprise me" stream), Multi-Model (themed park scene from 4 streamed assets with visibility chips + spin toggle), and Gallery (chip-picked themed Sketchfab models) modes. Themodel-viewerid stays a live registered demo; existingsceneview://demo/multi-model|scene-gallerydeep links keep working as aliases viaDEMO_ID_ALIASES. - Perf: smooth-transform animation hot path (#2265, part of #2263).
NodeAnimationDelegate.onFramenow readsnode.transformonce per frame instead of three times (eliminating two Filament JNI round-trips per animating node per frame) and feeds the interpolation the node's #2187-cachedposition/quaternion/scalevia a new pre-decomposed TRS-tupleslerp(startPosition, startQuaternion, startScale, endPosition, endQuaternion, endScale, …)overload insceneview-core— halving the per-call matrix decompositions from six to three. Behaviour and trajectories are unchanged; the originalslerp(Transform, Transform, …)overload is retained and now delegates to the tuple form. - Cache Filament component instance handles instead of re-querying them on every access (#2269, #2285, #2287, part of #2263). A
RenderableManager/LightManager/TransformManagerinstance handle is stable for the lifetime of the component on an entity, so it no longer pays agetInstanceJNI thunk on every read. Following the lazy-once pattern PR #2280 introduced forNode.transformInstance,RenderableNode.renderableInstanceandLightNode.lightInstancenow cache their handle on first use (skinning / bone-matrix / morph-weight / AABB / culling / priority reads, and reactive light setups that re-apply on every recomposition), and the AR plane visualizers (PlaneVisualizer,PlaneVisualizerV2) cache their plane entity's transform instance instead of looking it up per update. A0(not-yet-built) result is never frozen — it re-looks-up on the next access — so behaviour is identical; this is a pure hot-path allocation/JNI reduction with no public API change. - Batch per-frame ARCore updated-set lookups (#2270, part of #2263). The AR frame driver now builds the
getUpdatedTrackables/updatedAnchorsmembership sets once per frame and shares them with every node, instead of eachTrackableNode/AnchorNodecalling back into ARCore independently (1 JNI thunk + a fresh JNI-allocatedList+ an O(n) linearcontains()per node — O(N×M) work and N allocations every frame). Each node now does an O(1)HashSet.contains()lookup, collapsing the cost to O(N+M).rememberDetectedPlaneslikewise maintains its tracked-plane set incrementally fromframe.getUpdatedPlanes()(an ARCore delta) plus a mutable cache rather than recomputingsession.getAllTrackables(Plane).filter { … }.toSet()every Compose frame. Pure performance refactor — identical callbacks fire for the same trackables/anchors/planes, and the publicupdate(session, frame)signatures are unchanged. ModelNodeno longer re-scans static renderables' bounding boxes every frame (#2273, part of #2263).sanitizeEmptyBoundingBoxes()runs fromonFrameevery frame to disable culling/shadows on empty-AABB renderables (and re-enable them once valid), which previously meant agetInstance+getAxisAlignedBoundingBoxJNI call plus aFloatArray(3)allocation per renderable on every frame forever — ~7 200 JNI thunks/s for a 20-renderable model at 120 Hz. A renderable is now latched and skipped on subsequent frames only once it is observed valid AND its AABB cannot change at runtime — the model has no skins (modelInstance.skinCount == 0) and the renderable has no morph targets (getMorphTargetCount == 0). Skinned / morph-target renderables are never latched and keep the full per-frame valid↔empty check, so the runtimevalid→emptycollapse (degenerate bone pose, zeroed morph weights) that would otherwise crash Filament is still caught. Once every renderable of a fully-loaded static model is latched, the method early-exits with zero JNI work. Pure performance — identical visible behaviour, no API change.
Fixed¶
- AR plane renderer no longer shows an opaque white blob over bright scenes (#2224). The plane grid material used
blending: transparent(premultiplied alpha) but emitted a straight, non-premultipliedcolor, so it composited ascolor + (1-alpha)*background— the grid colour was added at full strength and the alpha cap only controlled background bleed-through. Over a bright camera feed this read as a near-opaque white blob regardless of the cap. The fragment now premultipliesbaseColor.rgb *= baseColor.a, so the plane composites as the intendedlerp(background, color, alpha)— a genuine ~20 % translucent grid. Diagnosed and validated with a new non-ARplane-grid-previewshader-QA tool that renders the exactplane_renderer.filamaton a static surface (debug builds only). Nodeworld-space TRS cache completes the #2187 fix (#2264).worldPosition/worldQuaternion/worldScale/worldRotationgetters no longer re-decompose the Filament 4×4 matrix on every read. Cached in_worldTransform/_worldPosition/_worldQuaternion/_worldScale/_worldRotationfields, invalidated throughonWorldTransformChanged()propagation.worldRotationextracts its Euler angles directly from the matrix (not via the quaternion) to stay bit-equivalent across gimbal-lock boundaries.ModelNodeadditionally invalidates its glTF sub-nodes' world cache on move and while animations play (their Filament transforms are written outside the Node setters). Also fixed a latent bug where re-parenting a node did not invalidate the world transform of the child and its descendants.Node.transformInstancecached after first lookup (#2269). The TransformManager instance handle is stable for the lifetime of an entity; cache it so transform getters/setters stop paying a JNI thunk per access (eliminates ~30 000 JNI calls/s on a 100-node animated scene at 120 Hz).NodeAnimationDelegate.onFramereadsnode.transformonce per frame (#2265). The old code fired the matrix round-trip three times per smooth-animated node per frame; the new code reads the current transform into a local val and reuses it. (Partial fix; theslerp(Transform, Transform)TRS-overload migration is tracked as a follow-up.)- Perf:
PoseNode.pose(andARCameraNode.pose) now write the world translation + rotation directly from the ARCorePosecomponents instead of routing throughworldTransform(pose.transform), which allocated a freshFloatArray(16)+Transformand ran a matrix decompose/recompose on every anchor / plane / face / image / camera pose update, every frame. Added an allocation-freePose.toTransform(out: FloatArray)scratch variant for callers that still need the matrix form. (#2266, umbrella #2263) - World ↔ local quaternion conversions no longer run a Mat4 polar decomposition on every call (#2267). Setting
Node.worldQuaternion/Node.worldRotation(and the underlyinggetLocalQuaternion/getWorldQuaternion) used to decompose a 4×4 matrix into its rotation component every time. A rotation-only conversion never needs the matrix:getWorldQuaternionisparentWorldQuaternion * localQuaternion, and for an unscaled nodegetLocalQuaternionisinverse(parentWorldQuaternion) * worldQuaternion. New direct-quaternion overloadsworldToLocalQuaternion/localToWorldQuaternion(and the Euler variants) skip the decomposition, reusing the world-space quaternion cache from #2264. Behavior-preserving:getLocalQuaternionkeeps the exact legacy matrix path for scaled nodes —inverse(M).toQuaternion()andinverse(M.toQuaternion())diverge onceMcarries scale (#2294 review), so the fast path is gated on an unscaled world transform. The legacyTransform-taking overloads are kept for source compatibility but@Deprecated. Part of the hot-path allocation audit (#2263). - Web
refreshContentCenteringno longer copies + writes back a mat4 per model per frame (#2268). The web auto-center / framing pass ran up to 10 startup frames (re-armed on everyloadModel/addGeometry) and, for each loaded model, crossed the WASM↔JS boundary to re-fetch theTransformManager, read 16 boxednumbers into a fresh[], then allocated a second 16-element array just to add the centring offset. It now caches theTransformManageronce at construction, snapshots each model's base transform into a primitiveDoubleArray(16)(un-boxed reads, allocated once per model), composes the offset into a single reusable scratch array mutated in place (zero allocation after warmup), and coalesces everysetTransforminto one GPU upload viaopenLocalTransformTransaction()/commitLocalTransformTransaction(). The final framing transform is unchanged. Web-port cousin of #2187; part of the hot-path allocation audit (#2263). - Hot-path allocation:
Mat4/Mat3.copyColumnsIntobuffer-fill overloads (#2271).Mat4.toColumnsFloatArray()allocated a freshFloatArray(16)on every call, fired thousands of times per second on an animated scene (per smooth-transform tick, gesture, and camera-manipulator update). New allocation-freeMat4.copyColumnsInto(out, offset = 0)/Mat3.copyColumnsInto(out, offset = 0)overloads write directly into a caller-supplied scratch buffer. The hottest callers —TransformManager.setTransformand theCamera.modelTransformsetter — now reuse a main-thread scratch buffer (safe: Filament JNI is main-thread-only and copies the array into native memory synchronously). The originaltoColumnsFloatArray()overloads are unchanged for cold callers. Part of the hot-path performance audit (#2263). Manipulator.transformno longer allocates ~10 objects per frame (#2272). The camera-manipulator transform getter, called once every frame from the render loop, used to allocate anArray<FloatArray>, threeFloatArray(3), and severalFloat3/Mat4/Float4objects on every invocation. It now reads the look-at vectors into reused scratch buffers and memoizes the resultingTransform, returning the cached matrix unchanged when the camera has not moved (the common no-input case). Part of the hot-path allocation audit (#2263).- Web: Eliminated per-
requestAnimationFrameallocation sources in the web render loop that caused a GC sawtooth (worst on iOS Safari).OrbitCameraController.update(), thesceneview.jsorbit/freelook/maplookAtbranches, the billboard transform update, and the WebXR per-view viewport now reuse preallocated scratch arrays mutated in place instead of allocating fresh arrays every frame. The billboard path also caches each entity'sTransformManagerinstance and batches its updates in a single local-transform transaction. Behaviour is identical — only the allocations are removed. (#2274, umbrella #2263) - Eliminated per-triangle
Vector3allocations in ray-vs-mesh collision (#2276, umbrella #2263).Ray.getOrigin()/getDirection()each returned a defensiveVector3copy, so a ray-vs-1000-triangle mesh test allocated ~3 000 throwaway vectors. The collision math (MeshCollider,Box,Sphere,Plane,AABB,Capsule) now reads the ray's backing vectors through new package-internalRay.originRef()/directionRef()accessors — zero copies on the hot path. The publicgetOrigin()/getDirection()keep their defensive-copy contract, so there is no API change. - SwiftUI auto-rotate no longer drives a full body re-eval at 60 Hz (#2277). On Apple platforms (
SceneViewSwift), the auto-rotate.taskloop mutated the@StateCameraControlsvalue every ~16 ms, invalidating the entire SwiftUIbodyand re-runningRealityView.update:(applyCamera + both light-slot diffs + content-centering + skybox diff) every frame for the lifetime of any auto-rotating scene. The orbit/camera state now lives in a reference-type box, so mutating it never invalidates the body; the camera transform is pushed straight onto the camera entity from the mutating sites (the auto-rotate task and the drag / pinch gesture handlers). No public API change —CameraControlsand all view modifiers are unchanged. - Light-slot refresh removes the previous light by cached reference instead of tree-walking (#2278).
SceneView'srefreshLightSlot(and the visionOSrefreshImmersiveSkybox) usedentities.root.children.first { … }to locate the entity to remove on a slot change. It now caches the provisioned entity reference onAppliedCacheand removes it directly — mirroringARSceneView'scoordinator.main/fillLightAnchorpattern — keeping the path O(1) regardless of scene size and guarding against a future equality regression turning a per-frame no-op into an O(n) walk. - SceneViewSwift: dragging an entity registered with
Entity.onDrag { }/NodeGesture.onDragno longer flies off-screen (#2283).entityDragGesturedispatched SwiftUI's cumulative drag translation on everyonChangedtick, but the documentedNodeGesture.onDragcontract promises a per-frame delta ("translation delta in world space"). So the naturalentity.position += deltahandler double-integrated the offset and the entity accelerated away from the pointer. The gesture now tracks the previous cumulative translation per entity (keyed byObjectIdentifier, in a reference box so per-frame ticks don't churn the SwiftUI body) and dispatchescurrent − previous, resetting the baseline on gesture end — the handler now tracks the pointer 1:1. No public API change; the implementation now matches the documented delta contract. - Review-nit follow-ups from the #2263 perf PRs (#2303, part of #2263). Three non-behavioural polish fixes surfaced during the independent batch-review: (1)
ModelNode.onWorldTransformChanged's KDoc rationale was wrong — the glTF sub-nodes are already parented throughchildNodes, so the base-class propagation reaches them; the override is now documented as redundant-but-harmless (kept for clarity/safety) rather than claiming it's required. (2)CameraManipulator's memoization dirty-check now carries a comment explaining that its exact==float comparison is intentional (a false miss only costs one extralookAtrecompute; an epsilon would wrongly skip a genuine sub-epsilon move), and the file regained its trailing newline. (3)TransformManager.setTransformgained a debug-only main-thread assert so an off-main-thread caller fails loudly instead of silently corrupting the sharedtransformScratchbuffer; the guard is gated onBuildConfig.DEBUGand is dead-code-eliminated (zero-cost) in release. ModelNodenow evicts a renderable from its sanitize-once latch when its geometry or bounding box is explicitly mutated (#2311, part of #2263). ThepermanentlyValidEntitieslatch (#2273 / #2310) skips re-scanning a static renderable's AABB once it is observed valid. The one out-of-band path that could re-introduce an empty AABB the latch never re-scanned — explicitly callingRenderableComponent.setGeometry/setGeometryAt/ theaxisAlignedBoundingBoxsetter with a degenerate box on a latched glTF child renderable — could lead to a Filament "AABB can't be empty" crash.ModelNode.RenderableNodenow overrides those three mutators to evict the entity from the latch, so the nextsanitizeEmptyBoundingBoxes()pass re-scans it and re-detects the empty AABB before Filament can crash on it. Internal-only — no public API change.- Completed the #2187 transform-drift fix — per-component setters no longer re-decompose (#2335).
node.quaternion = …(andposition/scale/rotation) used to route through the publictransform =setter, which re-decomposes the composed 4×4 matrix back into TRS on every write. Driving a single component at 60–120 Hz fed the matrix column-length (scale) and polar-decomposition (quaternion) imprecision back into the caches, so local scale crept off 1.0 (~1e-4 over 10 000 frames) — the original #2187 mesh-warp, reintroduced through the setter path. The #2187/#2217 fix had only corrected the getters. The component setters now push the composed matrix to Filament via a new privateapplyCachedTransform()WITHOUT reading it back, so they never round-trip through decomposition; local scale now stays within 1e-6 of 1.0 over 10 000 frames. The publictransform =setter still decomposes (its input is an arbitrary external matrix that genuinely needs TRS extraction). The public API is unchanged. Covered by a new Engine-backed instrumentedNodeLocalTransformDriftTest(the pure-mathNodeTransformDriftTestcould not catch a setter that round-trips through the real Filament matrix). - Fixed
DemoHostActivity(debug deep-link test harness) crashing withUnknown demo idwhen launched fordouble-pendulum,spatial-audio, orplacement-scene— these three demos are registered in the catalog but were missing from the host'swhen()routing. They now route to their composables. Real users were unaffected (the normalMainActivitydeep-link channel always handled them); the crash only hit the instrumentation/manual-QA--es demo_idpath. Found during the #2239 interactive QA sweep.
Tests¶
- Added
DeepLinkRouterTestcoverage for the six #2239 Batch 1 deep-link aliases (custom-mesh/shape→custom-geometry,collision/view-node→picking-collision,camera-controls/gesture-editing→camera-gestures), asserting bothvalidateandparseresolve each retiredsceneview://demo/<id>link to its consolidated demo. - Removed the orphaned
shapeDemo_default_staterender-screenshot test left behind by the #2239 Batch 1custom-geometryconsolidation: it launched the retiredshapedeep-link slug (aliased tocustom-geometry) against the deletedshape_defaultgolden, so it only ever silentlyassumeTrue-skipped. Custom-geometry render coverage is provided bycustomGeometryDemo_default_state. - Fixed a stale
llms.txtreference naming the retiredgesture-editingdemo as the canonical gesture example; it now points at thecamera-gesturesdemo's Node Gestures tab. - Added
NodeWorldTransformDriftTest, an instrumented (androidTest) Filament-Engine-backed regression test for the world-space TRS cache (#2264, the world half of the #2187 transform-drift fix). It runs on the emulator with a realEngine/TransformManager— the world cache readsTransformManager.getWorldTransform()(a JNI call) so it cannot be covered by the pure-mathNodeTransformDriftTest. Covers: world-scale stability over 10 000 spin frames, and world-cache invalidation completeness after a local transform write, a parent move, reparenting (the latent bug the #2280parentInstancesetter side-fix closed), and adding a child to an already-moved parent — plus a combined never-stale guard. Wired intorender-tests.yml(which already runs:sceneview:connectedDebugAndroidTest). Refs #2284 #2264 #2280 #2263. - Added an Engine-backed instrumented test (
NodeSmoothFollowTest) that pins the AR-reticle smooth-follow glide behavior preserved by #2296 (#2266). It drives the exactNode.worldTransform(position =, quaternion =, smooth = true)path thatHitResultNode/DepthHitResultNoderoute through, on a real FilamentEngine, and asserts the node eases toward a far target (strictly between start and target on early frames, converging after enough frames) — never snapping — plus a non-smooth negative control that snaps immediately. Closes the last verification gap from the #2263 hot-path audit; ARCore can't run on the local emulator, but the glide is aNode/slerp behavior, so it is deterministically testable without ARCore.
Docs¶
- Hot-path / allocation-free API guidance (#2263). New "Hot Paths & Allocation-Free APIs" section in the Performance guide documents the per-frame allocation/decomposition class of bug the cross-platform hot-path audit fixed: never call a decomposing or allocating getter inside a render-rate loop. Includes a per-platform "avoid → use instead" cheat sheet (Android
Mat4.copyColumnsInto/ TRS-tupleslerp/ set wholetransform; ARPose.toTransform(scratch); KMPRayreuse; Web scratch-array reuse; Apple no per-frame@State) and the "why" (float drift #2187, GC sawtooth on Safari, JNI thunks). The same guidance is mirrored into the three AI agent skills (agents/sceneview*/SKILL.md) andllms.txtso AI-generated code stops reintroducing it. - Updated the demo catalog count across the docs + website (samples.md, llms-full.txt, try.md, website index) from the stale pre-#2239 "59 demos (30 non-AR + 29 AR)" to the current 47 demos (17 non-AR + 30 AR) after the demo consolidation.
- Fixed a broken iOS-samples doc link (
samples-ios.md):ImageDemo.swift→ImagePlaneDemo.swift(the actual file name). - Extended the
camera-gesturesinteraction test to also exercise the Camera Modes tab (the absorbedcamera-controlshalf), not just Node Gestures. - Doc↔API drift audit (manual run): corrected the stale "version 4.15.0" Maven-artifacts label in
llms.txtto match the current4.16.10install snippets, and fixed two recipes (samples/recipes/procedural-geometry.md,samples/recipes/physics.md) plussamples/README.mdthat calledrememberMaterialInstance(materialLoader)with nocolorargument — no such single-argument overload exists (everyrememberMaterialInstance/createColorInstanceoverload requires a colour), so the snippets did not compile. Replaced with the SDK-levelremember(materialLoader) { materialLoader.createColorInstance(...) }pattern. (TherememberMaterialInstancehelper itself lives insamples/common, not the published SDK.) - Documentation now stays in sync with the public API automatically, via a two-tier guard. A new
check-doc-drift.shruns per-PR (advisory, inci.yml→repo-hygiene) and WARNs when a change touches a public-API surface (sceneview/arsceneview/sceneview-core/SceneViewSwift/sceneview-web) without updating the relevant docs (llms.txt, KDoc,docs/docs/*,samples/recipes/*) — non-blocking, since it is a heuristic. A complementary weeklydoc-audit.ymlworkflow (Mondays) has an Opus agent reason over the whole repo and open a draft PR with concrete doc patches (or a de-duplicated tracking issue), so a wrong prose patch can never land silently. The detector is self-tested bytest-check-doc-drift.sh. - Clarified the
rememberMaterialInstancenote inllms.txt,gpt/knowledge-api.mdand the website.well-known/llms.txt: the flat "there is NOrememberMaterialInstancefunction" was imprecise (and contradicted the demos, which use a sample-only helper of that name insamples/common). Reworded to say there is none in the published SDK and to copy thematerialLoader.createColorInstance(...)pattern — so an AI reading both the docs and the demos isn't confused.
v4.16.10 — Lint & security patch (2026-05-27)¶
Fixed¶
- Lint: declare
VIBRATEpermission insceneviewlibrary manifest soHapticEngine'sVibrator.vibrate()calls no longer generateMissingPermissionlint errors in the library and its consumers. - Security: patch CVE-2026-8723 (medium) — pin
qstransitive dependency to>=6.15.2inmcp/packages/rerun,mcp/packages/interior,mcp/packages/gaming, andmcp-gatewayvia npmoverrides.
v4.16.9 — Sketchfab viewer polish + code quality (2026-05-27)¶
Fixed¶
- Feedback flow — the confirmation Snackbar after submitting a feedback report
now stays visible for the full
SnackbarDuration.Long(10 s) instead of the defaultShort(4 s), giving users enough time to read the "Feedback sent!" message before it disappears (#2230). - Sketchfab viewer — the loading sheet now shows a determinate
LinearProgressIndicator+X.X / Y.Y MBcounter while a GLB is streaming from Sketchfab, replacing the silent indeterminate spinner that gave no feedback during 20+ second downloads of heavy models. An advisory label ("Heavy model — may take a moment") appears for models ≥ 500k polys (#2232). - Sketchfab viewer — models no longer float on a blank background: a directional
light + invisible
plane_renderer_shadow.filamatplane at the model's ground level cast a soft contact shadow beneath every Sketchfab model (#2235).
v4.16.8 — Google Play 16 KB page-size + plane renderer polish (2026-05-27)¶
Fixed¶
- Fix Play Store upload rejection: enable 16 KB page-size alignment for native libraries in the demo AAB (
packaging.jniLibs.pageAlignSharedLibraries = true, required by Google Play since January 2026 for apps targeting Android 15+). - Plane renderer white-blob: tighter alpha cap (#2224 — second iteration). v4.16.4 dropped the alpha hard-cap from saturation to 0.45 but on-device QA in sunny outdoor scenes still read as an opaque white blob (45 % cool-white tint on already-light camera input ≈ 80 %+ perceived white). v4.16.5 tightens to a 0.20 alpha cap and caps
lineitself at 0.4 ingridLine()so the saturation is bounded at the source, not just clamped post-hoc. Grid coefficients also reduced (0.4 / 0.3 instead of 0.6 / 0.5). Industry baseline (Apple ARKit / Wayfair / ARCore Depth Lab reticle) ships plane viz at 20-30 % alpha unlit — this now matches. - Library 16 KB page-size alignment (#2226): add
experimentalProperties["android.nativeLibraryAlignmentPageSize"] = "16k"tosceneviewandarsceneviewlibrary modules so Filament's prebuilt.sofiles have ELF PT_LOAD segments aligned to 16 KB at pack time. Required for consumers' APKs to pass Google Play's new enforcement (Android 15+, enforced since January 2026). Consumers must also add this property to their own app-levelbuild.gradle.
v4.16.6 — 2026-05-27¶
Fixed¶
- macOS App Store: fixed all compile errors blocking macOS archive since #1049
(Xcode 16.2+). Guarded
navigationBarTitleDisplayMode,CADisplayLink,secondarySystemBackground, and AR demo scene destinations in#if os(iOS)blocks. Closes #1794.
v4.16.5 — 2026-05-27¶
Fixed¶
- Fix opaque white plane bug (#2224). At oblique camera angles (typical for AR floor planes) the V1 procedural grid shader's
fwidth(uv)saturated, collapsinggridLine()to ~1.0 across the whole plane and turning the detected ground into an opaque white blob. Three-lever fix: cool-white tint instead of pure white (MATERIAL_COLOR = Color(0.85, 0.90, 1.0)), grid alpha hard-capped at 0.45, denser cells viaBASE_UV_SCALE = 4.0(was 8.0) sofwidth(uv)stays in a stable range. Detected planes now read as a subtle translucent grid overlay, as intended by #1616. - iOS demo: fixed App Store archive crash — added
.gimbalcase to three exhaustive switch statements inCameraControlsDemothat were broken whenCameraControlMode.gimbalwas introduced in #1049 (Xcode 16.2+ treats missing enum cases as compile errors).
v4.16.3 — 2026-05-27¶
Fixed¶
- Fix iOS/macOS archive failure:
CameraControls.gimbalis only available in the iOS 18.2+ / macOS 15.2+ SDK (Xcode 16.2+). Guard it at compile time —.gimbalmode falls back to the orbit gesture path on SDKs older than 16.2. ExplicitRealityKit.CameraControls.*qualification added to all four native-mode cases to eliminate the type-inference ambiguity withSceneViewSwift.CameraControls.
v4.16.2 — 2026-05-27¶
Added¶
- iOS — native camera modes (
CameraControlMode): four new iOS-only cases (.none,.tilt,.dolly,.gimbal) delegate directly to Apple'srealityViewCameraControls(_:)modifier instead of SceneView's custom gesture math. The existing cross-platform modes (.orbit,.pan,.firstPerson) are unchanged — they keep orbit inertia, auto-rotate, and fit-to-bounds framing. Closes #1049 (Phase 2 — exposing the 4 Apple-only modes).
Changed¶
bridge-ios-compile.ymlis the first workflow opted into the self-hosted macOS runner introduced in #2192. Itsruns-onswitched frommacos-15to${{ vars.SELF_HOSTED_MACOS_ONLINE == 'true' && 'sceneview-mac' || 'macos-15' }}— when Thomas's Mac is online the type-check runs on bare metal (faster, nomacos-15minute spend), otherwise it falls back transparently to the GitHub-hosted runner. Picked as the pilot because of its low trigger frequency (path-gated onflutter/sceneview_flutter/ios/**+SceneViewSwift/**) — minimal blast radius if the self-hosted leg misbehaves. The PR itself touches the workflow file so the very push that lands this change validates the routing end-to-end.
Fixed¶
- Fix macOS archive failure:
CameraControls.gimbalis iOS-only — guard with#elseif os(macOS)and fall back to orbit gesture path on macOS (#2219 follow-up). - Fix Play Store upload rejection: enable 16 KB page-size alignment for native libraries in the demo AAB (
packaging.jniLibs.pageAlignSharedLibraries = true, required by Google Play since January 2026 for apps targeting Android 15+). - Bump MediaPipe Tasks Vision
0.20230731→0.10.26: pre-0.10.26 builds ship 4 KB-aligned ELF.sofiles that Google Play rejects with "Artifact does not support 16KB page size" (enforced January 2026). The same root cause was fixed in v4.15.4 on the release branch only — this backports the fix tomainso future releases are not affected. - Restored V1 as the default plane renderer (#2203). v4.16.0 briefly shipped V2 (depth-driven PBR mesh + HDR reflection + type-aware shading + scan-in) as the default, but on-device QA on a Pixel 9 showed the V2 visual output not matching the design intent — a washed-out translucent grid sheet instead of the promised HDR reflection + relief. V1 is restored as the default in v4.16.1 while V2 is polished. V2 stays available behind
ARSceneView(planeRendererVersion = PlaneRendererBase.Version.V2)as an experimental opt-in so early adopters can help shape the redesign. See.claude/plans/v2-references-study.md+v2-google-ar-catalog.md+v2-non-google-catalog.mdfor the comparative research (ARCore Depth Lab, Apple ARKit + RoomPlan, Niantic Lightship, Snap Lens Studio) that informs the next iteration.
v4.16.0 — 2026-05-26¶
Added¶
- Plane Renderer V2 — detected ARCore planes now render as a depth-driven PBR mesh
lit by ARCore's HDR estimate (#2203).
Floors, ceilings and walls each carry a distinct material identity, a brief scan-in
animation runs the first time a plane is detected, and the reflection ramps in over
~1 s to mask the HDR estimate stabilisation. The legacy flat-polygon renderer remains
available via
ARSceneView(planeRendererVersion = PlaneRendererBase.Version.V1)for one release cycle and is now@Deprecated. Includes a newar-plane-renderer-v2demo insamples/android-demowith a live V1 ↔ V2 toggle so the difference reads instantly. - Plane Renderer V2 — type-aware shading per
Plane.Type: a floor, a ceiling and a wall visible at once now read as three distinct surfaces. Floor (HORIZONTAL_UPWARD_FACING) renders cool-white withroughness 0.35; ceiling (HORIZONTAL_DOWNWARD_FACING) renders warm-white withroughness 0.65; wall (VERTICAL) renders neutral grey withroughness 0.80. Same singleMaterial, oneMaterialInstanceper plane — no extra Filament objects. ARCore re-classifications mid-tracking re-apply the preset on the next frame; unknown future plane types fall back to the floor preset rather than crashing. Opt in viaARSceneView(planeRendererVersion = PlaneRendererBase.Version.V2). PR #4 of #2203.
Changed¶
-
setup-self-hosted-runner.shv3 — install path moved from~/Library/Application Support/sceneview-runner/to~/sceneview-runner/. v2 picked the macOS-convention location which contains a space, breaking the runner's step-script invocation (/bin/bash -e <path>splits on the space →No such file or directory). The pilotbridge-ios-compilePR #2204 failed in 34 seconds on theSelect Xcodestep because of this exact issue (run id 26418464635). v3 keeps the LaunchAgent bootstrap design unchanged, only relocates the runner files. The installer auto-detects an existing v2 install at the legacy path, de-registers it from GitHub, and unloads its LaunchAgent before installing fresh — old files are left in place for manualrm -rf. -
Until the v3 runner is reinstalled, set the repo variable
SELF_HOSTED_MACOS_ONLINE=false(gh variable set SELF_HOSTED_MACOS_ONLINE -R sceneview/sceneview --body "false") so every opted-in workflow falls back tomacos-15. Re-running the v3 installer marks ittrueagain automatically via the heartbeat.
Fixed¶
- [Android AR] Fix
DepthMeshNodenever rendering its depth mesh —lastRebuildTimestampMswas initialised toLong.MIN_VALUE, causing the throttle guard (now - lastRebuildTimestampMs < refreshIntervalMs) to overflow to a large negative number on every frame and always return early. Changed to0Lso the first rebuild fires immediately as designed. (#2186) - [Android 3D] Fix
Nodetransform floating-point drift when updatingposition,quaternion, orscaleat high frame rates (60–120 Hz) — e.g.node.quaternion = newQin anonFrameloop (#2187). The root cause was that each individual-property setter decomposed the Filament 4×4 matrix to read the other two components, feeding float imprecision back on every tick. After ~10 000 frames the scale drifted visibly and the mesh warped. Fix: cache pristine TRS backing fields (_position,_quaternion,_scale) updated once on everytransformwrite; individual getters and setters use the caches, eliminating the matrix-decomposition round-trip. catmullRom(): Fix centripetal/chordal parameterisation — thealpha != 0path now uses the Barry-Goldman pyramidal recurrence over chord-length knots instead of the uniform matrix formula, soalpha = 0.5(centripetal) genuinely avoids cusps and self-intersections near sharp turns. The uniform path (alpha = 0) is unchanged.ModelLoader.createInstance(): Annotate with@MainThread— Filament'sAssetLoader.createInstance()is a JNI call that must run on the Filament main thread; the annotation surfaces a warning in the IDE and lint when called from a background coroutine.
v4.15.4 — 2026-05-26¶
Fixed¶
- Play Store deploy: set
inAppUpdatePriority: 3on every release upload (bothr0adkll/upload-google-playand the Python promote / fallback paths) so the in-appUpdateBanneractually fires when a new release lands. Pre-fix the workflow defaulted to priority 0 ("Google's discretion") and v4.15.2 silently never prompted v4.15.1 users —AppUpdateManager.appUpdateInforeturnedUPDATE_NOT_AVAILABLEfor days while Play Store itself indexed the release fine. Priority 3 = "high — surface within ~24h". Crash-fix releases can edit the workflow once to bump to 5 ("immediate"). (#2209) - Play Store production deploy unblocked. v4.15.3 production track 403'd on
:commitwithPERMISSION_DENIED — Artifact does not support 16KB page size. Root cause traced via 5 redispatches + 2 diagnostic PRs tolibmediapipe_tasks_vision_jni.sofrom MediaPipetasks-vision:0.10.14— its arm64 ELF was 4 KB-aligned (p_align = 0x1000). Filament 1.71.4 + ARCore 1.54.0 + Compose were all already 16 KB-aligned. Bumpedmediapipe-tasks-visionto0.10.26(the first release with "All the latest Android packages from Google Maven are now supporting the Android 16kb page size" per MediaPipe v0.10.26 release notes). Verified locally: rebuiltlibmediapipe_tasks_vision_jni.sonow reportsp_align = 0x4000. No API changes between 0.10.14 and 0.10.26 affect SceneView demo usage —compileReleaseKotlinclean. (#2214)
v4.15.3 — 2026-05-26¶
Changed¶
-
Self-hosted macOS runner infrastructure (opt-in) —
.claude/scripts/setup-self-hosted-runner.shinstallsactions/runner, writes a user LaunchAgent plist directly andlaunchctl bootstraps it (skippingactions/runner'ssvc.sh, which uses the deprecatedlaunchctl loadand fails on macOS 11+ withInput/output error; see actions/runner issue 1424), plus a second launchd heartbeat that updates the repo variablesSELF_HOSTED_MACOS_ONLINE/SELF_HOSTED_MACOS_LAST_SEEN. Workflows opt in by changingruns-on: macos-15toruns-on: ${{ vars.SELF_HOSTED_MACOS_ONLINE == 'true' && 'sceneview-mac' || 'macos-15' }}— the expression form supported by GitHub Actions since late-2024 — and fall back transparently to a GitHub-hosted runner when the Mac is asleep / off / the runner service is dead. The plist'sKeepAlive=truemakes the runner survive reboots, sleep/wake, and the runner's own auto-update cycle. Targets the 6macos-15jobs (ios.yml, bridge-ios-compile.yml, rn-ios-compile.yml, app-store.yml × 2, render-tests.yml) plus the NIGHTLY-ONLY iOS device-QA leg (#1601) that is currently skipped on per-push runs due to macOS-hosted cost. No existing workflow is modified by this commit. Inspired by Zach Rattner's M4 Mac cluster playbook. -
Biome v2 linter wired for
mcp/src/**/*.ts+mcp/scripts/**/*.js+website-static/js/sceneview.jsvia a repo-rootbiome.json. Usecd mcp && npm run biome(advisory) ornpm run biome:fix(auto-fix). Excludes generateddist/,mcp/src/generated/,__fixtures__/, vendoredqrcode-*.js, and Kotlin/JS-emittedsceneview-web.js. Not wired to CI for now — baseline reveals 216 errors / 236 warnings to clean up first. Adoption inspired by the same Mac-cluster playbook (Biome replaces ESLint/Prettier at Yembo). -
@claudemention bot —.github/workflows/claude.ymlruns the officialanthropics/claude-code-action@v1whenever a contributor drops@claudein an issue body/title, issue comment, PR review, or PR review comment. Auth viaCLAUDE_CODE_OAUTH_TOKEN(Claude Max subscription, no per-call API spend). Concurrency keyed per issue/PR so duelling replies are impossible. Setup is one-time:claude setup-token+gh secret set CLAUDE_CODE_OAUTH_TOKEN -b "<token>". Open-source contributors benefit too — they don't need an Anthropic account to ask Claude for help on a SceneView issue. -
SceneView statusline (
/.claude/scripts/statusline.sh, wired via.claude/settings.json) — shows branch,~worktree-slugmarker (so parallel sessions never confuse which checkout they're editing),VERSION_NAMEfromgradle.properties, free RAM in GB (useful for the emulator pool — flags when free RAM drops below the 3 GBEMU_MIN_FREE_RAM_MBfloor), and the active Claude model. No network calls; runs fast. -
CLAUDE.md trimmed 992 → 746 lines by deleting the nested "Previous state" session-state snapshots (lines 529-779 in the old file) — they were already mirrored chronologically in
.claude/handoff.md. CLAUDE.md now keeps only the current state + a stub pointing tohandoff.mdfor everything older. Every future session loads ~250 fewer lines of dead session log.
Fixed¶
- iOS App Store deploy: patch Swift 6 strict-concurrency error in
ARPlaneNodeDemo.swift:97that broke the v4.15.2app-store.ymlarchive step. Theprivate enum AssocKey { static var delegate = 0 }global (used only as anobjc_setAssociatedObjectkey) is nownonisolated(unsafe) static var delegate: UInt8 = 0— canonical opt-out for the "address-of-global as key" idiom. Fixes the v4.15.2 iOS deploy red without re-tagging.
v4.15.2 — iOS demo catalog parity sprint complete + Android crash burn-down (2026-05-26)¶
A double-headline release. iOS closes umbrella #910:
13 new SwiftUI demos (Augmented Faces, Depth Occlusion, Image Tracking, Plane Node, Point Cloud,
Collision, Debug Overlay, HDR Environment, Gesture Editing, People Occlusion, Body Tracker, Scene
Mesh, Reflection Probes, Shape Extrude, Texture Streaming, Video Texture) plus an append-only
demo-registry pattern (#1872) so future iOS demos can land as a single *Scene.swift file with
no project.pbxproj merge conflicts. Android ships a sweep of 5 user-visible regression
fixes (#2188,
#2191,
#2193,
#2194,
#2195) — the in-app feedback crash on
Play Store builds, an empty Sketchfab Explore tab (CloudFront WAF), a 5-second ANR on Sketchfab
preview, and chip-overlap UI papercuts. New cross-platform SceneMeshNode (#1760)
brings ARKit ARMeshAnchor parity to Android via ARCore StreetscapeGeometry. iOS gains three
native CameraControlMode cases (#1049
Phase 2) that delegate to Apple's realityViewCameraControls(_:) modifier. Install on Android via
Play Store internal track within minutes of tagging; iOS via TestFlight.
Added¶
- iOS — native camera modes (
CameraControlMode): three new native cases (.none,.tilt,.dolly) delegate directly to Apple'srealityViewCameraControls(_:)modifier (iOS 18+, macOS 15+, visionOS 2+) instead of SceneView's custom gesture math. The existing cross-platform modes (.orbit,.pan,.firstPerson) are unchanged — they keep orbit inertia, auto-rotate, and fit-to-bounds framing. Closes #1049 (Phase 2 — exposing the native Apple camera modes as verified in the Xcode SDK). - iOS deep-link registry widened to full demo catalog.
DemoDeepLinkRegistry.allowedIdsnow contains all 42 demo IDs (matching Android'sDemoRegistry.kt), so everysceneview://demo/<id>QR code is reachable on iOS — available demos open their real destination; coming-soon demos route to aDeepLinkPlaceholderinstead of silently dropping the link. Added missingdestination(for:)cases forAnimationDemo,ARInstantPlacementDemo,ARLightingDemo,ARRecorderDemo,MaterialsDemo,OrbitalARDemo,SceneGalleryDemoandMultiModelDemo(#1579). - iOS QA mode deep-link arg. Appending
?qa_mode=1to anysceneview://demo/<id>URL (or passing-qa_mode 1as a launch argument) writesUserDefaults["qa_mode"], which freezes auto-rotation inModelViewerScreenandSketchfabModelViewerScreenfor deterministic QA screenshots — mirrors Android'sqa_modeintent extra. Read from any view via@AppStorage(DeepLinkRouter.qaModeDefaultsKey)(#1579). - iOS QA:
lib/ios-axe.sh— helper script wrapping AXe (accessibility-driven iOS Simulator automation) for label-based taps, JSON UI-tree dumps, and screenshots. Mirrorslib/android-cli.sh's pattern; falls back gracefully toxcrun simctlwhen AXe is not installed. Implements slice 1 of the iOS device-QA parity plan. (#1673) SceneMeshNode— new ARCore node wrappingStreetscapeGeometrymeshes with unifiedMeshClassificationsemantics (#1760). Provides ARKitARMeshAnchorparity on Android: every face in the mesh is labelled with aMeshClassification(FLOOR, WALL, CEILING, TABLE, SEAT, WINDOW, DOOR, TERRAIN, BUILDING, UNLABELED) and anonClassifiedFace(faceIndex, classification)callback lets callers build per-face colour maps, physics layer masks, or audio zones. On ARCore the label is coarse (one classification per geometry — TERRAIN or BUILDING); on ARKit it is per-face (fine-grained indoor labels). The callback signature is identical on both platforms so the same consumer code compiles unchanged.ARSceneScope.SceneMeshNode(streetscapeGeometry, …)composable wired inARSceneScope; demo added asar-scene-meshin the Samples tab.- iOS demo: append-only demo registry pattern. Adding a new iOS demo now requires creating a single
*Scene.swiftfile with six header directives (@sceneId,@title,@subtitle,@icon,@category,@available); no other file needs editing.samples/ios-demo/scripts/collate-ios-demos.shdiscovers all scene files, sorts them by@sceneIdfor a stable diff, and emitsGeneratedScenes.swiftautomatically before each Xcode build via a "Collate iOS demos" Run Script phase.GeneratedScenes.swiftis.gitignored — parallel PRs adding different demos can never conflict on it (#1872). - iOS Augmented Faces demo (
ar-face): newARAugmentedFacesDemousingARFaceTrackingConfiguration+AnchorEntity(.face); ring of coloured spheres orbiting the face pose tracked by TrueDepth camera (iPhone X+); simulator placeholder for non-device builds. Promotesar-facefrom deep-link placeholder to a full iOS demo. - iOS AR Depth Occlusion demo (
ar-depth-occlusion): newARDepthOcclusionDemousingSceneReconstructionNode.enableOcclusion()for LiDAR-powered real-world depth masking; toggle to enable/disable occlusion at runtime; graceful fallback banner for non-LiDAR devices; simulator placeholder. Promotesar-depth-occlusionfrom deep-link placeholder to a full iOS demo. - iOS AR Image Tracking demo (
ar-image): newARImageTrackingDemousingAugmentedImageNode.createImageDatabase()with a bundled QR code reference image; 3D cube overlaid on detected image; simulator placeholder shown on non-device builds. Promotesar-imagefrom deep-link placeholder to a full iOS demo. - iOS AR Plane Node demo (
ar-plane-node): detects ARKit horizontal and vertical planes, places a translucent blue marker cube at each plane centre, and displays a live plane-count pill. Mirrors AndroidARPlaneNodeDemo. (#910) - iOS AR Point Cloud demo (
ar-point-cloud): renders ARKit live tracking feature points viaARView.debugOptions.showFeaturePoints, shows a live point-count pill, and offers a toggle to enable/disable the overlay. Mirrors AndroidARPointCloudDemo. (#910) - iOS — Collision & Hit Test demo: port the
collisiondemo from placeholder to a full implementation — fiveGeometryNodeshapes (cubes and spheres) are tap-highlighted viaSceneView.onEntityTapped; an on-screen "Reset Colors" button clears all highlights; Maestrointeraction.yamlpromoted fromplaceholder.yamlsmoke to a realdemo.yamlflow (#910). - iOS demo: Debug Overlay — RealityKit sphere stress test with live FPS stats, frame time, node/triangle counts, and a rolling FPS sparkline. Matches Android's
DebugOverlayDemo: preset buttons (1/10/100/500/1 000 spheres), progressive spawn, and a 10-second stress ramp from 1 → 1 000 spheres.sceneview://demo/debug-overlaynow routes to the real demo instead of the coming-soon placeholder. (#910) - iOS — HDR Environment demo: port the
environmentdemo from placeholder to a full SwiftUI implementation —SceneViewDemonow shows a.demoSettingsSheetwith a grid of environment presets (.studio,.outdoor,.sunset,.night,.warm,.autumn,.nightSky) switchable at runtime; Maestrolighting.yamlpromoted from placeholder todemo-settings.yamlsmoke (#910). - iOS — Gesture Editing demo: port the
gesture-editingdemo from placeholder to a full implementation — aModelNode(ferrari_f40) is draggable, pinch-scalable, and two-finger-rotatable in Edit Mode; camera orbits freely in View Mode; settings sheet shows a mode toggle, Reset button, and live transform readout (#910). - iOS demo — Occlusion Material: new
OcclusionMaterialDemoshows RealityKit's built-inOcclusionMaterialin action — an invisible, depth-writing plane that cuts a sphere, with a toggle to reveal the occluder as a semi-transparent slab. Reachable viasceneview://demo/occlusion-material. Closes the last pure-3D gap in the iOS Advanced category relative to the Android catalog (#910). - iOS AR People Occlusion demo (
ar-people-occlusion): toggle ARKitpersonSegmentationWithDepthto hide virtual cubes behind real people walking in front; requires A12+ chip (#910). - iOS AR Body Tracker demo (
ar-body-tracker):ARBodyTrackingConfiguration+ RealityKitBodyTrackedEntitymarks the detected skeleton root joint in real time; requires A12+ chip (#910). - iOS AR Scene Mesh demo (
ar-scene-mesh):ARWorldTrackingConfiguration.sceneReconstruction = .meshWithClassificationbuilds a live LiDAR mesh with a debug wireframe toggle; requires LiDAR device (#910). - iOS — Reflection Probes demo: port the
reflection-probesdemo from a placeholder to a full SwiftUI implementation usingReflectionProbeNode. Shows a metallic sphere and cubes with varying metallic values inside a box probe zone; an environment picker switches between four IBL presets (Sunset, Night Sky, Studio, Outdoor) with a live intensity slider. - iOS — Shape Extrude demo: port the
shapedemo from a placeholder to a full SwiftUI implementation usingShapeNode. Six preset shapes (Triangle, Star, Pentagon, Hexagon, L-Shape, Arrow) with adjustable extrusion depth slider (0–0.4 m) and a PBR/unlit material toggle. - iOS demo: add Texture Streaming demo (
sceneview://demo/texture-streaming) — interactive PBR material preset switcher (Gold/Silver/Copper/Ceramic/Plastic/Rubber) on a sphere usingPhysicallyBasedMaterial; teaches runtime material swap without geometry rebuild (#910).
Changed¶
- Bump Filament from 1.71.0 to 1.71.4 (patch — no
.filamatrecompile needed; includes Metal async resource loading, bounds-check fixes infilaflat, and iOS arm64 simulator support in Xcode 16+) (#2156). - Refresh store listing assets: update app icon (Android + iOS) to the canonical 3D isometric cube branding, regenerate feature graphic ("3D and AR for Android, iOS & Web"), and replace all App Store / Play Store screenshots with fresh captures (#2180).
- Bump Compose BOM to
2026.05.01(commit6a2b4b4d1).
Fixed¶
- Xcode project registration for new AR demos.
ARPeopleOcclusionDemo,ARBodyTrackerDemo,ARSceneMeshDemo, and their scene-registry files were not registered in the Xcode project's Sources build phase — fixed alongside the new demos so the iOS targets actually compile them. (#910) - CI (
app-store.ymlsubmit step): switch from the legacyappStoreVersionSubmissionsAPI to App Store Connect'sreviewSubmissionsAPI v3 (2023+). The old endpoint returned403 "Allowed operation is: DELETE"whenever a stale submission was attached to an absorbed draft (the #1687 / #1795 retargeting pattern); the read permission needed to find that stale submission was not in scope on our deploy service account. The new flow (POST /v1/reviewSubmissions+POST /v1/reviewSubmissionItems+PATCH submitted: true) is independent of any legacy submission state, so the 403 class is eliminated entirely. Closes the long-running #1831 saga end-to-end (#2141 closes #1831). - ARFaceDemo: front-camera unavailability diagnostic. Added a 5-second timeout after which, if no AR frame has been received, the status pill turns red and reads "Front camera unavailable on this device". This surfaces the silent black-screen regression on Pixel 9 (#1612) where
frontCameraConfigmay fall back to the BACK camera, leaving the selfie feed dead without any user-visible error. - CI (quality-gate):
feedback-workernpm testis now run as part of the quality gate — a future regression in the worker is caught on every PR that touchesfeedback-worker/. (#2032) - Feedback (Android demo): lower the screen-recording size cap from 28 MB to 25 MB to give 5 MB of headroom for the AAC audio track + multipart envelope (vs the previous ~2 MB) before the worker's 30 MB 413 threshold. (#2032)
- Feedback (FeedbackContextTest): fix stale KDoc mentioning the removed
routekey; addisEmulator()reachability test. (#2032) - iOS (SceneViewSwift):
SceneEntities.deinitno longer traps if the instance is released off the main thread. ReplacedMainActor.assumeIsolatedwith an explicitThread.isMainThreadguard +DispatchQueue.main.syncfallback so an off-main release degrades gracefully instead of crashing. (#2068) - iOS demo (samples):
ModelViewerDemo,PhysicsDemo, andSpatialAudioDemonow set.environment(.studio)on theirSceneView— matching the android-demo IBL fix (#2110) so metallic glTF models are consistently lit across the iOS demo catalog. (#2114) - CI: CI Gate no longer hard-fails on docs-only PRs. A 90-second grace period replaces the previous 50-minute timeout — if no other check runs register (because every workflow was path-filtered out), the gate exits green immediately. (#2117)
- Fix Play Store CI deploys blocked by undeclared Foreground Service (FGS) permission (#2120).
The production fallback now preserves the staged edit in Play Console (instead of deleting it
on FGS failure), making the FGS declaration section visible under App content. A new
commit_edit_idfast-path inworkflow_dispatchlets you commit the preserved edit in ~2 min after declaring FGS — no 40-min rebuild needed. - ARSceneView:
detectConfigDowngradesnow captures the post-sessionConfiguration-callback depth mode, so a callback-driven depth-mode request that gets silently downgraded is correctly surfaced asARConfigDowngrade.DepthMode. (#2122 / #2096 gap 1) - MaterialsDemo: fixed infinite "Loading…" scrim when the
materialsregistry category is empty (null selected slug now exits to anEmptystate instead of staying inLoadingforever). (#2122) - Feedback (Android demo): detect emulator in
FeedbackContext(isEmulatorflag). The review screen now shows a warning hint when submitting from an emulator without a typed note, since emulator mics are silent and Whisper returns an empty transcript. (#2123) - Feedback (worker): the GitHub issue body now explains why there is no transcript when both transcript and typed text are empty: emulator submissions get a specific "no physical mic" message; other silent-audio cases get a generic explanation. A maintainer note is added to avoid confusion when an issue has no actionable content. (#2123)
samples/android-demo/build.gradle: honour-PversionNamefrom Play Store workflow — versionName was hardcoded, causing Play Console to show the stale name from the build.gradle source instead of the release tag.- Fix
.well-known/assetlinks.jsonandapple-app-site-associationreturning HTTP 404 onsceneview.github.io—upload-artifact@v7silently stripped dot-prefixed directories unlessinclude-hidden-files: trueis set, causing the deploy job's patch step to fail (#2155). docs.yml: fix/.well-known/files returning HTTP 404 onsceneview.github.io—peaceiris/actions-gh-pages's internalshelljs cpglob does not expand dot-prefixed subdirectories, soassetlinks.jsonandapple-app-site-associationwere silently dropped on every deploy; a post-deploy patch step now adds the missing directory via a direct SSH git commit (#2155).- iOS registry: remove stale
ar-eis/ar-pose-placementdeep-link aliases — the canonical Android IDs (ar-image-stabilization,ar-pose) were already present inallowedIds; the aliases were unreachable duplicates that silently droppedsceneview://demo/ar-image-stabilizationQR-code taps. (#2173) - iOS demo — renamed placeholder scenes
ArEisScene→ArImageStabilizationSceneandArPosePlacementScene→ArPoseSceneso their@sceneIddirectives match the canonical Android IDs (ar-image-stabilization,ar-pose) used by QR codes and deep links; closes the gap left by #2174 which fixedallowedIdsbut not the scene catalogue. - Fix
device-qa.shcrash on macOS (timeout: command not found):lib/maestro.shnow falls back togtimeout(homebrew coreutils) or runs unbounded when neither GNUtimeoutvariant is available (#2184). - Feedback (Android demo): stop crashing the app when the user triggers screen recording on a Play Store build that ships without
FOREGROUND_SERVICE_MEDIA_PROJECTION(the #2120 catch-22).FeedbackRecordingService.isRecordingAvailable()now detects the missing typed-FGS permission on Android 14+; the flow short-circuits to text + audio beforestartForegroundServiceraisesForegroundServiceDidNotStartInTimeException.start()/stop()are also belt-and-suspenders try/caught. Robolectric regression suite locks the SDK-gated behaviour. (#2188) - Sketchfab (Android demo Explore tab): repair the silently-empty Discover/Gallery/Tutorials carousels. AWS CloudFront's WAF in front of
api.sketchfab.comwas returning HTTP 202 + an empty body +x-amzn-waf-action: challengeto any request carrying OkHttp's defaultUser-Agent: okhttp/<version>(treated as bot traffic), so the JSON decoder threwExpected start of the object '{', but had 'EOF' instead, each feed swallowed the error, and the user saw a half-rendered Explore tab. Now sends an explicit app-identifyingSceneViewDemo/<version> (Android; +https://sceneview.github.io)User-Agent and surfaces a typedWafChallengeerror so the "Sketchfab unavailable" banner explains the state instead of three self-hiding carousels. (#2191) - Sketchfab (Android demo): stop the 5+ second ANR when opening the model preview sheet. The Filament
Engineis now pre-warmed at the sheet root on the first transition out ofPreview(gated bystage !is Preview), so the ~5 s synchronous JNI cost overlaps with the Ken-Burns + spinner UI of theDownloadingstage instead of (a) blocking the user's card-tap on a stale Explore-tab background (the original ANR) or (b) freezing on a stopped-spinner moment betweenDownloadingcompletion andRendering(an earlier partial fix). The Engine slot survives the Downloading → Rendering transition, so the model appears the instantrememberModelInstancefinishes parsing the GLB — no second freeze. (#2193) - Feedback chip (Android demo): stop masking the bottom row of content across tabs. Introduces a shared
FEEDBACK_FAB_RESERVED_SPACEconstant (in the newfeedback/FeedbackChrome.kt) applied as bottomcontentPaddingon the Samples grid, the About column, and the AR-View launcher column, so the floating chip floats over a gutter rather than over the last items. The chip is also hidden while the liveARSceneViewis on screen (via aDisposableEffecttogglingFeedbackChrome.chipVisible), so the AR-View bottom action bar (model picker + Reset + Share) is no longer half-masked on the left. (#2194) - AR-View "Try an AR demo" tiles (Android demo): stop rendering the same generic
Icons.Filled.ViewInAron every tile.FeaturedArDemonow carries a per-demoImageVector(AddLocationAlt,Face,Cloud,LocationCity,Layers,SelfImprovement) so users can tell the 6 demos apart at a glance — matching the Samples-tab grid where each demo already had a unique icon. (#2195) - iOS — Video Texture demo: add
VideoTextureDemo.swiftandVideos/sample.mp4to the Xcode project (project.pbxproj) so thevideodemo that was already implemented (but orphaned) now compiles and runs. FixesGeometryNode.plane(width:height:)call to use the correctwidth:depth:parameter. - Fixed
BillboardNodesilently ignoring billboard rotation on macOS. The#available(iOS 18.0, visionOS 2.0, *)guard inBillboardNode.init(child:)excluded macOS, soBillboardComponentwas never applied and entities faced a fixed direction instead of the camera. SinceSceneViewSwiftrequires macOS 15+ (which shipsBillboardComponent), the guard is removed. Added a Platform Support table toSceneViewSwift/README.mddocumenting thatSceneView(3D) is fully supported on macOS butARSceneViewis iOS-only (#914). - iOS demo (SketchfabService):
downloadBinarynow surfaces real download progress instead of always emitting1.0at completion. ReplacedURLSession.download(from:)(no intermediate callbacks) with aURLSessionDownloadDelegatethat reports per-byte progress, so the model viewer's progress bar animates smoothly on slow connections. (#982) - Fixed iOS AR screenshot capturing a black hole instead of 3D content.
ARTab.shareARScreenshotpreviously usedUIView.drawHierarchy, which skips the Metal layer and produces a transparent / black hole where the 3D AR content lives. Now usesARView.snapshot(saveToHDR:completion:)— RealityKit's Metal-aware capture path — which correctly captures both the camera background and 3D content. The simulator path shows a user-friendly "AR screenshots require a physical device" message instead of producing a broken image (#983). sceneview-webREADME CDN/API mismatch fixed. The README marketed a non-existentsceneview.jsCDN file and aSceneView.modelViewer(...)global with methods (setQuality,setBloom,addLight,createText/Image/Video, …) the build never exposed — every<script>snippet 404'd and the API table was fiction. It now documents the realsceneview-web.jsartifact path and the actualwindow.sceneviewAPI surface (createViewer,modelViewer, and theSceneViewerinstance methods), matchingsceneview-web.d.ts.sceneview-webnow ships its TypeScript declarations.package.jsongained a"types": "sceneview-web.d.ts"field and the hand-written.d.tsis now infiles[], so TS consumers get typings instead ofany.sceneview-mcpsceneview://known-issuesresource no longer crashes on malformed GitHub API items. The issue type guard validated onlynumber/title, thenformatIssuesunconditionally readissue.user.login,issue.labelsandissue.updated_at— a partial API item (e.g. during a GitHub incident) threw aTypeErrorand took down the whole resource. Items are now normalized with safe defaults foruser,labelsandupdated_at.
Tests¶
- iOS deep-link registry: sync
DemoDeepLinkRegistry.allowedIdsto the full Android catalog (65 IDs covering all 60 Android demo IDs) — 23 new AR and 3D demo IDs added so QR codes for newer demos no longer silently 404 on iOS; corresponding placeholder flows added to Maestro.maestro/ios/for CI smoke coverage. - Android Maestro: expanded demo coverage from 43 to 58 demos — added 13 missing AR demos (
ar-depth-of-field,ar-fog,ar-depth-collider,ar-depth-visualization,ar-people-occlusion,ar-point-cloud,ar-raw-depth-point-cloud,ar-plane-node,ar-scene-mesh,ar-scene-semantics,ar-ml-object-label,placement-scene,ar-collaborative,ar-body-tracker) and 2 Advanced demos (occlusion-material,spatial-audio) to the device-QA harness (#1913).
Docs¶
- iOS — Scene Reconstruction parity: update
cheatsheet-ios.mdandllms.txtto markSceneReconstructionNode(renderable mesh) andenablePhysics(in:)(physics collider) as Available — closes the documentation gap from #1860. The library wrapper ships since the earlierSceneReconstructionNode.swiftimplementation. - docs(ios) —
samples-ios.mdrefreshed with the full 59-demo iOS catalog table (3D Basics, Lighting, Content, Interaction, Advanced, AR) and updated minimal working examples including the newCameraControlModenative Apple modes (.none,.tilt,.dolly, iOS 18+). Closes the documentation gap left after the iOS parity sprint (umbrella #910).
v4.15.1 — Play Store R8 deploy fix + burn-down sweep: black-model IBL, Sketchfab repair, demo-hang & macOS-archive fixes (2026-05-22)¶
Added¶
- Surface AR camera-config / depth-mode downgrades (#2096).
ARSceneViewnow exposes anonConfigDowngradedcallback that fires with a typedARConfigDowngrade(DepthModeorCameraConfig) when a requested capability is unsupported on the device and is silently downgraded to a working fallback — so apps can adapt their UI instead of behaviour diverging silently across devices.
Fixed¶
- macOS demo target archives again (#1794). Guarded iOS-only SwiftUI APIs in the shared
samples/ios-demoSwift source so theSceneViewDemomacOS target compiles: added cross-platformColor.systemBackground/secondarySystemBackground/tertiarySystemBackgroundhelpers and anavigationBarTitleInline()modifier inTheme.swift,#if os(iOS)-guarded the iOS 18.zoom(sourceID:in:)navigation transition and thetopBarTrailingtoolbar placement. - Demo settings sheet remembers its last detent per demo (#2084). The demo-app settings bottom sheet now reopens at the detent (partially-expanded vs fully-expanded) the user last left it at, individually for each demo. The detent is persisted in
SharedPreferenceskeyed by demo title, so it survives navigating away from the demo and full process death. A demo never opened before still defaults to the partial detent. materialsandscene-galleryAndroid demos no longer hang on "Streaming…" (#2088). A failed model resolution is now captured into an error state and surfaced with an error scrim and a Retry button, instead of being swallowed into anullpath that left the loading scrim spinning forever. Both demos are flaggedDemoStatus.KnownIssueso the Samples grid shows an honest known-issue chip.- Sketchfab integration repaired in the demo apps (#2095). All 29 Stage-1 placeholder model uids in
SampleAssets.kt/SampleAssets.swiftwere fabricated and returned HTTP 404, silently breaking every streamed sample demo. They are replaced with 29 real, API-validated Sketchfab models (each verified200+isDownloadable: true+ CC-BY 4.0). Thematerialsandscene-gallerydemos no longer hang and are restored fromKnownIssuetoWorking(reverting the #2088 stopgap). The Android Explore feed now shows the "Sketchfab unavailable" banner when the API key is rejected with HTTP 401/403 instead of silently collapsing to an empty feed, an OkHttp disk cache was added toSketchfabServiceto cut rate-limit (429) pressure on basic-plan keys, and theverify-sketchfab-keyCI step now runs onworkflow_dispatchrelease paths, not only tag pushes. - Fixed the
android-demorelease AAB build failing atminifyReleaseWithR8withMissing class javax.lang.model.**. MediaPipe'stasks-visionPOM dragged the fullcom.google.auto.value:auto-valueannotation processor (and a shaded JavaPoet) onto the runtime/minify classpath; R8 full-mode promoted the compile-time-onlyjavax.lang.model.**JDK classes to a hard error. AutoValue is now excluded from thetasks-visiondependency and matching-dontwarnkeep rules were added, unblocking the Play Store release. (#2106) - glTF models no longer render solid black in demos that didn't set an environment (#2110). A glTF model with metallic / smooth PBR materials needs an image-based-lighting (IBL) environment to reflect. SceneView's default environment is a lightweight neutral IBL paired with a solid black skybox, so metallic surfaces had nothing bright to reflect and rendered black. The non-AR model demos (
FogDemo,CameraControlsDemo,GestureEditingDemo,PostProcessingDemo,OcclusionMaterialDemo,SceneGalleryDemo,ModelViewerDemo) now share arememberModelDemoEnvironmenthelper that supplies the bundled studio HDR IBL (the same one the multi-model scene uses), so the Damaged Helmet and other PBR models are correctly lit. No model materials were overridden. - Fresh iOS App Store screenshots +
-demolaunch argument (#917). The App Store Connect listing carried stale screenshots — Android-device captures, several of them blank white AR scenes, and phone images letterboxed onto the iPad canvas. A new set of genuine iOS-simulator captures showing real rendered 3D content now ships undersamples/ios-demo/appstore-screenshots/(iphone-6.9/at 1320×2868,ipad-13/at 2064×2752), regenerable via.claude/scripts/capture-appstore-screenshots.sh. The demo app gained a-demo <id>launch argument that routes straight to a demo on first frame (reusingDemoDeepLinkRegistry), giving the capture pipeline a deterministic, dialog-free entry point alongside the existingsceneview://demo/<id>user-facing deep link.
Tests¶
- Deterministic
CollaborativeSessionTest(#2091). The'hello propagates a participant'test (and its siblings) intermittently failed on CI with a coroutineTimeoutCancellationException: cross-session message propagation ran onDispatchers.Defaultwhile assertions polled a realwithTimeout, so a contended runner could starve the thread pool past the deadline.CollaborativeSessionnow accepts an injectable I/O dispatcher (test-only, production unchanged) and the test drives propagation on aStandardTestDispatcherwithrunTestvirtual time — no thread pool, no wall-clock race.
v4.15.0 — Cross-platform bridge & audit hardening: Flutter/RN iOS bridges, resource-leak sweep, CI-drift fixes (2026-05-22)¶
Added¶
- "Replay + analyse" mode in the AR Recording demo (#2027). The android-demo AR Recording demo gains a fourth mode that replays a dataset and interprets it: every replayed frame is folded through the
ARRecordInterpreterlibrary API and the runningARRecordInterpretation— tracked-frame %, trajectory length, dominantTrackingFailureReason, plane count/area — is overlaid live on the replay. When the dataset ends (rememberARPlaybackStatus == FINISHED) a final report card sums up the take with a green/amber tracking verdict and a per-failure-reason breakdown.
Changed¶
- AR demos: fitting per-demo content instead of the generic Damaged Helmet (#2023). Nine Android AR demos no longer load
khronos_damaged_helmet.glbpurely as a generic floating stand-in. The six already-bundled models are redistributed so each demo's placed object reads as intentional content grounded in the room: Image Stabilization and Cloud Anchor use the lantern, Depth of Field and Augmented Image use the toy car, Terrain Anchor and Rerun use the fox / Shiba, Record & Playback uses the fox, and both placement cycles (ARPlacementDemo,ARInstantPlacementDemo) swap the helmet entry for the upright Soldier character. The helmet is kept only in the two occlusion demos (Depth Occlusion, People Occlusion), where a hard-surface PBR payload genuinely fits.
Fixed¶
- feedback-worker: minor cleanups deferred from the hardening review (#2028). The
202response now carries areasonfield ("quota"vs"github_error") so a caller can tell a deliberate issue-quota throttle from a GitHub-side failure. An empty or whitespace-onlyContent-Lengthheader is now rejected with411instead of slipping through as a zero-length body (Number("")is0). The Whisper-detected transcript language is surfaced as aTranscript languagerow in the GitHub issue context table instead of being discarded. The unused'purged'value was dropped from thefeedback.statusCHECK constraint (media expiry is tracked bymedia_purged, neverstatus). - android-demo: in-app feedback — lower-priority review follow-ups (#2030). Rotating the device mid-recording no longer leaves the rest of the clip stretched —
FeedbackRecordingServicenow re-fits theVirtualDisplayto the rotated screen aspect inside the fixed encoder surface (deliberate, centred letterboxing) on a configuration change. AMediaProjectionrevoked by the system or another app is now reported with a distinct "Recording stopped early" message instead of the generic "recording didn't work" copy. The tab-screen feedback FAB collapses to an accessible icon-only FAB on a narrow screen or at a large font scale, so the extended label can no longer overflow or crowd the navigation bar. The "My feedback" screen gains a "Refresh status" action that drops the 5-minute GitHub status cache so a user can re-check a ticket immediately. - Fixed five resource leaks in the Android core libraries.
Node.destroy()now recursively destroys its children as documented (#2036);MeshNodecan free its ownedVertexBuffer/IndexBufferandStreetscapeGeometryNodeopts in (#2037);ARCameraStream.destroy()releases itsIndexBuffer(#2039);Delaunator'slegalize()stack grows on demand instead of silently dropping edges (#2041); and theAnchorNode.anchorsetter detaches the replaced ARCore anchor (#2043). - iOS
NodeGestureno longer leaks entities (#2038). Per-entity gesture handlers are now stored in a RealityKit component attached to the target entity instead of in process-globalstaticdictionaries. The handler closures live exactly as long as the entity does — the commononDrag(cube.entity) { cube.position += … }capture pattern no longer leaks the entity and its resources for the whole process lifetime — and twoSceneViewinstances can no longer share or wipe each other's gesture state.removeAllHandlers()is replaced by the scene-scopedremoveAllHandlers(under:). - iOS
CameraControlsconvenience initminRadiusdefault corrected to1.0(#2040). It previously defaulted to the pre-v4.4.0 value0.5, which clips the perspective camera into geometry on the true-camera orbit path — soCameraControls(mode:sensitivity:)silently re-introduced the bug. Both initializers now agree. - iOS
ViewNode<Content>documentation is now honest (#2042).ViewNodecurrently renders a placeholder white plane and does not display the SwiftUIcontentit is given (the UIView→texture pipeline is tracked by #1035). The type is now marked@available(*, deprecated)and its doc-comment no longer claims interactive SwiftUI rendering. - iOS removed 17 dead
#if os(...)guards (#2044). Inner#if os(iOS) || os(visionOS) || os(macOS)guards nested inside identical always-true file-level guards across 12SceneViewSwiftfiles were removed (the code inside stays). A note inCONTRIBUTING.mdkeeps new code from re-introducing them. - Web XR sessions no longer leak the Filament engine + WebGL context (#2045).
WebXRSession,ARSceneViewandVRSceneViewnow destroy theSceneViewthey created when the session ends — both viastop()and via theonendhandler (system-UI / headset-menu exit) — with an idempotency guard so the two paths cannot double-free. - Web XR render loop applies the per-eye projection matrix and renders both eyes (#2046). Each frame now sets the Filament camera from
XRView.projectionMatrix(correct FOV / passthrough registration) via a newCamera.setCustomProjectionbinding, and the VR path renders everyXRViewinto its own viewport instead of onlyviews[0]— VR is now genuinely stereo. - Web:
createViewerImplno longer leaks awindowresize listener (#2048). The untracked, never-removed listener was redundant withSceneView.autoResize(which also updates the viewport + projection) — it has been removed. - Web:
sceneview-web.d.tsmatches the Kotlin source (#2057).setAutoRotateSpeedis documented as radians per frame (was wrongly "per second" — a ~60x speed error for consumers), the missingsetAutoCenterContentmethod is now declared, and the stale version example is refreshed. - Web: committed
sceneview-web/package.jsonno longer carries misleading publish fields (#2058). Themain/files/publishConfigentries pointed at abuild/dist/js/...path the build never produces; they are removed (CI'srelease.ymlgenerates the real published manifest) with a comment recording thatrelease.ymlis the single source of truth. mcp/dist/is no longer committed to git (#2047). The compiledtscoutput was tracked in version control yet regenerated by thepreparescript on everynpm install/npm publish, so it silently drifted fromsrc/— the committeddist/generated/llms-txt.jsanddist/generated/version.jsembedded a SceneView SDK version three minor releases stale.mcp/dist/is now fully.gitignored (the npm tarball is always built fresh on publish), and the.gitignoretest-artefact glob is widened frommcp/dist/*.test.jsto the whole directory so nested compiled test files are never tracked.- Flutter:
SceneView/ARSceneViewno longer dispose a caller-owned controller (#2050). The widget now disposes only the controller it created itself; a controller passed in by the caller is left untouched, so controller reuse and widget re-parenting no longer break. - Flutter iOS: 3D
onTapcallback now fires (#2051). The iOS bridge wires SceneViewSwift's entity hit-test to theonTapmethod channel, matching Android. ARonTap/onPlaneDetectedremain Android-only for now — the Dart docs now state this explicitly instead of implying parity. - Flutter iOS: platform views no longer leak the RealityKit scene (#2052). Both iOS platform-view classes now detach the
FlutterMethodChannelhandler indeinit, breaking the retain cycle that kept the hosting controller, ARSession, and scene alive after the Flutter widget was disposed. - React Native:
onTap/onPlaneDetectedevents now actually fire (#2053). The two event props were exported but never dispatched. Android now registers them viagetExportedCustomDirectEventTypeConstantsand dispatches aTapEvent(tapped node name + world position) /PlaneDetectedEvent(one per newly-tracked ARCore plane) through the view'sEventDispatcher. iOS wiresonTapto SceneViewSwift's tap callback. - React Native iOS:
geometryNodes/lightNodesparity gap disclosed (#2054). The props are fully rendered on Android but not on the iOS RealityKit bridge — the TypeScript doc comments now state the iOS limitation explicitly instead of silently dropping the props. - React Native:
ARSceneViewdepthOcclusion/instantPlacementwired to the AR session (#2055). Android now forwards both flags to ARCore viaConfig.DepthMode.AUTOMATIC/Config.InstantPlacementMode.LOCAL_Y_UP; the iOS gap (no SceneViewSwift knob) is disclosed in the TypeScript doc comments. - React Native: podspec git tag fixed (#2056).
react-native-sceneview.podspecresolved its git source to the bare version (4.14.0) but the repo's release tags arev-prefixed; the source tag is nowv#{s.version}. - Flutter plugin's iOS bridge now compiles against the real SceneViewSwift API (#2065).
SceneViewSwiftUIWrapper/ARSceneViewSwiftUIWrapperreferenced APIs that do not exist onSceneViewSwift—ModelNode(path)(noStringinitialiser) andForEachinside@NodeBuilder(unsupported) — so the iOS plugin never compiled. The wrappers now use the real imperativeSceneView { (Entity) -> Void }content closure and the asyncModelNode.load(_:)API, streaming models in via a persistent content root (3D) and tap-to-place anchoring (AR). A pre-existing Swift 6 actor-isolation error (SceneState()constructed off the main actor) is also fixed. A newbridge-ios-compile.ymlworkflow type-checks the plugin's iOS Swift against the published SceneViewSwift module on every PR so this can't regress. - React Native iOS bridge now compiles against the real
SceneViewSwiftAPI (#2067).RNSceneViewContent/RNARSceneViewContentreferenced APIs that do not exist (ModelNode(String), anARSceneView { anchor in … }content closure, anoverridenrequiresMainQueueSetupon a plainNSObject), so the module's iOS support never built. The bridge is rewritten to use the genuineSceneViewSwiftsurface — asyncModelNode.load(_:),SceneView's imperative content init, andARSceneView'sonSessionStarted/onTapOnPlane— and a newrn-ios-compile.ymlCI workflow type-checksreact-native/react-native-sceneview/ios/*.swiftagainst the real package so this can't regress. The podspec no longer declares a CocoaPodss.dependencyonSceneViewSwift(it is SwiftPM-only); the README documents adding it via Xcode's Swift Package Manager. Companion fix to #2065 (Flutter). - flutter ios: actually break the method-channel retain cycle (#2069). the platform-view's
setMethodCallHandlernow installs the handler with a[weak self]capture; a bare method reference strong-heldself, so the previously addeddeinitcould never run and the platform view, hosting controller, and RealityKit/AR scene still leaked on every create/dispose cycle. - React Native Android:
ARSceneViewdepthOcclusion/instantPlacementnow apply on a live AR session (#2070). PR #2066 forwarded both flags through the consumedarsceneview:4.7.0sessionConfigurationcallback, but that callback runs only once at session creation — toggling either prop from JS afterwards was a silent no-op. The RN manager now captures the live ARCoreSessionviaonSessionCreatedand re-applies theConfigfrom aLaunchedEffectkeyed on the two flags, so a runtimedepthOcclusion/instantPlacementtoggle genuinely reconfigures the running session. - CI: two recurring drift classes made structurally impossible (#2071). The
docs/docs/llms.txtmirror of rootllms.txtis no longer committed — it is regenerated from rootllms.txtat docs-build time (docs.yml, beforemkdocs build) and.gitignored, so it can never drift and reden thellms.txt mirror in syncquality-gate check on an otherwise-clean PR (same gitignore-and-generate fix asmcp/src/generated/llms-txt.ts#1928 andGeneratedDemos.kt#1976);check-llms-drift.shnow enforces the structural invariant that the mirror stays untracked. Separately,sceneview-web'sSCENEVIEW_VERSIONconstant and itsSceneViewVersionTest.ktregression pin are now swept and auto-fixed bysync-versions.sh, so a release version bump no longer leaves the constant stale (shipping a wrong version) and the:sceneview-web:jsTestjob red. - Flutter iOS AR bridge:
clearScenenow removes placed models and tap placements no longer leakAnchorEntitys (#2078). The Flutter plugin's iOS AR placement (ARPlacementControllerinSceneViewPlugin.swift) previously added a freshAnchorEntityto theARViewscene on every plane tap and never removed any — 100 taps left 100 anchors retained — andclearSceneonly dropped the load cache, leaving every tap-placed model on screen permanently. The bridge now mirrors the React Native AR bridge's design: a single reusable contentAnchorNodeis captured once viaonSessionStarted, every tap-placed model is added as its child, and aclearScene(sync(to: [])) callsremoveAll()on that anchor so placed models are actually torn down and the scene's anchor count stays bounded. - React Native iOS: a superseded model load no longer leaks a stale model into the scene (#2079).
RNSceneViewContent.loadModels()andRNARSceneViewContent.placeModels()are driven by SwiftUI.task(id:), which cancels the in-flight task whenever the JSmodelNodesprop changes. A cancelled task still resumes past itsawait ModelNode.load(_:), so the old code could insert a now-stale model into the scene after the prop had already moved on. Both closures now re-checkTask.isCancelledimmediately after everyawaitand bail out before mutating the scene, matching the cancellation discipline already used by the Flutter 3D bridge. - iOS/macOS demo app marketing version stuck at 4.9.0 (#2085). The
samples/ios-demoXcode project'sMARKETING_VERSION(which drivesCFBundleShortVersionString) was frozen at4.9.0, so every iOS and macOS build since v4.9.0 reported marketing version4.9.0to the App Store regardless of the real SDK version — the release pipeline only bumped the build number (CURRENT_PROJECT_VERSION). Both build configurations of theSceneViewDemoapp target are now at the source-of-truth version, andsync-versions.sh--fixrewritesMARKETING_VERSIONin lockstep withgradle.propertiesVERSION_NAMEso a future release bump sweeps it automatically and it can never drift again. - Damaged Helmet renders all-black across demos (#2087). The bundled
samples/android-demo/.../models/khronos_damaged_helmet.glbcarried its base-color and emissive textures as WebP-encoded images. Filament's glTF loader (gltfio) decodes embedded textures with stb_image, which does not support WebP, so the base-color texture silently fell back to a black 1×1 placeholder — and with the helmet material'smetallicFactor = 1the model collapsed to an all-black blob in every demo that loads it (Model Viewer, Lighting, Camera Controls, Environment, …). This also produced unusable Play Store screenshots. The two WebP textures have been re-encoded to JPEG in place; geometry, nodes, samplers and material parameters are untouched. The helmet now renders with correct PBR shading. (The web-demo copy was already JPEG and unaffected.) - Relocated the tablet Play Store screenshots added by #2092 from the
orphaned
samples/android-demo/play/listings/tree (dead since #1710) into the canonical, CI-syncedsamples/android-demo/distribution/play-store/en-GB/graphics/directory, renamed totablet7-screenshot-*.png/tablet10-screenshot-*.pngsoplay-store.yml's listing-sync actually uploads them. Removed the re-created deadplay/tree, including the 3 Chromebook captures — the Playedits.imagesAPI has no Chromebook image type, so large-screen devices reuse the 10-inch tablet screenshots.
Docs¶
- Corrected the Android demo count across all docs surfaces (#2049).
samples/README.md,CLAUDE.md,docs/docs/samples.md,docs/docs/try.mdandwebsite-static/index.htmlstated three contradictory totals (14 / 37 / 42) with the AR/non-AR split backwards; they now agree on the authoritative figure derived from the per-demo fragment registry — 59 demos (30 non-AR + 29 AR).docs/docs/samples.mdis also rewritten to describe the current append-onlyDemoRegistryinstead of the obsolete 4-tab / 14-demo structure. - android-demo: Play Store tablet & Chromebook screenshots. Added a generated set of large-screen Play Store listing assets under
samples/android-demo/play/listings/en-US/graphics/— six 16:9 tablet screenshots (2560×1440, used for both the 7-inch and 10-inch listing slots) and three 16:9 Chromebook screenshots (2400×1350). The captures cover the headline surfaces — the Dynamic Sky, Environment Gallery, Model Viewer and Geometry Primitives demos plus the Samples and About tabs — taken on a Pixel Tablet emulator running the bundled-asset demos.
v4.14.0 — 2026-05-21¶
Added¶
- AR Record interpretation: new
ARRecordInterpreter(+rememberARRecordInterpreter()) folds every frame of a replayed AR Record dataset into anARRecordInterpretation— camera trajectory length & extent, tracked-frame ratio with a per-TrackingFailureReasonbreakdown, and discovered plane count & area — turning a record/playback session into a quantified, CI-assertable tracking-quality report (#1441). - Play Store CI observability. A new
play-vitals.shrelease-gate (wired intorelease-checklist.shsection 15) grades the real-world crash & ANR rate from the Play Developer Reporting API — advisory by default, blocking underPLAY_VITALS_HARD=1(#1691). A new dailyplay-reviewsjob inmaintenance.ymlingests Play Store ratings + reviews via the Android Publisher API and auto-opens a de-duplicated triage issue for any review matching a crash/bug signal (#1692). Both reuse the existing deploy service account read-only — no new write scope. - People Occlusion —
ARCameraStream.isPersonOcclusionEnabledoccludes virtual objects behind real people using ARCore Scene Semantics'PERSON-class segmentation mask (flagship parity with ARKitARFrame.segmentationBuffer, AR FoundationAROcclusionManager). Newcamera_stream_person_occlusion.filamatcamera material (a strict superset of the depth-occlusion material) and anar-people-occlusiondemo. RequiresConfig.SemanticMode.ENABLED; outdoor scenes only (#1761). - Body tracking on Android via MediaPipe Pose (#1763): a new
io.github.sceneview.ar.bodypackage inarsceneviewships renderer-agnosticBodyPose/BodyLandmarkvalue types and a 17-jointJointenum named to match ARKit'sARSkeleton.JointNamefor cross-platform parity.BodyPose.fromMediaPipeLandmarks(...)projects the 33 raw MediaPipe Pose Landmarker landmarks onto the joint set (synthesisingROOT/SPINE/NECKas anatomical midpoints), andSKELETON_BONESexposes the bone topology for overlays. A newar-body-trackerdemo insamples/android-demoruns Google's on-device MediaPipe Pose Landmarker on the AR camera feed and draws a live 2D skeleton overlay. Honest parity note: ARCore has no native body-tracking API, so unlike ARKit'sARBodyTrackingConfiguration+BodyTrackedEntitythis is image-space tracking (normalised pixel coordinates + relative depth), not a world-anchored 3D skeleton — ideal for 2D overlays, fitness/gesture detection and AR filters, but not a drop-in for a world-anchored rig. The MediaPipe runtime stays a sample-only dependency; the publishedarsceneviewartifact carries only theBodyPose/Jointvalue types. - Collaborative AR — multi-user sessions. New
io.github.sceneview.ar.collaborativepackage brings shared-coordinate-frame multiplayer to ARCore.CollaborativeSession(and the lifecycle-boundrememberCollaborativeSession()helper) orchestrates a shared AR experience on top of the existingCloudAnchorNode: one device hosts the shared Cloud Anchor, every other resolves the same id, and participant camera poses + placed-node transforms are relayed between peers as JSON-lines messages. The networking layer is a pluggableCollaborativeTransportinterface — SceneView deliberately does not pick a stack — shipped alongside an always-available, no-networkingLoopbackCollaborativeTransportreference impl that makes the API unit-testable and demonstrable on a single device.CollaborativeWireFormatis pure Kotlin with zero new runtime dependencies, and the whole merge core (CollaborativeState, last-writer-wins) is covered by 52 JVM unit tests. The newar-collaborativesample demo proves the full sync end-to-end without a second phone. ARCore has nocollaborationDataAPI (unlike ARKit) — this is the honest, buildable shape of multi-user AR on Android. A production Nearby Connections transport is filed as a follow-up (#1764). - In-app feedback — the Android demo app now has a "Feedback" button on every tab: users record their screen + voice to report a bug or share an idea, the recording is transcribed server-side and filed as a pre-filled GitHub issue, and a "My feedback" screen tracks each submitted ticket's live Open/Closed status with a tap-through to the real issue. (#1930)
- In-app feedback — screen + mic recording (1C): the Android demo app captures a screen recording with microphone audio via
MediaProjectionand amediaProjectionforeground service, demuxes the AAC audio track into a standalone file for server-side transcription, and shows a review screen (duration, optional note, record-again / send) before the recording is submitted. Recording is optional for the "Idea" category. (#1933) - In-app feedback — upload & context capture (1D): the Android demo app uploads each feedback submission as a multipart
POSTto the feedback worker, with a determinate progress bar and graceful retry on failure. The submission carries an automatic context snapshot — app version, Android version, device, locale, free RAM, and the exact demo / navigation route the feedback is about — and a confirmation screen shows the created GitHub issue number with a tap-through link. The worker base URL is a single configurableBuildConfigfield (FEEDBACK_WORKER_URL). (#1934)
Changed¶
- Secondary Camera (PiP) demo: added an Orbit chip that flies the picture-in-picture camera around the model on its own, independently of the user's main-view orbit. This makes the per-instance
cameraNodebinding visibly independent — one scene, two cameras moving on their own — instead of just parking the PiP at a fixed angle (#1256). - Consolidated the two lighting demos into one (#1444). The Android demo's
lighting("Light Types") andmovable-light("Movable Light") cards were near-identical — same helmet model, same topic — so they are merged into a single Lighting demo with an in-demo segmented-button mode switch: Light Types (directional / point / spot, intensity, colour) and Movable Light (drag to orbit the light). No feature is lost — every control from both demos is still present. The Samples tab now carries one lighting entry instead of two. The retiredsceneview://demo/movable-lightdeep link keeps working:DeepLinkRouteraliases it tolightingvia a newDEMO_ID_ALIASEStable. - Demo app:
DemoScaffoldnow exposes an opt-inonResetparameter that renders a consistent, always-in-the-same-place Reset action in the demo's top app bar, giving every demo a predictable path back to its initial state and re-arming its core interaction. A brief confirmation snackbar ("Demo reset — ready to try again") tells the user the demo is ready for re-interaction. Wired into the owner-flagged AR Depth Occlusion demo. (#1966)
Fixed¶
- Post-Processing demo now makes SSAO visibly flagrant (#1443). The damaged-helmet model is staged sitting on a plain matte ground plane instead of floating in the void, and the camera is raised to an angle that frames the floor. SSAO darkens the contact zone between the helmet and the plane, so toggling the SSAO switch now makes a soft contact shadow plainly appear and disappear — the post-processing difference reads at a glance instead of being a subtle change easy to miss.
- Cloud Anchors demo: renamed the setup runbook
STREETSCAPE_SETUP.md→ARCORE_CLOUD_SETUP.mdso a Cloud Anchor demo no longer routesERROR_NOT_AUTHORIZEDusers to a Streetscape-named doc, and updated all 14 references across the demos,arsceneview,build.gradleandllms.txt(#1614). The on-screen Host/Resolve actions already shipped viaSceneActionBarin #1986. - In-app feedback (Android demo): hardened the screen-recording feedback feature after a review — capped the recording so a long clip can no longer 413, added an in-demo feedback entry point, fixed the demo-id context key, made the upload error messages specific, and survived process death mid-flow (#1930).
- feedback-worker: closed security + correctness blockers from review — enforce the 30 MB upload cap before buffering the body (streaming guard +
Content-Lengthvalidation), SHA-256 IP hashing in the rate limiter, fenced-code Markdown rendering of user text/transcripts on the public issue, base64 Whisper input verified + multi-MB-safe, orphaned-R2 cleanup on D1 failure, incremental retention cron, cached GitHub installation token, and admin-token brute-force rate limiting (#1930). - CI Gate stopped failing every PR (#2013). The
CI Gateworkflow shelled out to.github/scripts/ci-gate-aggregate.shwith noactions/checkoutstep, so the helper was never on disk and the gate died with "No such file or directory" on every PR — the real cause of the v4.13.0 admin-merge spree. A checkout step was added. The aggregator also now drops advisory checks (e.g.Coverage (advisory)) from its pending-wait set, not just from the failure verdict, so a slow or hanging advisory job can no longer push the gate past its deadline. - Release / docs workflows survive a failed Pages-rebuild trigger (#2014). A non-
201response from the GitHub Pages build API (e.g. an expiredPAGES_REBUILD_TOKENreturning HTTP 401) is now a loud warning instead of a hard failure — the release/docs artifacts already published, so the auxiliary rebuild trigger must not fail the run. - Honest capability badges for the Flutter/RN bridges (#909). The Flutter and React Native demo apps and READMEs no longer over- or under-state what the bridges actually expose. The Flutter demo's About tab gains a tri-state "Bridge Coverage" list (Android + iOS / Android only / Not yet bridged), the RN demo's AR tab labels
depthOcclusion/instantPlacementas "Not yet bridged" since those props are accepted but never applied to the ARCoreConfig, and every README now carries a coverage map. Stalev3.6.1version strings in the Flutter demo were corrected. verify-sketchfab-key.sh: droppedcurl -ffrom the live API probe so the real HTTP status reaches thecase— the401|403"token revoked" branch was unreachable and a revoked Sketchfab key silently passed the release guard.docs.yml: ref-scoped the workflow concurrency group (pages-${{ github.ref }}) so a release tag's two triggers (push+release) no longer self-cancel mid-deploy, while same-ref dedup is preserved.- Removed the dead
.github/scripts/ar-emulator-screenshots.sh— it had no caller anywhere in the repo. telemetry-ci.yml: addedbranches: [main]to thepush:trigger so it no longer runs on every branch push.check-workflow-scripts.sh: now scans every workflowif:expression and fails on a context disallowed inif:(notablysecrets) — the class of invalid-if:bug behind the v4.13.0 release startup-failure.collate-changelog.sh: the preamble splice now keeps every line before the first##section instead of emitting only line 1, so intro prose between# Changelogand the first section is no longer dropped on release.- Fixed the release pipeline: the
secretscontext is not allowed in a GitHub Actions stepif:expression, which maderelease.yml(anddocs.yml) invalid workflow files and blocked the v4.13.0 publish. The token-presence check is now done inside the step'srun:script.
Tests¶
- Device-QA screen recording moved to the host-side emulator console (#1671). New
android_cli_screenrecord_*helpers useadb emu screenrecord, which is immune to the Emulator 36.x gfxstream regression that recorded-gpu hostFilament content as near-empty — so the QA emulator drops the 35.6.11 version pin and runs the latest emulator. - Android demo QA — emulator boot snapshots.
setup-ar-emulator.shgains--seed-snapshot/--no-snapshot: a clean post-ARCore-install boot snapshot (qa-clean) is seeded once and cold-booted from on every subsequent QA run with-no-snapshot-save, so runs start from an identical warm state and the AVD userdata partition no longer degrades after ~6 runs. Faster, deterministic local QA. Android Studio Journeys was assessed but deferred — it requires an AGP 9.0.0 bump (#1672). - Web device-QA WebXR coverage now drives a full
immersive-ar/immersive-vrsession against the IWER emulated device — requests the session, runs the XR animation frame loop, nudges pose/controllers and ends it — replacing the fixture-pending soft-skip, so a WebXR-plumbing regression fails the suite instead of silently skipping (#1674, #1748). - Device-QA: the Android leg now screen-records each run via host-side
adb emu screenrecord, completing cross-platform parity with the iOS (simctl io recordVideo) and web (Playwrightpage.screencast) legs. Host-side capture is immune to the Emulator 36.x gfxstream regression that recorded-gpu hostFilament content as near-empty. The Android and iOS QA recordings are now surfaced intodevice-qa-artifacts/alongside the web screencasts. - Added a non-AR demo regression suite — pure-JVM state-machine tests for
AnimationDemo's cinematic camera scripts and a demo-registry integrity check — plussamples/android-demo/DEMO_TESTING.mddocumenting the three test layers (#880).
Docs¶
- Privacy disclosures updated for the opt-in in-app feedback feature: the demo app's privacy policy (
.github/PRIVACY_POLICY.md,docs/docs/privacy.md, websiteprivacy.html) now discloses screen + microphone capture, device/app context, Cloudflare Workers AI (Whisper) transcription, private Cloudflare R2 storage, the 90-day retention window, and that a public GitHub issue carries only the transcript + context. Adds a Play Store Data safety reference doc (samples/android-demo/distribution/play-store/DATA_SAFETY.md) for the maintainer to transcribe into the Play Console. (#1935) llms.txt: added an explicit "Web API model — builder DSL, NOT a Node scene-graph" section to the SceneView Web reference, with a concept-mapping table (Android/iOSNode↔ Web builder DSL) and correct-vs-incorrect code examples. This stops AI assistants from generating Android-styleNode-tree code that does not compile againstsceneview-web, and documents that a node scene-graph for Web is a tracked v5 milestone effort (#895).
v4.13.0 — 2026-05-21¶
Added¶
- AR Augmented Images — on-device runtime registration. New
RuntimeAugmentedImageDatabasehelper (rememberRuntimeAugmentedImageDatabase()) lets you register a brand-new reference image at runtime — e.g. from a photo the user just took — without a pre-bundledarcoreimgdatabase.addImage(name, bitmap, widthInMeters)runs the ARCore feature extraction off the main thread and re-applies the session config on the main thread itself, returning a typedAddImageResult(Added/LowQuality/Error) so low-quality captures are recoverable. NewFrame.captureCameraBitmap()andImage.toArgbBitmap()extensions grab the live AR camera frame as an uprightARGB_8888bitmap ready for the database. The Image Tracking demo now ships a "Capture this view" button demonstrating the full on-device flow (#1553). - Record & Playback demo now surfaces live ARCore tracking quality while recording — a status pill, a "tracking lost" soft warning, and a per-take "tracking healthy X% of frames" stat — so a capture going bad (e.g. shot from a moving vehicle) is obvious in real time instead of only on playback (#1650).
- CI: daily
maintenance.ymljob that monitors Android App Links + iOS/macOS Universal Links verification health — cross-checks the hostedassetlinks.json/apple-app-site-associationagainst the committed source of truth and the demo apps' intent-filters/entitlements, opening a tracking issue when the QR → demo deep-link flow is broken (#1695). PlacementScenecomposable (#1765) — one-line tap-to-place AR scene with SceneformArFragmentparity: bundlesARSceneView+ plane rendering + a built-in centre-screen reticle + tap-to-place anchor creation + an instant-placement fallback, so callers only declare what rides each placed anchor. NewPlacement Scenedemo insamples/android-demo.PointCloudNode+rememberPointCloud()(#1773): renders ARCore's live tracking feature points (Frame.acquirePointCloud()) as an in-scene Filament point cloud — AR FoundationARPointCloudManagerparity — with a configurable color and confidence filter. Ships a newPoint CloudAR demo.PlaneNodecomposable +rememberDetectedPlaneslifecycle helper forarsceneview(#1774): react to ARCore detected-plane lifecycle (onAdded/onUpdated/onRemoved) declaratively from Compose — the SceneView equivalent of AR Foundation'sARPlaneManager.planesChanged— instead of hand-rolling aframe.getUpdatedTrackables(Plane::class.java)loop. New "Plane Lifecycle" demo insamples/android-demo.MaterialLoader.createOcclusionInstance()— invisible, depth-writing material (RealityKitOcclusionMaterial/ SceneformmakeOcclusionMaterialparity). Compose helperrememberOcclusionMaterialInstanceships insamples/common. New "Occlusion Material" demo in the Android demo app (Advanced category). For AR scenes that want occlusion against the live depth camera, keep usingARCameraStream.isDepthOcclusionEnabled. (#1776)- Scene Semantics label-overlay material (#1868, follow-up of #1730): a new
semantics_overlay.filamatFilament material colour-codes ARCore's per-pixel 12-class outdoor segmentation, exposed viaMaterialLoader.createSemanticsOverlayInstance(texture, opacity)plusMaterialInstance.setSemanticsTexture/setSemanticsOpacity.ARSceneSemanticsDemonow renders the live segmentation as a camera ↔ semantic blend overlay (with a colour legend) alongside the existing top-3 label HUD. ReticleNodelibrary-level placement reticle (#1882). Newarsceneviewnode +ARSceneScope.ReticleNode { ... }Composable for the "tap to place" UX every AR placement demo previously had to reinvent.ReticleNodeis a thin wrapper overHitResultNode— it delegates the screen-coordinate hit test (including #1891's plane-only defaults and the 30 cmminCameraDistancefloor) toHitResultNodeand adds only theonHitResultChangedcallback so callers can drive an "aim at a surface" hint and capture the last-known hit on tap-to-place without attaching a duplicate hit test inonSessionUpdated. Auto-hide on no-hit comes for free fromHitResultNode's trackable/visibility contract. Visual marker is left to the caller as a child node so the reticle stays material/style-agnostic. Documented inllms.txt(and the docs mirror) +sceneview-mcpbundle.- Jetpack XR hand tracking (Slice 2, #1902): new preview
XrHandNodemirrors anandroidx.xr.arcore.Handas a scene-graph node with one child node per skeleton joint, aSceneScope.XrHandNodecomposable, the JVM-testableXrHandSkeletonjoint/bone math, and anar-hand-trackingdemo that renders a static reference skeleton on non-XR phones. XrFaceNode— Jetpack XR face tracking (androidx.xr.arcore.Face) for Android XR headsets, the preview sibling ofAugmentedFaceNode, plus the runtime-freeXrFaceMeshadapter and anar-xr-facesample demo (#1903).
Changed¶
- AR plane visualization redesigned — the dated dense dot-grid overlay is replaced by a modern procedural soft grid with anti-aliased lines and a feathered edge fade (#1616).
- AR Pose Placement demo now places a real bundled Lantern model instead of a placeholder cube/sphere and shows the live X/Y/Z coordinates as in-scene text. (#1618)
- Demo app UX-consistency pass (#1620 thread 1): dropped low-value Settings sheets — demos with no real controls (
OrbitalARDemo,ARMLObjectLabelDemo) no longer show a Settings FAB, AR demos whose sheet only held the dev-onlyForceTrackingFailureMenu(ARStreetscapeDemo,ARImageDemo,ARSceneSemanticsDemo) now show the FAB only in QA mode, and the verbose "How to test" help cards inARDepthOcclusionDemo/ARImageStabilizationDemowere trimmed to a one-line hint so the sheet is just the real toggle. Status/device-support text that was buried in sheets is now surfaced on-screen. Consolidated the duplicate Play Store listing directories into a singlesamples/android-demo/distribution/play-store/en-GB/source of truth (text +graphics/), and extended theplay-store.ymllisting-sync to upload the feature graphic and screenshots via the Playedits.imagesAPI so they reach the store automatically on release (#1710). - Upgrade detekt 1.23.8 (silently no-op'd on Kotlin 2.3.x) → detekt 2.0.0-alpha.0; per-module baselines committed under
buildSrc/config/detekt/baseline-<module>.xmlgrandfather existing violations and theDetektCI step is now blocking on NEW violations (#1740). - Release builds of the demo apps now fail loud when
SKETCHFAB_API_KEYorARCORE_API_KEYis empty (#1915): the AndroidassembleRelease/bundleReleasepath and the iOSReleasearchive abort with a clear actionable error instead of silently shipping a store build with invisible Sketchfab carousels (the #1909 silent-fail class). Debug builds stay permissive; forks opt out withSV_ALLOW_MISSING_SECRETS=1. - Pruned the unused
focusPoint/radiusspotlight parameters fromplane_renderer.mat(#1922): these declared a half-built "spotlight around the focus point" effect whose fragment-shader falloff andPlaneRenderer.ktsetter were both already commented out, so they never affected rendering. The.matsource, the orphaned Kotlin constants/getFocusPoint(...)helper, and the regeneratedplane_renderer.filamatblob are all updated together — no behaviour change. - CI: split JaCoCo coverage off the PR-blocking unit-test job (#1955) — the blocking
Unit testsjob now runs the plaintestDebugUnitTestsuite (fast, deterministic, 30-min timeout), while JaCoCo instrumentation + reports run in a separate non-blockingCoverage (advisory)job, so a slow runner can no longer push the unit-test gate over its timeout and turnCI Gatedouble-red. - Demo app: enforced one action-placement rule — every demo's primary action (Host, Drop, Place, Record, Clear, Reset) is now an on-screen button via the shared
SceneActionBar, while only secondary configuration (toggles, pickers, the Cloud Anchor ID field) stays in the Settings sheet (#1964). - Added labelled "Camera distance" sliders to the
CameraControlsDemoandModelViewerDemoAndroid demos (#1965): zoom was pinch-only — now discoverable and Maestro-testable (no pinch in Maestro) — and the sliders complement pinch-to-zoom rather than replacing it.ModelViewerDemo's slider drives the sameDemoSettings.cameraDistancedeep-link hook (#1571). - Build:
samples/android-demo'sGeneratedDemos.ktis no longer committed — it is.gitignored and regenerated before Kotlin compilation by the newgenerateDemoRegistryGradle task, killing the per-PR merge-conflict class that hit every demo-adding PR (#1976).
Fixed¶
- Streetscape Geometry demo now surfaces clear "go outdoors" guidance after 15 s with no geometry, instead of spinning forever on "Looking for streetscape geometry…" indoors (#1615).
- Depth occlusion now actually occludes (#1617):
ARCameraStreamdraws the depth-aware camera quad first (Filament priority 0) when occlusion is enabled so the real-world depth written viagl_FragDepthprimes the z-buffer before virtual geometry is depth-tested — previously the quad was always drawn last (priority 7), writing real-world depth too late to ever hide a virtual model behind real furniture. - AR demos + docs polish (#1777):
ARDepthOcclusionDemonow shows a transition spinner while the depth toggle rebuilds the camera stream (+ aconnectedAndroidTestthat flips depth mode 10× and asserts stability);LightEstimatorgains anenableColorCorrectiontoggle and exposes the rawlastColorCorrectiontriple;sessionConfiguration/sessionCameraConfigKDoc now warns about mid-session config swaps;llms.txtdocuments camera-config swapping and editable nodes (TransformableNodeparity). - Fix
sceneview.github.iono longer rebuilding on push: GitHub Pages' legacy auto-build does not fire for the SSH deploy-key pushesdocs.yml/release.ymlmake, and the existing "Trigger GitHub Pages build" workaround was permanently skipped because itsif:condition tested anenv:var set on the same step (not yet in scope) and referenced a non-existent secret. The step now testssecrets.*directly, falls back to the existingPERSONAL_TOKEN, and fails loudly on a bad API response; a dailymaintenance.ymljob alerts if the live site lags its source by more than a day (#1826). - Spatial Audio demo (Android): the bouncing sphere is now clearly visible — larger radius, brighter on-brand material, closer camera framing, and a two-light key/fill setup. (#1927)
- In-app update flow in
samples/android-demois now demo-UI-native (#1941):InAppUpdateManager.checkForUpdate()no longer auto-starts the Google Play consent modal on resume — it only surfaces an integrated Material 3UpdateBanner("A new version is available"). A newInAppUpdateManager.startUpdate()triggers Google's single consent dialog, called solely on the user's deliberate tap of the in-app "Update" button. This fixes the double-modal (a secondonResumein theAVAILABLEwindow is now a no-op), the "feels like leaving the app" jarring unprompted popup, and the flaky "Restart" button —completeUpdate()is now a no-op unless the install isREADY_TO_INSTALLand the install-state listener stays registered untilINSTALLED. - Hardened the demo apps' in-app update flow (#1942 follow-up): cancelling Google's flexible-update consent modal is now delivered via an
ActivityResultLauncher(startUpdateFlowForResult), so a cancel resets the banner to a retryable state instead of stranding the Update button; the in-app update info is kept until the download is confirmed started so a cancelled flow can be retried; adestroyedguard stops late Play Core callbacks from mutating state afteronDestroy; the manager re-attaches to an already-running download after a rotation; and the Android TV banner's new "Update" button is now reachable by D-pad. - Web Spatial Audio demo Play button now actually plays (#1944): the
samples/web-demoSpatial Audio panel's#audio-play/#audio-stopbuttons were dead — clicking Play produced zero Web Audio API activity. The wiring lived only in the Kotlin/JSMain.kt::setupSpatialAudio(), butindex.htmlships the hand-writtenjs/sceneview.jsruntime, not the Kotlin/JS bundle, so that code never executed. The buttons are now wired in the inline-JS runtime alongside the other tab demos: a click constructs theAudioContext(inside the user gesture, per the autoplay policy),fetch+decodeAudioDatas the bundledaudio/bell.wav, builds theAudioBufferSourceNode -> PannerNode("HRTF") -> GainNodegraph, starts looping playback, and orbits the panner around the listener — matching the Android/iOS Spatial Audio demos. A new Playwright regression test (tests/audio.spec.ts) hooks the Web Audio API before page load and asserts the graph is constructed on a real#audio-playclick, so CI catches a future regression. - Post-v4.12.0 audit polish (#1957):
OpenGL.createEglContext()now reports a descriptiveEGL context creation failederror instead of a bare!!NPE;cameraConfigFilter { }gains scalartargetFps(…)/depthSensor(…)/stereoCamera(…)convenience functions so single-sensor filters no longer needsetOf(…)(theSetAPI from #1844 is unchanged); malformedGeometryvertex lists with partial attribute declarations now fail with a named-attribute error instead of an opaque render-time NPE; and two edge cases gained regression tests —DepthMeshNode.computeAabbwith an identical-Z depth frame, and theFrame.hitTestDepthzero-width / zero-focal-length intrinsics guard. - The
.well-known/deep-link manifests (assetlinks.json+apple-app-site-association) are now deployed to the live site — the website assembly step'scp website-static/*glob silently skipped the dot-prefixed directory, so Android App Links / iOS Universal Links auto-verification returned HTTP 404 (#1998). - CI workflow hardening (#1702, #1708, #1984): the
CI Gateaggregator no longer red-lights a PR when an advisory check (e.g.Coverage (advisory)) isCANCELLED/SKIPPED/FAILURE— the pass/fail decision now excludes any check whose name matches anADVISORY_CHECKSsubstring, for all conclusions (a transient concurrency-cancel of advisoryCoveragehad blocked otherwise-mergeable PR #1889 for ~3h). The decision logic is factored into.github/scripts/ci-gate-aggregate.shwith a regression suite (test-ci-gate-aggregation.sh, wired intoci.ymlrepo-hygiene) covering the CANCELLED-advisory case. Also confirmed and pinned:docs.ymluses matchingupload-artifact/download-artifact@v7pairs (no@v8mismatch), andci.yml'squality-gatejob uses the sharedsetup-gradlecomposite action so it gets thegradle-wrapper.jarSHA validation supply-chain guard like every other Gradle job.
Removed¶
- Removed the dead Kotlin/JS source set of
samples/web-demo(#1946): the web demo'ssrc/jsMain/.../Main.ktbuilt aweb-model-viewer.jsbundle thatindex.htmlnever loaded — the shipped page has always run on its hand-written inline<script>+ self-hostedjs/sceneview.js. The deadMain.kt/WebXRParityDemos.kt, the Kotlin/JS Gradle wiring (build.gradle.kts,webpack.config.d/, the:samples:web-demosettings.gradleinclude) are gone; the static deliverable moved fromsrc/jsMain/resources/tosamples/web-demo/site/. The web demo is now a plain static site with one source of truth (the inline JS) —docs.ymldeploys it with a verbatim file copy and the Playwright suite serves it directly. Root cause of #1541 and #1944.
Tests¶
- QA:
web-perf-qa.shnow enforces a tuned Lighthouse perf budget (mobile preset — FCP/LCP/CLS + perf-score) instead of always emitting an advisory verdict, anddevice-qa.shrecords the result as an advisoryweb-perfleg so a budget breach surfaces indevice-qa-report.json's release gate (#1898, follow-up of #1879). - Registered the new
occlusion.mat/occlusion.filamat(added by #1832) in thetools/GenerateFilamat.shinventory under a new Profile E (-a vulkan -a opengl -p mobile), so the.filamatABI drift guard now covers all 21 material blobs (#1949).
Docs¶
- Added a "Hand / Face / Body tracking parity" table to the iOS cheatsheet mapping mobile ARCore, Jetpack XR (
XrHandNode/XrFaceNode), ARKit phone, visionOS, and WebXR — and cross-linked it from the Jetpack XR integration design notes (#1904). - Removed the stale
website-static/llms.txt(pinned at v4.0.9, 12 versions behind the canonical rootllms.txt) so the deployedsceneview.github.io/llms.txtalways serves the current API reference (#1956); added cross-platform parity rows for v4.12.0 Spatial Audio (#1900) and Haptic Feedback (#1901) to the iOS and Android cheatsheets, stating the real iOS / Web maturity (#1958). - Corrected the stale node-type count across
README.md, the website, structured data, docs, and the MCP docs to the actual 41 (24 3D + 17 AR) — previously claimed 35-39 and missed the ARPointCloudNode,PlaneNode, andReticleNodeadditions.
v4.12.0 — 2026-05-21¶
Added¶
- Auto-fit camera framing (#1439): a new library-level helper in
io.github.sceneviewcomputes the orbit distance at which a model's bounding sphere exactly fills the viewport, regardless of the model's intrinsic glTF size.fitDistanceForBounds(bounds, verticalFovDegrees, aspect, padding)is pure trigonometry (yaw-invariant — fits the bounding sphere, not the raw box);CameraNode.frameToContent(node)/CameraNode.frameToBounds(aabb)reposition the camera in one call;verticalFovDegreesForFocalLengthandBox.toAabb()convert Filament's focal-length /Boxtypes;SceneAutoFitStateis a one-shot guard for use in aSceneViewframe loop. The Model Viewer demo now auto-fits its orbit radius to the displayed model — a 5 cm bee and a 5 m crate are framed identically without per-demoscaleToUnitstuning. Android-only for now; iOS already frames fromvisualBounds(#1026 / #1391). arsceneview: Environment-aware AR fog —ARFogNode(inio.github.sceneview.ar.node) blends the live camera passthrough toward a coloured haze using the ARCore depth image, so distant real-world surfaces fade while near ones stay crisp. MirrorsFogNode'sdensity/color/enabledparameters so the same numbers fog both real and virtual geometry visually consistently, plus AR-onlystart/enddistance bounds. Inspired by ARCore Depth Lab's AR Fog sample. Opt-in, off by default — collapses to a no-op whenenabled = false(zero shader cost via a branchlessfogEnabledgate). RequiresConfig.DepthMode.AUTOMATIC(orRAW_DEPTH_ONLY) andARCameraStream.isDepthOcclusionEnabled = true. The depth-aware camera material (camera_stream_depth.mat) was extended with the fog term and its.filamatblob recompiled with the matching matc 1.71.0 toolchain — seeCONTRIBUTING.md. Demo: newARFogDemoinsamples/android-demo(deep linksceneview://demo/ar-fog), with sliders that drive both the real-world fog and a virtualFogNodein lockstep so the parity is visible side-by-side (#1717).- New
ARMLObjectLabelDemoin the Android demo app — ML Kit object detection on the AR camera feed, with 3D billboard labels anchored at detected real-world objects via depth hit-tests. Uses the bundled offline ML Kit model (com.google.mlkit:object-detection), so the demo works without any extra asset download. Ships alongside a newFrame.cameraImage()extension onarsceneviewexposing the YUV CPU image for ML / CV pipelines. (#1737, #1733) arsceneview: surfaced 11 ARCoreConfig.*Modeenums as typed DSL params onARSceneView—planeFindingMode,depthMode,instantPlacementMode,geospatialMode,streetscapeGeometryMode,cloudAnchorMode,augmentedFaceMode,imageStabilizationMode,semanticMode,updateMode,focusMode. Each defaults to ARCore's recommended value, is applied to theConfigBEFORE thesessionConfigurationcallback (so the callback still wins as an escape hatch), and is reactive — flipping a param via Compose state reconfigures the running session without recreatingARSceneView. Demos (ARCloudAnchorDemo,ARInstantPlacementDemo,ARPlacementDemo) migrated off the rawsessionConfigurationcallback (#1766).- sceneview-web: WebXR feature parity composables —
XRDepthInfo+DepthOcclusionShader(depth-sensing),XRHandNode(handedness).joint(Joint.INDEX_TIP) { ... }(hand-tracking, 25 joints),XRImageTrackingNode(index = 0)(image-tracking with the newXRFeature.IMAGE_TRACKINGconstant),XRAnchorNode(xrAnchor)(anchors). Mirrors the Androidarsceneviewcomposables.XRFramegains thegetDepthInformation(view)andgetImageTrackingResults()extensions plus atrackedAnchorsaccessor (#1778, part of #1754). - New
SpatialAudioNode(Android + iOS + Web) — positional 3D audio attached to scene nodes with inverse/linear distance falloff. Each node owns its own player so two nodes never cross-talk. Android phase-1 per-nodeMediaPlayerbackend (Spatializerin phase 2); iOS RealityKit spatial audio; Web AudioPannerNodeHRTF. Phase 1 of #1900 — drive the listener withsetSpatialAudioListenerPose(position, forward, up)from the render loop; automatic camera tracking is phase 2. - New
rememberHapticFeedback()(Android) +SceneViewHaptic(iOS) + Webnavigator.vibratefallback. 7 presets (light/medium/heavy/success/warning/error/selection) +continuous()+pattern()+cancel().continuous(intensity, durationMs)takes a millisecondInton every platform — Android, iOS and Web — so cross-platform callers pass the same value. Library API replaces ad-hoc per-demo wrappers. Phase 1 of #1901; NodeGesture modifiers + AR event modifiers come in phase 2 / phase 3. - Filament materials CI guard (#1912 Part B):
tools/GenerateFilamat.shis rewritten to resolve the pinned matc version fromgradle/libs.versions.toml, download + cache the matched matc tarball under~/.cache/sceneview/matc-<version>/, and compile every.matsource with its profile-specific flag list. A new--checkmode regenerates each blob to a tmp dir and byte-diffs against the committed.filamat, exiting non-zero on drift; the gate is wired into.claude/scripts/quality-gate.shso PRs that ship a.matedit without a matching.filamatrecompile are now blocked automatically. The smoke recompile surfaced and fixed threewebsite-static/blobs that were still compiled against matc 1.70.x while the runtime moved to Filament 1.71.0.
Changed¶
- API consistency polish (#1844). Tier-2 Wave-4 follow-ups bundled into one release surface:
ARSceneView(onSessionFailed = …)soft-deprecated in KDoc in favour of the typedonSessionFailure(#1759). Both still fire when set; the legacy callback stays available indefinitely for backwards compatibility.rememberARPlaybackStatusported to theproduceStateidiom — matchesrememberCameraGeospatialPose/rememberEarthStateinstead of the bespokeLaunchedEffect + mutableStateOfpair.ARSceneScope.DepthHitResultNodeadds a customhitTest: (Frame) -> DepthHitResult?lambda overload — mirrorsHitResultNode's 2-overload surface. Apps wanting multi-pixel / moving-reticle depth selection no longer have to subclass the node.cameraConfigFilter { … }DSL:depthSensorandstereoCameraare nowSet<…>?instead of singletons — symmetric withtargetFpsand with the underlying ARCoreset*(EnumSet)API.setOf(X)keeps the singleton case ergonomic. Empty sets fail fast (validation moved out of #1845).- Cheatsheets refreshed:
docs/docs/cheatsheet.mdabsorbs the Wave-4ARSceneView(onSessionFailure / playbackDatasetUri / flashMode)parameters;docs/docs/cheatsheet-ios.mdlists every new Android-only API surface so AI agents stop emitting iOS code referencingARSessionFailure,DepthHitResultNode,cameraConfigFilter,Frame.cameraImage(),rememberARPlaybackStatus, orARRecorder.addTrack / recordTrack / State.IO_ERROR. - Changelog fragments gain a
Performancecategory — covers pure perf wins (#1810-style) that don't fitFixedorChanged..claude/scripts/collate-changelog.shrecognises it. mcp/src/generated/llms-txt.tsis now build-generated, not committed (#1928). The ~230 KB embeddedllms.txtbundle is.gitignored and regenerated by theprebuild/prepare/testnpm lifecycle scripts, removing the guaranteed merge conflict every parallel PR that touchedllms.txtused to hit. The publishedsceneview-mcptarball still ships the compileddist/generated/llms-txt.js.
Fixed¶
- AR demos render placed PBR models as flat-black silhouettes (#1611). Two fixes to the
ARSceneViewIBL path. (1) The baselineenvironment.indirectLightis now applied viaLaunchedEffect(environment)instead ofSideEffect— demos that surface per-frame ARCore state to UI state (latestFrame,isTracking) used to recompose every frame and silently resetscene.indirectLightback to the baseline, dropping the per-frame rebuilt IBL produced by ARCore'sENVIRONMENTAL_HDRestimate. (2) The per-frame rebuild is now gated byshouldRebuildIndirectLight(estimation, baseIndirectLight)so partial estimations (e.g. reflections cubemap in flight, irradiance SH not yet stable) skip the rebuild instead of producing an IBL with empty irradiance OR empty reflections — KTX1-loaded baselines expose SH via the native handle (noirradianceTexture), so the legacy fallback returned a no-IBL builder and Filament collapsed diffuse PBR to black. Pinned by 4 new pure-JVM cases inIndirectLightRebuildDecisionTest. Verified on Pixel 9: placed Damaged Helmet, Fox, Lantern, Toy Car, Shiba and streamed Sketchfab models now render lit on first frame instead of as flat silhouettes. DepthMeshNode.uploadGeometryno longer caches the directByteBufferused forVertexBuffer.setBufferAt/IndexBuffer.setBuffer. Filament's JNI captures a global ref to the buffer and copies ASYNCHRONOUSLY on the render command stream; reusing the buffer across uploads (introduced by the #1810 perf opt) could clobber bytes Filament had not yet consumed → torn vertex uploads, corrupted mesh frames. Reverted to a freshByteBuffer.allocateDirect(...).order(nativeOrder())per upload — matches the standard pattern used by every other Geometry.kt caller. A follow-up issue can revisit the perf cost with a callback-based ring buffer if profiling shows the GC churn matters in practice. Closes #1841.- Sketchfab missing-API-key defensive layer (#1909). When the release build ships with a blank
SKETCHFAB_API_KEYsecret, the Android + iOS demos now surface a neutral "Sketchfab carousels disabled — API key missing" banner in the Explore tab (with a tap-to-explain dialog) instead of silently rendering empty carousels + a dead search bar. CI release pipelines (build-apks.yml,play-store.yml,app-store.ymliOS + macOS archives) fail-fast on tag pushes when the secret is empty/blank or rejected byGET /v3/me, via the new.claude/scripts/verify-sketchfab-key.sh(mirrors the existing ARCore-key guard from #1177). Debug builds also emit a single Logcat /os.LoggerWARN pointing at thelocal.properties/ scheme env-var workaround. Note: the actual secret rotation that re-enables Sketchfab features in shipped releases is a manual GitHub Secrets action — this PR delivers the defensive layer so a future regression is loud at CI time and visible to users. .claude/scripts/quality-gate.sh: TheLARGE_FILEScheck no longer aborts the whole quality gate underset -euo pipefailwhen staged files are under the 10 MB threshold (the common case). Restructured the per-file&&chain into nestedifblocks so the size comparison returning false stays local to the loop iteration instead of propagating throughpipefailand bailing the script viaset -e. Local pre-push runs now reach the finalQuality Gate Summaryblock as intended (#1914).- Filament materials audit Part A (#1918): removed the orphaned
view_renderablematerial — its.matsource and the ~114 KBview_renderable.filamatblob shipped in every APK despite no code path ever loading it (superseded byview_texture_lit/view_texture_unlit). The static audit of all 21 material sources confirmed no dangling parameter reads (no Kotlin call references a parameter the.matdoes not declare) and no leakedMaterialInstances — everycreateInstance(...)is tracked byMaterialLoader,ARCameraStream, orPlaneRendererand destroyed on teardown. Addedwebsite-static/materials/README.mddocumenting the deliberate web-vs-Android divergence, and reviewed the A-vs-B matc flag-profile split as intentional.
Tests¶
- Web device-QA:
assertRendered()incatalog.spec.tsand the non-blank check inrender.spec.tsare now HARD failures (no more soft-warn). Two complementary signals must hold: WebGL context alive (gl.isContextLost() === false) and compositor screenshot shows non-flat luminance variance. Combined with the--enable-unsafe-swiftshaderChromium flag landed earlier, this closes the green-on-nothing risk on GPU-less CI runners (#1593, addresses #1674 items 1+2).
Docs¶
- Filament materials documentation (#1919 Part C): every
.matsource now carries a header comment block (purpose, used-by node/loader, per-parameter contract, matc flag profile), and theCONTRIBUTING.md"Filament runtime ↔ .filamat ABI invariant" section is updated with thetools/GenerateFilamat.shworkflow, thequality-gate.shdrift gate, and the four A/B/C/D matc flag profiles.
v4.11.2 — 2026-05-21¶
Added¶
arsceneview: AR depth-of-field driven by ARCore environment depth — newarDepthOfField(view, camera, options)composable +ARDepthOfFieldOptions(focusDepth, blurStrength, enabled)data class wire Filament's native DoF post-pass to the same z-buffer thatARCameraStream's depth-occlusion material already writes (gl_FragDepthincamera_stream_depth.mat), so tapping a near object throws the far background out of focus and vice-versa — both the virtual scene and the camera background blur from the same focus point. No new.filamatrequired. Tap-to-focus helperFrame.depthFocusDistance(xPx, yPx): Float?reuses the depth hit-test added in #1712. Opt-in (off by default; zero cost on disabled frames). RequiresConfig.DepthMode.AUTOMATIC/RAW_DEPTH_ONLY+ARCameraStream.isDepthOcclusionEnabled = true. NewARDepthOfFieldDemoin the sample app demonstrates the canonical wiring;llms.txtdocuments the API surface (#1716).arsceneview: Scene Semantics API —Config.SemanticMode.ENABLEDis now support-gated viaARSession.configure(silently downgrades toDISABLEDon devices without the on-device ML model, matching the depthMode / flashMode auto-fallbacks), and three newFrameextensions expose the per-pixel labels:Frame.semanticImage(): Image?(R8 label ordinal raster),Frame.semanticConfidenceImage(): Image?(R8 confidence raster), andFrame.semanticLabelFraction(label: SemanticLabel): Float(cheap GPU-backed pixel-share query, returns 0f when semantics are off / not yet available). Comes with a newARSceneSemanticsDemoshowing a live top-3 label HUD over the camera feed. Outdoor only — the ML model has no indoor training data. The custom.filamatlabel-overlay material is tracked separately as a follow-up (matc toolchain ABI work) (#1730).arsceneview: surfaced ARCore CPU camera image access viaFrame.cameraImage(): Image?— a 1-line wrapper aroundacquireCameraImage()returningnullonNotYetAvailableExceptionand documenting the caller-owneduse { }lifecycle. Unblocks ML Kit / OpenCV / custom CV pipelines. Pair with the newcameraConfigFilter { facing = …; targetFps = …; depthSensor = …; stereoCamera = … }DSL onARSceneView.sessionCameraConfigto pick a session-wideCameraConfig(resolution, FPS, depth/stereo-sensor usage) without hand-rollingSession.getSupportedCameraConfigs(filter). Falls back to the session's current config when no match exists so session creation never crashes (#1733).- Jetpack XR foundation — runtime availability check + integration design (#1738). Adds
io.github.sceneview.ar.xr.XrFeatures.isAvailable(context)to gate Android XR (headsets, glasses) code paths, declares theandroidx.xr.arcore:arcore:1.0.0-alpha14dependency alias, and records the module / runtime decision inarsceneview/docs/JETPACK-XR-INTEGRATION.md. Hand tracking node + demo (Slice 2) and Jetpack XR face tracking node (Slice 3) ship in follow-up PRs. Phone-only apps are unaffected — the XR dependency is opt-in and the Perception runtime is reached via reflection. arsceneview:sealed class ARSessionFailure— typed taxonomy covering all 25 ARCore exception subclasses (install, permission, camera, quota, cloud-anchor, augmented-image, recording/playback, session/config) plus anOtherescape hatch. NewARSceneView(onSessionFailure: ((ARSessionFailure) -> Unit)? = null)callback dispatches alongside the legacy raw-ExceptiononSessionFailedso apps can do exhaustivewhenmatching (the compiler catches missing cases the day ARCore adds a new failure category). OriginalExceptionpreserved on.causefor every subtype.CloudAnchorNode.onHostedalready passed the specificCloudAnchorState(not a binaryisError),AugmentedImageNode.trackingMethod+onTrackingMethodChangedalready surfacedFULL_TRACKINGvsLAST_KNOWN_POSE, andConfig.addAugmentedImage'sImageInsufficientQualityExceptionis now routed via the newARSessionFailure.ImageInsufficientQualitysubtype. Backwards compatible — existingonSessionFailedcallers see no change (#1759).- arsceneview: new
SceneUnderstandingdata class +ARSceneView(sceneUnderstanding = ...)parameter that groups four scattered AR rendering flags (occlusion,lighting,physics,planeVisualization) into one discoverable knob — mirrors RealityKit'sARView.environment.sceneUnderstanding.optionsfor cross-platform parity. The parameter is opt-in (defaults tonull); when null, the individual flags retain their pre-#1767 defaults. Named constantsSceneUnderstanding.Full,.Minimal,.Nonecover the common configurations. AI assistants now find one parameter instead of four (#1767). arsceneview: rounded out the ARCore recording/playback surface (#1770).rememberARPlaybackStatus(session): State<PlaybackStatus>— Compose State that surfacesNONE/OK/FINISHED/IO_ERROR(theFINISHEDtransition is the only public end-of-replay signal, useful for rewind / loop / next-dataset logic).ARRecorder.State.IO_ERROR— distinct from genericERROR. Set byrecordFrame(session)when ARCore reportsRecordingStatus.IO_ERROR(disk full, storage detached, permission revoked mid-recording) so apps can offer a "clear cache and retry" CTA.ARRecorder.addTrack(uuid, mimeType)+ARRecorder.recordTrack(handle, frame, data)— exposes ARCore'sRecordingConfig.addTrack+Frame.recordTrackDataflow for ML annotation / ground-truth / custom sensor packets written inside the same MP4.ARSceneView(playbackDatasetUri: Uri? = null)— scoped-storage equivalent of theplaybackDataset: File?param (Android 10+). Acceptscontent://URIs straight from the SAF picker so apps don't have to copy into app-private storage. Mutually exclusive withplaybackDataset— setting both throwsIllegalArgumentException.samples/web-demo(QA): IWER (Immersive Web Emulation Runtime,iwer@^2.2.1) WebXR shim is now injected into the Playwright page viapage.addInitScript(...)under a Meta Quest 3 emulated device profile, and a newtests/webxr.spec.tsclicks#enter-ar/#enter-vrand asserts no console errors, no WebGL context loss, and no unhandled rejections — closing the WebXR scaffolding gap (#1878, follow-up of #1748). The rich replay test soft-skips until a real recorded XR session fixture is added (separate follow-up, requires a real WebXR-capable device).- QA harness: advisory web-perf scaffold (#1879). New
.claude/scripts/web-perf-qa.shruns Lighthouse (mobile preset) againstsamples/web-demoand emitsweb-perf-summary.jsonwith FCP / LCP / CLS + the Lighthouse performance score. Wired intodevice-qa.shas an advisory sub-leg of the web run (continue-on-error, never blocks the release gate). Thresholds are deliberately deferred — follow-up tracked. samples/android-demo: developer-only debug toggle that force-emits anyTrackingFailureReasonso the actionable-message overlay wired by #1735 can be validated indoors without staging a real failure (dark room, textureless surface,EXCESSIVE_MOTION, etc.). NewForcedTrackingFailuresingleton +ForceTrackingFailureMenu()composable section undersamples/android-demo/.../common/— visible only whileDemoSettings.qaModeis on (long-press the demo's peek-chip or launch with--ez qa_mode true), so end users never see it. Wired intoARImageDemoas a proof-of-concept; a follow-up issue covers the remaining 11 AR demos that share the sametrackingFailureMessageoverlay (#1881).samples/android-demo: extended the developer-only force-tracking-failure debug toggle (#1881 / #1887) to the remaining 11 AR demos that consumeTrackingFailureReason.ForceTrackingFailureMenu()is now reachable from each demo's Settings sheet (still gated byDemoSettings.qaMode, so end users never see it), and each demo's status-overlay path now readsForcedTrackingFailure.overridedirectly so flipping the override re-renders the banner without waiting for the next ARCore tracking-failure callback. Wired demos:ARCloudAnchorDemo,ARDepthOcclusionDemo,ARDepthVisualizationDemo,ARImageStabilizationDemo,ARInstantPlacementDemo,ARPlacementDemo,ARRawDepthPointCloudDemo,ARRecordPlaybackDemo,ARRerunDemo,ARSceneSemanticsDemo,ARStreetscapeDemo(#1888).
Changed¶
samples/web-demo(QA): Playwright bumped to^1.59.0and the legacyvideo: 'on'capture is replaced with apage.screencast-drivenscreencasttest fixture that brackets every test, writes one.webmper test undertest-results/screencasts/<slugified-title>.webm, and exposes ascreencast.chapter(title, description?)API for tagging meaningful boundaries (tab switch, model load, failure).device-qa.shmirrors the recordings into$ARTIFACTS/web-screencasts/so the web leg now ships per-test video parity with the Maestro Android / iOS legs (#1748)..claude/hooks/pre-risky-github-op.sh: added anSV_BATCH_REBASE=1env-var escape hatch so legitimate multi-PR rebase batches (e.g. a 9-branch ARCore audit sprint) no longer require clearing~/.claude/logs/force-push.logto bypass the 1-force-push-per-24h cap. The bypass logs an explicit notice to stderr and tags the entry[SV_BATCH_REBASE]for auditability, and the BLOCK message now points to the escape hatch instead of suggesting log tampering (#1796).- Append-only demo registry for
samples/android-demo(#1797). Adding a demo to the Android sample app no longer requires editing shared files. Each demo is registered by a single*Fragment.ktfile underio.github.sceneview.demo.fragments; a collator (samples/android-demo/scripts/collate-demos.sh) aggregates them intoGeneratedDemos.kt, sorted by id so two parallel PRs never collide on the same anchor. The quality gate runs the collator in--checkmode to block stale generated files. - ci:
.claude/scripts/worktree-auto-prune.shpolish pass — respectsgit worktree lockby default (with--unlock-lockedoverride, #1833), broadens active-session detection fromnode/claudeto every process whose cwd is inside a worktree so gradle daemons / Python venvs / IDE indexers also block prune (#1834), writes one JSON line per evaluated worktree to~/.claude/logs/worktree-prune-YYYYMMDD.logfor post-incident forensics, batches the merged-PR lookup into a singlegh pr list(was N ×gh pr view), and wrapslsofintimeout 10sso a hung scan can't hang the prune (#1839). New.claude/scripts/test-worktree-auto-prune.shexercises 7 scenarios — merged, unmerged, dirty, locked, locked+--unlock-locked, live subprocess,--keep— and runs advisorily insidequality-gate.sh(#1835). CONTRIBUTING.md now documents the full skip ladder and flag set. - AR perf minor polish (#1846). Tier-2 Wave-4 PERFORMANCE follow-ups. None individually MAJOR; collectively close out the audit's remaining minor findings.
DepthMeshCollisionTestcolumn-order test hardened — added a 90° Y-rotation + non-axis-aligned translate case that compares the inline matmul (post-#1810) against kotlin-math's referenceMat4 * Float4. A column-swap in the inline math would now fail on every non-zero rotation component instead of silently passing the translate-only fixture.DepthMeshNode.acquireDirectBuffercapped at a newMAX_UPLOAD_BUFFER_BYTES = 1 MBceiling — a one-off oversized depth image no longer permanently inflates the upload-buffer cache. Buffers under the cap retain the no-shrink amortisation behaviour from #1810. NewDepthMeshNodeUploadBufferCapTestpins the invariant.DepthHitResultNodeper-framePose.makeTranslationdocumented as load-bearing — investigation found ARCore'sPoseis immutable by design andDepthHitResultcarries no reusable Pose, so one alloc per node per frame is the floor for this surface. Inline KDoc steers future perf passes away from a false "fix".rememberARPlaybackStatusalready migrated to baretry/catch (e: RuntimeException)in #1857 — noThrowablewrapper allocation on IO_ERROR frames.samples/android-demo: per-demostrings.xmlfragments. Title, subtitle, and demo-specific UI strings now live in dedicatedres/values/strings_demo_<id>.xmlfiles alongside each demo's*Fragment.kt, so two parallel PRs adding two different demos no longer collide on the centralstrings.xml. Android's resource merger fans everyres/values/*.xmlin at build time, soR.string.demo_*references resolve identically (no Kotlin / composable changes). The sharedstrings.xmlkeeps only app-level strings (navigation, AR launcher, About, accessibility…). Follow-up of #1797's append-only fragment registry (#1870).samples/android-demo:collate-demos.shnow also rewrites the "Sample app demos (Android)" section ofllms.txt(and its mirrordocs/docs/llms.txt) between dedicated marker comments, sourced from the same per-demo*Fragment.ktfiles that driveGeneratedDemos.kt. Adding a new demo no longer touches anyllms.txt: drop the fragment, run the collator, regenerate the MCP bundle (node mcp/scripts/generate-llms-txt.js), commit.--checkmode bit-compares all three outputs and the existingcheck-llms-drift.sh+quality-gate.shwiring picks the new section up unchanged (#1871, follow-up of #1797 / PR #1869).
Fixed¶
.claude/scripts/impact-check.sh: trace line per check +--failflag + ERR trap so the script no longer exits 1 silently in lean / sparse clones. Each check now announces itself on stderr (the last trace line points at any unexpected failure), every path-dependent check[SKIP]s instead of dying when its inputs are absent (sceneview/,arsceneview/,SceneViewSwift/),grep | wc -lpatterns are guarded against thepipefailzero-match exit, and an ERR trap names the dying check + line. Default exit is now 0 (report-only);--failopts in to non-zero for the quality gate.SV_IMPACT_TRACE=1forcesset -x; auto-trace fires when stdout is not a TTY (CI / agent) unlessSV_IMPACT_TRACE_AUTO=0(#1782, #1786).build.gradle: document that thewebpack <5.107.0Yarn resolution pin (added in #1791 for:sceneview-web:jsBrowserProductionWebpack) also covers:sceneview-web:jsTestand:sceneview-web:jsBrowserDistribution. The root cause is shared —kotlin-web-helpers/dist/tc-log-error-webpack.jsstill doesrequire("webpack/lib/ModuleNotFoundError")after webpack 5.107.0 moved that file tolib/errors/, and karma surfaces the resolution failure with a misleading top-of-stackkarma/bin/karmaline. The pin already keeps fresh-clone:jsTestruns green; this commit just makes the comment match the actual scope so future bound-lifts don't accidentally re-break test execution. Validation on a clean clone (rm -rf build/ && ./gradlew :sceneview-web:jsTestand:jsBrowserDistribution): both BUILD SUCCESSFUL with webpack 5.106.2 resolved (#1785).- arsceneview:
DepthMeshNode.computeAabbnow clamps every half-extent belowDEGENERATE_AABB_HALF_EXTENT_Mto the degenerate cube, not just the all-empty-positions case (#1806). The earlier #1783 fix only handledpositions.isEmpty(); geometry where every sample shared the same coordinate (e.g. a constant-range depth image, or the first frame after a scene reset where only one off-grid(0,0,0)vertex made it through) still emitted a zero half-extent and tripped Filament'sAABB can't be emptySIGABRT. AddedDepthMeshNodeAabbTestwith the issue's reference case (all positions(0.5, 1.0, -2.0)) plus mixed-axis and single-vertex cases. Surfaced by the May 2026 Tier-2 SECURITY audit. - arsceneview: defensive input validation on the AR depth pipeline (#1812).
DepthMeshNode.updatenowrequires non-zero camera intrinsics (width, height, focal length) so degenerate ARCore frames raise a clear error instead of poisoninglatestSnapshotwithInf.Frame.hitTestDepthandunprojectDepthPixellikewise reject zero focal length — the latter throwsIllegalArgumentExceptionfor direct callers; the former returnsnull.DepthCollider.setBodiesRegionrejects flat-packed arrays whose size is not a multiple of 3 (IllegalArgumentException) and silently falls back to disabled culling when the resulting region is non-finite.nearestSurfaceYBelowskips out-of-bounds index triplets so a future drift betweenpositions/indicescannot AIOOBE on the render thread. All paths covered by JVM unit tests. - ci:
app-store.yml's submit step now GETs the version record'sappStoreVersionSubmissionrelationship and DELETEs any stale submission before re-POSTing — so the submission CREATE is idempotent across re-runs. When the #1687 + #1795 absorption logic retargets a stranded draft, that draft's old submission used to remain attached and 403 every subsequent CREATE ("Allowed operation is: DELETE"). v4.11.1 hit this on the stranded367draft → renamed to4.11.1→ POST refused. Closes the last loose end of the #1795 / #1687 saga (#1831). DepthMeshNodeno longer leaks the oldVertexBuffer/IndexBufferwhen an upload step throws mid-frame (engine teardown is the realistic trigger).rebuildBuffersIfNeededreturns the freshly-built buffers without mutating theowned*fields;uploadGeometrycommits the swap + destroys the old buffers ONLY aftersetGeometryAtreturns. On any exception the new buffers aresafeDestroy-rolled-back and the owned* fields remain reachable fordestroy(). Closes a latent leak introduced by the #1805 UAF fix (#1840).ARDepthColliderDemo: collapsed the per-ballapply.onFramefan-out into a single Scene-levelonSessionUpdatedcallback —publishCollisionRegionnow runs ONCE per AR frame (was N times for N balls → ~300 transientFloatArray/sec at 5 balls × 60 fps). Replaced themutableListOf + activeBallNodes += thispattern with a per-ball-countarrayOfNulls<SphereNode>(ballCount)slot store written by index. Recompositions that don't changeballCount(slider, theme, parent state) no longer leak stale node refs, so the region-cull AABB stays bounded. Region-cull payload is now packed by a purepackCentres(...)helper with a JVM regression test pinning the "no stale entries bleed through" invariant. Closes #1842.arsceneview: hardenARRecorder+cameraConfigFilter+ playback wiring against five privacy / misuse / threading regressions surfaced by Tier-2 Wave 4 security review (#1845):ARSceneView(playbackDatasetUri = …)now allowlistscontent://andfile://schemes only (rejectshttps://,data:, custom schemes at the SceneView boundary instead of handing them silently to ARCore — caller-side permission requirements documented on the KDoc);cameraConfigFilter { targetFps = emptySet() }raisesIllegalArgumentExceptionat builder time (was silently degrading to the session default camera config, which on Augmented Faces sessions is front-facing — a developer requesting back-only got the front camera with no signal) and the runtime catch is narrowed to ARCore's documentedRuntimeExceptionfailure point (Session.getSupportedCameraConfigs) so builder errors propagate to dev-time tests;ARRecorder.recordFrameIO_ERROR transition now commitsstate+errorMessageinside aSnapshot.withMutableSnapshot { }so Compose readers can no longer observe the in-betweenstate == RECORDINGpaired with a non-nullerrorMessage;ARRecorder.recordTrack(handle, …)short-circuits tofalsewhenhandlewas not registered viaaddTrackon the same recorder (was forwarding to ARCore — cross-recorder reuse leaked packets between unrelated recordings);ARRecorder.addTrackis bounded toMAX_PENDING_TRACKS = 64and a newclearTracks()API drops the in-memory registry (prevents theaddTrack(UUID.randomUUID(), …)leak when wired into a recomposing block — unique UUIDs bypass the idempotent dedup, the cap surfaces the misuse at the call site).- CI:
quality-gate.shnow blocks llms.txt mirror drift (#1847). The drift detectors fordocs/docs/llms.txtand the MCP bundlemcp/src/generated/llms-txt.tsused to live only insync-versions.sh, which is not called by the PR-blocking gate. A new dedicatedcheck-llms-drift.shis wired intoquality-gate.shso any divergence from rootllms.txt(e.g. theDepthHitResultNodedrift that landed via #1822) fails the gate instead of silently sitting onmain. - docs, scripts: plug version-bump tooling holes for derived doc surfaces (#1848). Bumped stale Maven coordinates / SPM tags / CDN @version pins in
arsceneview/Module.md,sceneview/Module.md,docs/docs/manifest.json,docs/docs/structured-data.json, and the three agent skills underagents/sceneview*/(SKILL + references/cheatsheet + references/migration + references/recipes) — they had drifted to 4.3.x / 4.4.x / 4.9.x. Added 14 ERROR-level checks to.claude/scripts/sync-versions.shcovering manifest.jsonrelated_applications[].id, structured-data.jsonsoftwareVersion+releaseNotestag + Maven prose, plus every per-skill Maven coordinate / npmsceneview-web@/@sceneview-sdk/react-native@/ SPM tag prose line, with matching--fixrewrites so future releases catch the drift. samples/android-demo:ARRawDepthPointCloudDemonow guides the user through motion-stereo convergence on non-LiDAR Pixels — a first-launch overlay ("Move your device for raw depth to converge") that auto-dismisses on the first non-zero frame or after 8 s, plus a passive top-right chip when the point count stays at zero for more than 2 s. The default confidence threshold is also lowered from 63/255 to 32/255 so motion-stereo's first frames produce visible points immediately. Before, a fresh launch showed "0 points" with no indication that the demo needs phone motion, which read as a broken demo (#1873).samples/android-demo:ARDepthColliderDemonow spawns balls in front of the current camera pose instead of the AR-session origin, so the balls are always visible regardless of how the user has moved before tapping Drop (#1874), and hides the underlyingDepthMeshNoderenderable by default — the cream dotted grid that the default material drew on every real surface was distracting and ambiguous. A new "Show depth mesh (dev)" Settings switch re-enables the visualization for collider debugging (#1875).samples/android-demo:ARPlacementDemonow surfaces a screen-centre placement reticle so the user can see where their next tap will land — a thin unlit cyan disc that follows the centre-of-screen hit-test result each frame via the AR-scopeHitResultNode, with an "Aim at a surface…" prompt when no hit is detected (#1882). The previously-empty Settings sheet is populated with a bundled-model chip row (Damaged Helmet / Fox / Lantern / Toy Car / Shiba — or "Auto-cycle"), a "Snap to plane" toggle (default ON, gating tap acceptance to detected planes), a "Show reticle (dev)" toggle (default ON), and a prominent filled "Clear All" button — the empty placeholder above the Reset row that Pixel 9 QA flagged is gone (#1883).HitResultNodedefaults to plane-only (#1891). The screen-coordinateHitResultNode(xPx, yPx, ...)overload now defaultspoint = false,depthPoint = false,instantPlacementPoint = false, plus a new defensiveminCameraDistance: Float? = 0.3ffloor that drops hits closer than 30 cm from the camera. Pixel 9 device-QA surfaced the previous wide-open defaults causing a fullscreen overlay on session start — depth / feature hits before motion-stereo convergence return positions <10 cm from the lens, and a child placement disc then blanks the camera feed. Opt each filter back in explicitly once your scene is tracking-stable.samples/android-demo+samples/ios-demo: restored the Sketchfab integration on the published Play Store and TestFlight binaries. TheSKETCHFAB_API_KEYGitHub secret was empty (or whitespace) for several recent releases, soBuildConfig.SKETCHFAB_API_KEY/Info.plist:SketchfabAPIKeyresolved to""andSketchfabConfig.apiKeyreturnednull. That silently hid the three Explore-tab carousels (Staff Picks / Most Liked / Recently Added), turned the search bar into a no-op (queries got persisted to Recent Searches but were never executed), and forced everySketchfabAssetResolver-driven streamed demo (MultiModelDemo, ARPlacementDemo, ARInstantPlacementDemo, scene gallery, ...) onto its bundled-GLB fallback. The secret has been re-issued with a verified-valid token; #1910 tracks moving the request path throughmcp-gatewayso this regression class can't recur (#1909).- Strip lying "implemented" badges from the Flutter demo (#909).
samples/flutter-demo/lib/pages/features_page.dartwas claiming green for several methods whose iOS bridge path is a no-op (ModelNodepos/rot,onTap,onPlaneDetected,Environment). Those cards are now labelled "Android only" with the iOS gap pointed at the #909 umbrella. Added a FlutterMethodChannelsmoke-test suite and a React NativeARRecorderJest smoke-test scaffold so future drift surfaces as a red test instead of a green badge. sceneview-webSCENEVIEW_VERSIONconstant lagged 2 releases (4.9.0while shipping4.11.1). Bumped to4.11.1and promoted thesync-versions.shcheck for this code-resident constant from WARN-only to a hard MISMATCH so it can never silently drift again. The regression-pin jsTest (#1357) was bumped in lockstep.- Contributor scripts:
worktree-auto-prune.shno longer silently deletes worktrees with unmerged work whengit fetchfails. Previously a failed fetch only printed a warning and continued with whatever localorigin/mainwas cached, so a worktree on a branch with commits past main could be misclassified asahead=0and removed. The fetch now exits with an error; pass--allow-staleto opt back into local refs for offline runs. In--allow-stalemode, candidates additionally require a merged-PR signal —ahead=0alone is no longer trusted. New active-session guard (on by default,--no-check-active-sessionsto disable): a worktree is skipped if any livenode/claudeprocess has its cwd inside it. The scan re-runs immediately before the destructive loop to close the prompt-window race. The wrappercleanup-branches-worktrees.shpropagates--allow-stalewhen its own fetch fails so offline runs through the wrapper still work.
Docs¶
arsceneview: TightenARDepthOfFieldKDoc with the upstream Filament verification (colorPassOutput.depthis the buffergl_FragDepthwrites to, so DoF post-pass + camera-stream depth occlusion compose without surprises) and surface three device-QA caveats that need eyeballing on real hardware: reverse-Z + early-Z culling around theclip.z = 0.9999fvertex hack, MSAA resolve filtering on the depth attachment, andcocParamscalibration against the AR camera node's projection. Pure docs change; no API/behaviour delta (follow-up to #1716).README.md,llms.txt, and the docs landing page now explicitly position SceneView as the Compose-native successor to Google's archived Sceneform — ARCore for perception, Filament for rendering, Jetpack Compose for the API — so developers and AI assistants searching for a Sceneform replacement find SceneView (#1736).- docs: cross-platform parity table in
cheatsheet-ios.mdmapping the four May 2026 Android-only AR surfaces (DepthMeshNode/DepthCollider/Frame.hitTestDepth/CloudAnchorNode.hostFuture-cancel) to their RealityKit / ARKit counterparts; rootllms.txtcross-platform notes added in each section pointing readers to the cheatsheet. SceneViewSwift implementation work split into #1859 (CloudAnchorNode Future) + #1860 (Scene Reconstruction). #1813 arsceneview: rewrote theARSessionFailureKDoc +llms.txtexamples to use a fully exhaustivewhen(all 25 subtypes +Other) and removed theelse -> showGenericRetryCta()fallback that silently defeated the sealed-class compile-time-safety contract introduced by #1759. Also added a "compact" pattern showing how to dispatch many subtypes via a category-mapping helper withoutelse ->. AI agents copy-pasting the snippet now keep the exhaustive-whenguarantee (#1843).
v4.11.1 — 2026-05-20¶
Added¶
rememberDepthCollider() — depth-driven static physics collider so PhysicsNode bodies bounce off the real floor / table / wall in AR. Thin wrapper over DepthMeshNode (#1739): each rebuild's vertex/index buffers feed a per-frame surface lookup via the new FloorProvider interface on PhysicsBody. SceneView port of arcore-depth-lab's "Collider" scene (#1713).
- Android demo: added an ar-depth-visualization AR demo that renders the ARCore environment depth image as a false-color overlay (warm = near, cool = far), with a slider that blends the live camera feed (0) and the colorized depth map (1). The colorization runs through pure-Kotlin helpers in samples/android-demo/.../demos/internal/DepthVisualization.kt covered by JVM unit tests, and the demo handles "depth not supported" and "depth warming up" with explicit banners — never a black screen (#1714).
- Android demo: added an ar-raw-depth-point-cloud AR demo that visualizes ARCore's Config.DepthMode.RAW_DEPTH_ONLY output as a screen-space point cloud. The demo acquires raw depth + the companion confidence image on every frame, drops samples below a Compose-slider-driven confidence threshold, false-colors the survivors with a warm-near / cool-far ramp, and renders them over the camera feed via a Canvas. The filtering/sub-sampling logic is extracted as pure-Kotlin internal helpers in samples/android-demo/.../demos/internal/RawDepthCloud.kt and covered by 14 JVM unit tests. Honest unsupported / warming-up states surface explicit banners — never a black screen (#1715).
- arsceneview: surfaced ARCore v1.45+ Flash Mode as a new flashMode: Config.FlashMode parameter on ARSceneView (default OFF). Toggling between OFF / TORCH recomposes the session config reactively, and unsupported devices / front-camera sessions silently downgrade to OFF via Session.isFlashModeSupported() — matching the existing depthMode auto-fallback behaviour (#1732).
- arsceneview: surfaced CloudAnchorNode.TTL_DAYS_RANGE = 1..365 and added the CloudAnchorRegistry interface plus a SharedPreferencesCloudAnchorRegistry default for persisting hosted Cloud Anchor IDs (name → cloudAnchorId, hostedAt, ttlDays) across app launches, with isExpired() / purgeExpired() helpers. CloudAnchorNode.host() now validates ttlDays ∈ 1..365 and documents the required ARCore data-privacy disclosure (#1734).
- DepthMeshNode — reify ARCore environment depth as a renderable Filament mesh. New rememberDepthMesh() + DepthMeshNode composables in ARSceneScope turn the live depth image into a triangulated grid in the scene, with edge-discontinuity culling so triangles never stretch across depth jumps. Rebuild is interval-rate-limited (default 5 Hz). Exposes the camera-space vertex / index buffers via a DepthMeshSnapshot callback so downstream consumers (depth-driven physics collider, debug overlays) can read the geometry without poking Filament internals. SceneView equivalent of arcore-depth-lab's ScreenSpaceDepthMesh. (#1739)
- Async Future cancellation across CloudAnchor / Terrain / Rooftop (#1768). CloudAnchorNode.host now returns the underlying HostCloudAnchorFuture so callers can cancel pending Google Cloud requests on UI disposal (avoiding billing accrual for users who navigated away). CloudAnchorNode.resolve, TerrainAnchorNode.resolve, and RooftopAnchorNode.resolve carry explicit return types (ResolveCloudAnchorFuture / ResolveAnchorOnTerrainFuture? / ResolveAnchorOnRooftopFuture?) for the same reason. Billing rationale + DisposableEffect.onDispose { future.cancel() } pattern documented in KDoc and llms.txt. JVM unit tests pin the return-type contract via reflection.
- arsceneview: surfaced four Geospatial accessors as Compose-friendly helpers (#1769). rememberCameraGeospatialPose(session) returns a State<GeospatialPose?> that updates each frame with the live device lat/lng/altitude (null until ARCore acquires a GPS lock + Earth.trackingState == TRACKING). GeospatialPose.snapshot() captures all 7 fields (lat/lng/altitude/heading/horizontalAccuracy/verticalAccuracy/orientationYawAccuracy) into a GeospatialPoseSnapshot data class so apps can retain them across frames — the existing .transform extension drops the four accuracy / heading fields. rememberEarthState(session) exposes Earth.EarthState (ENABLED / ERROR_INTERNAL / ERROR_NOT_AUTHORIZED / ERROR_RESOURCE_EXHAUSTED / ERROR_APK_VERSION_TOO_OLD / ERROR_GEOSPATIAL_MODE_DISABLED) as Compose State. Session.awaitVpsAvailability(lat, lng) is a suspend wrapper around checkVpsAvailabilityAsync — apps can gate "place Terrain anchor" buttons on actual VPS coverage instead of guessing and surfacing ResourceExhaustedException after a network round-trip.
- ARCore extension one-liners (#1771). Thin Kotlin wrappers around frequently-needed ARCore APIs: HitResult.distance, Camera.displayOrientedPose, Plane.polygon, Plane.subsumedBy, Camera.intrinsics(useTexture) returning a CameraIntrinsicsSnapshot (focalLength / principalPoint / imageWidth / imageHeight), public Frame.depthImage() / Frame.rawDepthImage() / Frame.rawDepthConfidenceImage() accessors that swallow NotYetAvailableException, and suspend ArCoreApk.awaitAvailability(context). Documented in llms.txt under "Low-level helpers".
- arsceneview: added types: Set<StreetscapeGeometry.Type> and minQuality: StreetscapeGeometry.Quality filter parameters to ARSceneScope.StreetscapeGeometryNode (default {BUILDING, TERRAIN} / Quality.NONE — no filtering). Apps can now request only BUILDING meshes (drops the noisy ground terrain in dense urban scenes) and gate on BUILDING_LOD_2 to render only the higher-LOD geometry — saves a frame-rate cliff on low-end devices. Geometries that don't pass the filter become composable no-ops and never allocate Filament buffers. The companion cameraConfigFilter { … } DSL covering this issue's second acceptance criterion (DepthSensorUsage / StereoCameraUsage knobs on the camera config filter) ships in #1733 (#1772).
- arsceneview: new ARSceneScope.DepthHitResultNode(xPx, yPx, content) composable — Compose-idiomatic mirror of HitResultNode for placement against the ARCore depth image. Each frame re-runs Frame.hitTestDepth and moves to the resulting world-space surface point; depthHitResult exposes the live DepthHitResult for surface-normal-aligned content (#1814).
Changed¶
- AR demos now surface ARCore tracking-failure reasons via a single
trackingFailureMessage(reason)helper ported from arcore-android-sdk'sTrackingStateHelper. EachTrackingFailureReason(BAD_STATE,INSUFFICIENT_LIGHT,EXCESSIVE_MOTION,INSUFFICIENT_FEATURES,CAMERA_UNAVAILABLE) maps to a localised string resource (tracking_failure_*instrings.xml), giving the user actionable guidance instead of nothing. Inlinedwhenbranches across 8 AR demos (Image, DepthOcclusion, ImageStabilization, InstantPlacement, Placement, Rerun, RecordPlayback, Streetscape) collapse into a 1-line helper call. Addresses the "no guidance" half of #1615 (#1735). - arsceneview: threading-hardening sweep on the AR depth and cloud-anchor paths (#1811).
DepthMeshNode.update / latestSnapshot / currentVertexBuffer / currentIndexBuffer / onMeshRebuiltnow carry@MainThreadannotations with KDoc notes spelling out "render-thread only — reading from a background coroutine is unsupported".CloudAnchorNode.hostTaskis@VolatileandcancelHost()wraps its read-cancel-clear sequence insynchronized(this)so callers can safely cancel an in-flight host fromviewModelScope.launch { … }without racing the ARCore async callback (which fires on the GL/render thread).DepthCollider.setBodiesRegiongains a KDoc render-thread pin matching the surroundingfloorYAt/ingestSnapshotcontract. A newCloudAnchorNodeThreadingTestreflects the@Volatileand exercises the synchronized contract under contention.
Fixed¶
- arsceneview: per-frame
IndirectLightno longer leaks native memory across long AR sessions with intermittent light estimation. TheIndirectLightbuilt inonARFrameis now tracked via a dedicatedAtomicReferenceand destroyed explicitly on every supersession or onDisposableEffectteardown, independent ofscene.indirectLightmutations by third parties. The rebuild decision (estimation vs. environment baseline per channel) is extracted to a purepickIndirectLightSourceshelper covered by JVM unit tests (#1756). - arsceneview: clarify the depth
ByteBufferlifecycle invariant inARCameraStream— the buffer borrowed from ARCore's depthImageis now documented as intentionally NOT cloned, with the upload-completed callback as the load-bearing synchronisation point that closes the ARCore image exactly once. Updates the previous misleading comment that claimed the buffer was cloned. Adds a pure-JVM sentinel test pinning thebuffer.clear()metadata-only contract and the setter-doesn't-allocate invariant (#1757). - Cache the identity tangent buffer on
AugmentedFaceNode(#1758). The(0, 0, 0, 1)identity-quaternion buffer used to honour Filament's FLOAT4 TANGENTS stride contract under unlit face materials is now built once at mesh creation and reused unchanged — no per-vertex rewrite on subsequent calls, even when the cached size matches. JVM unit test pins the cache hit/miss contract. - react-native:
@sceneview-sdk/react-nativepackage.jsonnow declarespublishConfig.access=public, anchoring the scoped-package public-access intent inline.release.yml'snpm publish --access publicCLI flag stays as belt-and-suspenders so any future workflow_dispatch retry or manual republish from a fresh checkout cannot regress to a 404 on the registryPUT(#1788). - build: v4.11.1 re-validates the
sceneview-webKotlin/JS production webpack chain that broke on v4.11.0 — webpack 5.107.0 movedlib/ModuleNotFoundError.jstolib/errors/ModuleNotFoundError.jswhile kotlin-web-helpers still resolved the legacy path, crashing every:sceneview-web:jsBrowserProductionWebpackinvocation. Fixed onmainby the webpack<5.107.0resolution pin in #1791; this release ensures the production publish +Deploy website + docspipelines run end-to-end on the v4.11.1 tag (#1789). - ci:
app-store.yml's submit step now readsVERSION_NAMEfrom the rootgradle.propertiesas itsworkflow_dispatchfallback forASC_VERSION_STRING, instead ofbuild_version(which returnsCFBundleVersion— the build number, not the marketing version). v4.11.0's manual deploy created a nonsensical App Store version record named367and 403'd on submission because of this; the fallback is now anchored to the project's single source of truth (#1795). - arsceneview: closed a use-after-free window in
DepthMeshNode.rebuildBuffersIfNeeded(#1805). The oldVertexBuffer/IndexBufferweresafeDestroy'ed beforeRenderableManager.setGeometryAtrebound the renderable to the new buffers — for one frame the renderable referenced freed Filament native handles. Reordered to build new → rebind → destroy old so the renderable never points at freed memory. AddedDepthMeshNodeBufferRebuildTestJUnit suite that pins the ordering across two successive growths via a mock-engine recorder. Surfaced by the May 2026 Tier-2 SECURITY audit. - arsceneview:
DepthColliderclass-level KDoc example now passes the collider throughfloorProvider = colliderinstead of the non-existentdepthCollider = colliderparameter (#1807). Code pasted from the KDoc previously did not compile. TheARSceneScope.rememberDepthColliderKDoc and thePhysicsNodeKDoc already used the correct form, so the bug was isolated toDepthCollider.kt. - sceneview: deprecated mass-overload
PhysicsNode's@Deprecated(ReplaceWith(...))now preserves the newly-addedfloorProviderparameter (#1807). The IDE quick-fix on the deprecation previously silently stripped AR floor wiring. The deprecated overload itself also gained afloorProviderparameter so the replacement is a 1:1 source-compatible swap. mcp: regeneratedsrc/generated/llms-txt.tsso the npm bundle and the Cloudflare Worker gateway both ship the full May 2026 AR sprint surface (DepthMeshNode/rememberDepthMesh/rememberDepthCollider/Frame.hitTestDepth/HostCloudAnchorFuture/ResolveCloudAnchorFuture/ Future-returningCloudAnchorNode.host&resolve/TerrainAnchorNode.resolve/RooftopAnchorNode.resolve). Added async-versions.shCI drift guard that rebuilds the bundle in-memory and fails when it disagrees with rootllms.txt, so a future sprint can no longer land API additions inllms.txtwhile leaving MCP clients on a stale snapshot. Documented the regen step in the newmcp/CONTRIBUTING.md(#1808).docs: fixed brokenTerrainAnchorNode.resolveandRooftopAnchorNode.resolveexamples inllms.txt— both usedearth = earth, but the real signature takessession: Session(the function readssession.earthinternally). Code pasted from the docs now compiles. Healed mirror drift between rootllms.txtanddocs/docs/llms.txt(theCloudAnchorRegistry+ttlDaysblock from #1734 was missing in the mirror). Added aDisposableEffect.onDispose { future?.cancel() }snippet to the Terrain and Rooftop sections so AI agents emit the same cancel-on-dispose pattern they already produce forCloudAnchorNode(#1768). Added a "Threading" note toFrame.hitTestDepth(~L846) andDepthMeshNode(~L1039) — both must run on the AR frame / GL-main thread; KDoc said so already butllms.txtdidn't. Added "See also" cross-references betweenDepthMeshNode,rememberDepthColliderandFrame.hitTestDepthso devs landing on one discover the other two (#1809).- arsceneview, sceneview: kill per-frame allocation hot paths in the AR render loop (#1810).
ARScene.onARFrame: single-passfor (n in childNodes) when (n) { is PoseNode -> ...; is DepthMeshNode -> ... }replaces twofilterIsInstance<...>().forEach { }walks (~240 list allocations/sec at 60 fps on the render thread).DepthMeshNode.uploadGeometry: vertex / index upload now reuses two cached directByteBuffers grown in powers of two (was ~100 KB/s direct-buffer churn at 5 Hz, ~600 KB/s at 30 Hz).DepthMeshCollision.transformPositionsToWorld: inline 4×4 × (x,y,z,1) matrix multiply writes straight into the outputFloatArray, removing ~9k transientMat4 * Float3allocs/sec.PhysicsBody.step: velocity + position integrated as plainFloattriples, committed in exactly 2Positionallocs per body per frame (was 3-4 → ~1200/sec at 5 balls × 60 fps).ARDepthColliderDemo: now drivesDepthCollider.setBodiesRegion(...)once per frame from the active sphere centres + 15 cm padding so the KDoc-documented region-cull fast path is no longer bypassed (was ~540k tri-tests/sec; region-cull collapses to the bodies' shared AABB).- arsceneview: defensive
onDisposeordering onARScene's per-frameIndirectLightrebuild — clearscene.indirectLight = nullBEFOREengine.safeDestroyIndirectLight(...)so a lateonARFramequeued on the GL thread cannot dereference a freed native handle (#1814).
Docs¶
arsceneview: document ARCore 1.54's Geospatial Depth inllms.txtand onStreetscapeGeometryNode. EnablingConfig.DepthMode.AUTOMATICtogether withConfig.GeospatialMode.ENABLEDandConfig.StreetscapeGeometryMode.ENABLEDautomatically extends environment-depth accuracy from ~8 m (motion-stereo only) to ~65 m by fusing depth with Streetscape geometry + sensors. Every existing depth consumer (Frame.hitTestDepth,DepthMeshNode,rememberDepthCollider,ARCameraStreamocclusion) benefits transparently — no API change required (#1731).- docs: new migration block in
docs/docs/migration.mdfor theCloudAnchorNode.host()return-type change (Unit→HostCloudAnchorFuture, #1768). Covers the source-compatibility break + theDisposableEffect.onDispose { future.cancel() }recommendation with billing rationale (#1814). - llms.txt:
DepthHitResultNodesection andFrame.hitTestDepth@returnKDoc clarification documenting the single-vs-list asymmetry vsFrame.hitTest(depth at one pixel is unique) (#1814).
v4.11.0 — 2026-05-20¶
Added¶
- Android demo: added a
cameraDistancezoom deep-link parameter — a--ef camera_distance <f>intent extra and asceneview://demo/<id>?cameraDistance=<f>query parameter that override the 3D hero-orbit camera distance. This lets the Maestro device-QA flows exercise 3D camera zoom, which Maestro cannot do by pinch;.maestro/android/flows/demo.yamlnow captures a near + far framing formodel-viewer. Invalid or out-of-range values fall back to the demo's default framing (#1571). Frame.hitTestDepth(xPx, yPx)raycasts the ARCore depth image and returns aDepthHitResult(world position, camera-facing surface normal, distance) — placement onto any real-world surface, not just detected planes, inspired by arcore-depth-lab's "Oriented Reticle" (#1712).- New cinematic turntable camera:
applyCinematicOrbit(cameraNode, timeSeconds)drives a slow, eased "hero shot" orbit around the content — long lens, gentle downward tilt and a soft vertical bob. Three feel presets are provided (CinematicCameraProfile.HeroProduct,SlowCinematic,NeutralWeb);CinematicCameraProfile.Defaultis the contemplativeSlowCinematicprofile. Pairs withSceneView(autoCenterContent = true, cameraManipulator = null)for a one-call cinematic showcase. - Remote files loaded over http(s) — glTF/GLB models, KTX environments, textures — are now cached on disk by the new
FileCache. The first load downloads and persists the bytes; every later load reuses the cached file, so there are no repeated downloads and assets stay available offline. Caching is wired transparently intoFileLoader.loadFileBuffer, withContext.fileCacheDir/Context.clearFileCache()to inspect or reclaim it, andFileCache.enabledto opt out.
Changed¶
validate-demo-assets.shnow cross-checks every asset physically bundled under the demo asset roots againstassets/catalog.jsonand fails CI if a bundled asset is undeclared, making catalog drift a build failure instead of a manual discovery (#1666).- CI workflow hygiene: a detekt static-analysis step is wired into the
lintjob (advisory for now — detekt 1.23.8 registers no tasks on the current Kotlin 2.3 toolchain, so the step reports without gating PRs pending a detekt upgrade and baseline),docs.ymlartifact action versions are aligned, thequality-gatejob restores Gradle wrapper validation, and a stale Node-version comment intelemetry-ci.ymlis corrected (#1699, #1702, #1703, #1708).
Fixed¶
- Samples cleanup: dropped the stale "Coming in v1.1" version label from the iOS demo's coming-soon placeholders (now a plain "Coming soon" badge), and documented that AR demos intentionally skip the 3D first-frame loading scrim (#1361).
- iOS:
FogNode.heightBased(...)andFogNode.heightFalloffare now formally deprecated with a compile-warning instead of silently no-op'ing at runtime — RealityKit has no per-pixel height fog equivalent to Filament'sView.fogOptions.heightFalloff. UseFogNode.exponential(density:color:)instead. The iOS Fog demo no longer advertises a "Height" mode. (#1380) - Device QA runs no longer show
cancelledwhen only the advisory android/ar emulator leg is flaky (#1643). The emulator-legscript:blocks boundedadb wait-for-deviceanddevice-qa.shwith internaltimeouts. A flaky CI emulator now produces a clean step failure (absorbed bycontinue-on-error) instead of letting the job run totimeout-minutes— a timed-out job endscancelled, and a cancelled job drags the whole run conclusion red even when web/build/the other legs passed. - Release device-QA gate is now deterministic and non-blocking — it dispatches its own uncancellable Device QA run, waits with a hard timeout, treats web+ar as required and android as advisory, and proceeds-with-warning on timeout, so a flaky harness can never block a release indefinitely (#1683).
PhysicsNodeno longer clobbers or destroys the caller's existingNode.onFramecallback — it now saves the prior callback, chain-calls it each frame, and restores it on dispose (#1694). - Web: glTF animations now play instead of freezing at t=0,
OrbitCameraController.dispose()detaches its DOM listeners, andSceneView.destroy()releases leakedLightManagercomponents (#1697, #1698, #1700). Android TV demo: D-pad controls now work on launch — the rootBoxisfocusable()and requests focus on first composition so key events reach theonKeyEventhandler. - PhysicsDemo: each falling body now gets its own
ModelInstancespawned from a sharedModel, so every streamed crash-test mesh renders instead of only one (#1706). - VideoDemo no longer auto-plays the video if the user tapped Pause before the player became ready — the prepared callback now honours the user's desired playback state (#1707).
- Play Store deploy now self-heals a corrupt release AAB. A truncated or zero-byte App Bundle from a flaky CI runner (which silently cost the v4.6.0 and v4.6.1 store releases, #1412/#1415) used to sail past gradle's exit 0 and only blow up at upload. The
Build release AABstep now verifies the artifact is a readable zip and rebuilds once from clean before aborting, so a transient I/O flake no longer loses a release.SceneViewno longer triggers "Modifying state during view update" Xcode runtime warnings —appliedMainSlot,appliedFillSlot, andappliedSkyboxResourceare now held in a private reference-type cache class rather than individual@Stateproperties, so mutations insideRealityView.update:are invisible to SwiftUI's state-change detection.
Tests¶
- Android demo: wired the 12 live-only AR demos to honour
DemoSettings.arPendingPlaybackFile. A new sharedrememberArPlaybackDataset()helper resolves the--es ar_playback_file <path>deep-link extra (set by the autonomous AR replay device-QA harness) into theARSceneView(playbackDataset = …)parameter. Previously onlyar-record-playbackconsumed the extra, so the harness could only grade the other AR demosalive; they can now graduate toreplayedwith frame-indexed assertions. When the extra is absent — i.e. every normal launch — the helper returnsnulland the demos behave exactly as before, so there is no live-AR regression for real users (#1576).
Docs¶
- Corrected stale version references that the v4.10.0 release left behind — the docs landing-page "Latest Release" stat,
llms-full.txt's SceneView version line, the iOS deployment-target docs (now iOS 18 / macOS 15 / visionOS 2), the SwiftUI codelabs' SPM version rule, and an overstated web-demo changelog entry — and hardenedsync-versions.shto scan these files so future releases bump them automatically (#1693). - Refreshed the stale
ROADMAP.md(was pinned at v4.0.9) to v4.10.0 and resolved theCLAUDE.md↔sync-versions.shcontradiction overmcp/package.json— the Version Location Map now documentssceneview-mcpas an independent npm version track that must NOT be synced to the SDKVERSION_NAME(#1701, #1705).
v4.10.0 — 2026-05-17¶
Added¶
- Web demo catalog expanded with Lighting, Animation, Text, and Environment tabs (#1362). The
samples/web-demoplayground previously exposed only Models / Geometry / Physics / Settings — a small fraction of the SDK versus Android's ~39 demos. It now ships four new tabs in the demo app: Lighting adds and removes directional/point/spot lights, Animation loads self-hosted animated glTF models and drives keyframe playback, Text renders billboarded 3D text nodes, and Environment controls image-based lighting via spherical-harmonic presets, background color, and bloom strength. These tabs are wired against the demo's hand-vendoredsamples/web-demo/.../js/sceneview.jsviewer helper — they are a web-DEMO addition and do not change the publishedSceneViewJSKotlin/JS API surface. First slice of the cross-platform demo-parity effort; web-demo only. - Auto-fit camera framing (#1439): a new library-level helper in
io.github.sceneviewcomputes the orbit distance at which a model's bounding sphere exactly fills the viewport, regardless of the model's intrinsic glTF size.fitDistanceForBounds(bounds, verticalFovDegrees, aspect, padding)is pure trigonometry (yaw-invariant — fits the bounding sphere, not the raw box);CameraNode.frameToContent(node)/CameraNode.frameToBounds(aabb)reposition the camera in one call;verticalFovDegreesForFocalLengthandBox.toAabb()convert Filament's focal-length /Boxtypes;SceneAutoFitStateis a one-shot guard for use in aSceneViewframe loop. The Model Viewer demo now auto-fits its orbit radius to the displayed model — a 5 cm bee and a 5 m crate are framed identically without per-demoscaleToUnitstuning. Android-only for now; iOS already frames fromvisualBounds(#1026 / #1391). - Demo: Material Streaming (#1480). New Advanced-section demo in the Android sample app showing runtime texture/material streaming — a single loaded model whose surface material is swapped live from a chip picker (Polished Steel, Brushed Gold, Copper, Matte Plastic, Glazed Ceramic). The swap reassigns the node's Filament
MaterialInstanceviasetMaterialInstanceAt(...)with no geometry rebuild or model reload, lit by a studio HDR so the metallic/roughness contrast reads. Distinct from the PBR Materials demo (#1423), which streams a whole new model per chip. Material sets are bundled in-app so the demo renders offline; streaming the same material/texture data from a remote catalogue (Sketchfab material packs, a.ktxtexture-set CDN) is a documented follow-up. - device-qa: added the maestro iOS leg —
.maestro/ios/flows drive every iOS demo reachable via thesceneview://demo/<id>deep link insamples/ios-demolike a real user (custom-scheme launch, camera-orbit drag, tap, one screenshot per demo, crash assertion), with per-category subflows and launch-only smoke for AR demos (RealityKit AR cannot run on the simulator);ios-device-qa.shis the maestro wrapper that boots a simulator, builds + installs the demo and sweeps the simulator log for crashes (#1563). - device-qa: added
.claude/scripts/device-qa.sh— the autonomous cross-platform device-QA orchestrator that ties the four platform harnesses (Maestro Android, Maestro iOS, Playwright web, AR replay) into one unattended pass, boots the emulator/simulator each leg needs, builds + installs the demo app, and aggregates every platform's machine-readable verdict into a singledevice-qa-report.jsonplus a human-readable summary; it exits non-zero if any selected platform fails, is disk-aware (reusesdisk-gated-spawn-check.shand cleans build output between legs), and degrades a missing emulator/simulator/browser toskipped(treated as a failure under--ci). The release checkpoint (release-checklist.sh+ the/releaseskill) now blocks tagging on a greendevice-qa-report.json, and a path-gateddevice-qa.ymlCI workflow (also reused bynightly-ci.yml) runs the web and Android legs (#1566). - Device-QA emulator can now boot visible (windowed) via the opt-in
--windowflag orEMU_VISIBLE=1onsetup-ar-emulator.sh; the default stays headless and CI is unchanged (#1660).
Changed¶
- Android demo polish (#1443): demo-grid cards now carry a hairline
outlineVariantborder so their boundaries stay visible against the dark ParticleBackground; the Image Planes demo is staged as a three-picture wall gallery (framed procedural landscapes at varying depth and angle) instead of a single floating logo; the Billboard demo plants its billboard and fixed signs on a ground plane with an angled camera and explanatory caption so the orbit-time difference between the two node types is obvious. - device-qa: fixed
qa-android-demos.sh,ios-device-qa.shandar-replay-qa.shresolvingREPO_ROOTone level shy — they live in.claude/scripts/so the repo root is two levels up, not one. When invoked bydevice-qa.sh(whose CWD is not the repo root) the scriptscd'd into.claude/instead, so Maestro flow discovery found nothing ([qa] no such flow: .maestro/android/3d-basics.yaml). All three now deriveREPO_ROOTfrom${BASH_SOURCE[0]}/../..so every path (.maestro/...,./gradlew, the demo module) resolves regardless of the caller's CWD (#1585). sceneviewnode API honesty (#1598, #1599): verifiedMeshNodedoes not leak itsRenderableManagercomponent —RenderableNode.destroy()already releases the renderable built onentity(#1598 confirmed stale, no code change needed). Deprecated thePhysicsNode/PhysicsBodymassparameter — the Euler integration applies only gravity, which is mass-independent, somasswas a silent no-op; it is now@Deprecatedwith a clear message and the mass-free overload is the canonical one (#1599).- CI workflow hygiene (#1601, #1602): documented the device-qa.yml four-leg split (per-push web+android vs nightly-only ios+ar) and why
samples/ios-demo/**is deliberately absent from its path trigger; unified telemetry-ci.yml onnode-version: 20to match device-qa.yml and docs.yml; deleted the orphan top-leveldocs/screenshots/directory (a byte-identical, unreferenced duplicate ofdocs/docs/screenshots/, which MkDocs actually serves). - Device-QA harness now selects a single shared Android emulator RAM-aware and parallel-session-safe (#1647). Before booting,
setup-ar-emulator.shreuses any already-running emulator, gates a fresh boot on free host RAM, scales the-memoryflag to RAM headroom, and takes an advisory lock so concurrent Claude Code sessions cooperate on one emulator instead of each booting their own — fixing emulator resource contention and boot failures on RAM-constrained hosts. No multi-emulator pool: there is always exactly one shared emulator. - Removed a stale verification TODO in the Android demo's
AnimationDemo—ModelNode.playAnimation'sloopparameter is verified to be honoured correctly. (#1649) - device-QA harness (#1654): the emulator-selection layer is now a RAM-budgeted adaptive pool — it leases a free running emulator or boots a new one on a distinct
-portwhenever live host RAM safely allows (capfloor((free_RAM − headroom) / per-emu budget), clamped[1, EMU_POOL_MAX]), re-gates free RAM as a hard memory-safety check before every boot, reclaims stale per-emulator leases, and pinsANDROID_SERIALto the leased device — superseding the strict-single emulator of #1647 while keeping the floor at 1 on RAM-tight hosts. - Repo hygiene: stale
claude/*branches no longer pile up on the remote. TheAutomatically delete head branchessetting is now enabled, so every PR branch is dropped the instant its PR merges. Thecleanup-branches-worktrees.shbackstop was reworked to fetch PR status with two bulkgh pr listcalls instead of onegh pr viewper branch — the per-branch form fired hundreds of sequential API calls and timed the dailybranch-cleanupjob out before it could delete anything, which had let the remote grow to ~190 branches.
Fixed¶
- visionOS target of the
SceneViewSwiftSwift package now compiles (#1366): the deployment target is raised to visionOS 2.0 (RealityKit'sDirectionalLight/PointLight/SpotLightentities and per-entityshadowAPI are@available(visionOS 2.0, *)),SceneViewuses the cross-platformRealityViewContentinitializer on visionOS instead of the@available(visionOS, unavailable)RealityViewCameraContent, light components drop the visionOS-unavailableisRealWorldProxy:initializer parameter, and a newBuild Swift Package (visionOS)CI step inios.ymlbuilds the xrOS SDK on every iOS PR so this can't regress silently. - iOS: the
Multi-Model Parkdemo now frames all four streamed models centered and correctly sized. The previous fix translated the content root so its bounding-box centroid landed at the world origin, butMulti-Model Parknests its models under anAnchorEntity— RealityKit re-pins that anchor to its world target every frame, so the translation and the anchor fought each other and the framed centroid ran away to infinity, leaving a fully black viewport. The auto-framing pass now points the orbit camera at the content's world-space centroid instead of moving any scene node, which removes the feedback loop entirely. Framing is also computed from the union of every loaded model and re-runs until that union is stable, so partially-streamed scenes no longer latch early. Single-model demos (Model Viewer, Geometry) are unaffected (#1391, #1514, #1385). - Orbital AR demo now renders its four streamed planets. The demo loaded its resolver-staged GLBs through the two-argument
rememberModelInstance(modelLoader, String), which Kotlin overload resolution binds to the asset-path overload — so thefile://cache URI was handed toAssetManager.open, threwFileNotFoundException, and the four streamed planets stayednullwhile the four bundled-asset planets kept working. The streamed branch now loads the local file viaModelLoader.loadModelInstance, which understandsfile://URIs. Same root cause as the Multi Model demo fix (#1422). - React Native: bumped the iOS bridge podspec
SceneViewSwiftdependency from the year-old~> 3.4pin to~> 4.9, matching the published SPM tag the bridge code already targets. (#1512) - iOS test build:
AugmentedImageNodeTests.swiftfailed to compile under the iOS 26.2 SDK (#1515). TheAugmentedImageNode.ReferenceImage(name:image:physicalWidth:)initializer becamethrowsin #883, but the test still called it withouttry— the macOSswift testtarget stayed green only because it never built the iOS-gated test file. The throwing call sites now usetry(and a siblingCameraControlsTests.swiftnow importsRealityKitforBoundingBox), and theiOS CIworkflow'sxcodebuildsteps gainset -o pipefailso a failing test-build is no longer masked byxcpretty's exit 0. - CI: raise
Unit tests + coverageandCI Gatetimeouts (#1554). The full JaCoCo pass runs close to the old 30-min job timeout on a slow runner; it tipped over on a release PR and cascaded a confusing double-red. TheUnit tests + coveragejob timeout is now 45 min, and theCI Gateaggregator's internal poll deadline (50 min) and job timeout (60 min) comfortably exceed it so a slow-but-succeeding job is seen as completing. - iOS: detected ARKit planes now render as a subtle translucent overlay instead of an opaque bright-green debug fill that obscured the camera feed (#1557).
- Device-QA Android leg no longer hangs silently in CI (#1560). The leg ran 40+ minutes with zero output before the job timed out:
device-qa.shredirected the whole wrapper's output to a file shown only after it returned, andqa-android-demos.shbuilt the demo APK with Gradle-q(no output at all). The Android leg now streams live viatee, builds with--console=plain, and bounds the cold APK build and each Maestro run withtimeoutso a genuine hang fails fast with a clear diagnostic instead of eating the CI job budget. The job'stimeout-minutesis raised to 60 to give a legitimate cold build headroom. - Device-QA Android leg no longer aborts at Maestro flow-parse time (#1560).
.maestro/android/flows/demo.yamlpassed the demo deep link as adeepLink:sub-property oflaunchApp, but Maestro 1.39 has no such property — the flow failed to parse withUnknown Property: deepLinkbefore a single demo ran, sodevice-qa.sh --platform=android --fastreportedpassed=0 failed=1. The fix delivers the demo id andqa_modeflag aslaunchApparguments:instead, which Maestro maps to intent extras (--es demo <id>,--ez qa_mode true).MainActivityalready reads exactly those extras throughDeepLinkRouter.validate— the same closed-registry allow-list thesceneview://demo/<id>scheme uses — so demo routing and the deterministic-screenshot animation freeze are both preserved. Harness-only fix; no demo code changed. - Device-QA web leg — Geometry catalog test split per primitive (#1560). The single
Geometry tab — every primitive adds, recolours and renderstest looped over all four primitives in one test body and still overran even the tripledtest.slow()180s budget on GPU-less CI runners. It is now four independent per-primitive tests plus a dedicated Clear-All test, so each heavy WebGL-interaction pass gets its own budget. Every primitive is still exercised; harness-only change, no demo code touched. - Device-QA web leg no longer times out on GPU-less CI runners (#1560). Three Playwright catalog tests (
Models,Geometry,Settings) failed withTest timeout of 60000ms exceededon the GitHub Ubuntu runner: software-rasterised headless WebGL renders every Filament frame several times slower than a real GPU, so the looped model-load / geometry-add / render-quality-rebuild work overran the 60s budget. The demo itself was never hanging — it passed the same suite in seconds on a GPU-equipped host. Fix is in the harness, not the demo: the three heavy WebGL-interaction tests now calltest.slow()(triples their timeout) and the Models test waits for the demo's real load-completion signal (#loading-chipclearing) via a newwaitForModelChipIdlehelper instead of a blindwaitForTimeout(2500)— deterministic across fast local GPUs and slow CI runners, and faster locally because it no longer over-sleeps. The other 14 tests and the global 60s timeout are unchanged. - web-demo: self-host the curated catalog GLB models and the IBL environment, and screenshot-sample the canvas in the Playwright suite, so the browser viewer renders and the device-QA suite passes. The catalog previously loaded every model — including the initial scene model — from jsDelivr's gh-proxy, which returns HTTP 403 for large GLB blobs under
assets/;initSceneView()never resolved and the demo stayed stuck on its loading overlay. The 12-model catalog andneutral_ibl.ktxare now bundled undersamples/web-demo/src/jsMain/resources/and a localversion.jsonremoves the last 404, eliminating all external asset failures. ThesampleCanvastest helper now decodes a Playwright screenshot instead ofgl.readPixels, which returned all-zero pixels on Filament'spreserveDrawingBuffer:falsecontext even when the canvas was visibly rendering (#1573, #1586, #1362). - iOS demo: resolved Swift 6 concurrency warnings — the
OrbitalARDemoandDoublePendulumDemoper-frame timer closures now hop onto the main actor before touching main-actor-isolated scene state, fixing real data-race risks.DemoDeepLinkRegistry.destination(for:)is now@MainActor-isolated, andSceneViewDemoAppadopts the modern two-parameteronChange(of:)signature (#1574). - Web demo: self-host the Filament/SceneView engine (#1586).
samples/web-demo'sindex.htmlloadedfilament.jsandsceneview.jsfromcdn.jsdelivr.net— a jsDelivr hiccup 404'd both engine scripts and turned the Playwright device-QA suite red. Both files (plusfilament.wasm) are now bundled undersrc/jsMain/resources/js/and referenced by relative path, so they ship withjsBrowserDistribution. Engine init is also decoupled from the default model load: a flaky model miss now surfaces as a transient chip instead of a fatal "Failed to initialize" overlay. CI Gate no longer fails a PR when a Device QA workflow run was manually dispatched on the branch — Device QA check runs are excluded from the aggregator (#1588). - Auto-fit camera framing is now reachable, and Android multi-model framing no longer bunches in the corner (#1595, #1596): the #1439 auto-fit API (
SceneAutoFitState,frameToContent,frameToBounds) shipped with no caller —SceneViewnow exposes anautoFitContentparameter that drives it, moving the camera so the content fills the viewport regardless of the model's intrinsic glTF size. BothSceneAutoFitStateandSceneAutoCenterStatenow use a diagonal-stability gate (Android port of web'sAutoCenterGate, #1391 / #1540) instead of a first-frame latch, so an async model that finishes loading after a sibling already framed still triggers a re-frame. The gate also latches after a bounded number of passes so a perpetually-animated scene stops re-framing instead of fighting user interaction. - Web: guard
SceneView.loadModelagainst a use-after-free — a reloaded or destroyed model's pendingloadResourcescallback no longer touches the freedFilamentAsset(#1597). sceneview-webSceneView.loadModel(#1597): the auto-center pass no longer frames the scene on a model whoseloadResources()is still in flight (premature/wrong framing on an unreadable bounding box), and reloading the same model URL now destroys the priorFilamentAssetinstead of orphaning it on the GPU — mirroring theEnvironmentResourceTrackerleak-free-swap pattern from the IBL/skybox fix (#1496).assets/catalog.jsonsynced with bundled demo assets (#1603). Two assets that ship insamples/android-demo/src/main/assets/and are actively referenced by demos were missing from the catalog that declares itself the "source of truth for all demo assets across platforms": thethreejs_soldier.glbanimated character (used by OrbitalARDemo, AnimationDemo, MultiModelDemo, the AR view, android-tv-demo, and ios-demo) and thechinese_garden_2k.hdrPoly Haven environment (used by EnvironmentDemo). Both now have full registry entries withsource/author/license/sourceUrlprovenance andusedInarrays, matching the existing entry schema.- Device-QA AR leg no longer fails with a shell syntax error on its first CI run (#1608). The
arjob's ARCore sideload was an inline multi-lineif … fiblock in theReactiveCircus/android-emulator-runnerscript:. That action runs each line ofscript:as a separatesh -c, so the standaloneif … thenline aborted withSyntax error: end of file unexpected (expecting "fi")and env vars never persisted across lines. The sideload logic moved into a dedicated.claude/scripts/sideload-arcore.shhelper (ABI-aware ARCore APK resolution from the publicgoogle-arSDK release, honest non-fatal exit when ARCore is genuinely unavailable), and the workflow now invokes it as a single self-contained line — matching the workingandroidjob. EngineDestroyQueueno longer resurrects a queue after engine teardown (#1630).EngineDestroyQueue.of(engine)is backed by aWeakHashMap; aNode.destroy()arriving afterEngine.safeDestroy()(a disposal order that does happen) used togetOrPuta fresh, live queue against the now-dead engine — the enqueuedTexture/Streamwas then never drained (no render loop left) → GPU-memory leak and latent use-after-free if Filament reused the handle. Teardown now records the engine as destroyed and removes its live map entry; a staleof()returns an already-drained queue whoseenqueueTexture/enqueueStreamdestroy the resource immediately instead of queueing onto the dead engine. Dropping the live entry also fixes theWeakHashMap-value-strongly-references-key leak that pinned destroyed engines.- Web demo IBL no longer 404s on subpath deploys (#1631). The default IBL URL in the vendored
sceneview.jswas the absolute path/environments/neutral_ibl.ktx, which resolved correctly from a domain root but 404'd on subpath deploys (e.g./sceneview/), silently dropping image-based lighting to the synthetic SH fallback. It is now the relative pathenvironments/neutral_ibl.ktx, matching the self-hostedmodels/convention and working on both layouts. Additionally,.claude/scripts/validate-demo-assets.shno longer skips the entire vendoredweb-demoresources/js/tree — it now narrowly filters only the JSDoc placeholder literalmodel.glb, so a real broken asset literal in a future vendored js file is caught instead of silently passing. - Web
AutoCenterGatenow latches after a bounded number of framing passes (MAX_FRAMING_PASSES = 10), so an animated / skeletal / physics scene whose union diagonal jitters every frame stops re-centring the camera forever — parity with Android'sFramingGateceiling (#1633, #1629). - Device-QA Android leg — CI emulator stability (#1643). The Maestro flow ran correctly (app launch + camera-orbit swipes) but the CI emulator went offline mid-flow under the SceneView Filament 3D demo's GPU/RAM load. The
androidandardevice-QA jobs now boot the emulator with-memory 4096, andqa-android-demos.shretries the Maestro flow once when — and only when — the device drops offline (a genuine demo failure, where the device stays online, is not retried). - Daily Maintenance workflow now actually fires on its cron schedule (#1646).
.github/workflows/maintenance.ymlhad never run fromschedule:despite being marked active — its scheduled trigger had been registered against an account that is no longer active, so GitHub silently dropped every scheduled event for ~2 months (the workflow only ever ran when dispatched manually). Editing theschedule:block re-registers the cron under the current committing account. The cron is also moved off the congested top-of-hour (0 7→11 7UTC) so GitHub's scheduler no longer drops it in the hourly burst. All six maintenance jobs — dependency-version checks, stale-issue marking, the daily digest, agent-skill drift, CI health, and merged-branch pruning — now run unattended again. ARRecordernow converts theSurface.ROTATION_*constant passed asrecordingRotationinto degrees (0/90/180/270) before handing it to ARCore'sRecordingConfig.setRecordingRotation, which expects degrees — not the ordinal (0/1/2/3). Previously a 90° capture was recorded as1°, leaving AR datasets stored sideways. New publicARRecorder.surfaceRotationToDegrees(Int)exposes the mapping. (#1648)- Device-QA release gate (#1670): an all-skipped (or skipped-only) advisory leg is no longer aggregated as a hard
failed.device-qa.shnow splits the verdict by leg weight — only a non-passing required leg (e.g.web) blocks the gate (exit 1,releaseGate.verdict=blocked), while afailedor honestskippedadvisory leg (android/ar, e.g. the #1645ar-record-playbackskip on the CI emulator) surfaces as awarnand exits 0.release-checklist.shsection 14 then WARNs instead of FAILing for that case, so an honest environment skip no longer false-blocks a release tag. - Frame-deferred GPU texture destroy queue (#874).
ImageNode.destroy()no longer leaks its FilamentTexture, andViewNode.destroy()no longer risks a nativeSIGABRT(Invalid texture still bound to MaterialInstance) from freeing its texture/stream too eagerly — both now enqueue their GPU resources on a per-EngineEngineDestroyQueuethat destroys them a few rendered frames later, on the main thread, after Filament has reclaimed the boundMaterialInstance. High-churn UIs (feeds, infinite scrollers, particle emitters) that create many short-livedImageNodes perEnginelifetime no longer accumulate GPU memory. - DynamicSky demo now holds a "Loading helmet…" scrim until its model is ready, matching every other helmet-loading demo so no demo opens on a bare scene (#881).
- iOS demo: the Samples-tab full-screen demo cover now has an explicit Close button so a demo opened from the Samples list can always be dismissed back to the list (#1580).
- Play Store deploy now self-heals a corrupt release AAB. A truncated or zero-byte App Bundle from a flaky CI runner (which silently cost the v4.6.0 and v4.6.1 store releases, #1412/#1415) used to sail past gradle's exit 0 and only blow up at upload. The
Build release AABstep now verifies the artifact is a readable zip and rebuilds once from clean before aborting, so a transient I/O flake no longer loses a release.
Tests¶
- device-QA: wire the AR replay leg into CI (#1592).
.github/workflows/device-qa.ymlpreviously ran only the web (Playwright) and android (Maestro) legs; the AR replay harness (ar-replay-qa.sh+ARReplayHarnessTest, #1565) had no automated coverage, so the per-release device-QA pass effectively skipped AR. A newarjob boots an ARCore-capable emulator on the KVM-accelerated GitHub runner, sideloads Google Play Services for AR from the public google-ar SDK release, and runsdevice-qa.sh --platform=ar --ci, uploading thear-qa-summary.json/device-qa-report.jsonartifact like the other legs. - AR replay device-QA harness (
ARReplayHarnessTest+ar-replay-qa.sh) no longer reports a misleadingpasswhen the recorded ARCore session was never actually replayed. ARCore dataset playback needs camera-stream support the x86 software-GPU CI emulator does not provide, soar-record-playbackadvancingreplayedFrames: 0is now gradedskipped(with the reason surfaced) rather than greenalive.ar-qa-summary.jsongainsskipped/failedcounts and a per-demoreason;ar-replay-qa.shexits3and the device-QA AR leg recordsskipped— skips never count as passes (#1645). - Device-QA CI: prebuild the android-demo APK in a separate cached
build-android-apkjob and install the artifact in the emulator legs (no cold build on the 2-core emulator runner); the release gate now gradescontinue-on-errorlegs — a red advisory leg (android/ar) surfaces as a WARN instead of being silent or hard-blocking (#1652, #1651). - Device QA workflow (#1665):
workflow_dispatch(release-gate) runs now get a unique, non-cancellable concurrency group keyed ongithub.run_id, so a subsequent push tomaincan no longer cancel an in-progress release-gate Device QA run. Push-triggered runs still share apushgroup and auto-cancel stale runs. - iOS device-QA now screen-records each run (#1673).
ios-device-qa.shpreviously captured only one screenshot per demo; it now records the whole Maestro run viaxcrun simctl io recordVideo(h264, to keep clear of the hevc frame-glitch artefacts), bringing the iOS leg to parity with the Android leg's screen recording. The recording is strictly best-effort —recordVideoneeds hardware Metal, which CI VMs may lack, so a recording failure never fails the QA run — and is stopped with SIGINT so the.movfinalises cleanly. The file lands undertools/qa-screenshots/ios/(gitignored). - Web QA: pass
--enable-unsafe-swiftshaderto the Playwright Chromium runner (#1674). Chrome removed the automatic SwiftShader fallback for WebGL. On a GPU-less CI runner ANGLE has no hardware path and nothing to fall back to, so WebGL context creation would fail outright — the Filament.js viewer would never get a context and the web-demo test suite could go green-on-nothing. The flag re-enables the software rasteriser so headless CI keeps a real WebGL context. - Web device-QA now screen-records every test (#1674). The Playwright suite gains
video: 'on', bringing the web leg to parity with the Android and iOS device-QA legs so a 3D regression can be reviewed frame-by-frame. In headless Chromium the recording is software-rendered — the authoritative "did it render" assertion stayshelpers.ts:sampleCanvas(a compositor screenshot with a luminance-variance check); the video is for human review. Recordings land undersamples/web-demo/test-results/(gitignored).
Docs¶
- Device-QA harness documentation (#1567). Documented the autonomous cross-platform device-QA harness: a new "Device QA" section in
CLAUDE.md(how to rundevice-qa.sh, what each platform leg covers, where reports land, and the per-release-checkpoint mandate), aCONTRIBUTING.mdsubsection on adding/updating Maestro and Playwright flows when adding a demo, an orchestrator pointer in.maestro/README.md, and a Device QA section on the docs-site contributing page. Closes the final slice of umbrella #1560. - Refreshed stale doc references: corrected the Android demo count to 43, reconciled the Maestro AR catalog count, updated the version note in
CLAUDE.md, and repointed the iOS QA script reference. -
Align Apple platform minimums in docs with
SceneViewSwift/Package.swift(iOS 18.0, macOS 15.0). (#1621) -
expanded the web-demo playwright suite into full per-tab / per-demo qa coverage — exercises every models, geometry, physics and settings demo with camera interaction, canvas render assertions and console-error checks, and emits a machine-readable
web-qa-summary.jsonfor the device-qa orchestrator (#1564) - device-qa: added a maestro harness —
.maestro/android/flows drive all 42 android demos like a real user (deep-link launch, camera-orbit drag, tap, one screenshot per demo, crash assertion), with per-category subflows and amaestro.shauto-install helper;qa-android-demos.shis now a thin maestro wrapper (#1562). - device-qa: added an autonomous ar replay harness —
ARReplayHarnessTestdrives every augmented-reality demo through a recorded arcore session headless on the emulator (no physical device), asserts no crash, and emits a machine-readablear-qa-summary.json; thear-replay-qa.shscript is the orchestrator entrypoint that builds, runs and pulls the verdict (#1565). - device-qa: fixed the android (maestro) leg of the device-QA CI workflow failing every run with the opaque
qa-android-demos.sh rc=1 (flow=3d-basics). TheInstall Maestrostep (and themaestro.shauto-install helper) fetched the installer fromget.maestro.dev, which does not resolve — the canonical host isget.maestro.mobile.dev. Because the install ran ascurl … | bash, the curl DNS failure was masked by the pipe and the step passed falsely-green, so Maestro was never on PATH and the flow could not run. The installer URL is corrected and the workflow step now runs underset -o pipefailwith an explicittest -xon the binary, so a future install failure aborts loudly at the install step instead of surfacing as a misleading flow failure (#1560).
v4.9.0 — Cross-platform demo catalogs, web auto-center parity & teardown safety (2026-05-16)¶
Added¶
rememberPausableHeroYawgained an opt-inidleResumeMillisparameter: after the user stops interacting with the viewport, the hero auto-rotation gently resumes once the idle timeout elapses. Each gesture restarts the countdown, so the spin only comes back when interaction has truly stopped. Demos that omit the parameter keep the original pause-forever behaviour. Wired into the View Node demo. (#1440)- AR Orbital demo: an on-screen directional arrow now appears at the viewport edge whenever the chase target (the orbiting toy car) is outside the camera frustum, pointing the user toward it so they know which way to turn to catch it. The arrow is driven by a per-frame
projection · view · worldPointprojection that also handles the behind-the-camera case (#1482). - React Native & Flutter demo apps gain Materials / Animation / Environment demos (#1362). Part of the cross-platform demo-parity umbrella: the RN and Flutter sample apps showcased only a small slice of the bridge surface. The
react-native-demoapp adds three tabs — Materials (lit PBR vsunlitgeometry materials), Animation (auto-playing glTF clips viaModelNode.animation) and Environment (HDR image-based lighting plus theautoCenterContenttoggle) — and its bottom tab bar is now horizontally scrollable so the catalog can keep growing. Theflutter-demoapp gains a dedicated Demos tab with four runnable per-feature scenes — Materials (GeometryNode.unlit), Model Animation (loadModelwith animated Khronos assets), Environment (setEnvironment+setAutoCenterContent) and Camera Modes (setCameraControlMode) — complementing the existing flat "Bridge Features" reference checklist. Every demo uses only APIs the Fabric / PlatformView bridges actually expose; no dead UI for un-bridged features.
Changed¶
- AR demo: Strengthened the Record & Playback demo's end-of-recording UX. After Stop, the saved-recording callout now explains where the file lives and that it is a standard MP4 carrying ARCore data tracks, and offers Replay, Share, Open (play as a normal video) and Export-to-Downloads. The just-recorded file is highlighted with a "Just recorded" badge in the Playback list so it is obviously discoverable. (#1438)
- Double Pendulum demo reworked with an original SceneView visual identity and fixed camera framing (#1481). The Android demo keeps the genuine shared-KMP double-pendulum physics but is restaged as a ball-and-rod "Orbital Pendulum": glossy weighted bobs drawn at each link's actual point mass, an asymmetric long-lead / short-trailing arm ratio, a SceneView brand-token palette (primary blue → gradient violet), a warm studio backdrop, and an off-axis key/rim light rig — so it reads as SceneView's own demo rather than a port. The camera now auto-frames the full reachable swing envelope (it targets the swing-disc centre and backs off proportionally to the arm reach), fixing the poorly-aimed framing flagged in QA.
Fixed¶
- AR camera background no longer renders washed-out / low-contrast.
createARViewwas usingToneMapper.Linear, but the camera-stream shader'sinverseTonemapSRGB()pre-applies an inverse Filmic tone-map curve (Inverse_Tonemap_Filmic(pow(c, 2.2))) that only round-trips back to the original camera pixels when the View re-applies the matching Filmic tone mapper. WithLinearthe inverse curve was left uncancelled, flattening the live camera feed. The AR view now usesToneMapper.Filmicand keeps bloom/AO off so the background is faithful to the real camera image (#1434). - AR placed content no longer vanishes on transient plane loss, and placed models no longer flash black (#1435). In the Android demo's
ARPlacementDemoandARInstantPlacementDemo, eachAnchorNodenow keeps rendering its model while the ARCore anchor isPAUSED(it holds its last known pose) instead of disappearing the moment the camera looks away from the plane — content only hides on a permanentSTOPPEDanchor. Newly placed models are also kept hidden for a short settle window after loading so Filament finishes uploading their textures, eliminating the black flash on placement. Behaviour is centralised in the newdemos/internal/ArPlacementhelper with JVM regression tests. - AR Face Mesh demo: the face mesh now actually tracks.
Session.Feature.FRONT_CAMERAonly makes the front camera eligible — the session stayed on the default BACK camera config, soAugmentedFaceMode.MESH3Dproduced zero trackables and no mesh ever appeared. The demo now passessessionCameraConfig = ::frontCameraConfigso ARCore opens the selfie camera. Added a publicfrontCameraConfig(session)helper inarsceneviewfor any Augmented Faces consumer (#1436). - Image Tracking (Augmented Images) demo now shows an in-app "what to scan" card displaying the actual reference target image, so the user knows exactly which image to point the camera at. The card auto-collapses to a chip once an image is recognised and can be re-expanded by tapping it (#1437).
- Animation demo model no longer renders as a black silhouette against the HDR environment (#1468). The demo's
rooftop_nightskybox renders at full HDR luminance, but the image-based light defaulted to only 5,000 lux — half SceneView's balanced 10k default — so the soldier read as unlit against the bright sky. The default IBL intensity now matches the balanced 10k default; the slider still lets users dial down for a darker, atmospheric look. - Video demo: the viewport background is now a clean neutral black instead of a light near-white wash (or a stale gradient leftover from the previous screen). The demo loaded its HDR environment with
createSkybox = false, leaving anullskybox — Filament does not clear background pixels without a skybox, so the uncleared swap-chain buffer leaked through and broke the dark theme every other demo uses. The HDR IBL is now paired with an explicit opaque black skybox (#1469). - Text Nodes demo: pulled the camera back so the top "Hello SceneView" label is no longer clipped at the top viewport edge in the default framing.
- Gesture Editing demo — the X/Y/Z axis gizmo is now bounded to the model instead of running off all four screen edges (#1471). The world-origin axis gizmo was 1 m long while the helmet renders at 0.3 m, so with the camera framed on the small helmet each axis tip extended well past the viewport and looked like an infinite debug line. The gizmo length is now derived from the model scale (1.5× the helmet's
scaleToUnits), keeping each axis just longer than the model's bounding box as a clear, bounded reference. - ViewNode demo no longer shows a black viewport for several seconds on entry (#1472). The scaffold's first-frame scrim dismissed on the SceneView's very first Filament frame, which arrives almost instantly because the quads carry no asset to load — but a
ViewNoderenders its embedded Compose card to an off-screen window and uploads it as a texture only a handful of frames later, leaving two black quads exposed. The demo now holds the loading scrim for a short frame warm-up so the embedded card texture is uploaded before the scrim cross-fades out. - Android demo: AR demos (Record & Playback, Terrain Anchors) now show a "Starting camera…" spinner overlay while ARCore initializes the camera, instead of a bare black viewport that read as a frozen/broken screen. The overlay clears on the first delivered AR frame.
- AR Record & Playback demo: the "REC" elapsed-time pill is now inset below the system bars so it no longer overlaps the status bar / notch / camera cutout.
- AR Image Stabilization demo: the EIS toggle now actually switches stabilization on and off (#1475). The demo previously rebuilt the entire
ARSceneView(key(eisOn)) on every toggle, which tore down the ARCore session and silently invalidated the placed helmet anchor — the demo's only reference object vanished the instant the user flipped EIS, so an "EIS ON" state was never visible. The toggle now reconfiguresConfig.ImageStabilizationModelive viaSession.configure(a runtime-mutable flag), keeping the session, tracking, and anchor intact. The status pill reflects what ARCore actually applied — "EIS ON", "EIS OFF", or "EIS UNSUPPORTED" when the device or recording can't do EIS — instead of a stuck "OFF". Android demo app only. - AR Instant Placement demo: replaced the tall per-model status column (which overflowed the top third of the viewport and overlapped placed models) with a single compact badge for the most recently placed model, and gave the "Clear All" button a solid filled background so it is legible over the camera feed.
- VideoNode / MaterialLoader: hardened MaterialInstance teardown against native crashes (#1539, follow-up to #1497). The
VideoNode.materialInstancesetter now drains the frame pipeline before freeing the superseded MaterialInstance — previously it was freed while the external video texture was still GPU-bound, the sameInvalid texture still bound to MaterialInstanceSIGABRT #1497 fixed fordestroy().MaterialLoader.destroyMaterialInstancenow removes the instance atomically, so two threads can no longer both pass the tracking guard and double-destroy the same native MaterialInstance. sceneview-web: multi-model scenes no longer render bunched in a corner, and the camera now auto-fits content size (#1540).SceneView'sautoCenterContentpass latched on the first render frame with non-degenerate bounds, so an async model that finished loading after a sibling had already centred never re-centred — the multi-model regression #1391 fixed on iOS. The webAutoCenterGatenow ports the iOS #1391 logic: it re-frames on every union-diagonal growth and latches only once the union diagonal is stable across consecutive frames, so a deferred async model always pulls the framing back to the combined extent. The pass also now callsfitToModels()to auto-dolly the orbit camera to the content size — previously the web viewer only auto-centred and never auto-fit, mis-framing very small or very large models. The union-AABB computation is shared between the auto-center path andfitToModels()(no duplicate read).- Web demo tab navigation no longer double-fires on every click (#1541). Tab buttons were wired twice — once by the inline JS in
index.html(the shipped runtime, loaded via CDNsceneview.js) and again by a duplicatesetupTabs()in the Gradle-compiled KotlinMain.kt, which is not referenced by the page. The dead Kotlin tab path has been removed so each.tab-btnclick runs a singleswitchTabhandler. - Docs: reconciled
samples/README.mdwith the actualDemoRegistry— corrected the android-demo demo count (now 42: 28 non-AR + 14 AR), fixed the tab list (Explore, AR View, Samples, About), and removed rows advertising demos that don't exist (gltf-camera,ar-point-cloud,autopilot-demo). - Docs: fixed HDR asset paths across
samples/recipes/(environment-lighting.md,multi-model.md,editable-model.md) to match the bundledenvironments/*_2k.hdrfiles, and corrected the Flutterfeatures_page.dartsnippet to reference the realenvironments/studio_small.hdrasset. CI Gateno longer flips red on fork PRs that are merely awaiting maintainer approval. GitHub reports such checks with conclusionaction_required; the aggregator now treatsaction_requiredas pending-equivalent (it keeps waiting for the run to be approved-then-completed) instead of counting it in the failed set. It also added a name-based core-check guard so the gate cannot exit green before every always-runci.ymlcheck (Detect changed paths,Repo hygiene checks,Quality gate (full)) has registered for the head SHA — closing a race where a slow-to-register workflow could be missed. The guard is a no-op for genuine docs-only PRs (whereci.ymlis path-filtered out entirely), so light PRs are never blocked (#1543).- Docs version staleness fixed (#1544).
CLAUDE.md's "Latest release" block claimedv4.4.0and instructed AI sessions to treat it as the latest version — 4 minors stale (repo is4.8.0); it is now version-agnostic and points atgradle.properties:VERSION_NAMEas the single source of truth.README.md's SwiftPM install snippets (from: 4.4.0/(SPM, from 4.4.0)) are bumped to4.8.0, and a broken intra-repo anchor inCLAUDE.mdis corrected.sync-versions.shnow also recognises the unquotedfrom: X.Y.ZSwiftPM prose form used inREADME.md, so this drift is caught automatically on future releases.
v4.8.0 — Bottom-sheet settings, web & RN bridge fixes (2026-05-16)¶
Added¶
- Demo settings bottom sheet gains a header, "Reset" button and status-aware peek chip (#1154).
DemoScaffold(Android demo app) now renders a pinned sheet header; demos can opt into anonResetSettingscallback to show a "Reset" text button that restores their defaults, and into apeekHeaderstring so the closed peek chip can surface a short live status (e.g. "3 anchors placed") instead of the generic "Settings" label. Drag-down-to-dismiss now fires a subtle haptic tick, and the previously hardcoded chip/FAB labels moved to string resources.FogDemowires up the new reset button as the reference adoption. Part of the #1154 umbrella (Stage 3 polish, Android slice).
Fixed¶
- Play Store listing sync no longer marks a successful deploy red (#1386). The
Sync Play Store listing (en-US)job inplay-store.ymlis nowcontinue-on-error: trueand swallows a403 Forbidden(missing 'Edit store listing' permission) with a warning. The AAB build/publish jobs stay strict, so the listing-text sync is best-effort and can never block a release. - iOS
SceneViewnow frames multi-model scenes by the union of all loaded content (#1391). The fit-to-bounds camera pass added in #1385 framed a single content entity and latched on the first model that loaded, so multi-model demos likeMulti-Model Parkrendered their streamed models bunched in a corner of an otherwise empty viewport. The pass now computes the union axis-aligned bounding box of every content entity, centres the camera on the union centre, dollies to fit the whole union, and re-frames as each streamed/async model finishes loading — latching only once the union stabilises. Single-model demos are unaffected. Part of #1373. - Model Viewer demo: the top-right "Streaming…" asset-source pill now clears to "Streamed" once a streamed model finishes loading, instead of staying pinned for the whole session. The streamed model instance is now loaded from a stable composable slot so the load-completion state invalidates the chip correctly (#1464).
- Scene Gallery demo no longer shows contradictory status labels: the top-right asset-source chip now stays "Streaming…" until the model is fully loaded, matching the centre loading overlay, instead of flipping to "Streamed (cached)" the moment the file path resolves (#1465).
- Light Types demo: re-scaled the backdrop wall from 3 × 2.4 m down to 1.6 × 1.2 m and re-centred it on the helmet. The oversized quad previously filled ~⅔ of the viewport with a hard diagonal top edge, cramming the model into the lower-left corner (#1466).
- Movable Light demo: the draggable yellow light handle is now persistently visible. The light's orbit radius was reduced from 1.5 m to 0.75 m and its elevation clamped to ±50° so the handle stays inside the fixed camera's frustum for the whole drag, instead of swinging off-screen for most of the orbit. The handle sphere is also slightly larger (radius 0.09 m) so it reads as a clear, aimable target (#1467).
sync-versions.shno longer bumps the Flutter/RN plugins' consumed SceneView dependency (#1494). Theio.github.sceneview:(ar)sceneview:X.Y.Zcoordinate in the Flutter plugin and React Native bridge Gradle files is a dependency on the published Maven Central artifact, so it must lag to the last released version — pointing it at the in-flight release broke theBuild flutter-demo APKCI check during v4.7.0. The script now reports these consumed-dependency coordinates WARN-only (never MISMATCH) and excludes them from every--fixsweep, while the plugins' own package versions still bump correctly.- Web
SceneViewno longer leaks IBL + skybox GPU resources (#1496).sceneview-web'sSceneView.loadEnvironmentcreated a FilamentIndirectLightandSkyboxbut never tracked the handles —destroy()left both resources allocated on the GPU, and a 2ndloadEnvironment/loadDefaultEnvironmentcall overwrote the scene's environment while orphaning the previous handle. The handles are now tracked by anEnvironmentResourceTracker, the previous IBL/skybox is destroyed before a replacement is bound, anddestroy()detaches and destroys both. - CI hygiene cluster 2 (#1500). Narrowed
app-store.yml's tag trigger fromv*to the strictv[0-9]+.[0-9]+.[0-9]+semver glob so pre-release or stray tags can no longer fire an App Store deploy;ci-gate.ymlno longer treats astalecheck conclusion as a failure (astalerun is superseded, not broken) and now refuses to report green until at least one non-self check run has registered, closing a warm-up race;render-tests.ymlpathsnow also matchesbuild.gradle*/settings.gradle*/gradle.propertiesso renderer-affecting build-script changes are not skipped; andrelease.yml's dead cross-rundokka-api-docsartifact upload was removed (download-artifactonly resolves same-run artifacts). - React Native Android bridge now compiles against the current SceneView 4.7.0 (#1501).
react-native/react-native-sceneview/android/build.gradle.ktsdepended on the year-oldio.github.sceneview:sceneview:3.6.0/arsceneview:3.6.0— pre the v3.6Scene-to-SceneViewcomposable rename and missing every 4.x feature — while the published@sceneview-sdk/react-nativepackage is versioned 4.7.0. The Maven coordinates are bumped to the last-published4.7.0(a consumed dependency, so it tracks the released artifact per #1494). The stalecompose-bom:2024.06.00is aligned to the repo's2026.05.00, and the obsoletecomposeOptions { kotlinCompilerExtensionVersion }block is replaced by the Kotlin 2.x Compose Compiler Gradle plugin (org.jetbrains.kotlin.plugin.compose). The bridge Kotlin already targeted the 4.x API surface, so no source migration was needed — only the build configuration lagged. - Refreshed stale docs flagged by the post-v4.7.0 audit (#1502).
docs/docs/desktop-filament.mdpinned Filamentv1.70.1throughout (clone/download archives and thefilament-android:1.70.1AAR), but the repo runtime is1.71.0and the committed.filamatblobs are v71 — all six references now read1.71.0.CLAUDE.md's pre-push unit-test command mixed:sceneview:testwith:arsceneview:testDebugUnitTest; both modules now usetestDebugUnitTest, consistent with CI. The fully-completeddocs/v3.6.0-roadmap.md(all 14 issues done, predating the 4.x line) is moved todocs/archive/v3.6.0-roadmap.md. - web-demo:
Main.kt's stale tab switcher fixed (#1503). The Kotlin/JSswitchTab()toggledpanel-viewer/panel-geometry, butindex.htmlshipspanel-models,panel-geometry,panel-physics,panel-settings— a drift that left 3 of 4 panels unreachable from that entry point, withcurrentTabalso defaulting to the non-existent"viewer".switchTab()now toggles all four shipped panels andcurrentTabdefaults to"models". The shippedindex.htmlinline-JS demo (the actual runtime path) already wires all four tabs plus the Double Pendulum physics and Settings panels correctly. The stalesamples/web-demo/README.mdand the Playwright suite were also refreshed to match the shipped 4-tab UI, and a newrender.spec.tstest clicks every.tab-btnand asserts the matchingpanel-*becomesactive. - android-demo: honest demo-status badges, correct AR icon, and a fixed Blender recipe path (#1504). The
DemoStatusenum andStatusChipUI were fully built, but all 41ALL_DEMOSentries used the defaultWorking, leaving the honest-badge feature inert. The four ARCore Geospatial / Cloud Anchor demos (ar-cloud-anchor,ar-streetscape,ar-terrain,ar-rooftop) — which all require a Cloud project API key not wired into default builds — are now markedKnownIssue, so they surface a "Preview" chip instead of lying as all-green.ar-instant-placementno longer uses theHourglassEmpty('coming soon') glyph despite routing to a real working demo — it now uses the placement-themedBolticon. Finally,samples/recipes/blender-to-sceneview.mdno longer points at the non-existent concrete pathsamples/android-demo/src/main/assets/models/car.glb; it now shows an illustrative relative path with a note clarifying thatcar.glbis the reader's own exported file and pointing at the real sample models. VideoNode.destroy()no longer risks a native SIGABRT (#1497).destroy()freed the externalTexture/Streamimmediately after destroying theMaterialInstancethat referenced them — the exact ordering that triggers Filament'sInvalid texture still bound to MaterialInstanceabort, since MaterialInstance reclamation is coupled to the render loop rather than to thedestroy()call site. Teardown now drains the frame pipeline (Engine.drainFramePipeline()) between destroying the MaterialInstance and freeing the external texture/stream, mirroring the safe pattern documented on the siblingImageNode. AddsVideoNodeTestpinning the teardown ordering.
v4.7.0 — Bridge expansion, slimmer APK & demo-app polish (2026-05-16)¶
Added¶
- Animated 3D particle background on the Samples home (#1488). The android-demo Samples tab now renders a subtle, on-brand particle field behind the demo grid — a
SceneViewscene of drifting low-poly spheres with a slow auto-orbiting camera, seeded per launch. A first visual experiment that dogfoods the SDK on the app's own home screen; tuning constants live inParticleBackground.kt.
Changed¶
- Daily maintenance digest in CI (#1303).
maintenance.ymlnow runs a report-only mirror of the/maintainskill — a new.claude/scripts/maintenance-report.shproduces a structured table (CI health, open issues/PRs, dependency drift, version sync, agent-skill drift, release decision) emitted to the workflow step-summary and an auto-updated tracking issue. The script is strictly read-only, retries transient API failures, and never blocks the workflow. - Consolidated the three PR CI workflows into one (#1370).
ci.yml,pr-check.ymlandquality-gate.ymlare merged into a singleci.ymlwith ONEchangespath-detection job gating every downstream job (build,lint,unit-test,web-desktop,flutter-demo,compile-kmp,repo-hygiene,quality-gate). Eliminates the duplicatedorny/paths-filterrun and two redundant checkout + JDK + Gradle-cache-restore chains per PR. TheCI Gateaggregator is unchanged — it polls the Checks API and treatsskippedas passing.Closes #1370. - Dropped the dead
desktop-democompile step fromci.yml(#1396). Theweb-desktopjob ran:samples:desktop-demo:compileKotlinDesktopwithcontinue-on-error: truepermanently, so it could never fail the build — it only burned runner minutes. Sincesamples/desktop-demois a deliberate Compose Canvas wireframe placeholder (it does not use SceneView or Filament), the step was removed along with the now-redundantsamples/desktop-demo/**path filter. The job is renamed "Build web targets" to match what it actually does. - Android demo APK slimmed ~36% (#934). Bundled demo assets are now compressed with no visible quality loss: the 7 HDR environments are downsampled 2K → 1K in linear-radiance space (energy-preserving 2×2 box average, 42 MB → 11 MB) and the 6 GLB models use
KHR_draco_mesh_compressiongeometry plusEXT_texture_webptextures (21 MB → 9.5 MB), both decoded natively by Filament's bundledgltfio.android-demobuild.gradlealso drops duplicate transitive licence/metadata files viapackaging.resources.excludes. Release APK: 98.6 MB → 62.7 MB. No code or API change — all 37 demos load the same asset paths. - Playground model picker now shows thumbnails (#953). The website playground listed 30+ models in a plain
<select>dropdown — a weak marketing surface next to Sketchfab/Babylon. The picker is now a visual thumbnail gallery: each model is a 256×256 self-hosted WebP preview (rendered offline, ~200 KB total for all 34, lazy-loaded), grouped by category, with light/dark styling fromDESIGN.md. The native<select>is kept hidden as the accessible source of truth so all existing preview logic and keyboard access are unchanged. The "Open in Cursor/Windsurf/Copilot" AI links are relabelled "Open Cursor/Windsurf/Copilot" since those tools have no prompt deep-link and only open their homepage.
Fixed¶
- Demo Settings sheet no longer dismisses itself instantly (#1420). The
DemoSettingsLayerbottom sheet treated its initialSheetValue.Hiddenstate as a dismissal, slamming the panel shut before it could animate open — making the Settings controls dead in every demo.Hiddenis now only honoured as a dismiss once the sheet has actually settled in a shown detent. - Light Types demo no longer renders an empty black scene (#1421). The demo composes a helmet, an off-centre backdrop wall, and a light-source marker;
autoCenterContentcentred the union of all three, shifting the helmet far off the hero camera's fixed orbit pivot. The demo now passesautoCenterContent = falseso each node keeps its authored position and the camera frames the lit helmet as intended. - Multi Model demo no longer hangs on "Loading 4 models…" (#1422). The demo loaded its four resolver-staged GLBs through the two-argument
rememberModelInstance(modelLoader, String), which Kotlin overload resolution binds to the asset-path overload — so thefile://cache URI was handed toAssetManager.open, threwFileNotFoundException, and every model instance stayednull. The demo now loads the local file viaModelLoader.loadModelInstance, which understandsfile://URIs, so the scene reaches a rendered state. - Streamed demos no longer hang forever on their loading spinner (#1423).
SketchfabAssetResolverstaged the offline fallback (and network downloads) by opening an output stream directly on the shared cache path. When a demo resolved the same model from bothprefetchAlland its per-slugproduceStateat once, the two writers interleaved and left a truncated GLB on disk that poisoned the cache permanently — the PBR Materials, Multi Model and Scene Gallery demos stayed stuck on "Streaming material…" / "Loading…" indefinitely. The resolver now stages into a per-call temp file and atomically renames it into place, and re-stages any cached fallback whoseglTFmagic header is missing so an already-poisoned cache self-heals. - Explore tab no longer crashes and loads thumbnails reliably (#1424).
AsyncNetworkImagedecoded Sketchfab thumbnails at full resolution intoARGB_8888bitmaps; ~30 oversized images at once exhausted the heap and the tab crashed withOutOfMemoryError— which therunCatchingfetch path never caught because anErroris not anException. Decoding is now downsampled via a two-passBitmapFactoryinSampleSize, the fetch path catchesThrowableand degrades to a silent placeholder, the in-memory cache is bounded by entry count (so one oversized bitmap can no longer evict every other thumbnail and cause the "appears one time in ten" flicker), and the shared OkHttp client now has connect/read/call timeouts so a stalled CDN connection can't pin an IO thread and leave carousels spinning. - Edge-to-edge insets in the Android demo (#1425). Removed the large empty gap above the "Samples" tab header — the nested
LargeTopAppBarno longer double-counts the status-bar inset already applied by the rootScaffold. The in-app "Update ready / Restart" banner is now z-ordered above every screen and inset below the status bar, so it is no longer clipped behind a demo's top app bar. - Flat geometry no longer vanishes when rotated (#1426). The Geometry Primitives plane and the Text Nodes labels now use double-sided materials, so they stay visible when their back face turns toward the camera instead of blinking out under single-sided culling.
- Camera feel re-tuned across 3D model demos (#1427). The default camera now sits further back (
DefaultCameraNodeZ2.0→2.75, Y0.3→0.4) so origin-placed models are no longer framed too tight. Orbit/pan sensitivity is reduced (orbitSpeed0.005→0.003) so finger drag tracks the model more calmly, and pinch-zoom is made more responsive (DEFAULT_PINCH_ZOOM_SPEED1/30→1/18) so zooming no longer feels sluggish. - Camera Controls demo: the Free Flight camera mode no longer renders a black
viewport on launch and is now usable on touch devices. Free-flight previously
spawned the camera at the origin — inside the helmet model — and offered no touch
gesture to translate (Filament drives flight movement from held keys). The demo now
sets
flightStartPositionto the framed home position and shows an on-screen movement pad (forward / back / strafe / up / down) wired to the manipulator's key controls. Each mode also shows a short usage hint. (#1428) - Gesture Editing demo — rotate/scale gestures now actually transform the helmet (#1429). The 60 Hz live-transform poll was recomposing the whole demo (and the
SceneView) every frame, which re-ranSceneScope.ModelNode's declared-transformSideEffectand reverted every gesture-driven rotation before it was visible. The poll is now isolated in its ownLiveTransformOverlaycomposable so gesture transforms persist. The "Moving camera" gesture-mode pill was also moved off the top-center anchor to the top-start corner so it no longer overlaps the top-end pos/rot readout card. - Collision & Hit Test demo — hit-test fires outside object bounds (#1430). The demo hand-authors five shapes and a camera manipulator pinned to their row, but left
autoCenterContentat itstruedefault. Auto-centering recentred the shapes to the scene origin, off the camera's orbit pivot — so the camera orbited empty space and taps no longer lined up with the rendered shapes. DisabledautoCenterContentfor this demo (same root cause as the LightingDemo #1421 fix) so each shape keeps its authored position and collision matches what is shown. - Custom Mesh auto-rotate no longer stops on a stray tap (#1431). The "Auto-Rotate" molecule demo silently paused its spin the first time the viewport was touched, so it looked like the rotation stopped on its own. Rotation is now continuous and controlled solely by the explicit Auto-Rotate switch.
- Lines & Paths — the Stroke Width slider now visibly rebalances the lines (#1432). Moving the line-width control in Settings appeared to do nothing. The per-point stroke beads now have a fixed base geometry and are driven by
Scale— a transform thatSphereNodere-applies unconditionally every recomposition — so dragging the slider tracks every bead every frame with no vertex-buffer rebuild. The beads were also rebalanced (smaller base radius, denser run along the line) so the default reads as a clean medium stroke instead of a string of oversized spheres, and the line beads now overlap into a continuous tube at higher widths. - Scene Gallery demo no longer appears stuck in a loop (#1433). The four gallery chips carried unverified placeholder Sketchfab uids, so every chip fell back to a bundled model — and two chips ("Reading Lamp" + "Wooden Chair") shared the same fallback GLB, making the chips look inert. Each gallery entry now points at a distinct bundled model with an honest label, so switching chips visibly changes the rendered model.
- Physics demo: the rigid-body simulation now actually runs — the
SphereNodecomposable no longer re-pushesposition/rotation/scaleto the node on every recomposition, which was clobbering the per-frame position written byPhysicsBody(the same fix already applied to the bareNodecomposable). Spheres now drop, bounce, and settle as intended (#1463). - Physics demo: re-framed the camera so the grey ground plane is vertically centred in the viewport instead of being shoved into the bottom third with its near edge clipped.
- AR placement demos no longer drop the helmet face-down (#1477). The bundled Khronos DamagedHelmet GLB ships a residual +90° X root rotation from its Blender export, which landed it nose-into-the-floor when placed under an ARCore plane anchor. The Cloud Anchor, Tap to Place, and Depth Occlusion demos now apply a shared
-90°X correcting rotation at placement time so the helmet stands upright, visor forward. Other bundled cycle models are unaffected. - Playground
Duckmodel no longer 404s (#1487). Added the self-hostedDuck.glbasset so the playground'sDuckmodel option and the Spring Physics example load correctly instead of issuing a 404 for the missing file.
Added¶
- Flutter bridge:
addGeometry/addLightare now rendered natively on Android (#909). These twoSceneViewControllermethods previously returnedresult.success(null)without drawing anything — the Flutter demo's feature badges did not reflect that. The AndroidSceneViewPluginnow appends to reactivegeometryNodes/lightNodesCompose state lists, socube/box,sphere,cylinderandplaneprimitives anddirectional/point/spotlights render in bothSceneViewandARSceneView, matching the React Native bridge. Material instances are cached per(color, unlit)and released on dispose. The Flutter demo's GeometryNode / LightNode feature cards are re-labelled "Android only" (the iOS RealityKit port stays tracked under the #909 umbrella). First Dart unit tests for the plugin (data-class serialization + controller attach guards) were also added. - Regression tests for the
samples/commonshared helpers (#972). TheLifecycleAwareLaunchedEffect(#936) andrememberMaterialInstance/rememberUnlitMaterialInstance(#937) helpers shipped with no tests. New JVM/Robolectric suites pin their contracts:LifecycleAwareLaunchedEffectTestdrives a realTestLifecycleOwnerto assert the body cancels ononStopand re-runs from the top ononStart, andRememberMaterialInstanceTestfails if a future edit putsmetallic/roughness/reflectanceback into theremember(...)key (the 60 HzMaterialInstancechurn caught by the #937 review).:samples:common:testDebugUnitTestis now wired into the CI unit-test step.
Changed¶
- Render tests are no longer orphan
@Ignore'd code (#912). The five headless Filament render-test classes (RenderSmokeTest,LightingRenderTest,GeometryRenderTest,VisualVerificationTest,DemoParametersRenderTest) were wholly class-level@Ignore'd — compiled but never executed, so they could never catch a regression and silently drifted. They now use a runtime capability gate (RenderTestCapabilities.assumeGpuReadbackAvailable()): the tests run on a hardware-GPU runner that opts in via-Pandroid.testInstrumentationRunnerArguments.gpuReadback=true, and cleanly skip (JUnit assumption, not orphan, not failure) on the SwiftShader / Apple-Silicon emulator where Filament's asyncreadPixelscallback never fires (the harness limitation tracked in #803).Closes #912.
v4.6.2 — CI hotfix: land the demo app on the Play Store + API docs (2026-05-16)¶
Fixed¶
- Play Store release deploy no longer blocked by AAB validation (#1416). The pre-upload guard now introspects Android App Bundles with
bundletool dump manifest(the correct tool for.aabfiles) instead ofaapt2, which can only read APKs and was mis-reporting bundles as corrupt. The validation step is also markedcontinue-on-errorso a tooling gap can never veto a release. This unblocks the demo-app Play Store deploy that missed v4.6.0 and v4.6.1. - API-docs deploy no longer races the website deploy on a release tag (#1417).
release.yml's Dokka deploy anddocs.yml's site deploy both push to the externalsceneview.github.iorepo; on a release tag they ran concurrently and the second push failed non-fast-forward. A shared cross-workflowconcurrencygroup now serialises the two pushes so a release reliably publishes both the API docs and the site.
v4.6.1 — CI hotfix: unblock the Play Store deploy for the demo app (2026-05-16)¶
Fixed¶
- Play Store deploy no longer blocked by AAB manifest validation (#1413).
validate-release-artifact.shnow resolvesaapt2from$ANDROID_SDK_ROOT/build-tools/<newest>/instead of relying onPATH(where it never is), and the pre-upload guard now warns and skips instead of hard-failing when the validation tooling itself is unavailable — only a genuine manifest mismatch blocks a release.
v4.6.0 — Demo polish & cross-platform parity: iOS/Android demo unification + Samples tab fixes + AR screenshot regression pipeline + CI hygiene (2026-05-16)¶
Added¶
- Reusable branch + worktree cleanup task. New
.claude/scripts/cleanup-branches-worktrees.shdeletes merged local and remoteclaude/*branches (singlegit push --delete, no bot-burst) and prunes stale.claude/worktrees/*directories, with current-branch / unmerged / open-PR safety guards and a--dry-rundefault. A dailybranch-cleanupjob inmaintenance.ymlprunes merged remote branches automatically. - AR demo screenshot regression pipeline (#1050). New
ARPlaybackScreenshotTestreplays the bundled ARCore recording throughARRecordPlaybackDemoand captures the rendered AR frame at fixed ARCore frame indices (f=30/60/120/180) for golden comparison. Captures are gated on a per-frame counter (DemoSettings.arPlaybackFrameCount, bumped once peronSessionUpdated) rather than wall-clock sleeps, so they land on the same frame on every machine regardless of emulator load. Wired intorender-tests.ymlon a pinned emulator profile and documented insamples/android-demo/AR_TESTING.md. - Flutter & React Native demos: Double Pendulum physics demo (#1332) — a new "Physics" tab in
samples/flutter-demoandsamples/react-native-demoruns the chaotic two-link pendulum with link-length / gravity sliders + reset, mirroring the Android, iOS and web demos. The bridge sample apps have no per-frame transform-mutation API, so the integrator is a 1:1 port of the sharedsceneview-coreDoublePendulumsimulation rendered via a FlutterCustomPainter/ React Native views.Closes #1332.
Changed¶
play-store.ymlnow validates the release AAB manifest before upload (#1301). A new gate runs after thebundleReleasebuild and fails the job fast if the artifact'spackage,versionName, orversionCodedon't match what gradle was told to build — catching a stale or wrong-variant bundle in ~1 s instead of as a Play Console rejection minutes later. Backed by.claude/scripts/validate-release-artifact.sh+ anandroid_cli_describehelper (wrappingaapt2, since theandroidCLI'sdescribesubcommand introspects projects, not built artifacts).Closes #1301.cross-platform-check.shcan cross-check the demo APK manifest (#1302). A new opt-in--with-apkflag builds (or reuses) theandroid-demodebug APK and inspects its manifest via theaapt2-backedandroid_cli_describehelper to verify the exposed entry points match expectations — theio.github.sceneview.demopackage id, a launchableMainActivity, and thesceneview://deep-link scheme — then cross-checks the AndroidDemoRegistrydemo count against the iOSSamplesTabinventory so a platform missing a demo surfaces as drift. The fast source-only path stays the default.- CI hygiene cluster (#1360). Consolidated the website deploy to a single path —
docs.ymlnow publishes the complete built site (marketing + MkDocs + web-demo + Dokka API) to the canonical apex reposceneview/sceneview.github.io, and the redundantdeploy-website.yml(which pushed onlywebsite-static/to a competing URL) is removed. Fixed the iOS SPM cache keys inios.ymlandapp-store.ymlto hash the checked-insamples/ios-demo/Package.resolvedinstead of a nested workspace path that does not exist on a clean runner (the key was a constant empty hash, so the cache never invalidated). Raised theCI Gatejob timeout to 35 min so the poll loop's own diagnostic surfaces before a hard runner kill. Linked the permanentlycontinue-on-errordesktop-democompile step to tracked follow-up #1396. - Unified demo titles & subtitles across Android and iOS (#1376). Every demo now shows one canonical, user-facing title and subtitle on both platforms, so the Play Store and App Store apps no longer look like different products.
- Added
jsTestcoverage for thesceneview-webcore logic classes (#1394). New unit tests pin theOrbitCameraControllerorbit/zoom/pan math (spherical-to-Cartesian eye conversion, phi/distance clamping, auto-rotate, damping), theGeometryGLBBuilderGLB container output (header, chunk alignment, accessors,KHR_materials_unlitextension, node transforms), and the auto-center one-shot gate. ThedidCenterContentflag was extracted into a testableAutoCenterGateso the #1357 regression — a 2ndloadModelmust re-run content centering — is now directly covered.Closes #1394. - API docs: KDoc for the
sceneviewmodule geometry, texture and material helpers (#965). Added accurate KDoc to previously undocumented public declarations in thesceneviewAndroid module: the six geometry builders (Cube,Cone,Cylinder,Sphere,Capsule,Torus), the texture helpers (ImageTexture,VideoTexture,TextureSampler2D/TextureSamplerExternal,Texture.use/setBitmap),RenderableManagerextensions,NodeAnimator, and the ubershaderMaterialInstanceparameter setters. Documentation only — no behavior change.arsceneviewis tracked separately.
Fixed¶
- Samples cleanup (#1361). Rewrote
samples/MULTIPLATFORM.mdso the architecture diagram and recipe list match the real tree (*-demo/folders, the 11 actualrecipes/*.mdfiles). Finished the android-demo first-frame loading-scrim rollout to the remaining 13 non-AR demos so cold starts no longer flash a black viewport (AR demos intentionally skip it — they show a live camera feed, not a black Filament viewport). Converted the deadelse -> PlaceholderDemorouter fallback inMainActivity.ktinto a debug-only drift guard that crashes loudly if a newALL_DEMOSentry is added without a matching route, while still degrading gracefully in release builds. - iOS demo cleanup + Android parity (#1373). Renamed the
Scenestab toSamples, aligned the Samples category taxonomy to Android, removed theAuto Rotate/AR Record & Playbackduplicate entries and the dead Explore buttons, fixed theSettingspill overlapping the controls FAB, and corrected several inaccurate demo subtitles and captions. - iOS demo: Samples tab black rectangle (#1392). Tapping a 3D demo in the Samples tab opened it in a
.medium-detent.sheet, which rendered the demo's full-screenSceneView(RealityView) viewport as a black, half-height panel covering the demo-card list and the Settings button. Every available demo now opens in a.fullScreenCover; the partial.sheetis reserved for the lightweightComingSoonScreen, which has no 3D surface. sceneview-webjsTestsuite can now run in CI (#1401). The Karma / ChromeHeadless test bundle threwUncaught ReferenceError: Filament is not definedat load time — the@JsModule("filament")external is mapped to a global that no script injected into the headless page, which failed the entirejsTestrun before any test executed. Akarma.config.d/filament-stub.jsconfig now serves a no-opFilamentglobal before the test bundle, so the pure-logic web tests (camera/config builders,ContentCentering, version pin, WebXR constants) actually run. Also fixed two latent test failures the blocker was hiding:ContentCentering.centeringOffsetreturned a signed-0.0for an already-centred axis (now normalised to0.0), andSceneViewVersionTest's pinned literal was a version behind.sceneview.github.io/docs/and/api/no longer serve the landing page (#925). Thedocs.ymlworkflow now deploys the complete assembled site — marketing landing page (root), MkDocs technical docs (/docs/), the Kotlin/JS web-demo (/web-demo/), and the Dokka API reference (/api/sceneview/) — to the user-facingsceneview/sceneview.github.iorepo. Previouslydocs.ymldeployed to a different Pages host whiledeploy-website.ymlpublished onlywebsite-static/(which carried adocs/meta-refresh redirect stub) tosceneview.github.io, so the MkDocs and Dokka content never reached those routes and GitHub Pages' 404 fallback served the landing page byte-for-byte. The redundantdeploy-website.ymlworkflow and the obsolete redirect stub have been removed;docs.ymlis now the single authoritative site deploy.MaterialLoader/EnvironmentLoaderno longer leak theirCoroutineScopeacross composition disposal (#933). Each loader'sdestroy()cancels itsCoroutineScope, andrememberMaterialLoader/rememberEnvironmentLoaderwiredestroy()toDisposableEffect.onDispose, so an in-flightloadMaterialAsync/loadHDREnvironmentjob can no longer outlive the owning composition and touch a destroyedEngine.EnvironmentLoader.clear()no longer cancels the scope — it now releases environments only, so callingclear()on a still-live loader never leaves it with a dead scope.- AnimationDemo cinematic camera no longer drains the battery while backgrounded (#974). The four scripted camera loops (Hero, Reveal, Vertigo, Tracking) now park on a clean boundary when the app goes to the background and resume from the exact same pose — no teleport back to the initial yaw, unlike the
repeatOnLifecycle-based helper that #936's review had to revert here. A newLifecyclePausingLaunchedEffect/LifecyclePauseGatepair insamples/commonprovides the reusable state-preserving primitive for anywhile(true)loop that wants lifecycle pausing without a state reset.
Tests¶
- JaCoCo coverage delta gate (#973). A committed baseline (
.claude/data/jacoco-baseline.txt) records per-module line coverage, and.claude/scripts/jacoco-delta-check.shfails when a PR drops coverage more than the configurablethreshold_pp(0.5pp default). Wired into theunit-testCI job as an informational, non-blocking step for now — promoting it to a hard gate once it has been green for two consecutive weeks is the#973follow-up.
Docs¶
/issue-batchskill rewritten as a launch-and-go continuous cycle (#1297). The skill now encodes the validated operating mode: a replace-on-completion pipeline of 6-8 lean-clone background agents (shallow sparse clones, ~0.3-0.6 GB vs ~2.3 GB full), fire-and-forgetgh pr merge --auto, disk-gated spawn (refuse < 15 GB), disjoint-module parallelism, autonomous dispatch, and a release checkpoint per iteration.Closes #1297.
v4.5.0 — visionOS immersive-space skybox + fragment changelog system + iOS unlit/reactive-light parity + CI hardening (2026-05-15)¶
Changed¶
- Adopted a towncrier-style fragment changelog (#1337). PRs now drop a small file in
changelog.d/instead of editingCHANGELOG.md's## Unreleasedanchor, so parallel PRs no longer conflict on the changelog..claude/scripts/collate-changelog.sh X.Y.Zcollates the fragments into a new## vX.Y.Zsection at release time.Closes #1337.
Added — iOS¶
- visionOS immersive-space skybox (#1235). A
SceneViewpulled into a fully immersiveImmersiveSpacenow renders itsshowSkyboxHDR environment as a background. The new.immersiveSpace()modifier opts in; the HDR is mapped onto an inverted sphere parented under aWorldComponentroot, sinceRealityViewContent.environment(the windowed iOS / macOS.skybox(_:)path from #1215) is unavailable on visionOS. Windowed / volumetric visionOS scenes are unchanged.
Fixed — iOS¶
- camera auto-framing now scales-to-fit the scene bounds (#1026, #1041). the
SceneViewdefault camera now dollies to a distance that fits the content bounding box in the viewport — accounting for the vertical fov and live aspect ratio — instead of sitting at a fixed pose, so models are no longer rendered too small, too low, clipped, or overflowing across the ios demos.
Added — Documentation¶
- KDoc for the
sceneview-corecollision API (#965). Documented previously-undocumented public declarations in the collision module (Box,Sphere,Plane,Ray,RayHit,Vector3,Quaternion,Capsule,MeshCollider,ChangeId,TransformProvider) plus theEasingcurve set and the cross-platformlogWarninglogger.
Added — Docs¶
- New recipe: iOS visual-polish pipeline (#1218).
docs/recipes/ios-visual-polish.mddocuments how to combine the v4.4.0 HDR-skybox background render, PBR default material, and Apple AR Quick Look hand-off — decoded from @radcli14'stwolinks. The iOS demo'sDynamicSkyDemodeep-night bucket now uses the dramaticSceneEnvironment.nightSkyHDR.
Added — Samples¶
- Web demo: Double Pendulum physics demo (#1221) — a new "Physics" tab in
samples/web-demoruns the chaotic two-link pendulum with link-length / gravity sliders + reset; the integrator mirrors the sharedsceneview-coreDoublePendulumsimulation that drives the Android and iOS demos. Reachable via the#double-pendulumdeep link.
Fixed — iOS true look-around camera (#1236)¶
- iOS
.firstPersonnow rotates the perspective camera in place instead of orbiting the scene root, so switching orbit ↔ firstPerson no longer teleports the camera; newrecentersTargetOnOrbit(_:)modifier +CameraControls.recenterTarget()fix pan→orbit pivot drift.Closes #1236.
Tests¶
- Regression pins for three untested AR rendering fixes from the 2026-05-14 batch (#1120). New JVM tests pin the
environmentalHdrSpecularFilter = truedefault (#1086), the no-double-close hoisted cubemap upload callback (#1091), and the 7@VolatileLightEstimatortoggles (#1095).
Added — CI¶
- Nightly full-CI safety-net workflow (#1324).
nightly-ci.ymlruns the full heavy validation surface (compile + builds + unit tests + render tests + quality gate) againstmainHEAD once a night, reusing the existing workflows viaworkflow_call, so a path-gated-out regression still surfaces within 24h. Not a PR gate.
Fixed¶
-
iOS
FogNode.heightFalloff/heightBasedare now honestly documented as a RealityKit parity gap (#1380). the height gradient was a silent no-op — a uniform translucent sphere cannot vary opacity by world height; the parameter is kept for Android parity but now clearly documents that height-based fog renders identically to exponential fog on iOS (#1373). -
iOS
GeometryNode/ShapeNodeunlit: truenow returns a flatUnlitMaterial(#1359). Theunlit:parameter previously produced a litSimpleMaterialthat still reacted to scene lighting, contradicting the KDoc contract — it now yields anUnlitMaterial, matchingImageNodeandGeometryMaterial.unlit. -
SceneViewmain/fill light mutations are now reactive (#1306).rememberMainLightNode/rememberFillLightNodere-run theirapplyblock on every recomposition (viaSideEffect), so Compose-state-driven light properties (intensity, direction, color) propagate to the Filament scene without re-keying theremember— matching the iOSRealityView.update:reactive light contract.
Changed — Samples¶
- Migrated the remaining
samples/android-demodemos to therememberMaterialInstance/rememberUnlitMaterialInstancehelpers (#971).CollisionDemo,LightingDemo,VideoDemo,ARStreetscapeDemo,GeometryDemo,DebugOverlayDemo,PhysicsDemoand the sharedAxes3DNodeno longer allocateMaterialInstancehandles via rawmaterialLoader.create*without disposal — the helpers own the lifecycle. Behaviour-preserving.
Fixed — Web¶
sceneview-webstaleSCENEVIEW_VERSION+ auto-center not resetting on a 2nd model load (#1357). The@JsExport-reachableSCENEVIEW_VERSIONwas two majors stale (3.6.0); it now reports4.4.0and is pinned by a jsTest.SceneView.loadModelnow resetsdidCenterContentso a model loaded after the first one was auto-centered gets re-centered, mirroring Android'sSceneAutoCenterState.reset().
Fixed — CI security¶
discord-notify.ymlno longer interpolates user-controlledgithub.event.*fields into inline shell scripts (#1313). Issue title/author and release name/tag now pass throughenv:and are referenced as quoted shell variables, closing a GitHub Actions script-injection vector.
Fixed — Android demo¶
- Demo viewports no longer flash black for 5–12 s on cold start (#1022).
DemoScaffoldnow shows a surface-tinted loading scrim over the 3D viewport until the SceneView presents its first Filament frame, wired via the newrememberFirstFrameState()helper.
Fixed — ViewNode rendering¶
ViewNodeno longer renders as a permanent black rectangle after a background → foreground cycle (#984).ViewNode.WindowManagernow retries the off-screen window attach via an owner-View attach listener when the owner is not yet attached at resume time, instead of silently dropping the attach.
Fixed — Samples¶
SecondaryCameraDemocamera-angle controls are now TalkBack-friendly (#1256). The section label is exposed as a heading and the selectedFilterChipcarries an explicit "Selected camera angle" state description.
Changed — CI¶
-
ci.yml's "Build & lint" job split into parallelbuild/lint/unit-testjobs, andquality-gate.ymlswitched to a shallow checkout (#1311). The three Android jobs share the Gradle cache and run concurrently (~3 min wall-clock saved); the quality gate dropsfetch-depth: 0since its scripts only diff against the working tree / HEAD. -
CI/publish workflows' inline
pip installdeps moved into per-workflow.github/workflows/requirements/*.txtfiles so Dependabot'spipecosystem tracks and bumps them (#1286). Same packages, same pinned versions installed — Dependabot just cannot see inlinepip install x==ylines in workflow YAML, so the pins would have gone stale silently. -
render-tests.ymlreverted from a 3-shard emulator matrix back to a single job (#1119). All 5 render-test classes are class-level@Ignore'd on SwiftShader CI (#803), so the shard matrix booted 3 emulators to run 0 tests — strictly more CI cost for the same coverage. The matrix scaffold can be re-applied once #803 lifts the ignores.
Fixed — arsceneview¶
ARRecorder.start(recordingResolution=…)now restores the session's camera config onstop()(#1358). The higher-resolution CPU image stream raised for a recording no longer silently persists for the rest of the AR session — the prior config is captured before the swap and restored onstop()(and on a failedstart()).
Fixed — Docs¶
- Reconciled stale version refs that survived the v4.4.0 release and hardened
sync-versions.shto catch them (#1356). README badges/CDN/SPM snippets,ai-context.md,android-xr-emulator.md,website-static/js/package.json, the Kotlin toolchain version inllms.txt, and the root↔docsllms.txtARRecorder.saveToPhotoLibraryparagraph now all read 4.4.0 / 2.3.21;sync-versions.shgained checks for every one of those off-map locations.
v4.4.0 — iOS skybox renders + true-orbit camera + iOS Stage 2 demo parity + Double Pendulum physics demo + sceneview-swift mirror retired (2026-05-15)¶
Changed — AR LightEstimator allocation & robustness refactor (#1105)¶
LightEstimator.update()no longer allocates on the AR render thread. Per-frameEstimation, color-correction, cubemap face-offset, RGB-triplet, and 27-element irradiance buffers are now hoisted, reused fields; anENVIRONMENTAL_HDRcapability probe (Session.isSupported, cached per mode) early-returns instead of feeding silently-degraded HDR estimates to Filament; the legacy Sceneform1.8pixel-intensity gain is now a named constant. Public behaviour is unchanged.Closes #1105.
Fixed — AR recording resolution (#1065)¶
ARRecorderno longer records at ARCore's low-res 640×480 default. ARCore writes the CPU image stream into the MP4, whose stock default is the device's lowest-resolution camera config.ARSceneView'ssessionCameraConfignow defaults to the newhighestResolutionCameraConfigselector (highest-resolution BACK-facing, 30 FPS config), so every AR scene — and every recording — runs at full camera resolution without opt-in.ARRecorder.start(...)also gains an optionalrecordingResolution: Size?parameter to request a specific resolution explicitly.Closes #1065.
Added — Agent skills¶
- Published
sceneview-iosandsceneview-webagent skills, and documented the Androidsceneviewskill'sandroid-cliregistry submission (#1080, #1081, #1082). Newagents/sceneview-ios/(SwiftUI + RealityKit) andagents/sceneview-web/(Filament.js + WebXR) skills with install scripts;check-sceneview-skill.shnow validates all three; submission packet and steps tracked inagents/REGISTRY.md.
Added — Web auto-center content (#1052)¶
sceneview-webnow auto-centres loaded content on the orbit-camera target — library-level port of iOSautoCenterContent(#1026). Enabled by default; opt out withautoCenterContent(false)in the DSL builder orsetAutoCenterContent(false)on the JS viewer. Models placed off-origin now frame centred in the canvas without per-demo workarounds. Android sibling tracked in #1051.
Fixed — iOS demo bug cluster (#1054–#1059)¶
- iOS demo bug cluster —
swift testbuild,CameraControlsrename, deep-link, Plane, first-paint audit. Localcd SceneViewSwift && swift testruns the full 603-test suite again (#1054): the 36SceneViewSwiftTestsclasses are now@MainActor-annotated so their RealityKit@MainActornode factories no longer raise ~500#ActorIsolatedCallcompile errors under the Xcode 26 toolchain.OrbitCameraDemo.swiftrenamed toCameraControlsDemo.swift(#1055) so the file matches itsstruct CameraControlsDemo(project.pbxprojsynced). Thesceneview://demo/multi-modeldeep-link no longer hangs on an eternal black "Loading park scene…" scrim (#1056) —MultiModelDemoloads its four park models concurrently and reveals each progressively, so one slow heavy USDZ no longer blocks the rest.GeometryDemo'sPlaneis no longer invisible/edge-on (#1058) — the demo now stands the XZ plane upright to face the orbit camera. First-paint audit (#1059): RealityKit does not exhibit Android #1022's Filament shader-compile black first frame on non-AR demos, so no library-level scrim was needed on iOS.
Added — iOS¶
ARRecorder.saveToPhotoLibrary(_:)now returns the saved asset'sPHAsset.localIdentifier(String?) (#1057). Callers get a handle to the saved recording — resolve it later viaPHAsset.fetchAssets(withLocalIdentifiers:options:)or deep-link to it — closing the cross-platform parity gap with Android'sARRecorder.exportToDownloads()Uri?return. The result is@discardableResult, so existing v4.3.0 call sites are unaffected.
Added — Bridges¶
- Flutter + React Native bridges now expose the v4.3.0 iOS additions —
CameraControlMode(orbit/pan/firstPerson),autoCenterContent, andARRecorder(record-only via ReplayKit) (#1053). Cross-platform consumers can drive iOS camera modes, opt out of auto-centring, and record AR sessions; Android gracefully falls back where the feature is iOS-first (tracked in #1051).
Removed — Samples¶
- Removed the French localization from the sample apps — sample apps are English-only by design (#1294). Deleted
samples/android-demo/.../res/values-fr/strings.xml; the default English resources remain the single source of truth.
Fixed — 3D rendering quality umbrella (#1074)¶
DefaultCameraNodenow starts at a framed 3/4 view — the default 3D camera placement moved from(0, 0, 1)(1 m dead-ahead of the world origin, flat front-on, under-framing anything but a tiny object) to(0, 0.3, 2)looking at the origin. This frames a typical 0.3–1 m model placed at origin correctly out of the box and mirrors iOS RealityKit'slook(at: .zero, from: [0, 0.3, 2])default, so the same scene frames identically on Android and iOS. The position is exposed asDefaultCameraNode.DEFAULT_Y/DEFAULT_Zand pinned inSceneFactoriesTest. This is a visual behavior change — demos that supply their owncameraNodeare unaffected. The remaining umbrella items (IBL intensity 30k→10k, PostProcessingDemo SSAO toggle, light/material leaks, render-quality clobber,indirectLightApplythreading, PhysicsDemo light retune) already shipped in #1079, #1088, #1089, #1092 and #1147.
Changed — CI¶
release.ymlnow deploys the generated Dokka API docs tosceneview.github.io/api/sceneview/<version>/+/latest/and wraps Dokka generation in a 3× retry to tolerate transient Maven Central 503s (#1252, #1127).Deploy iOS App to App Storeis now tag-only — fixes Apple upload-limit failures on every main merge (#1318).- Lighter
mainCI:Deploy Demo to Play Storeis now tag-only (#1321),Deploy website + docsis path-gated to docs/website/markdown changes, andRender Tests/Build sample APKspath filters were tightened to skip doc-only, CI-only andmcp/-only merges (#1311).
Changed — MCP¶
sceneview-mcpaddssearch_android_docs/fetch_android_doctools wrapping Google'sandroid docsCLI, and makes thepackage-filesregression test deterministic by routing thegenerate-llms-txtbanner to stderr (#1083, #1113).
Added — Double Pendulum demo (shared KMP physics)¶
- New
DoublePendulumsimulation insceneview-core(Addresses #1221) — a pure-Kotlin, platform-independent two-link (double) pendulum inio.github.sceneview.physics.DoublePendulumStateholds twoDoublePendulumLinks (length, mass, angle, angular velocity), a fixedpivot,gravityanddamping;DoublePendulum.step(state, dt)advances it with a symplectic (semi-implicit) Euler integrator, sub-stepped at1/240 sso the chaotic motion stays numerically stable at any frame rate. Exposesjoint/tipjoint positions and atotalEnergyaccessor; covered by 12commonTestcases including energy-conservation (bounded energy band withdamping = 0) and rest-state stability. Adapted from @radcli14's MIT-licensedtwolinks. - New "Double Pendulum" demo on Android and iOS — a chaotic two-link mechanism rendered as metallic PBR links swinging in real time, driven by the shared
DoublePendulum. Android (samples/android-demo) wires thesceneview-coresimulation into aSceneView { }frame loop with sliders for link lengths / gravity / reset; iOS (samples/ios-demo) ships the SwiftUI equivalent. The iOS demo hand-ports the same integrator math into a localDoublePendulum.swift, kept numerically identical to the Kotlin source, since iOS cannot consume the KMP module directly until thesceneview-coreXCFramework lands (#1033). Reachable viasceneview://demo/double-pendulumon both platforms. Web / Flutter / React Native ports are deferred follow-ups under #1221.
Changed — iOS default material is now physically-based (#1223)¶
- Procedural geometry on iOS now defaults to
PhysicallyBasedMaterialinstead ofSimpleMaterial(#1223) —GeometryNode.cube/sphere/cylinder/cone/plane/torus/capsule(color:),ShapeNode(points:color:),LineNode(from:to:color:), and litImageNodes previously created RealityKitSimpleMaterial, which is effectively unlit-flat: it does not react to image-based lighting (the HDR environmentSceneViewwires by default) and cannot express metallic/roughness. The library default is now a matte-neutral PBR material (metallic: 0,roughness: 0.5) so shapes pick up soft environmental shading and reflections — the single biggest visual-quality jump for iOS demos. This is a visual behavior change, not a breaking API change: every existing call site keeps compiling and produces visually-similar-or-better output. Callers who explicitly want the old flat-fill look (debug visualizations, overlays) can opt back in with the newunlit: Bool = falseparameter, e.g.GeometryNode.cube(color: .red, unlit: true). Also fixes a latent bug whereGeometryMaterial.pbr(...)was internally backed bySimpleMaterial(so metallic/roughness never reached the PBR pipeline) — it now correctly builds aPhysicallyBasedMaterial.Closes #1223.
Added — night_sky environment preset (#1219)¶
- New
night_skyHDR environment bundled across iOS and Android demos — a dramatic Milky Way starfield over a dark landscape (Poly Havendikhololo_nightby Greg Zaal, CC0 1.0 public domain). iOS exposes it asSceneEnvironment.nightSky(added toallPresets, so it auto-surfaces in the demo's environment picker); Android adds a "Night Sky" chip toEnvironmentDemo. Pairs well with metallic PBR materials for chrome-mirror reflections. Web demo does not bundle HDRs and is unaffected.
Changed — iOS demo¶
- The AR tab launcher now doubles as a discovery surface (#1253) — a 2×3 grid of headline AR demo cards under the "Start AR Camera" CTA (Plane Placement, Instant Placement, AR Lighting, AR Recording, Orbital AR, AR Debug), each opening the demo full-screen — closing the launcher-parity gap with Android's
ArLauncherScreen. The AR tab's placed-model count is also derived from the live anchor collection so it can no longer drift.
Fixed — iOS demo¶
AppStoreUpdatersnooze is now version-keyed instead of a 7-day TTL (#1231) — dismissing one release's update banner no longer hides a newer release's banner; a new App Store version invalidates the snooze automatically, matching the Web/Flutter/RN samples. TheSceneViewDemoTestsunit-test target is now wired intoSceneViewDemo.xcodeprojso theAppStoreUpdatertests run in CI (#1227).
Fixed — iOS rendering¶
-
SceneEnvironment.showSkybox = truenow actually paints the HDR as the scene background (PR #1215, ported from @radcli14's sceneview-swift#1) —SceneViewpreviously loaded the HDR and applied it as IBL viaImageBasedLightComponent, but never assigned it toRealityViewContent.environment, so the scene rendered against the default neutral void regardless of which environment preset was selected. The new path caches the loadedEnvironmentResourcein a@Stateand applies it viacontent.environment = .skybox(resource)in theRealityView.update:closure with a diff guard against the last applied resource (no per-frame ARC churn). The.task(id:)keys on(name, showSkybox)so toggling the flag on the same env re-runs the loader, and clears the cached resource at the start of every task tick so cross-env transitions don't show stale skyboxes under a new IBL. -
Orbit + pan camera modes now physically move the perspective camera in world-space (PR #1215) —
applyCamera()was faking the camera move by rotating + scalingentities.rootwhile the perspective camera stayed pinned at[0, 0.3, 2]. With a global skybox, that made the background appear stationary while content visually orbited around the user — visually wrong from the camera's POV. Orbit and pan now position the camera viaCameraControls.cameraPosition()+look(at: target, ...), so the skybox correctly wraps. The scene root stays at identity for both modes;camera.orbitRadiusis now the literal camera-to-target distance.firstPersonretains its rotate-the-root semantics (FOV pinch via #1034) — the true "stand still and look around" rewrite remains a v4.4.0 follow-up. -
FOV no longer bleeds from
firstPersonpinch intoorbit/pan— switching tofirstPerson, pinching FOV down to e.g. 30°, then back toorbitkept the 30° pinched FOV on the perspective camera (visible as a stuck zoom-in).applyCamera()now writes the baseline60°FOV inorbit/panregardless ofcamera.fov, and only mirrorscamera.fovinfirstPerson. OnfirstPersonexit,camera.fovitself is reset to60so the next entry starts fresh.
Changed — CameraControls defaults (BREAKING for direct constructors)¶
CameraControls.orbitRadiuspublic default changed from5.0to2.0—5.0was unreachable through any public modifier (cameraControls(_:)only accepts aCameraControlMode), and the internal@Statealready overrode to2.0so existing demos retain their on-screen framing. Callers constructingCameraControls()directly will see the same2.0default the SceneView uses internally; the apparent angular size of a 1m model at default state is identical to the pre-v4.4.0 fake-orbit framing (28.07° at 60° FOV).CameraControls.minRadiuspublic default changed from0.5to1.0— under the new true-camera path,0.5puts the perspective camera inside any model with extent >1m (which most demo content has). The old0.5was safe under the fake-orbitscale = 5.0 / radiusscene-scale hack but clips into geometry now. Override for smaller content.
Changed — SPM URL retirement¶
sceneview-swiftSPM mirror retired in favour of monorepo-direct package resolution (PR #1215) — every install snippet across docs, codelabs, GPT prompts,.github/copilot-instructions.md,SceneViewSwift/README.md,llms.txt(4 copies — root, docs, website-static, well-known), the website (index.html/docs.html/playground.html), the PWA manifest, the schema.orgsameAsgraph, and the bundled MCPllms-txt.tsnow points athttps://github.com/sceneview/sceneview(.git). Thesceneview/sceneview-swiftmirror has been archived read-only; its frozenv4.0.0tag still resolves for SPM consumers pinned to the old URL, but no further releases will be cut there. Existing consumers should re-add the package in Xcode pointing at the monorepo URL — the rootPackage.swift(added in PR #920) declares theSceneViewSwiftproduct.
Changed — CI / scripts hardening¶
- CI/scripts hardening batch (#1226, #1230, #1237, #1114) — new
check-sceneview-swift-urls.shPR gate forbids reintroducing the archivedsceneview-swiftmirror URL;sync-versions.shnow uses a portable_sed_inplacehelper (BSD/GNU); publish-timepip installcalls inplay-store.yml/app-store.ymlare pinned to exact versions. CONTRIBUTING.md now documents that docs-only PRs skip the quality-gate + render-tests (#1128).
Fixed — in-app update polish across all 6 sample platforms (#1244–#1249)¶
Follow-ups to PR #1216 (in-app auto-update feature) — six small, platform-specific hardening fixes that bring the auto-update behaviour to parity across Android, Web, React Native, Flutter, iOS and macOS:
- Android —
InAppUpdateManagerlistener stacking (#1244): the early-return guard added in #1216 only coveredDOWNLOADING/READY_TO_INSTALL. A fast double-resume landing twocheckForUpdate()calls while the first was still in theCHECKING/AVAILABLEwindow would issue a parallelappUpdateInforequest and a duplicatestartUpdateFlow, double-prompting the user. A privateinFlightflag now gates re-entry, set on entry and cleared on both the success and failure listener so a network failure can't permanently lock out checks. Covered by two new Robolectric tests inInAppUpdateManagerTest. - Web — update snackbar hidden behind the loading overlay (#1245): the snackbar (
z-index: 60) sits below the loading overlay (z-index: 100), so a version check resolving during engine init showed the snackbar stranded behind the spinner. The snackbar is now gated on engine-init complete — if the check resolves early it is deferred and flushed onceloading-overlay.hiddenis set. - React Native — update banner overlapping the header (#1246): the absolutely-positioned banner used
top: 0, overlapping the "SceneView / React Native Demo" header. It now offsets by aHEADER_HEIGHTconstant so it sits cleanly below the header (react-native-safe-area-contextis not a dependency of this demo, so a measured constant is the minimal fix). - Flutter — deprecated
withOpacity()(#1247):Color.withOpacity(0.8)inapp.dartreplaced with the Flutter 3.27+withValues(alpha: 0.8)API.flutter analyze lib/app.dartnow reports no issues. - iOS / macOS —
AppStoreUpdater.openAppStore()no-op on macOS (#1248): theopencall was wrapped in#if canImport(UIKit) && os(iOS), making it a silent no-op on the macOS target. A#elseif os(macOS)branch now opens the Mac App Store viaNSWorkspace.shared.openwith themacappstore://scheme. - iOS — update throttle hard-locked on clock rollback (#1249): if the system clock rolled backward,
now − lastCheckAtwent negative — always below the throttle — so updates never re-checked.shouldCheck()now clampslastCheckAttomin(last, now)on read, repairing a future-stamped timestamp. Covered by a new test inAppStoreUpdaterTests.
Fixed — Android demo regressions (#1265, #1266)¶
Two regressions from cd4034ff (PR #1224) that the #1241 emulator QA sweep proved still broken:
- AR Instant Placement — "Initializing camera" pill alignment (#1265): the scanning-indicator pill rendered bottom-left, overlapping the Clear All button, despite a
BoxScope.align(TopCenter)set directly on itsAnimatedVisibilitywrapper.AnimatedVisibilityintroduces its own layout node for the enter/exit transition and thealignmodifier on that wrapper is not reliably honoured by the animated child. The alignment is now carried by a staticBoxthat is a direct child of the outerBox, withAnimatedVisibilityinside it handling only the fade — matching the structure of the working stats pill above. Pill stays pinned top-center, clear of the bottom Clear All button. - Debug Overlay — invisible stress-test spheres (#1266): the
SceneViewcontent block had noLightNodeand the spheres were spawned with nomaterialInstance, so the default Filament material rendered black on the black background. Added a directional key light and a shared on-brand color material (created once viaremember, so the stress test still measures pure geometry overhead). The earlier #1212 grid-centering fix already places the count == 1 sphere at origin; this completes the visibility fix.
Fixed — tooling¶
- Web demo — deferred update snackbar stranded on engine-init failure (#1279) —
flushPendingUpdateSnackbar()(added in PR #1271 to defer the update snackbar past engine init) was only called in theSceneView.modelViewer(...)success path; an engine-init rejection left a deferred version stuck inpendingUpdateVersionforever. The.catch()path now flushes too — the snackbar is pure DOM andflushPendingUpdateSnackbar()nullspendingUpdateVersion, so it can never double-show.Closes #1279. -
DemoInteractionTest— FR-locale gap in control helpers (#1282) —secondaryCamera_pipAnglesnow resolves the PiP-angle chip labels fromR.string.demo_secondary_camera_chip_*instead of hard-coded English literals, so the interaction test passes on a French-locale device. Demos that still inline English control labels in the composable need a per-demo resource-extraction sweep first (tracked separately).Closes #1282. -
worktree-auto-prune.shno longer risks destroying a parallel session's uncommitted work (#1278) — the script now skips any worktree with a non-emptygit status --porcelain, uses plaingit worktree remove(fail-safe) instead of--force, accepts repeatable--keeppaths, and reclaims squash-merged worktrees via agh-backed merged-PR check that degrades gracefully offline.Closes #1278.
Docs¶
- New recipe: Blender → SceneView asset pipeline (#1222) —
samples/recipes/blender-to-sceneview.mdanddocs/docs/recipes/blender-pipeline.mdwalk contributors through authoring a custom 3D model in Blender and shipping it in a SceneView app:.glbis native on Android, while Apple platforms go.glb→ Reality Converter →.usdz→ Reality Composer Pro (Blender's own USDZ exporter produces broken materials). Adapted from @radcli14'sblender-to-realitykittutorial (MIT, 17⭐), with a SceneView-specific call-out on the Android Filament JNI main-thread rule. Cross-linked from both quickstarts and the API cheatsheet. Closes #1222.
Added — Android library-level autoCenterContent (#1051)¶
SceneView(autoCenterContent = true)— port of the iOSautoCenterContentfeature (#1026 / PR #1038). DSLcontentnodes are parented to an intermediate content-root node which the library translates once — on the first frame their union bounding box is non-empty — so the content centroid lands at the orbit pivot and renders centred without per-nodeModelNode(centerOrigin = …). Lights / camera areSceneViewparameters (never DSL children) so they stay put. Opt out withautoCenterContent = falsefor intentional off-centre composition.
Follow-ups (filed against the master polish-pipeline reference #1218)¶
- #1219 — Bundle ambientCG NightSkyHDRI008 (CC0) as
night_skyenv preset (iOS + Android + Web) - #1221 — Cross-platform 'Double Pendulum' physics demo (port of @radcli14's
twolinks) - #1222 — Recipe: Blender → glb → Reality Converter → usdz → Reality Composer Pro pipeline
- #1223 — Switch library-default material from
SimpleMaterialtoPhysicallyBasedMaterial
Special thanks to Eliott Radcliffe (@radcli14) — the skybox + true-orbit camera fixes were ported with Co-authored-by credit from his sceneview-swift PR #1. The asset-pipeline tutorial referenced by #1222 is from his blender-to-realitykit repo (MIT, 17⭐).
v4.3.6 docs hotfix — Cloud Anchor ERROR_NOT_AUTHORIZED post-SHA-1 troubleshooting (#1177 follow-up)¶
iOS Stage 2 demo parity catch-up (#1194)¶
Six Android-only Sketchfab-streaming demos shipped by Stage 2 (#1152) now have proper iOS ports so the cross-platform parity guarantee (feedback_ios_mirror_android.md: iOS V1 == strict Android subset, no hidden gaps) holds end-to-end. The previous placeholder shape — model-viewer / multi-model deep-links routing to SceneGalleryDemo — is gone.
Added — iOS samples¶
AnimationDemo.swift— 5-model carousel (bundled cyberpunk character + 4animation-category streamed slugs) with play / pause / speed slider / loop chips. Cinematic camera shots (Hero / Reveal / Vertigo / Tracking) + IBL intensity slider from Android remain Android-only — see the iOS demo's settings sheet for the upfront roadmap note.ModelViewerDemo.swift— full-screencyberpunk_hovercarhero with a "Surprise me" extended button that searches the Sketchfab catalogue server-side, downloads the pick viaSketchfabService.downloadModel, and replaces the hero in place. Button hidden whenSketchfabConfig.apiKeyisnil(App Store builds) so we don't ship a non-functional affordance.MultiModelDemo.swift— themed "Park" diorama (tree / bench / dog / bird) composed from the 4 streamedpark-category slugs. Per-model visibility chips + spin toggle wired throughAnchorEntity+SceneView.autoRotate(speed:).ARPlacementDemo.swift— tap-to-place AR demo with a 5-bundle cycle and the 6 streamedar_placement-category chips. ReusesSceneViewSwift'sARSceneView(onTapOnPlane:)raycast hook.ARInstantPlacementDemo.swift— instant-placement variant with a toggle. ARKit doesn't exposeConfig.InstantPlacementMode.LOCAL_Y_UPdirectly; the iOS port approximates via.estimatedPlaneraycasts so taps land before plane geometry has fully converged.PhysicsDemo.swift— rewritten from the v4.3.x cubes-only version to the Stage 2 streaming shape: bundled cubes default + 4 streamedphysics-category crash-test meshes (vase / stool / barrel / amphora). Drop count capped at 20 active bodies because RealityKit'sPhysicsBodyComponentslows past that.
Changed — iOS plumbing¶
AutoRotateDemo.swiftstruct renamed fromAnimationDemo→AutoRotateDemoto free up the canonical name. The "Auto Rotate" Samples-tab entry continues to point at this struct; the new "Animation" entry routes toAnimationDemo.swift.SamplesTab.swift— added Model Viewer / Multi-Model Park entries under Geometry, and promoted "AR Plane Placement" + "AR Instant Placement" fromComing soonto fully wired demos.DemoDeepLinkRegistry.swift—model-viewerandmulti-modelids no longer route to theSceneGalleryDemoplaceholder; both land on the dedicated demos.ar-placementnewly routed toARPlacementDemo.
Fixed — iOS Stage 2 demo polish (#1280)¶
ARPlacementDemo/ARInstantPlacementDemogain a "Clear all placed models" control that tears down every placed anchor (placed anchors previously accumulated for the demo's lifetime);ARInstantPlacementDemo's Instant/Plane toggle doc-comment + copy now honestly state both modes use the same.estimatedPlaneraycast (the toggle only shows/hides the plane + coaching overlays);ModelViewerDemo's "Surprise me" failures now surface a transient error banner instead of failing silently; and a confusing double-negation inMultiModelDemowas simplified.
Fixed — pre-existing AppStoreUpdater build break¶
AppStoreUpdater.swift:66default parametercurrentVersion: @escaping () -> String? = AppStoreUpdater.bundleVersionwas losing the@MainActorglobal-actor isolation under Swift 6 strict concurrency, breaking the iOS demo build on main. Added@MainActoron both the parameter type and the stored field so the implicit@MainActorfrom the class scope propagates correctly. Surfaced while validating #1194; the regression landed in #1216 earlier today.
Docs¶
docs/docs/cheatsheet-ios.md— new "Demo parity status (#1194)" section above the existing "iOS parity status (#1036)" table, summarising the six ports and the honest-subset notes (cinematic camera, per-model editing, sceneview-core physics).
No library APIs change. No new releases of :sceneview / :arsceneview / :sceneview-core are required.
Production Cloud Anchor users still hitting ERROR_NOT_AUTHORIZED on v4.3.5 after the App Signing key SHA-1 was added to the Google Cloud API key restrictions. v4.3.3 (PR #1197) shipped the SHA-1 runbook + actionable in-app error pointing only at that one cause, but field experience showed there are 4 other Cloud-Console-side causes that look identical at the device.
Investigation confirmed every code-side surface is healthy:
- ARCORE_API_KEY GitHub secret present (39 chars, last rotated 2026-05-06)
- samples/android-demo/build.gradle injects manifestPlaceholders["arcoreApiKey"] from env / local.properties
- AndroidManifest.xml carries <meta-data android:name="com.google.android.ar.API_KEY" android:value="${arcoreApiKey}" />
- ARCloudAnchorDemo.kt enables Config.CloudAnchorMode.ENABLED in sessionConfiguration
- play-store.yml's verify-arcore-key.sh CI guard passed green on the v4.3.5 release run (run 25891143675, 2026-05-14 23:24 UTC)
- Package name io.github.sceneview.demo matches the Cloud Console restriction (no applicationIdSuffix)
So the bug is Cloud-Console-side configuration drift, not an APK-side regression. v4.3.6 expands the docs surface so the next maintainer / contributor hitting this can self-diagnose without escalating.
Changed¶
samples/android-demo/STREETSCAPE_SETUP.mdadds a new "Troubleshooting —ERROR_NOT_AUTHORIZEDpersists after SHA-1 is whitelisted" subsection under the existing "Play App Signing key" block. Five-step checklist with direct Cloud Console deep-links (replace<PROJECT_ID>withpc-api-4638313286439917620-648for the SceneView demo project):- Billing enabled and active on the Cloud project (Geospatial / Cloud Anchors hit paid backends; silently rejects without billing).
- "ARCore API" enabled (not the legacy "ARCore Cloud Anchor API" — different products).
- API restrictions on the key separate from Application restrictions — must include "ARCore API" by name, or be set to "Don't restrict key".
- Propagation delay — observed up to 30 min in practice despite Google's "~1 min" claim.
-
Project-ID mismatch — verify the API key whose SHA-1 you whitelisted is the same key in the GitHub secret.
-
ARCloudAnchorDemo.kthost/resolve error messages broadened. The in-app banner forERROR_NOT_AUTHORIZEDno longer presumes the SHA-1 is the cause — it now reads "Check SHA-1 + billing + ARCore API restrictions in STREETSCAPE_SETUP.md.". This matches the v4.3.3 hotfix's actionable-error spirit but covers the full failure mode space surfaced post-#1177. -
.claude/scripts/verify-arcore-key.shreminder footer broadened to direct maintainers reading the CI log at the new 5-step checklist rather than only the SHA-1 runbook.
Fixed — android-demo¶
- Secondary Camera demo — restore PiP overlay (PR #1213) —
SecondaryCameraDemo.ktwas renamed "Camera Presets" in commitdfc241d5and lost its picture-in-picture overlay; the chips ended up just snapping the main camera, defeating the "multi-camera" pitch even though the registry entry still ships thePictureInPictureicon + "Picture-in-picture camera view" subtitle. TwoSceneViews now share the same engine/loaders and render the helmet simultaneously: the main view keeps the default orbital camera (user-interactive), and a smallSurfaceType.TextureSurfacePiP overlay top-start binds a dedicatedrememberCameraNodedriven by the Top / Side / Front / Corner chips viaLaunchedEffect(cameraPreset). Title restored to "Secondary Camera (PiP)" soDemoInteractionTest.secondaryCamera_pipAnglesfinds it again. Two correctness invariants doc'd inline: eachSceneViewgets its OWNrememberModelInstance(sharing one across views would double-destroymodelInstance.rooton dispose — SIGABRT — and reparent child light/camera nodes off whichever ModelNode built last) and the PiP receivescameraManipulator = null(without it the SceneView frame loop writescameraNode.transform = manipulator.getTransform()every frame, clobbering theLaunchedEffectpreset writes). iOS gets the matching "Coming soon" placeholder under.advanced(SamplesTab.swift,pip.fillSF Symbol, v4.4) —SceneViewSwift currently uses an internal@State private var camera = CameraControls(mode:)with no per-instancecameraNodebinding, so a true RealityKit PiP needs new SceneViewSwift public API (tracked for v4.4).
No library APIs change. No new releases of :sceneview / :arsceneview / :sceneview-core are required — the Cloud Anchor on-device fix is entirely Cloud Console configuration; the Secondary Camera fix is scoped to samples/android-demo/.
Changed — in-app update (samples)¶
InAppUpdateManagerTestnow covers the intermediateDOWNLOADINGstate + non-zerodownloadProgress(#1229);UpdateBannerauto-focuses its "Restart" CTA on D-pad hosts (#1228) — the TV demo passes an optionalrestartFocusRequesterso the Restart button grabs focus when an update reachesREADY_TO_INSTALL; phone hosts leave itnulland are unaffected.
v4.3.5 — Pixel 9 production polish: AR demo UX fixes + FR i18n + CI dedup + iOS pull-to-refresh (2026-05-15)¶
Added — iOS pull-to-refresh on Explore feeds (#1211 item 1 — PR #1225)¶
- iOS pull-to-refresh on Sketchfab Explore feeds —
samples/ios-demo/SceneViewDemo/Views/ExploreTab.swiftnow wires.refreshable { await loadSketchfabFeeds(force: true) }on the ExploreTabScrollView, mirroring the AndroidPullToRefreshBoxshipped in v4.3.4 (PR #1203). NewloadSketchfabFeeds(force: Bool = false)overload bypasses the "already loaded" guard when invoked from the swipe-down gesture, and conditionally gates the loader onSketchfabConfig.apiKeyso builds without the key don't spinner-flash on every refresh. Items 2 (matchedGeometryEffecthero zoom) and 3 (ARTab close affordance) from #1211 remain open as follow-ups.
Fixed — SPM version drift caught post-v4.3.4 (PR #1217)¶
- 2 stale SPM
from: "4.3.3"references bumped to 4.3.4 —pro/gpt-store/gpt-instructions.md:77andmarketing/stackoverflow/qa-drafts.md:215. Both files live in non-canonical directories thatsync-versions.shdoesn't sweep, so the drift slipped past the v4.3.4 release cut (#1153). Surfaced by.claude/scripts/impact-check.shafter PR #1203 landed.
Changed — CI workflow deduplication (~20 min saved per PR)¶
- Workflows trimmed — Audit of
.github/workflows/showedassembleDebugcompiling 4× per PR (across CI, PR Check, quality-gate, Build sample APKs) and unit tests running 3×. Every duplicate removed while keeping every distinct check: pr-check.yml— droppedcompile-android,lint,compile-web-demo,build-flutter-demo(all already covered byci.yml'sbuild,web-desktop,flutter-demojobs). Kept only the unique fast guards:check-deprecated-api,check-sceneview-skill,compile-kmp(KMP all-targets, beyondci.yml's JS-only build),check-workflow-scripts,validate-demo-assets. Also mirrored thepaths-ignoreblock fromci.ymlso docs-only PRs no longer spin up the ~5 min Gradle KMP compile.build-apks.yml— dropped thepull_requesttrigger. APKs were already built twice on every PR byci.yml+pr-check.yml; this workflow's unique value (artifact upload, GitHub Release attachment) only matters onpush/ tag.quality-gate.yml+.claude/scripts/quality-gate.sh— addedQUALITY_GATE_SKIP_ANDROID=1env var, set in the CI workflow so the gate no longer re-runsassembleDebug+ the same Android unit tests thatci.yml'sbuildjob already executes (with JaCoCo coverage). Local invocations ofquality-gate.shstill run the full path. MCP tests, version sync, security scans, asset CDN checks, website rules, and agent skill drift detection all still run on every PR and push.-
render-tests.yml— dropped thepull_requesttrigger. Tests are non-blocking (continue-on-error: true) and produce screenshots rarely consulted by reviewers; the signal is still captured on every push to main, withworkflow_dispatchavailable for ad-hoc feature-branch vetting. -
Supply-chain guard centralised — Moved
gradle/actions/wrapper-validation@v6from the (now removed)pr-check.yml:compile-androidstep into.github/actions/setup-gradle/action.ymlso every workflow that calls./gradlew(CI, PR Check, quality-gate, build-apks, render-tests, release, docs) inherits the validation. Catches any tamperedgradle/wrapper/gradle-wrapper.jarregardless of which workflow consumes it first.
Validated by 4 independent Opus reviewers before merge. Branch protection on main confirmed to have zero required status checks, so no renamed job blocks merges. No downstream workflow_run, needs:, Renovate, Codecov, or contributor doc reference was broken (verified via grep -rn workflow_run .github/workflows/ and a sweep of the last 20 merged PRs for artifact-name references).
Fixed — Pixel 9 v4.3.0 production audit follow-ups (umbrella #1176)¶
Five demo polish bugs caught in the Pixel 9 production audit. All are scoped to samples/android-demo/ and samples/android-demo/src/main/res/values-fr/strings.xml — no library APIs change.
-
AR Instant Placement — "Initializing camera" pill overlapped Clear All at startup (#1199) —
ARInstantPlacementDemo.ktnow hides the bottom-start "Clear All" button until at least one anchor has been placed (dead affordance pre-tap), and moves the "Initializing camera — you can already tap to place" toast pill fromBottomCentertoTopCenter(56 dp below the stats pill). Before, the two competed for the bottom anchor area and the user saw what looked like two half-overlapping buttons. Now: top of screen carries the transient init message, bottom is empty until an anchor exists. -
AR Pose Placement — primitives appeared unlit on Pixel 9 (#1200) —
ARPoseDemo.ktretunes both cube and sphere PBR materials fromroughness=0.85, reflectance=0.1toroughness=0.55, reflectance=0.2. The previous values were pinned all the way to "matte safety" to avoid an IBL specular blowout on the original metallic=0.5 setup, but swung too far the other way under ARCoreENVIRONMENTAL_HDR— the sphere lost all visible diffuse falloff and read as a flat 2D circle next to a barely-shaded cube. The new mid-rough setting keeps the IBL safe (no blowout, metallic stays 0) while restoring the diffuse gradient that makes the sphere read as a 3D sphere. -
Sketchfab model viewer — initial expand rendered model inside a circular crop (#1201) —
SketchfabModelViewerScreen.kt::RenderContentnow defers mountingSceneViewuntilrememberModelInstanceresolves (instance != null). Before, the SceneView was always composed and an opaque-surface loading placeholder was layered on top with a centeredCircularProgressIndicator. During the bottom-sheet expand transition, the opaque surface faded relative to the still-rendering SceneView surface underneath, producing a brief "model visible inside a circular porthole" frame (the user could see the model through the fading surface overlay, with the centered spinner ring framing the visible area). Now the placeholder owns the full 440 dp box cleanly until the GLB is ready, then the SceneView mounts in one swap. -
i18n: missing French translations for streamed-model credits sheet + asset-source chips (#1204) —
values-fr/strings.xmladds the 7 keys flagged by the post-#1099/#1160 audit:credits_sheet_title/credits_sheet_subtitle/credits_sheet_footer/credits_row_open_cdfor the streamed-model attribution sheet, anddemo_chip_bundled/demo_chip_streamed/demo_chip_streamingfor the DemoScaffold asset-source chip. Thedemo_ar_streetscape_*keys called out in the original issue body were already translated; this PR closes the broader audit gap discovered bycomm -23 used_keys.txt fr_keys.txt(139 used keys, 7 had EN entries but no FR entries). -
Debug Overlay — single-sphere case spawned off-screen at (-0.9, -0.9, 0) (#1212) —
DebugOverlayDemo.ktnow computes the grid footprint from the actual node count:cols = min(10, count),rows = min(10, ceil(count / cols)),layers = ceil(count / (cols × rows)), then offsets each sphere by-(axisLen - 1) / 2 × NODE_SPACINGso the cluster mean is always at origin. At count=1 → cols=rows=layers=1 → offsets all zero → sphere lands at (0, 0, 0) where the camera is looking. At count=100..1000 the new formula collapses to the same 10×10×N centered footprint as before. The previous formula(i % 10) - 5baked in a "10 wide" assumption that put count=1 at (-0.9, -0.9, 0) — 3.6× outside the camera frustum at the SINGLE_SPHERE_DISTANCE = 0.8 m camera distance.autoFitDistance(...)updated to read the new grid footprint so framing stays consistent.
v4.3.4 — Pixel 9 production hotfix: AR Face Mesh + Instant Placement UX + UTF-8 + iOS LightingDemo (2026-05-15)¶
Fixed — Sketchfab Explore cosmetic & iOS demo gaps¶
-
Sketchfab Explore — Polish name
My�liniceshows U+FFFD (#1181 — PR #1202) —SketchfabService.authenticatedGetnow decodes the response body as UTF-8 explicitly viaresponse.body.source().readString(Charsets.UTF_8)instead ofbody.string(). OkHttp'sstring()honours theContent-Typecharset and falls back to ISO-8859-1 when the header lacks acharset=parameter (which can happen at edge-cache rewrites), corrupting any non-ASCII byte. Sketchfab's API always returns UTF-8, so forcing the decode is both correct and defensive. New unit testdecodes non-ascii model names without substitutionexercises Polish / Czech / Greek / CJK fixtures. -
AR Examples menu — green pills replaced with M3 Expressive grid (#1185 — PR #1202) —
ArViewTab.kt'sArDemoCardnow mirrors theDemoCardpattern fromDemoListScreen.kt: gradient-tinted icon header on top + title + subtitle below, using the "Augmented Reality" category green accent (light#66BB6A/ dark#A5D6A7) so the AR View launcher feels like the same app as the Samples tab. Pre-refactor the cards used floating tertiary-tinted pills that read as a "different app" against the Samples-tab grid. -
iOS sample —
ARLightingDemo.swiftcompanion to #1151 fillLightNode port (#1155 — PR #1202) — New AR demo atsamples/ios-demo/SceneViewDemo/Views/Demos/ARLightingDemo.swiftshowcases the.mainLight(_:)+.fillLight(_:)modifiers shipped in v4.2.0 (PR #1151). Three filter chips toggle between.systemDefaulton both slots, dim-key.custom(LightNode.directional(intensity: 5_000)), and key-only (.fillLight(.disabled)) — registered under the AR section inSamplesTab.swift.
Added — Compose UX patterns in samples/android-demo¶
- Pull-to-refresh on Explore Sketchfab feeds (
ExploreTabScreen.kt) —PullToRefreshBoxreloads the Trending / Staff Picks / Recently Added carousels on swipe-down. The pull-down affordance is conditionally wired so it only shows when the Sketchfab API key is present (no spinner-flash on builds without the key). The refresh path goes through a single cancel-then-restart pipeline (refreshTickLaunchedEffect key) so toggling the "Animated" filter mid-refresh can't race two concurrent loads writing to the same lists. - System back exits live AR session (
ArViewTab.kt) —BackHandlerroutes the system gesture to the same exit path as the top-end Close button (detach anchors, return to the AR launcher screen). Manifest opts intoandroid:enableOnBackInvokedCallback="true"so Android 13+ routes back via the newOnBackInvokedDispatcher(prerequisite for any futurePredictiveBackHandlerupgrade). - Shared-element hero morph between viewer stages (
SketchfabModelViewerScreen.kt) —Crossfadereplaced withSharedTransitionLayout+AnimatedContent. The 220 dp Preview thumbnail morphs in place into the 440 dp Ken-Burns Downloading hero, then into the live SceneView surface, sharing bounds across the three stages with a consistent rounded-corner clip. The live render usesSurfaceType.TextureSurfaceso the layer alpha is honoured during the morph (the defaultSurfaceViewis a hardware overlay and would pop in opaque). Stage.Error is excluded from the shared bounds (no hero) and uses a clean 300 ms fade.
Added — iOS demo parity (umbrella #1211)¶
.refreshableon Explore Sketchfab feeds (ExploreTab.swift, PR #1225) — pull-to-refresh on the iOSScrollViewmirrors the AndroidPullToRefreshBoxin #1203.loadSketchfabFeeds(force: Bool)bypasses the "already loaded" guard when called from.refreshableso manual pulls actually re-fetch.- iOS 18 zoom navigation transition Explore card → viewer (PR #1232) —
.matchedTransitionSource(id:in:)on the carousel card pairs with.navigationTransition(.zoom(sourceID:in:))on the destination so the thumbnail morphs into the viewer's preview hero on push. The viewer now exposes an explicitStage.Preview(description / tag chips / "Open in SceneView" CTA / non-downloadable warning) matching Android'sStage.PreviewPreviewContent— the network download only fires after the user taps the CTA, and a Retry button on the error overlay resets to the preview state. Source IDs are namespaced by feed ("sketchfab-hero-staff-…"/"-liked-"/"-recent-") so a model appearing in more than one carousel doesn't collide on the matched namespace.
Fixed — Pixel 9 v4.3.0 production audit follow-ups (umbrella #1176)¶
Two findings (#1179 Face Mesh + #1184 Instant Placement) accumulated post-v4.3.3 and are the primary code content of v4.3.4. Two more (#1183 EIS auto-place + #1182 snap-fling) shipped on main before the v4.3.3 tag was cut but were not formally announced in the v4.3.3 body — they are written up here for completeness.
-
AR Face Mesh — full black surface on Pixel 9 (#1179 — PR #1198) —
samples/android-demo/.../ARFaceDemo.ktno longer passescameraExposure = -1.5f. The author had intended a "-1.5 EV bias", but Filament's single-argCameraComponent.setExposure(Float)is an absolute linear exposure scaling (1.0 ≈ ISO 100 ≈ EV 0), not a signed EV-stop bias as the prior KDoc misleadingly hinted. A negative scaling clamps the framebuffer to zero, hence the fully-black scene on Pixel 9 v4.3.0 production. The front-camera AR session already force-DISABLES light estimation (seeArSession.kt) and the newARDefaultCameraNodedefaults (f/12, 1/200 s, ISO 200 ≈ EV 11.6 — after PR #1088) + 10k+3k lux main+fill lights give a correctly exposed selfie preview on every device tested. Also rewrote thecameraExposureparameter KDoc inARScene.ktso future contributors don't repeat the misinterpretation. Pinned byARCompletenessDefaultsTest.ARFaceDemo no longer passes a negative cameraExposure valueso any grep-and-paste regression gets caught. -
AR Instant Placement — anchors silently floating after
STOPPED(#1184 — PR #1198) —samples/android-demo/.../ARInstantPlacementDemo.ktnow reconciles each placed anchor'sTrackingStateevery frame. When ARCore drops a placedInstantPlacementPoint's underlyingAnchortoSTOPPED(the user typically panned the camera away from where the point was approximated), we now detach the dead anchor, hide itsModelNode(which previously froze at the last good pose, visually "floating off into space"), and surface "Lost — tap to re-place" on the per-model badge. The top status pill gains a "N lost" segment when relevant. The per-model badge column iteratesplacedModelsrather thantrackingMethodsso anchors that flip toSTOPPEDbefore their firsttrackingMethodever fires still surface as Lost. -
AR Image Stabilization (EIS) — demo auto-places helmet on first tracking frame (#1183 — PR #1191) —
ARImageStabilizationDemonow auto-creates a 1 m-in-front anchor on the first stableTRACKINGframe and drops the helmet there, with a one-shotautoPlacedguard so Clear + manual tap still hand control back to the user. The v4.3.0 demo shipped with no model visible at start — users had to wait for the plane finder (5–10 s indoors) and tap, but the "How to test" panel never said so. Pixel 9 audit frames (key-frames/t340s.jpg/t360s.jpg) showed a 30-second EIS-toggle session where the user never saw a model. With the auto-place, the demo's core value (helmet stays glued while background stabilizes) is visible within ~1 s of TRACKING. The anchor pose isframe.camera.pose.compose(Pose.makeTranslation(0f, 0f, -1.0f))so the helmet appears straight ahead at eye level regardless of camera tilt, and works in featureless areas where the plane finder stalls. -
Sketchfab carousels — snap-to-card fling + edge padding (#1182 — PR #1196) — Both Explore-tab
LazyRows (curated samples + Sketchfab feed) gainflingBehavior = rememberSnapFlingBehavior(state)so scroll releases always land on a card boundary, never mid-card, pluscontentPadding = PaddingValues(horizontal = 4.dp)for first/last-card breathing room. The Pixel 9 audit caught two Sketchfab cards (queGRD,Myślinice) rendering truncated mid-name at a viewport edge — the cards themselves were fine (maxLines = 1, overflow = TextOverflow.Ellipsis), but the LazyRow released the flick mid-card. iOS has its ownScrollView/LazyHStacksnapping config and is intentionally not touched here.
v4.3.3 — AR production hotfix: actionable Cloud Anchor error + CI key guard (2026-05-14)¶
Fixed — AR production blockers (Pixel 9 v4.3.0 audit umbrella #1176)¶
This hotfix follows the v4.3.0 production audit. The umbrella's P0 / P1 code bugs all landed by v4.3.2 (PR #1136 AR IBL baseline + #1086 HDR specular filter + #1088 AR exposure + #1075 3D IBL intensity + #1190 R8 keep rules for Fused Location Provider). v4.3.3 closes the remaining production-blocker gap that requires a Cloud-Console-side change to fully unblock end users.
-
Cloud Anchor
ERROR_NOT_AUTHORIZEDnow surfaces actionable guidance (#1177) — Whenhost()orresolve()comes back withERROR_NOT_AUTHORIZED, the demo status banner now says"The ARCore Cloud API key is rejecting this APK's SHA-1. See STREETSCAPE_SETUP.md → \"Play App Signing key\"."instead of the raw enum. The root cause on a fresh Play Store deploy is that the App Signing key SHA-1 (post-Play-resign) isn't whitelisted on the Google Cloud API key — a manual Cloud Console step that the demo can't perform itself. -
STREETSCAPE_SETUP.mdadds a "Play App Signing key" runbook — Step-by-step for maintainers to add the post-resign SHA-1 fingerprint to the ARCore API key restrictions, eliminating the production blocker without re-cutting a release. -
CI guard for ARCore key wiring (
.claude/scripts/verify-arcore-key.sh) —play-store.ymlnow fails fast ifARCORE_API_KEYsecret is missing, ifsamples/android-demo/build.gradleno longer injects thearcoreApiKeymanifest placeholder, or ifAndroidManifest.xmldrops the${arcoreApiKey}reference. Catches the silent-regression class that ships an AAB with an unwired Cloud key.
Verified fixed (closing tracker issues)¶
-
#1097
spherePlaneResponsewrong contact point on negative side — fixed inCollisionResponse.kt(contactPoint = center - planeNormal * signedDistprojects along the original unflipped normal). JVM regression testspherePlaneResponseContactPointLandsOnPlaneOnEitherSidepins the behaviour on both sides of the plane. -
#1178 AR Terrain & Rooftop Anchors fail in release builds (R8 strip) — fixed in
arsceneview/consumer-rules.provia PR #1190. Consumer-side R8 now keepscom.google.android.gms.location.**,common.api.**, andtasks.**so ARCore can reflectively link Fused Location Provider whenConfig.GeospatialMode.ENABLED. -
#1061 AR rendering quality umbrella (multiplicative drift, no default IBL, mirror reflections, EV15 vs EV11.6 exposure) — all P0 / P1 sub-issues closed: #1062 (baseline-relative light apply pattern in
ARScene.kt,AtomicReferencebaselines), #1063 (neutral IBL fallback increateAREnvironment), #1064 (environmentalHdrSpecularFilter = truedefault inLightEstimator.kt), #1067 (AR exposure aligned to v4.1.0 3D defaults).Config.LightEstimationMode.ENVIRONMENTAL_HDRis the default inARScene.ktso PBR materials read ARCore's HDR cubemap + spherical harmonics + main-light estimate from frame one. Remaining sub-issues #1065 (recording resolution) and #1066 (camera-stream double-gamma) stay open as P1 polish for v4.4.
v4.3.2 — #1152 Sketchfab streaming complete + iOS key + DemoScaffold v2 + APK slim (2026-05-14)¶
Security — fast-xml-parser bumped to 5.7.0+ via npm overrides (Dependabot alert #139 — PR #1162)¶
Resolves CVE-2026-41650 / GHSA-gh4j-gqv2-49f6 — fast-xml-parser XMLBuilder fails to escape --> (comment) and ]]> (CDATA) delimiters, allowing XML injection / XSS / SOAP-injection when user-controlled data flows into those contexts.
- Package:
fast-xml-parser(npm, dev-only transitive inreact-native/react-native-sceneview). - Resolved version before fix:
4.5.6→ after fix:5.8.0. - Severity: moderate (CVSS 6.1).
- Dependency chain:
react-native(devDep) →@react-native-community/cli-platform-ios@11.4.1→fast-xml-parser@^4.0.12.
Fix shipped as an npm overrides block in react-native/react-native-sceneview/package.json — the standard npm 8+ way to force a safe transitive version without migrating react-native from 0.72 to a newer line. npm install --package-lock-only regenerated the lockfile cleanly; npm audit reports found 0 vulnerabilities. Dev-only chain (every entry in the affected closure is "dev": true); no published runtime artefact from @sceneview-sdk/react-native ships fast-xml-parser.
Changed — Stage 3 polish + APK slim-down + Credits sheet for streamed assets (#1152 — Stage 3)¶
Stage 3 closes the Sketchfab streaming umbrella (Stage 1 foundations, Stage 2 × 8 demo migrations, Stage 3 polish, Stage 4 docs). Four polish items shipped here:
APK / IPA slim-down. samples/android-demo/src/main/assets/models/animated_dragon.glb (8.0 MB) and samples/ios-demo/SceneViewDemo/Models/animated_dragon.usdz (8.6 MB) are removed. Both files were used as canonical picks by OrbitalARDemo + ArViewTab (Android) and OrbitalARDemo + ARTab + ExploreTab (iOS). Canonical references migrate to threejs_soldier.glb (2.1 MB, animated peer) on Android and phoenix_bird.usdz (1.1 MB, animated peer) on iOS. Fallback paths in SampleAssets.kt for streamed slugs (butterfly / hummingbird / bee / koi / songbird) flip from animated_dragon.glb to threejs_soldier.glb. Net Android release-APK savings ~5 MB (88 MB → 88 MB after measurement, was 93 MB before); ~8 MB AAB on-disk. iOS IPA savings ~8.6 MB.
Credits sheet (CC-BY attribution). New samples/android-demo/.../ui/CreditsSheet.kt + samples/ios-demo/SceneViewDemo/Views/CreditsSheet.swift ModalBottomSheet / SwiftUI sheet listing every streamed Sketchfab model the demo app may load, grouped by SketchfabSlug.category, with author + CC-BY 4.0 attribution + tap-to-open-Sketchfab-page rows. Anchored to the "Credits" card on the About tab. The sheet reads SampleAssets.all directly — adding a slug in the registry automatically credits it here. CC-BY 4.0 requires visible attribution; without this sheet, redistributing the streamed models violated the license.
Per-demo offline indicator chip. New AssetSourceState enum (Streamed / Streaming / Bundled) + optional assetSource: parameter on DemoScaffold. The chip is pinned to the top-end of the scene area, advertises the streamed-or-fallback origin of the currently visible asset, and auto-hides when null. Wired into OrbitalARDemo / SceneGalleryDemo / ModelViewerDemo / ARPlacementDemo as exemplars; remaining Stage 2 demos can opt in incrementally. Helps users (and reviewers) understand at a glance whether they're seeing the streamed CC-BY model or the bundled offline fallback.
iOS parity audit. OrbitalARDemo / SceneGalleryDemo / MaterialsDemo already stream via SketchfabAssetResolver (Stage 2 parity preserved). ModelViewerDemo / AnimationDemo / MultiModelDemo / ARPlacementDemo / ARInstantPlacementDemo / PhysicsDemo are Android-only in v4.3.x; per feedback_ios_mirror_android.md iOS V1 ships as a strict subset. Follow-up issue filed to track porting (see issue body — Stage 3 PR creation).
Cleanup. SketchfabSlug.sketchfabUrl computed property added on both platforms (link target for the Credits sheet). assets/CREDITS.md keeps the dragon entry for posterity — the model is still on Sketchfab and the CDN-hosted GLB at cdn.jsdelivr.net/.../assets/models/glb/animated_dragon.glb did not exist anyway (web-demo dragon entry was a dead link before this PR; now removed).
Added — In-app auto-update across every sample app¶
Every published sample app now checks for a newer build on resume and surfaces a banner that lets the user trigger the install in a single tap. The pattern stays in samples/ rather than the SceneView SDK itself — auto-update isn't a 3D/AR concern, and bundling Play Core / iTunes plumbing into sceneview-core would force every consumer to ship it.
Android (samples/android-demo, samples/android-tv-demo). io.github.sceneview.sample.common.update.InAppUpdateManager is now factored into :samples:common and wraps Play Core's AppUpdateManager.startUpdateFlow(FLEXIBLE). The matching UpdateBanner composable renders during DOWNLOADING / READY_TO_INSTALL only, with a "Restart" CTA that calls completeUpdate(). samples/android-demo's previous in-tree copy is deleted in favour of the common one; android-tv-demo gains the INTERNET permission + a TV-friendly banner overlay focused on Alignment.TopCenter. A secondary constructor allows tests to inject FakeAppUpdateManager directly. Seven Robolectric tests cover IDLE → DOWNLOADING → READY_TO_INSTALL → IDLE, checkForStalledUpdate (download finished while backgrounded), destroy() idempotency, FLEXIBLE-type sanity, and zero-totalBytes safety.
iOS (samples/ios-demo). New AppStoreUpdater ObservableObject hits https://itunes.apple.com/lookup?id=6761329763 on every ScenePhase.active transition, compares the result with Bundle.main.infoDictionary["CFBundleShortVersionString"], and renders a Liquid Glass .regularMaterial SwiftUI banner with Update (deep-links to itms-apps://itunes.apple.com/app/id...) + Later (7-day snooze) CTAs. Throttle: 12 h between network calls via UserDefaults; snooze key cleared after the window expires. Apple does not expose a programmatic install API on iOS, so the banner is the best we can do — documented in the manager's KDoc. XCTest fixture (SceneViewDemoTests/AppStoreUpdaterTests.swift) ships with a URLProtocol stub harness; the project-level test target wiring lands in a follow-up PR.
Web (samples/web-demo). document.addEventListener('visibilitychange') polls https://sceneview.github.io/version.json (cached for 12 h via localStorage) and slides a Liquid Glass snackbar from the bottom with a Reload CTA when the JSON reports a version newer than the build-time BUILD_VERSION constant. Snooze is keyed on the latest seen version so a future bump re-surfaces the prompt. New website-static/version.json is auto-deployed by the existing deploy-website.yml workflow at every website-static/** push — sync-versions.sh keeps the .version field in lockstep with gradle.properties VERSION_NAME.
Flutter (samples/flutter-demo). WidgetsBindingObserver triggers UpdateChecker.checkForUpdate() on AppLifecycleState.resumed. Android delegates to in_app_update (the community wrap of Play Core); iOS uses http + package_info_plus to read the iTunes lookup response and url_launcher to open itms-apps://. Material 3 banner surfaces the same Update / Later CTAs as the other platforms.
React Native (samples/react-native-demo). <UpdateChecker /> mounts at the root; AppState events drive the check, Android via sp-react-native-in-app-updates, iOS via fetch + Linking.openURL. New cross-platform 12 h throttle + 7-day snooze in component state. RN demo version literal bumped 3.6.2 → 4.3.1 to align with gradle.properties.
Infrastructure. .claude/scripts/sync-versions.sh gains 5 new checks (website-static/version.json .version field, web-demo Main.kt SDK_VERSION, web-demo index.html BUILD_VERSION literal, RN-demo package.json "version", RN-demo App.tsx VERSION literal) with matching --fix paths. llms.txt (mirrored to docs/docs/llms.txt) documents the pattern AI-first so a developer asking an AI to add auto-update to their SceneView app gets working code on the first try.
Added — Stage 4 docs + AI-first surfaces for Sketchfab streaming + DemoScaffold v2 (#1152 — Stage 4)¶
Stage 4 of the #1152 umbrella. The Stage 2 patterns shipped over the last 7 PRs (Sketchfab streaming + DemoScaffold v2 modal sheet + chip picker) now have first-class documentation on every AI-first surface SceneView exposes.
New recipe pages (mkdocs).
docs/docs/recipes/sketchfab-streaming.md— full how-to + license guidance + add-a-slug checklist + API-key wiring story.docs/docs/recipes/demo-settings-sheet.md—DemoScaffoldv2 API + picker pattern + gesture map + discoverability lesson from issue #951.docs/mkdocs.yml— nav restructured. "Recipes" was a single leaf; now it's a section with Overview + the two new recipe pages.
llms.txt updates (root + docs/docs/llms.txt mirror).
Two new sections inserted before "Android Advanced APIs":
## Sketchfab streaming for samples (#1152)— copy-paste resolver pattern (8 lines of Kotlin) + hard rules (CC-BY-only, no WebView, never network-required, attribute the author) + LRU cache contract + bounds sanity check.## DemoScaffold v2 — full-screen scene + ModalBottomSheet controls (#1154)— DemoScaffold API signature + picker pattern + gesture map.
docs/docs/llms.txt synced byte-for-byte to root via cp.
New MCP resources (sceneview-mcp npm package).
Two new examples:// URIs surface compact (< 4 KB each) inline examples that an AI agent can fetch in one round-trip when it needs to scaffold a demo:
examples://demo-with-settings— DemoScaffold v2 pattern.examples://sketchfab-streaming— SketchfabAssetResolver pattern.
Both are registered in mcp/src/index.ts's ListResourcesRequestSchema + ReadResourceRequestSchema handlers. Body strings live in a new mcp/src/examples.ts module so the build pipeline can pin their byte budget via mcp/src/examples.test.ts (16 new vitest cases — start with H1, mention key APIs, < 4 KB, point at full recipe).
Files touched:
docs/docs/recipes/sketchfab-streaming.md(new) — full how-to.docs/docs/recipes/demo-settings-sheet.md(new) — full how-to.docs/mkdocs.yml— Recipes section restructured.llms.txt+docs/docs/llms.txt— 2 new sections + version-resync to 4.3.1.mcp/src/examples.ts(new) — inline resource bodies.mcp/src/examples.test.ts(new) — 16 vitest cases pin the resource shape.mcp/src/index.ts— wires the 2 new resources into theListResourcesRequestSchema+ReadResourceRequestSchemahandlers.mcp/src/generated/llms-txt.ts— regenerated from rootllms.txt(the build pipeline embeds it viamcp/scripts/generate-llms-txt.js).mcp/src/__fixtures__/analyze-project/android-ok/build.gradle.kts— fixture bumped from 4.1.2 to 4.3.1 bymcp/scripts/generate-version.jsrunning duringnpm run prepare.
Acceptance:
cd mcp && npm testGREEN (2562 tests, 102 files — 16 new fromexamples.test.ts).bash .claude/scripts/sync-versions.shGREEN (0 errors, 1 pre-existing warning).cp llms.txt docs/docs/llms.txt— diff is now empty.
Changed — Stage 2 demo migrations: PhysicsDemo drops streamed crash-test bodies (#1152 — Stage 2)¶
samples/android-demo/.../demos/PhysicsDemo.kt keeps the existing PhysicsNode-driven simulation but replaces the coloured spheres carousel with the four streamed entries from SampleAssets.byCategory["physics"] — Ceramic Vase, Wooden Stool, Wooden Barrel, Clay Amphora (all CC-BY from Sketchfab). A first "Bundled spheres" chip preserves the v4.3.1 visual default for QA / offline / store-listing screenshot determinism.
Behavioural contract. The simulation is unchanged — every dropped body is treated as a bounding-sphere of collisionRadius = 0.08 m so the bounce reads naturally regardless of mesh shape. The visual mesh is a ModelNode parented to the simulated SphereNode; the parent sphere is still drawn (the colour ramp gives a soft pad underneath the streamed mesh) so the simulation feels like "spheres with mesh skins" rather than abstract solids. This honours feedback_demo_quality — the demo's value is the SDK simulation hook-up, not a custom physics engine that handles convex-hull colliders.
Switching the picker resets the scene (bodyCount = 5; generation++) so the new shape is what falls — useful because mixed scenes confuse what the user is supposed to be observing.
Offline / no-key behaviour preserved — the resolver's per-slug fallback path returns the registered bundled GLB even when SketchfabConfig.apiKey == null, so the carousel always renders something visible. The streamed slot will visually match the bundled fallback in that case.
Files touched:
samples/android-demo/.../demos/PhysicsDemo.kt— full rewrite of the composable. Adds the chip row, the slug resolver, and the streamed-mesh-as-child pattern.samples/android-demo/src/main/res/values/strings.xml+values-fr/strings.xml— 3 new keys:demo_physics_picker_label,demo_physics_picker_spheres,demo_physics_picker_subtitle.
iOS counterpart not in this PR. The iOS demo app does not currently have a PhysicsDemo.swift — RealityKit's built-in PhysicsBodyComponent makes the SceneView wrapper less interesting on iOS, and the iOS V1 doesn't expose a SceneView PhysicsNode analogue. The 4 physics slugs (2 new in the AR-placement PR, 2 from Stage 1) are registered in samples/ios-demo/.../Services/SampleAssets.swift ready for a future port.
SampleAssets slugs added: 0 — the 2 new physics entries (Wooden Barrel, Clay Amphora) shipped in the previous Stage 2 PR (PR #1187 AR placement); this PR consumes them for the first time.
30 s screen recording deferred — agent worktree has no Pixel device access; tracked in the #1152 acceptance checklist.
Changed — Stage 2 demo migrations: ARPlacementDemo + ARInstantPlacementDemo gain a "Pick what to place" sheet (#1152 — Stage 2)¶
Both AR placement demos now expose the SampleAssets.byCategory["ar_placement"] chip row in their DemoScaffold v2 controls sheet (delivered in PR #1169). Selecting a streamed slug (coffee mug / houseplant / wooden crate / side table / floor lamp / picture frame — six entries CC-BY from Sketchfab) arms it as the next tap's payload; subsequent taps on a detected plane spawn a fresh AnchorNode + ModelNode using the streamed glTF resolved through SketchfabAssetResolver.
A first "Bundled cycle" chip preserves the v4.3.1 behaviour — each tap rotates through the existing 5-model bundled GLB cycle (helmet / fox / lantern / toy car / shiba). This keeps the demo deterministic for QA / offline / store-listing screenshots and gives the user a clear "no surprises" mode side-by-side with the streamed picker.
Behavioural contract:
- Selected slug, download landed. Tap places the streamed slug. Multiple taps place multiple instances of the same slug.
- Selected slug, download still in flight. Tap silently falls back to the bundled cycle so the tap is never lost. The picker subtitle shows "Streaming X…" so the user knows the streamed pick will activate on the next tap.
- "Bundled cycle" selected. v4.3.1 behaviour preserved.
Offline / no-key behaviour preserved — the resolver's per-slug fallback path still returns the registered bundled GLB even when SketchfabConfig.apiKey == null, so a tap on a streamed chip always renders something. The streamed slot will visually match the bundled fallback in that case, which is the same trade-off Stage 1 documented.
Files touched:
samples/android-demo/.../demos/ARPlacementDemo.kt— adds the chip row, the slug resolver, the per-tap "selected vs cycle" decision.PlacedModel.assetPathrenamed toassetLocationso bothassets/-relative paths andfile://URIs flow through the samerememberModelInstancecall.samples/android-demo/.../demos/ARInstantPlacementDemo.kt— same chip row, hoisted to the outerARInstantPlacementDemocomposable so it survives thekey(instantEnabled)rebuild that re-creates the inner ARCore session.samples/android-demo/src/main/res/values/strings.xml+values-fr/strings.xml— 5 new keys:demo_ar_placement_picker_label,demo_ar_placement_picker_bundled,demo_ar_placement_picker_streaming,demo_ar_placement_picker_streamed,demo_ar_placement_picker_subtitle.samples/android-demo/.../sketchfab/SampleAssets.kt+samples/ios-demo/.../Services/SampleAssets.swift— growar_placementfrom 3 to 6 entries (Side Table, Floor Lamp, Picture Frame added) so the picker has IKEA-showroom variety. iOS registry mirrored 1:1 for future Swift port.
iOS counterpart not in this PR. The iOS demo app (samples/ios-demo) does not currently have an ARPlacementDemo.swift — the iOS V1 didn't port the tap-to-place AR flow. The 3 new ar_placement slugs are registered in iOS SampleAssets.swift ready for a future port; the iOS demo file itself is deferred. ARKit's RealityKit.AnchorEntity(plane:) factory shipped in v4.2.0 (#1025) — the iOS port mostly needs a SwiftUI chip row + the existing resolver glue.
SampleAssets slugs added: 6 — 3 new ar_placement (Side Table, Floor Lamp, Picture Frame) + 2 new physics (Wooden Barrel, Clay Amphora) + 1 (Editor's note: see PhysicsDemo PR) that pairs with the next Stage 2 PR. All CC-BY 4.0.
30 s screen recording deferred — agent worktree has no Pixel device access; tracked in the #1152 acceptance checklist.
Changed — Stage 2 demo migrations: MultiModelDemo composes the streamed "Park" scene (#1152 — Stage 2)¶
samples/android-demo/.../demos/MultiModelDemo.kt swaps its tabletop arrangement of bundled assets (shiba + lantern + helmet + dragon) for the streamed "Park" scene composition — oak tree (backdrop) + park bench (foreground prop) + idle dog + perched songbird, all four resolved through SketchfabAssetResolver from the new park category of SampleAssets.
The composed scene now actually showcases what "multi model" means in practice — a real outdoor vignette where each asset comes from a different author / source / tool, all unified by studio_warm_2k.hdr and the shared scene-yaw rotation. The dog + bird carry skeletal animations so the scene reads as alive instead of as a still life. Two models are static (tree, bench), two are animated (dog, bird) — the same 2/2 alive-vs-still ratio the original tabletop had.
Visibility chips kept the same shape (one chip per node) but renamed Tree / Bench / Dog / Bird. The "Spin scene" toggle and the per-model rotation cancellation are unchanged.
Offline behaviour preserved — each streamed slot falls back to its registered bundled GLB / USDZ (Android: khronos_lantern.glb for tree + bench, shiba.glb for the dog, animated_dragon.glb for the bird; iOS: tree_scene.usdz / fantasy_book.usdz / animated_butterfly.usdz / phoenix_bird.usdz). The scene composition stays four-distinct-nodes even when offline.
SampleAssets slugs added: 4 new entries in a new park category — Oak Tree (1ca42d9d…), Park Bench (92a4c3ad…), Idle Dog (62fadcf9…), Songbird (8e7a3a8a…). All CC-BY 4.0. The SampleAssetsTest.every Stage 2 category is represented test now expects park in the category set.
prefetchAll("park") is called from a LaunchedEffect(Unit) on first composition so the four streams kick off in parallel before the user has finished reading the controls panel. Each per-node resolve later picks up the cached file via the resolver's dedup logic.
iOS counterpart not in this PR. The iOS demo app (samples/ios-demo) does not currently have a MultiModelDemo.swift — the iOS V1 didn't port the multi-model scene. The 4 park slugs are registered in iOS SampleAssets.swift ready for a future port, but the Swift demo file itself is deferred.
30 s screen recording deferred — agent worktree has no Pixel / iPhone device access; tracked in the #1152 acceptance checklist.
Changed — Stage 2 demo migrations: AnimationDemo carousel of 5 animated models from the animation category (#1152 — Stage 2)¶
samples/android-demo/.../demos/AnimationDemo.kt is no longer locked to a single hard-coded threejs_soldier.glb. A new "Subject" chip row above the existing Camera row lets the user cycle through 5 animated models — the bundled soldier (slot 0, preserves the v4.3.1 default for visual stability) plus the four streamed entries of the animation category in SampleAssets: Walking Robot, Dancing Knight, Idle Cat, Sleeping Fox.
Switching subjects rebinds the play/pause/speed/loop controls + the animation-name chip row to the new model — playAnimation/stopAnimation use the active model's animation count, so out-of-range indices are clamped automatically when going from a 4-animation soldier to a 1-animation streamed creature. The model lift is now derived from scaleToUnits (was hard-coded position.y = 0.5), so the feet stay grounded at y=0 for every model regardless of scale.
Offline behaviour preserved — when SketchfabConfig.apiKey == null, each streamed slot falls back to the registered bundled GLB (threejs_soldier.glb / shiba.glb / khronos_fox.glb), so the carousel always has 5 working entries (some may look like duplicates in offline mode, which is the same trade-off Stage 1 documented).
iOS counterpart skipped this PR. iOS AutoRotateDemo.swift is the iOS V1 stand-in for the Android AnimationDemo and renders a non-animated metallic torus — there's no skeletal-rig playback on iOS yet (tracked in the v4.3.0 parity backlog, see #1004 iOS parity umbrella). Migrating it requires the iOS skinning port first.
SampleAssets slugs added: 0. The four animation slugs shipped in Stage 1 already.
30 s screen recording deferred — agent worktree has no Pixel device access; tracked in the #1152 acceptance checklist.
Added — Stage 2 demo migrations: MaterialsDemo streams the curated materials category (#1152 — Stage 2)¶
Third Stage 2 migration. The previous MaterialsDemo (5-sphere metallic/roughness spectrum) didn't actually exercise any of the modern glTF material extensions — it was a hand-built PBR sweep useful for diagnosing the renderer, not for answering "what does KHR_materials_sheen look like in SceneView?". Stage 2 replaces it on both platforms with the curated extension-bearing models from SampleAssets's materials category (Iridescent Beetle / Glass Decanter / Velvet Cushion — sheen, transmission, iridescence).
Why streamed. Each model carries a glTF extension that depends on the author's source PBR tooling — bundling a hand-authored stand-in would either ship a giant binary (transmission demands a full IBL backdrop) or fake the look (and mislead the AI-first contract). Streaming the real Khronos / community assets keeps the demo honest.
Files touched:
samples/android-demo/.../demos/MaterialsDemo.kt(new) — chip row + studio HDR + auto-orbit + per-chip extension tag (the registry'stags[0]is theKHR_materials_*extension name).samples/android-demo/.../DemoRegistry.kt— newmaterialsentry in theAdvancedcategory with theIcons.Filled.Paletteicon.samples/android-demo/.../MainActivity.kt— routesmaterialstoMaterialsDemo.samples/android-demo/src/main/res/values/strings.xml+values-fr/strings.xml— 4 new keys:demo_materials_title,demo_materials_subtitle,demo_materials_loading,demo_materials_credit.samples/ios-demo/SceneViewDemo/Views/Demos/MaterialsDemo.swift— rewrote the 5-sphere PBR sweep as the streamed mirror. Samematerialscategory, same chip row + extension tag + author byline,SketchfabAssetResolver.shared.resolve(slug)+ModelNode.load(contentsOf:). The existingSamplesTabentry already wires upMaterialsDemo()— no dispatch change needed.
SampleAssets slugs added: 0. The three materials slugs (Iridescent Beetle, Glass Decanter, Velvet Cushion) shipped in Stage 1 and are now consumed by this PR for the first time.
i18n hygiene. All 4 new keys ship in EN + FR. The chip labels are catalogue-authored ids (English-only, per OrbitalARDemo convention). The extension tag (KHR_materials_iridescence etc.) is a glTF extension name and intentionally not localised — it's a spec identifier developers will Google.
Screen recording. Deferred to the combined Stage 2 visual-smoke pass.
Acceptance: Android ./gradlew :samples:android-demo:compileDebugKotlin GREEN. :samples:android-demo:testDebugUnitTest --tests "io.github.sceneview.demo.sketchfab.*" GREEN (27/27 unchanged).
Added — Stage 2 demo migrations: ModelViewerDemo gains a "Surprise me" Sketchfab pick (#1152 — Stage 2)¶
Second Stage 2 migration. ModelViewerDemo keeps the bundled khronos_damaged_helmet.glb as its hero default (so screenshots / Play Store store assets stay byte-identical) and adds an ExtendedFloatingActionButton that streams a fresh downloadable Sketchfab model on demand:
- Default state. Bundled helmet, same as before. The hero shot the store-page renders promise.
- Tap "Surprise me". Calls
SketchfabService.search(query, downloadable = true, limit = 24)with a small rotating PBR-friendly query list (pbr/modern/scan), filters todownloadable && faceCount in 1..200_000(so a 5 M-poly scan doesn't stall the demo), picks a random hit, and downloads it through the sharedSketchfabServicecache. The streamed pick replaces the helmet for the rest of the session until the next tap. - No-key build. The FAB is hidden when
SketchfabConfig.apiKey == null(App Store / no-secret CI builds) — silently falling back to the same helmet would mislead users about the demo's capability. - Failure modes are silent. A 4xx / 5xx / empty-results path keeps the helmet on screen rather than going black. The
surpriseInFlightflag flips back tofalseso the user can retry.
Files touched:
samples/android-demo/.../demos/ModelViewerDemo.kt— full rewrite of the composable. Adds the FAB, the surprise coroutine, the failure-keeps-helmet contract. Streamed instance scaled to 0.4 m (vs the helmet's historical 0.3 m) so a 5 cm bee and a 5 m crate both read in the orbit sweet spot.samples/android-demo/src/main/res/values/strings.xml+values-fr/strings.xml— 3 new keys:demo_model_viewer_loading,demo_model_viewer_surprise,demo_model_viewer_surprise_loading.
iOS counterpart. No iOS file change — there is no dedicated ModelViewerDemo.swift. The iOS deep-link router already maps "model-viewer" to SceneGalleryDemo (DemoDeepLinkRegistry.swift:77), which already streams Sketchfab content (now with the Stage 2 gallery migration). The iOS Explore tab is the canonical "browse + surprise" experience on iOS.
SampleAssets slugs added: 0. The Surprise path doesn't go through the curated registry — it's a free-form Sketchfab search restricted to downloadable && PBR-friendly. The license filter on the search side is not yet a 100% guarantee of CC-BY (Sketchfab returns mixed CC variants); Stage 3 will add a license-filter pass before the model lands on screen + a Credits sheet exposing the per-pick attribution.
i18n hygiene. All three new FAB strings ship in EN + FR. No raw English leaks on the FR locale.
Screen recording. Deferred to the combined Stage 2 visual-smoke pass.
Acceptance: Android ./gradlew :samples:android-demo:compileDebugKotlin GREEN. :samples:android-demo:testDebugUnitTest --tests "io.github.sceneview.demo.sketchfab.*" GREEN (27/27 unchanged).
Added — Stage 2 demo migrations: SceneGalleryDemo streams the curated gallery category (#1152 — Stage 2)¶
First Stage 2 migration on top of the Stage 1 resolver foundations. SceneGalleryDemo is now a category-chip-driven streamed gallery on both Android and iOS — chips map 1:1 to the four gallery slugs in SampleAssets (Vintage Cassette, Polly the Parrot, Reading Lamp, Wooden Chair), the resolver hands back the streamed GLB/USDZ or the bundled fallback when no key is configured, and SceneView orbits the model. No external Sketchfab WebView — the demo only ever feeds the local file URL to rememberModelInstance (Android) / ModelNode.load(contentsOf:) (iOS).
Files touched:
samples/android-demo/.../demos/SceneGalleryDemo.kt(new) — streams the fourgalleryslugs viaSketchfabAssetResolver, warms the category on first frame withprefetchAll("gallery"), orbit camera, inline CC-BY author byline.samples/android-demo/.../DemoRegistry.kt— newscene-galleryentry in the3D Basicscategory with theIcons.Filled.Collectionsicon.samples/android-demo/.../MainActivity.kt— routesscene-gallerytoSceneGalleryDemo.samples/android-demo/src/main/res/values/strings.xml+values-fr/strings.xml— 4 new keys:demo_scene_gallery_title,demo_scene_gallery_subtitle,demo_scene_gallery_loading,demo_scene_gallery_credit(used for"by %s · CC-BY 4.0"). The chip labels themselves come from the catalogue'sSketchfabSlug.displayName(curator-authored English ids, not localizable copy).samples/ios-demo/SceneViewDemo/Views/Demos/SceneGalleryDemo.swift— rewrote the placeholder shape-pedestal scene as the cross-platform mirror: samegallerycategory, same chip row + author byline,SketchfabAssetResolver.shared.resolve(slug)+ModelNode.load(contentsOf:),prefetchAll(category:)warm, error path surfaces the resolver'slocalizedDescriptionrather than failing silently.
SampleAssets slugs added: 0. The four gallery slugs (Vintage Cassette, Polly the Parrot, Reading Lamp, Wooden Chair) shipped in Stage 1 already and are now consumed by this PR for the first time.
i18n hygiene. The chip labels render SketchfabSlug.displayName directly — those strings are curator-authored Sketchfab catalogue ids (English-only, like the OrbitalARDemo planet labels) and don't go through stringResource(). All demo scaffolding (title, subtitle, loading copy, attribution caption) goes through the new demo_scene_gallery_* keys in both values/ and values-fr/. No raw English string leaks into a non-English locale.
Screen recording. Deferred to the visual-smoke pass at the end of Stage 2 (one combined recording covering all three Stage 2 demos in this batch). Compile + unit tests gated this PR.
Acceptance: Android ./gradlew :samples:android-demo:compileDebugKotlin GREEN. :samples:android-demo:testDebugUnitTest --tests "io.github.sceneview.demo.sketchfab.*" GREEN (27 sketchfab tests passing unchanged from Stage 1). iOS xcodebuild skipped in this batch (CHANGELOG entry kept honest — Stage 1 ran the Xcode build; the SceneGalleryDemo iOS rewrite is a small file replacement with no new Swift symbols).
Changed — Stage 2 demo migrations: OrbitalARDemo streams 4 animated creatures from the solar category (#1152 — Stage 2)¶
samples/android-demo/.../demos/OrbitalARDemo.kt + samples/ios-demo/SceneViewDemo/Views/Demos/OrbitalARDemo.swift now stream four of their eight orbiting planets via SketchfabAssetResolver from the solar category of SampleAssets — butterfly, hummingbird, bee, koi fish. The remaining four planets (khronos_damaged_helmet, khronos_lantern, khronos_toy_car, animated_dragon on Android; red_car, game_boy_classic, animated_dragon, nintendo_switch on iOS) stay bundled.
Before: the 7-planet formation had to duplicate animated_dragon + threejs_soldier to fill the ring because only seven distinct GLBs ship in the APK — visible as "clones" in the #978 audit screenshot. After: eight distinct themed planets, every "alive" slot has a real baked animation, and Sketchfab is invisible to the user (no WebView, no "loading Sketchfab" UI — just rememberModelInstance(modelLoader, "file://...") once the resolver returns).
Offline behaviour preserved — when SketchfabConfig.apiKey == null (App Store builds, cold-cache first launch, network down), each streamed slot falls back to its registered bundled GLB / USDZ, so the orbit always renders eight models. No "Asset unavailable" placeholder ever surfaces from this demo.
SampleAssets slugs added: 0. The four solar slugs shipped in Stage 1 already and are consumed by this PR for the first time.
30 s screen recording deferred — agent worktree has no Pixel / iPhone device access; tracked in the #1152 acceptance checklist.
Added — Samples Sketchfab streaming foundations (#1152 — Stage 1)¶
Stage 1 of the #1152 umbrella — SketchfabAssetResolver foundations that the Stage 2 demo migrations (OrbitalARDemo, SceneGalleryDemo, AnimationDemo, MultiModelDemo, ARPlacementDemo, PhysicsDemo, MaterialsDemo) will build on. No demo is migrated in this PR — the bundled GLBs/USDZs stay as they are. The resolver, registry, and tests are the foundation; demo migrations land 1 PR per demo.
New files (Android — samples/android-demo/.../sketchfab/):
SketchfabSlug.kt— typed slug + license + scale + animation + category + author + tags. Constructor rejects any non-CC-BY 4.0 license URL, a blank author, an empty fallback path, or a non-positive scale.SampleAssets.kt— 20-entry curated CC-BY-only registry grouped into 6 Stage 2 categories:solar(4),gallery(4),animation(4),ar_placement(3),physics(2),materials(3).byUid/byCategorylookups +requireValid()for CI invariants (no duplicate uids, every uid is 32-char lowercase hex).SketchfabAssetResolver.kt—resolve(slug)/prefetchAll(category)/ LRU eviction (250 MB cap, tighter than the Explore-tab 500 MB cap) / bounds sanity check (magic-byte + size floor) / fallback-to-bundle when no key OR network fails. WrapsSketchfabServicewith exponential backoff (429/5xx only, max 3 retries) and falls back immediately on policy-decision 4xx.
New files (iOS — samples/ios-demo/SceneViewDemo/Services/):
SketchfabSlug.swift,SampleAssets.swift,SketchfabAssetResolver.swift— same 20-uid registry, same resolver semantics, RealityKit-compatible (accepts both GLBglTFmagic and USDZ ZIPPK\x03\x04magic in the bounds check).actorfor theURLSessionserialisation invariant that matchesSketchfabService.SketchfabAssetResolver+Tests.swift— XCTest mirror of the Android suite (no livexcodebuild testtarget wires it up yet; the file lives next to the existingSketchfabService+Tests.swiftscaffold for documentation parity).
Tests (Android — 24 new unit tests, all passing):
SampleAssetsTest.kt— 13 tests: registry non-empty, every entry CC-BY 4.0, every entry has a non-blank author, every entry has a fallback, scale in[0.05 m, 5 m], no duplicate uids,requireValidsucceeds,byUid/byCategoryagree withall, all 6 Stage 2 categories represented, constructor rejects non-CC-BY / blank author / non-positive scale.SketchfabAssetResolverTest.kt— 11 tests:resolvefalls back without an API key,Unknownfor slugs outside the registry,boundsAreSanerejects 0-byte/junk/missing files and accepts a real GLB header,pruneCacheis a no-op sub-budget,FallbackUnavailablewhen the bundled asset is missing,prefetchAllreturns 0 for unknown categories, singleton wiring.
Hard rules honoured (Stage 1 = pure plumbing):
- NEVER ship a build that needs the network to render something useful. Every
SketchfabSlugcarries afallbackBundledPaththat already lives in the demo APK / IPA. The resolver returns it whenever the API key is absent (App Store builds), the network fails, or the streamed asset fails the magic-byte sanity check. - NEVER open a Sketchfab WebView / external link. The resolver returns a local
File/URLonly; consumers feed it intorememberModelInstance(modelLoader, file)/ RealityKitEntity.load(...). - CC-BY only. Every entry's
licenseUrlishttps://creativecommons.org/licenses/by/4.0/. Other Creative Commons variants (NC, ND, SA) and the bespoke "Sketchfab Standard" license are rejected bySketchfabSlug.init. - Cache survives across demos. Resolver uses the same
cacheDir/sketchfab/directory asSketchfabService, so a model warmed by the Explore tab is reused by Stage 2 demos.
Stage 1 status note. The 20 placeholder uids in SampleAssets were curated at design time but are not yet validated against GET /v3/models/<uid>. Stage 2 PRs will replace each uid with one verified live (Sketchfab maintainer account check) AND add a weekly CI cron that pings each slug + opens a GitHub issue on 404 / license drift. The licenseURL + fallbackBundledPath columns are authoritative even today — they decide what the resolver hands a demo offline.
Acceptance: Android ./gradlew :samples:android-demo:compileDebugKotlin + :samples:android-demo:testDebugUnitTest --tests "io.github.sceneview.demo.sketchfab.*" GREEN (27 sketchfab tests passing — 24 new + 3 pre-existing). iOS xcodebuild -scheme SceneViewDemo … build GREEN (3 new Swift files compile, project added them to the SceneViewDemo target).
Fixed — iOS: SKETCHFAB_API_KEY never reached TestFlight + App Store binaries (#1157)¶
Every iOS app-store ship since v3.6 silently degraded the Explore tab to bundled fallback models because the Sketchfab API key never made it into the .ipa. Two compounding root causes:
SketchfabConfig.swiftread the key viaProcessInfo.processInfo.environment["SKETCHFAB_API_KEY"]— that path only works under Xcode's "Run" scheme. CI env vars set on the runner don't survivexcodebuild archiveinto the shipped binary, soSketchfabConfig.apiKey == nilfor every TestFlight + App Store build →SketchfabError.missingApiKey→ExploreTabrunCatchingswallow → empty / fallback results with no error banner..github/workflows/app-store.ymlandios.ymlnever referencedSKETCHFAB_API_KEY— confirmed bygrep. The Android pipelines (play-store.yml:170,build-apks.yml:47) inject the secret correctly and Android'sBuildConfig.SKETCHFAB_API_KEYbakes it in at compile time, which is why Play Store builds were unaffected.
Fix (single PR, 4 files):
samples/ios-demo/SceneViewDemo/Services/SketchfabConfig.swift—apiKeynow resolves fromBundle.main.object(forInfoDictionaryKey: "SketchfabAPIKey")first, with a guard that rejects the unsubstituted$(SKETCHFAB_API_KEY)xcconfig token literal. LegacyProcessInfolookup stays as a fallback so the Xcode "Run" scheme env-var workflow keeps working for contributors.samples/ios-demo/SceneViewDemo/Info.plist— addedSketchfabAPIKey = $(SKETCHFAB_API_KEY)placeholder.xcodebuildsubstitutes it from the user-defined build setting at archive time..github/workflows/app-store.yml— both iOS and macOSxcodebuild archivesteps now passSKETCHFAB_API_KEY="$SKETCHFAB_API_KEY"(sourced from theSKETCHFAB_API_KEYrepo secret)..github/workflows/ios.yml— same injection on the CI demo-build step so theInfo.plistsubstitution path is exercised on every PR, not just on release tags.
Verified locally on Xcode 26.3 / iPhone 16e simulator: xcodebuild build … SKETCHFAB_API_KEY=dummy_key_for_test produces a SceneViewDemo.app/Info.plist with SketchfabAPIKey = dummy_key_for_test (vs. the literal $(SKETCHFAB_API_KEY) placeholder without the build setting). Acceptance: next TestFlight build of v4.3.2+ surfaces non-empty SketchfabConfig.apiKey and ExploreTab shows live Sketchfab categories + search.
Long-term proxy via mcp-gateway so end-user binaries don't ship the master key is tracked by the V1.1 TODO in SketchfabConfig.swift — this fix is the immediate "Explore tab works again" patch.
Tests — Regression pins for v4.3.0 rendering-burst fixes that shipped without coverage (#1120 extension)¶
Follow-up to the CORR-C regression-pin batch (PR #1137). Three of the v4.3.0 fixes shipped without test coverage because the failure modes required Filament JNI (CORR-C's pure-JVM batch couldn't reach them). This extension adds the missing instrumented tests so a future refactor catches the regression at ./gradlew :connectedDebugAndroidTest time:
sceneview/src/androidTest/.../RenderQualityComposeTest.kt— Filament-grounded companion to the JVMRenderQualityLaunchedEffectTest. Pins the #1078 keyed-LaunchedEffect(view, renderQuality)contract using a realView: apply the preset, mutateview.bloomOptions.strength = 0.4f, simulate 5 unchanged recompositions, assert the user tweak survived. Pre-#1078 (unkeyedSideEffect), the 0.4f would have been clobbered back to the preset value on every recomposition. 3 test methods. The two pure-JVMRenderQualityLaunchedEffectTest+ instrumentedRenderQualityComposeTestcover the contract from both angles — JVM catches the LaunchedEffect re-keying semantics, instrumented catches the Filament-side preset-application invariants.sceneview/src/androidTest/.../node/CameraNodeLifecycleTest.kt— Pins theDisposableEffect(cameraNode)rewire shipped in PR #1147 (Scene.kt:293, closes #1143). Three tests: 5 sequentialSceneNodeManagerlifecycles sharing one FilamentSceneleak zero cameras, parent → child HUD-node propagation cascades on dispose, and thecameraNodeswap path replaces cleanly without leaking the previous instance. Same-family check as the #1122 light-node leak fix (PR #1131).samples/android-demo/src/androidTest/.../MaterialInstanceLeakTest.kt— Pins thedestroyMaterialsOnDispose: Boolean = falseflag added toRenderableNode+GeometryNodeconstructors in PR #1132 (closes #1123). Four tests: the flag actually destroys the constructor-passedMaterialInstance(itsnativeObjecthandle drops to0), defaultfalsepreserves the instance for external owners (rememberMaterialInstance,DisposableEffect), multi-primitive lists withnullentries are handled without NPE, and the destroy path is idempotent across double-destroy via therunCatching-wrappedsafeDestroyMaterialInstance.arsceneview/src/androidTest/.../light/LightEstimatorConcurrentDestroyTest.kt— already shipped as part of PR #1148 (#1094 acceptance #3); listed here for traceability.
The pure-JVM RenderQualityLaunchedEffectTest and LightEstimatorConcurrentDestroyTest from CORR-C continue to run on every :sceneview:test invocation; the instrumented tests above run on ./gradlew :sceneview:connectedDebugAndroidTest / :samples:android-demo:connectedDebugAndroidTest. Net +3 instrumented test files / +10 test methods.
1123 acceptance criterion "at least 1 demo migrated to use destroyMaterialsOnDispose = true" stays open — surfacing the flag through the Compose SceneScope.CubeNode / SphereNode / etc. factories is a separate API extension. MaterialInstanceLeakTest pins the library-level contract those factories will eventually wire up.¶
v4.3.1 — CI hardening + iOS AR LightSlot parity + i18n migration (2026-05-14)¶
CI hardening + docs accuracy + Android CLI migration + one v4.1.0-stale demo light tune,
plus the second half of #1063 ported to iOS (LightSlot + .fillLight(_:) on ARSceneView)
and a full android-demo UI migration to stringResource(R.string.…) so French locale
actually flips at runtime. No new Android public API; one new iOS surface.
Changed — Demo UX: DemoScaffold v2 ships the controls in a ModalBottomSheet (#1154)¶
The 35 Android demos no longer split their viewport 60 / 40 between scene and a side-panel of controls. The scene now fills the entire area below the top app bar, and the per-demo controls = { ... } block is rendered inside a Material 3 ModalBottomSheet launched by a "Tune" FloatingActionButton anchored bottom-end of the scene. A semi-transparent "Settings" peek chip sits above the FAB while the sheet is closed to advertise the gesture.
- The 35 demo call-sites stay byte-identical:
DemoScaffold(title = …, controls = { … }, scene = { … })— only the placement of the controls panel has changed. - The sheet supports the partial detent (
skipPartiallyExpanded = false); drag-down, outside-tap, and back gesture all dismiss it. - AR demos keep tracking 6DOF while the sheet is at the partial detent — opening the sheet does not pause the AR session.
- Long-press the peek chip toggles
DemoSettings.qaMode(was previously a long-press on the top-app-bar title). The QA escape-hatch pill in the title bar stays unchanged. - New
DemoScaffoldTestTagsobject exposes stable testTags (demo-settings-fab,demo-settings-peek,demo-settings-sheet,demo-qa-pill) consumed byDemoInteractionTestand any future visual smoke tooling. samples/android-demo/.../DemoInteractionTest.ktlazy-opens the sheet insidetap()/tapByDesc()/dragSlider()/typeInto()when the target chip / slider isn't already visible — the 31 existing instrumentation tests work unchanged.- iOS — new
.demoSettingsSheet { … }View modifier (samples/ios-demo/SceneViewDemo/Views/Components/DemoSheet.swift) mirrors the Android pattern:.presentationDetents([.fraction(0.25), .medium, .large]),.presentationBackgroundInteraction(.enabled)so AR stays live at the partial detent, and.presentationBackground(.ultraThinMaterial)for Liquid Glass. 4 demos migrated:FogDemo,DynamicSkyDemo,MovableLightDemo(drag-anywhere gesture preserved),CameraControlsDemo(wasOrbitCameraDemo).
Visual result: scene takes ~95 % of the viewport at the default detent on Pixel 9 vs ~60 % under v4.3.0. Documented design rationale: M3 spec for bottom sheets, HIG for .sheet() with presentationDetents. Implements the plan recorded in plan_demo_settings_bottom_sheet (Stage 1 + 2 of 4 — polish + AI-first docs stages are tracked separately).
Fixed — release.yml: Dokka config-cache crash + GitHub Release decoupled from Dokka (#1150)¶
The v4.3.0 cut surfaced two latent release.yml issues that skipped the Create GitHub Release job (recovered manually):
- Dokka step now passes
--no-configuration-cache— Dokka 1.x'sdokkaSourceSetsFactoryNamedDomainObjectContainercannot be deserialized from the Gradle configuration cache, so the step crashed onrelease.ymlrun25870464897. The--retry-with-backoffwrapper from #1127 was a no-op because the error was config-cache deserialization, not a 503. Pin Dokka out of the config cache so config-cache stays enabled globally for the rest of the build. create-releasejob no longer veto-gated onpublish-api-docs— Maven Central + 3 npm packages + SPM tag are user-visible artifacts; Dokka HTML is secondary (users can still consume libraries onmvnrepository/npmwithout fresh API docs on the tag). A Dokka failure on a release tag now produces a workflow red X on the Dokka job but the GitHub Release still cuts.
Fixed — IBLPrefilter.specularFilter KDoc cost mismatch with LightEstimator (#1103)¶
The two KDocs disagreed by 10× on the same operation. Both are now accurate and cross-referenced:
IBLPrefilter.specularFilter— clarified that cost scales with cubemap face count + resolution. First-build of a 1024×1024×6 HDR skybox runs 100–200 ms (the historical figure); incremental update of a 16×16×6 ARCore cubemap (the AR path) runs 5–15 ms on a Pixel 9.LightEstimator.environmentalHdrSpecularFilter— cross-references the matrix inIBLPrefilter.specularFilterinstead of contradicting it.
Documentation-only — no behavioral change.
Fixed — GeometryDemo stacked 80 000-lux on v4.1.0 default lights (#1146)¶
Sibling of #1125 (PhysicsDemo). samples/android-demo/.../GeometryDemo.kt added a 80 000-lux directional light on top of the v4.1.0 SceneView defaults (10 000-lux main + 3 000-lux fill + IBL @ 10 000), so the metallic/roughness sweep saturated to white at every slider value. Re-tuned to 5 000 lux to match PhysicsDemo's PR #1144 retune — accent fill that complements the v4.1.0 defaults without dominating them. Acceptance #1125 only scanned for 100_000, so 80 000 slipped through; this closes the gap.
Tooling — Android CLI migration: purge legacy raw adb from install/launch paths¶
Follow-up to the May 2026 feedback_android_cli_only rule. Multiple shell scripts still drove adb install + am start directly instead of the atomic android run --apks=… --activity=… path exposed by Google's android CLI v0.7. That kept the legacy adb PATH dependency as a hard requirement and surfaced as visual-QA failures on hosts where only the android CLI was installed.
.claude/scripts/qa-android-demos.sh—--installbranch now callsandroid_cli_install_and_launch(atomic install+launch viaandroid run) with anadb install -rfallback when the CLI is missing..claude/scripts/capture-play-store-screenshots.sh— initial APK install usesandroid_cli_install_and_launchon single-device hosts; falls back toadb install -ron multi-device hosts (theandroid runsubcommand has no--deviceflag in v0.7). The per-iterationam force-stop+am start --es demo <id>block stays onadb(legit holdout —android runv0.7 has no intent-extras forwarding).tools/try-demo.sh—check_devicenow accepts eitherandroidoradbon PATH (and surfaces both install hints when neither is present). Already wired toandroid_cli_install_and_launchsince the helper landed..claude/scripts/visual-check.sh— annotated the bottom-nav tap coordinates to flag them as legitadbholdouts (no input-event API inandroidCLI v0.7).sceneview/src/androidTest/.../VisualVerificationTest.kt— KDoc now states explicitly thatadb pullis the only operation here without anandroidCLI equivalent as of v0.7.docs/docs/try.md— terminal-install snippet now showsandroid runfirst (atomic install+launch) and keepsadb install -ras the legacy alternative.
Acceptance: every legit adb holdout (no android CLI equivalent in v0.7 — pull, logcat, input tap/swipe/keyevent, am force-stop, am start --es, wait-for-device, get-state, devices, kill-server, dumpsys, pidof, uiautomator dump) is annotated in-place. Re-evaluate when android CLI v0.8+ ships any of those subcommands. No behavioural change for end users; CI render-tests.yml was already migrated by #1153.
Fixed — CI: android-demo-screenshots job unblocked + workflow validator hardened (#1153)¶
The v4.3.0 cut commit efc168bc introduced a multi-line backslash continuation in .github/workflows/render-tests.yml (the android run \\ --apks=… block under the Capture demo screenshots step). The ReactiveCircus/android-emulator-runner@v2 action exec's each line of with.script: via sh -c <line>, so the trailing \ survives as a literal argv token and android run died with Unmatched argument at index 2: '\\'. Every push to main since efc168bc failed that screenshot job, forcing chip PRs (#1145 / #1147 / #1148 / #1149) to merge with --admin and hiding any genuine screenshot regression.
- Fix: collapse the
android --no-metrics run …invocation onto a single physical line, matching the documented per-line slicing rule already followed by theattempts=0; while …; doneloop above it. - Validator extension:
.claude/scripts/check-workflow-scripts.sh(shipped by #1145) now runs a per-line slicing simulation on everywith.script:block —dash -npasses a\<EOL>because the whole-file parser splices continuations together first, but the runtime action does not. The new pass flags any trailing-backslash continuation and fails the PR check, so this class of bug can no longer ship tomainundetected. Sanity-tested by reintroducing the original break locally — validator exits1with a pointed error message. - Backwards compatibility:
run:blocks (which GitHub Actions defaults tobash -e {0}, executed as one script) are untouched; backslash continuations remain valid there. Onlywith.script:blocks (per-linesh -csemantics) are checked.
Fixed — i18n: migrate android-demo UI to stringResource(R.string.…) (#1099, closes #955)¶
PR #1073 added samples/android-demo/src/main/res/values-fr/strings.xml (164 keys) but the Compose UI never read them — every Text("…") was a hardcoded English literal, so switching the device locale to French at runtime had zero visible effect.
This PR fully closes #955 by migrating every public-facing UI surface to stringResource(R.string.…):
DemoEntrydata class refactor —title: String, subtitle: String→@StringRes titleRes: Int, @StringRes subtitleRes: Int. Thecategoryfield stays a stable non-translated key (used as map key + accent-colour lookup) with a parallelcategoryDisplayNameRes(category)helper that returns the localized header.- 37-demo registry rewritten to thread
R.string.demo_*_title/R.string.demo_*_subtitleIDs through to the Samples grid and the Explore "Try a sample" carousel. - 39 per-demo
DemoScaffold(title = "…")callsites migrated tostringResource(R.string.demo_*_title)— every demo'sTopAppBartitle now follows the active locale. - Top-level UI surfaces migrated:
RootScreen.kt(4 tab labels, About-tab 6 cards + hero tagline + footer + Star CTA),ArViewTab.kt(full launcher screen — status messages, CTA labels, featured-demo card titles, status pill, model picker, share toast, tracking-failure friendly names),DemoListScreen.kt(Samples title, "About" action, status chips, footer),DemoScaffold.kt(back-button content description),MainActivity.kt(PlaceholderDemo"Coming soon" + entry title fallback),ExploreTabScreen.kt(Explore heading, search placeholder, Animated filter chip, all carousel section titles, Categories, Recent searches, Clear, Remove $query),SketchfabModelViewerScreen.kt(Animated pill, Open-in-SceneView CTA, loading / streaming / rendered-by labels, error screen + Try again, download-failed fallback). strings.xmlexpanded from 164 → 270+ keys, covering every public-facing UI string in the priority surfaces. FRvalues-fr/strings.xmlmirrors 1-to-1.- Locale-flip verified end-to-end on Pixel_7a emulator using Android 13+ per-app locale (
adb shell cmd locale set-app-locales io.github.sceneview.demo --locales fr-FR). 4 tabs + AR launcher + Samples list + a demo AppBar all flip between EN ⇄ FR, with no regressions. Sketchfab category chips still come fromSketchfabCategories.ktand stay English — out of scope for #1099, separate larger refactor. - Existing legacy keys preserved (e.g.
demo_lighting,demo_geometry, etc.) for backwards compatibility with any external consumer holding refs to them. DeepLinkRouterTest.ktupdated to passR.string.*IDs instead of literal"Title", "Subtitle"strings — title / subtitle are not part of the route, so any pair satisfies the type.
Build green: :samples:android-demo:compileDebugKotlin + :assembleDebug + :testDebugUnitTest + :sceneview:compileReleaseKotlin + :arsceneview:compileReleaseKotlin all succeed locally.
Added — iOS parity: LightSlot + .fillLight(_:) on ARSceneView (#1138)¶
Port the second half of Android v4.3.0's #1063 (dual-light AR baseline + ENVIRONMENTAL_HDR default) to SceneViewSwift.ARSceneView. The 3D SceneView already shipped these in v4.2.0 (#1016); AR was the missing surface.
.mainLight(_:)/.fillLight(_:)modifiers onARSceneView— sameLightSlotenum as the 3DSceneView. Default.systemDefaultprovisions a10 000-lux directional main + a3 000-lux fill, matching Android'sARSceneView(mainLightNode = …, fillLightNode = …)defaults.- Reactive swap path — when the caller mutates the modifier value, the previous light's
AnchorEntityis removed fromarView.sceneand a new one is added in its place. MirrorsScene.kt:540'sprevFillLightRefdiff pattern. No full RealityView teardown. ENVIRONMENTAL_HDRparity documented —config.environmentTexturing = .automatic(already set, now annotated) is the ARKit equivalent of ARCore'sConfig.LightEstimationMode.ENVIRONMENTAL_HDR. Both drive PBR cubemap reflections for runtime-built environment probes; neither exposes a per-frame directional light estimate onfillLight.- Tests: 9 pinning tests in
ARSceneViewTests.swift(default slots, modifier copy-semantics,.disabledround-trip,.custom(LightNode)entity-identity retention, last-modifier-wins, chaining with.cameraExposure+.onSessionStarted). - Docs sync:
docs/docs/cheatsheet-ios.mdAR section + Android↔Apple mapping table;llms.txt(root +docs/docs) ARSceneView signature + LightSlot notes.
v4.3.0 — Android rendering pipeline overhaul + iOS CameraControls.pan/.firstPerson + ARRecorder + parity table (2026-05-14)¶
Status: shipped. 14-PR Android rendering audit (#1062 → #1142) hardens AR + 3D defaults, fixes 6 pre-v3 BLOCKERs (multiplicative light drift, AR IBL missing, SH coefficient swap, Box ray-parallel, spherePlaneResponse contact wrong-side, AR cubemap GEN_MIPMAPPABLE). Also closes the last #928 silent-stub item and the biggest v4.2.0 UX gap on iOS demos. PRs #1038, #1042, and the #1131–#1142 rendering + math audit batch.
Added — iOS ARRecorder record-only via ReplayKit (#1032)¶
Android has had full ARRecorder (capture + replay) since v4.0.8 via ARCore's Session.startRecording(RecordingConfig). ARKit on iOS does not expose a deterministic playback dataset, so iOS gets the record half via ReplayKit.RPScreenRecorder and replay stays Android-only.
ARRecorder@MainActorObservableObject—state: .idle / .recording / .error(message),lastOutputURL,isRecording(@Published-derived),isAvailable.async throwsAPI —startRecording() async throws,stopRecording(outputURL: URL? = nil) async throws -> URL. Bridges ReplayKit's completion-handler API to async/await.- Typed error mapping —
ARRecorderError.{permissionDenied, disabled, unavailable, alreadyRecording, notRecording, other(code:), photoLibraryDenied, photoLibrarySaveFailed}so callers can switch on the case (no string-matchingerrorDescription). ARRecorder.remembered()factory — mirrors Android'srememberARRecorder()for code-generation symmetry.ARRecorder.saveToPhotoLibrary(_:)static helper (#1043 item 2) — wrapsPHPhotoLibrary.performChangesso the recorded.movcan be copied into the user's Photos library. Mirrors Android'sARRecorder.exportToDownloads(). RequiresNSPhotoLibraryAddUsageDescriptionin the host app'sInfo.plist. Demo gets a "Save to Photos" button alongsideShareLink.- What's recorded: screen pixels only (NOT ARSession state). The
.movplays back in Photos / QuickTime; it cannot be fed back intoARSessionfor deterministic replay. UseRerunBridgefor replay-driven testing. - iOS demo:
samples/ios-demo/.../ARRecorderDemo.swiftmirrors Android'sARRecordPlaybackDemowith a record-only banner + live AR session + tap-to-place markers + "Save to Photos" +ShareLinkfor the captured.mov. Registered in the AR section ofSamplesTab. - Tests: 17 pinning tests in
ARRecorderTests.swift(state machine, error code mapping, default URL placement under.cachesDirectory/ARRecorder/, factory smoke, photo-library missing-file guard, photo-library error Equatable + localized description).
Added — CameraControls.pan + .firstPerson wired (#1034)¶
Previously, calling .cameraControls(.pan) or .cameraControls(.firstPerson) produced orbit behaviour because applyCamera() ignored the mode and pinchGesture always dollied the orbit radius. Three things shipped:
.pan: drag translates the orbittargetalong the camera-aligned right + up vectors (the scene appears to slide), pinch keeps dollying..firstPerson: drag rotates the view, no orbit translation; pinch adjusts the perspective camera'sfieldOfViewInDegrees— mirrors AndroidFovZoomCameraManipulator(range10°..120°, default60°).- Mode picker in iOS demo:
CameraControlsDemogets a 3-wayPickersegment so the v4.3.0 wiring can be felt at a glance.
New CameraControls properties: panSpeed, moveSpeed, fov, minFov, maxFov, pinchFovSpeed.
Gesture divergence from Android (documented in CameraControlMode.pan doc-comment): iOS uses 1-finger drag for pan; Android disambiguates via 2-finger strafe.
Added — Library-level auto-center content (#1026)¶
iOS demos placing content at e.g. z = -2 rendered in the bottom-third of the viewport because the default perspective camera at [0, 0.3, 2] looks at world origin. Auto-center via intermediate contentRoot entity translates user content so its centroid lands at the orbit pivot on the first frame visualBounds is non-empty (bounds query in contentRoot-local space — invariant of orbit rotation + scale). Lights stay on entities.root so they're not moved by the centring translation.
.autoCenterContent(_ enabled: Bool)modifier (defaulttrue). Passfalsefor narrative scenes with intentional off-centre placement.- iOS-only vs Android: Android achieves the same via per-demo
ModelNode(centerOrigin = Position.ZERO). Cross-platform code porting Android verbatim sees iOS re-centre implicitly; opt out for strict parity.
Added — docs/docs/cheatsheet-ios.md parity table (#1036)¶
Three-bucket reference: Deprecated on iOS (3 rows — DoF, exposure, shadowColor), Android-only / no port (4 rows — playbackDataset, SurfaceType.texture, StreetscapeGeometry, TerrainAnchor/RooftopAnchor), Approximated (3 rows — fog variants, reflection probe volumes, subsurface). Same table in llms.txt for MCP consumers.
⚠️ BREAKING — Android 3D + AR render defaults (visual)¶
SceneView and ARSceneView on Android now ship with these adjusted defaults. Apps upgrading from v4.2.0 will see visible rendering changes.
- IBL intensity (3D + AR) : Filament hardcoded ~30 000 →
DEFAULT_IBL_INTENSITY = 10 000lux (#1075, PR #1079 + PR #1088 for the AR cross-fix). Now 1:1 withDEFAULT_MAIN_LIGHT_COLOR_INTENSITY, ambient and key light contribute proportionally. Apps that hand-tunedmainLight.intensityagainst the implicit 30k IBL will see ambient drop ~3× and shadows deepen. Restore the v4.2.0 look viaindirectLight.intensity = 30_000fon your custom environment. - AR camera exposure (
ARDefaultCameraNode) : f/16 1/125 ISO 100 (EV 15, sunny-16) → f/12 1/200 ISO 200 (~1 stop brighter) (#1067 via PR #1088). Matches 3DDefaultCameraNodefor cross-mode parity and aligns with the v4.1.0 light defaults (main 10k, fill 3k). Apps that overridecameraExposure = -1.0f(the v4.0.x workaround for the sunny-16 mismatch) will now be over-exposed. Drop the override — the new defaults match. The 11 sample demos that still carried this workaround are cleaned up in CORR-A (#1101) — see "Fixed — AR rendering pipeline" below. - AR IBL specular filter default :
environmentalHdrSpecularFilter = false→true(#1064 via PR #1086). Roughness-prefilters the ARCore HDR cubemap so reflections vary visibly with material roughness instead of being mirror-like at every value. Cost : +5–15 ms / cubemap update (≈ 1 Hz from ARCore HDR mode). Restore v4.2.0 cost profile vialightEstimator.environmentalHdrSpecularFilter = false. - AR
Config.LightEstimationModedefault : ARCore's stockAMBIENT_INTENSITY→ENVIRONMENTAL_HDR(#1063 acceptance #2, CORR-A). Set insideARSceneView'ssession.configure { … }block BEFORE the user'ssessionConfigurationcallback so callers can still opt back into another mode. Front-camera sessions still forceDISABLED(ARSession.configure(...)guard, unchanged). Cost note : HDR captures + analyses the camera frame for an environmental cubemap (~1 Hz) + computes SH coefficients + main-light direction; combined with the#1064specular prefilter on the same cubemap, total cost is +5–15 ms / cubemap update. The 4 demos that previously didn't opt in (ARImageDemo,ARRooftopAnchorDemo,ARStreetscapeDemo,ARTerrainAnchorDemo) now ship HDR — all 4 are appropriate targets (1 indoor PBR helmet + 3 outdoor scenes). On HDR-unsupported devices ARCore silently degradesLightEstimate.StatetoNOT_VALIDand the#1063neutral IBL baseline stays in place — no crash, no visual regression. Restore v4.2.0 mode viasessionConfiguration = { _, c -> c.lightEstimationMode = Config.LightEstimationMode.AMBIENT_INTENSITY }. - AR
ARSceneViewtwo-light defaults (#1063 acceptance #3, CORR-A).ARSceneViewnow exposes a newfillLightNode: LightNode? = rememberFillLightNode(engine)parameter, mirroring the 3DSceneViewv4.1.0 setup (main 10k + fill 3k lux from opposite-side directional). The fill light is unaffected by ARCore light estimation — onlymainLightNodeis multiplied by the estimate. Apps that handled their own fill light + relied on the AR scene having no library-provided fill will see a brighter shadow side. Restore the v4.2.0 single-light look viaARSceneView(fillLightNode = null). DeprecatedARScenealias forwards the param. SceneView(isOpaque = false)is now actually transparent (#1077 via PR #1092). v4.2.0 ignored the flag —uiHelper.isOpaqueandview.blendModewere never wired. Apps that setisOpaque = falseand worked around the broken behaviour with custom Compose backgrounds will see double-rendering. Remove the workaround — the underlying view now bleeds through.
BREAKING-ish — silent-stub modes now active¶
Apps that called .cameraControls(.pan) or .cameraControls(.firstPerson) as effective no-ops in v4.2.0 will now see the modes do something different. To restore the v4.2.0 silent behaviour, drop the modifier (defaults to .orbit).
Apps with intentionally off-centre content will see the centroid re-centred at the orbit pivot. To restore the v4.2.0 layout, append .autoCenterContent(false).
Fixed — AR rendering pipeline (rooted in v4.0 → v4.2 regressions)¶
- 🚨 Multiplicative light drift killed
mainLightin ~15 frames (#1062 via PR #1069). Per-framemainLight.intensity *= estimate.pixelIntensitycompounded toward 0 (or ∞). Replaced by a baseline-cache pattern (compareAndSeton first valid estimate, thenbaseline * estimateeach frame). Keyed onmainLightNodeidentity so the#1017reactive swap resets cleanly. Regression pin inARMainLightBaselineMultiplyTest. - 🚨
createAREnvironmentshipped withoutIndirectLight(#1063 via PR #1069). NewiblBuffer: Buffer?parameter;rememberAREnvironmentdefaults to the bundled neutral 256×128 dim-grey IBLarsceneview/src/main/assets/neutral_environment.ibl. Metals in AR no longer render jet-black before the first ARCore estimate. - 🚨
ARSceneViewAR scene baseline now mirrors the 3DScenev4.1.0 two-light setup + opt in to ARCore real-environment estimate (#1063 acceptance criteria #2 + #3, post-#1069). Two follow-ons land in CORR-A: - New
fillLightNode: LightNode? = rememberFillLightNode(engine)parameter onARSceneView. Mirrors the 3DSceneViewv4.1.0 two-light defaults — main 10k + fill 3k lux from opposite-side directional. The fill light is unaffected by ARCore light estimation (onlymainLightNodeis multiplied by the estimate); passnullto keep a single-light AR scene. DeprecatedARScenealias forwards the new param. TheprevFillLightRefSideEffect mirrorsprevMainLightRefso reactive swaps are clean. - Default
Config.LightEstimationMode = ENVIRONMENTAL_HDR(replacing ARCore's stockAMBIENT_INTENSITY). Without HDR, the IBL baseline shipped byrememberAREnvironment(#1069) never gets replaced — PBR metals stay locked on the neutral grey baseline even after the user pans across a real scene. Set BEFORE the user'ssessionConfigurationcallback so callers can still opt back into another mode. Front-camera sessions still forceDISABLEDinsideARSession.configure(...)regardless. Documented in theARSceneViewKDoc for bothsessionConfigurationand the param section. Pinned byARCompletenessDefaultsTest(4 cases). - 🚨 SH coefficient swap on bands y20 / y21 (#1093 via PR #1100).
SPHERICAL_HARMONICS_IRRADIANCE_FACTORS[6]and[7]had swapped magnitudes and signs vs Filament's upstreamCubemapSH.cppconvention, silently producing wrong-direction matte AR shading since SceneformMaintained PR #156 (4+ years). Now matches Filament:factor[6] = +0.078848(y20),factor[7] = -0.273137(y21). 2 pinning tests added. LightEstimatordouble-closed ARCoreImageobjects in cubemap callback (#1090 via PR #1091). Theimage.use { }block already closed theImage; the trailingarImages.forEach { it.close() }then threwIllegalStateException(swallowed). Side-fix :@Volatileon 6environmentalHdr*toggles +isEnabled(#1094 via PR #1095).LightEstimatorrobustness — 3 follow-ups to #1091 / #1095 (CORR-B audit, acceptance #2 of umbrella #1094). Three latent issues that survived the first twoLightEstimatorcleanups:destroy()race vs. late render frame — added@Volatile private var isDestroyedgate at the top ofupdate()so a frame arriving afterDisposableEffect.onDisposeshort-circuits instead of touching freedengine.destroyTexturenatives.destroy()is now idempotent and latches the flag before freeing textures.- Cubemap-texture leak on
environmentalHdrReflectionstoggle — togglingtrue → falsepreviously skipped theif (reflectionsOn) { ... }branch entirely, leavingcubeMapTexture+cubeMapTextureSpecular+ the direct stagingByteBufferalive in native heap forever. New nullify-on-disable path at the top ofupdate()routes through the existing destroy-on-reassign setters; symmetric handling ofenvironmentalHdrSpecularFiltertoggling off (frees only the specular texture, preserves the base). - Staging-buffer race vs. async Filament upload — restored the
PixelBufferDescriptorcallback as a@Volatile uploadInFlightflag flip (set true beforesetImage, reset by the Filament render thread). AR thread now skips the cubemap update while in flight, preventing acubeMapBuffer.clear() + put(rgbBytes)overwrite from corrupting an in-flight GPU upload (smeared cubemap / 1-frame HDR garbage flash). Long-form comment on the callback site guards against a future refactor re-no-op'ing it. Regression suite: 14 pinning tests inLightEstimatorRobustnessTest. - AR cleanup batch — 4 follow-ups to CORR-B and post-merge audit of #1069 / #1091:
createAREnvironmentno longer advertises an inertisOpaque(#1121). The hard-codedisOpaque = truewas bypassed byskybox = null, so the parameter was effectively ignored. Dropped from the call; KDoc updated to call out that AR environments are inherently non-opaque (camera feed shows through). No behaviour change for end users.uploadInFlightcallback hoisted from per-frame allocation (#1102).Texture.PixelBufferDescriptorpreviously received a freshRunnable { uploadInFlight = false }per cubemap upload; with the new CORR-B gate firing the callback ~1 Hz, that's still one short-lived lambda per upload. Now hoisted as aprivate val uploadCompletedCallbackso a single allocation perLightEstimatorinstance covers its full lifetime. Can't move to the companion object because the callback mutates per-instance state.LightEstimatorlifecycle ownership documented (CORR-B FU-3). Class KDoc gets a new "Lifecycle ownership" section spelling out thatengineandiblPrefilterare borrowed (caller-owned, typicallyARSceneView-scoped) and the correct LIFO teardown order (estimator first, then engine).- Instrumented stress test for concurrent
update()↔destroy()(#1094 acceptance #3).LightEstimatorConcurrentDestroyTest.ktlands in bothsrc/test/(algorithmic mirror, fast CI tier — 4 tests) andsrc/androidTest/(real Filament Engine smoke — 3 tests, JNI-grounded).arsceneviewgains atestInstrumentationRunnerconfig so./gradlew :arsceneview:connectedDebugAndroidTestworks. Asserts: no exceptions, monotonicisDestroyedtransition, post-destroy textures freed, engine survives ≥10 allocate→destroy cycles.
Fixed — 3D rendering pipeline¶
- 🚨
PostProcessingDemosilently disabled SSAO on first paint (#1076 via PR #1079). Demo state initialised atfalsebut the library default istrue. Initial paint inverted the library default, hiding ambient occlusion until the user toggled it. RenderQualitypreset clobbered user view tweaks on every recomposition (#1078 via PR #1089).view.applyRenderQuality(...)was in an unkeyedSideEffect. Moved toLaunchedEffect(view, renderQuality)— preset reapplies only on actual quality change, user-setview.colorGrading/view.bloomOptionssurvive across recompositions. Switching presets still overrides preset-owned fields (intended semantic).EnvironmentLoader.createHDREnvironmentconvenience overloads silently droppedindirectLightApply(#1124). The 4 convenience overloads (asset / rawRes / file) plusloadHDREnvironment(url:)andloadKTX1Environment(url:)delegated to thebuffer:overload but forgot to forward theindirectLightApplyhook — users who wanted to override the v4.1.0-balanced 10k IBL default (#1075) had to copy the buffer-loading boilerplate. Now all overloads exposeindirectLightApply: IndirectLight.Builder.() -> Unit = {}.EnvironmentDemogains an "IBL Intensity" chip row demonstrating the override. Pinned by a Java-reflection regression test that catches any future overload that re-introduces the drop.PhysicsDemostacked 100 000 lux DIRECTIONAL on top of the v4.1.0 default lights (#1125). Pre-v4.1.0 leftover from the era when the hardcoded main light was 100k. After #1075 rebalanced main to 10k + fill to 3k + IBL to 10k, this override read 10× the new main and blew the scene out under the v4.1.0 EV ≈ 11.6 camera. Retuned to 5 000 lux as a left-side counter-fill (opposite the library's 3k right-side fill).cameraNodeleaked into sharedSceneonSceneViewunmount (#1143). SameSideEffect+AtomicReferencepattern that #1122 / PR #1131 just fixed for the main + fill lights. Switched toDisposableEffect(cameraNode) { addNode; onDispose { removeNode } }so the camera (and any HUD-space child nodes parented under it) is removed fromnodeManageron composition disposal — clean for the documented "share scene between views" use case.
Fixed — Collision math¶
- 🚨 Box ray-OBB intersection broken for parallel rays (#1096 via PR #1098).
MathHelper.MAX_DELTA = 1e-10fwas below FLT_EPSILON (~1.19e-7) for normalised ray directions, so the parallel branch never triggered —Inf / Infslab comparisons produced lottery hits on flat OBBs. New explicitabs(d) < 1e-6fparallel detection at the 3 Box slab call sites + matching twin fix inMeshCollider.AABB.rayIntersection(PR #1100). Note :MathHelper.MAX_DELTAstays at1e-10fbecause bumping it would silently breakVector3.normalized()for short vectors (documented in KDoc). - 🚨
spherePlaneResponsereturned wrong contact point on negative side (#1097 via PR #1098). Used the flipped (collision)normalfor the contact-point projection — bounce side was double-shifted off the plane. Now usesplaneNormaldirectly for the projection identitycontact = center - planeNormal * signedDist, regardless of side. Ball-on-floor no longer clips through.
Fixed — Math + collision regressions (#1126 audit batch)¶
Four sub-items audited from the sceneview-core math/animation/collision packages. Each lands as its own PR with a regression pin.
SpringAnimatorunderdamped uses analytical velocity (#1126 item 1, PR #1135). Velocity was numerically differentiated from position — produced wrong magnitude under heavy damping and integration drift at low frame rates. Now uses the closed-form analytical derivative for the underdamped case, so spring physics is frame-rate independent and correct from the first step.Quaternion.slerptransform uses exponential decay (#1126 item 2, PR #1141).Transform.slerppreviously called rawQuaternion.slerp(a, b, t)witht = deltaTime * speed, which is NOT frame-rate independent (smallertat higher fps → slower convergence). Replaced by exponential-decay formulationt = 1 - exp(-speed * deltaTime)so convergence rate is identical at 30 / 60 / 120 fps.Matrix.decomposeRotationno longer usesthisas scratch (#1126 item 3, PR #1140). The method mutatedthisas a scratch buffer during decomposition, corrupting the source matrix when callers held a reference. Two concurrent decompositions on the same matrix raced. Now allocates a local scratch —decomposeRotationis pure + thread-safe.closestPointsBetweenSegments— Ericson §5.1.9 sign (#1126 item 4, PR #1139). A sign error in the parallel-segment branch (transcribed from Christer Ericson's "Real-Time Collision Detection" §5.1.9) returned the wrong end-point pair when one segment fully shadowed the other. Now matches the reference text + 6 pinning tests for the 4 parallel-overlap topologies.
Fixed — Engine resource leaks¶
- Main + fill light add wrapped in
DisposableEffect(#1122 via PR #1131).engine.scene.addEntity(light)was called from a bareSideEffectso a removedLightNoderecomposition left the light entity attached to the Filament scene forever. Now usesDisposableEffect(mainLightNode, fillLightNode)with explicitremoveEntityon dispose — symmetric add/remove, no Filament-side leak acrossLightSlotswaps. Pinned by the existingScenelifecycle tests + a new add/remove-balance assertion. destroyMaterialsOnDisposeflag onRenderableNode+GeometryNode(#1123 via PR #1132).MaterialInstanceallocated inside a node'sapplyblock was leaked because the node assumed the material was owned by the caller. NewdestroyMaterialsOnDispose: Boolean = falseparameter (default preserves caller-owned semantics); settruewhen the node creates its ownMaterialInstance.rememberMaterialInstancehelpers default totrue, so callers using the v4.0.x recommended pattern see no leak.
Fixed — AR cubemap upload (#1142)¶
- 🚨
Texture.Buildernow setsUsage.GEN_MIPMAPPABLEfor the ARCore HDR cubemap (PR #1142). v4.3.0 RC blocker. Filament 1.71 hardened the texture-usage check andengine.createTexturenow throws when a cubemap is built withoutGEN_MIPMAPPABLEand later submitted to mipmap generation.LightEstimatorcalledtexture.generateMipmaps()immediately aftersetImage, so AR sessions withenvironmentalHdrReflections = truecrashed on the first cubemap upload (~1 second afterSTART_TRACKING). Fix adds the flag at the twoTexture.Buildercall sites + a regression pin inLightEstimatorCubemapBuilderTest.
Tooling — Bundled ARCore session recording for demos¶
samples/android-demo/src/debug/assets/ar-recordings/bundled-pixel9-sample.mp4(16 MB, debug-only sourceSet — release APK untouched, #934 protected). LetsARRecordPlaybackDemoshow a non-empty list on first launch and unblocks emulator-testable AR demos. 4 JVM tests pin the ftyp +avc1+mettcodec box layout (catches camera-only video misclassified as ARCore dataset). CI regression via the bundled recording is tracked by #1050.
Fixed — Inertia mode-gating¶
CameraControls.applyInertia() now dispatches on mode: .pan glides the target translation; .orbit and .firstPerson keep the rotation path. Previously the inertia velocity stored during a .pan drag would inject ghost rotation on release.
Fixed — Triage sweep (PR #1040)¶
- Sync-versions
--fixmode now actually rewrites SwiftPMfrom:clauses (#990). The pre-existing fix block silently no-op'd underset -euo pipefailbecause the last loop iteration's[ ] && echoshort-circuit aborted the script before reaching the rewrite. Caught by 5-agent independent review of the same PR. Coverage extended from 30 → 45 checks (13 new SwiftPMfrom:snippets across docs/website/marketing, plus rootPackage.swiftinstall snippet). DemoInteractionTestAppBar titles aligned with registry labels (#1006): Animation→Auto Rotate, Multiple Models→Multi Model, Image Node→Image Planes, Billboard Node→Billboard, Shape Node→All Shapes. The Billboard chip is nowBillboard Panelto disambiguate from the AppBar.OrbitalARDemoFloat precision drift (#978) — modulo2πon orbit + spin angles (Android + iOS) so cumulative angle survives long-running sessions.ExploreTabScreenpartial-success path (#980) —supervisorScope+catchingFeedhelper so a transient Sketchfab feed failure no longer wipes the other two;CancellationExceptionre-thrown to keep structured concurrency intact.DeepLinkRouterTest.ktJVM compile — pre-existing breakage since2556c467(4-argDemoEntryctor lost wheniconfield was added). Caught during PR #1040 5-agent review; all 13 deep-link tests now compile and run.validate-spmregex hardened (#1007) with atargets:anchor so a commented-out// .library(name: "SceneViewSwift", ...)line cannot satisfy the check.- QA script
qa_android_demos.pyupdated to the renamed registry labels.
Documented — Triage sweep¶
- Filament runtime ↔
.filamatABI invariant (#1023) inCONTRIBUTING.md: the v4.1.0 → v4.1.1 hotfix lesson, the 12 blob list, the matc recompile recipe. CLAUDE.md QUALITY RULES cross-links to it so future sessions are auto-warned.
Closed without code — Triage sweep¶
- #884 RN+Flutter version drift —
@sceneview-sdk/react-native@4.2.0andsceneview_flutter@4.2.0aligned with the monorepo on npm/pub. - #1004 iOS parity v4.2.0 umbrella — SHIPPED end-to-end; deferred items split into focused #1032 / #1033 / #1034 / #1035 / #1036.
Tests — Regression pins for the 14-PR rendering burst (CORR-C batch)¶
Pins for 5 of the 14 fixes shipped on 2026-05-14 (the highest-impact ones; remaining 7 batched for a follow-up). Each pin lives next to the fix it protects:
BoxTest.kt— 5 new methods (1 perpendicular + 4 parallel-branch on x and z axes) pinBox.rayIntersectioncorrect behaviour for thin-slab boxes. Acceptance criterion oublié de #1096.MeshColliderTest.kt— 5 new methods pin the twin parallel-ray epsilon fix inMeshCollider.AABB.rayIntersectionacross x and z axes. Acceptance criterion oublié de #1100.SceneFactoriesTest.kt(new file) — pinsDEFAULT_IBL_INTENSITY = 10_000f, the 1:1 ratio withDEFAULT_MAIN_LIGHT_COLOR_INTENSITY, and the 3DDefaultCameraNode.DEFAULT_APERTURE/SHUTTER_SPEED/ISOtriple (#1067, #1075).ARDefaultCameraNodeTest.kt(new file) — pinsARDefaultCameraNodeexposure via the new companion constants, cross-checks parity with 3DDefaultCameraNode, and asserts ≥1 stop brighter than sunny-16 (#1067). 3DDefaultCameraNodewas refactored in the same PR to expose matchingDEFAULT_APERTURE/SHUTTER_SPEED/ISOcompanion constants; AR aliases them at compile time to eliminate drift risk.RenderQualityLaunchedEffectTest.kt(new file) — pins theLaunchedEffect(view, renderQuality)re-keying contract via a 25-line JVM simulator. Pins the contract (key-equality semantics) rather than the production call site — a separate follow-up will add a Compose UI test that verifiesScene.kt:278actually keys on bothviewandrenderQuality(#1078).
CI — Batch B0 (#1116, #1117, #1118)¶
publish-api-docsnow gatescreate-release(#1116) — a Dokka build failure on a tag push now produces a workflow red X instead of a silent "Other Changes" GitHub Release with no API documentation.continue-on-error: trueand|| echoswallow removed.quality-gate.ymlskips docs-only PRs (#1117) —paths-ignoremirrors the filter already in place onci.yml. Docs PRs (typo fixes in*.md,docs/**,website-static/**,marketing/**,branding/**) no longer burn ~12 min of Android + MCP gate time.mcp*/**intentionally NOT excluded so MCP tests still run on MCP-only PRs.- Composite actions for JDK + MCP setup (#1118) — new
.github/actions/setup-gradle(JDK + Gradle cache +chmod +x ./gradlew, defaults to JDK 21, acceptsjava-version: "17"for Flutter jobs) and.github/actions/setup-mcp(Node + npm-lockfile cache +npm ciinmcp/). Adopted across 7 workflows (release, ci, pr-check, render-tests, docs, build-apks, play-store, quality-gate). Net –68 LOC, eliminates JDK-version drift, single bump point for Node/Java versions. - Render-tests sharding (#1119) filed as a follow-up —
android-library-renderiscontinue-on-error: trueand not a merge gate, so a 4× emulator boot cost vs current 20 min wall-clock needs validation before committing.
v4.2.0 — iOS parity sprint: LightSlot, RenderQuality, NodeGesture, AR anchors (2026-05-13)¶
Status: stable. Ports the v4.1.0 BREAKING render-defaults change finally to iOS, plus closes the bulk of the #928 silent-stub batch and major chunks of the iOS parity umbrella #1004.
⚠️ BREAKING — iOS render defaults match Android v4.1.0+¶
SceneView on iOS now ships with the same out-of-the-box 2-light setup that Android landed in v4.1.0:
- Main / key directional light intensity:
1 000→10 000lux (×10), pointing straight down ((0, -1, 0)). - Fill light: new
LightNode.fill(intensity: 3 000, castsShadow: false)from(0.5, -0.5, 0.5)(upper-back-left → down-front-right). 30 % of main intensity, lifts the shadow side without flattening. - Existing iOS apps will render brighter / more cinematic. To restore the v4.1.x look exactly:
Added — LightSlot / LightNode.fill / mainLight / fillLight modifiers (#1016)¶
LightSlotenum —.systemDefault/.disabled/.custom(LightNode)(3-state, exhaustive switch). Cleaner thanOptional<LightNode?>sentinel.SceneView.mainLight(_:)+SceneView.fillLight(_:)modifiers.LightNode.fill(color:intensity:castsShadow:)factory, signature-consistent withLightNode.directional(...). No baked orientation (caller calls.lookAt(_:)).@MainActor public struct LightNode— replaces the unsoundSendableconformance (LightNode wraps a non-SendableEntity).- Known limitation (#1017): light slot is read once during scene setup. Reactive replacement via
.fillLight(.custom(newLight))mid-frame is not yet wired — Android'sprevFillLightRefswap pattern (Scene.kt:287-305) needs equivalent diffing in iOSRealityView.update:.
Added — RenderQuality preset (#1018)¶
RenderQualityenum —.cinematic/.default/.performance, mirrors AndroidRenderQuality.kt.SceneView.renderQuality(_:)modifier. Walks allDirectionalLightchildren + adjustsImageBasedLightComponent.intensityExponentper tier.- iOS / Android parity gap documented in the enum doc-comment: RealityKit doesn't expose SSAO / MSAA / HDR-buffer / bloom toggles, so the iOS preset honours what's available (per-light shadow toggle + IBL intensity exponent).
Fixed — SceneView.onEntityTapped(_:) real entity hit-test (#1019, #928)¶
Previously the callback was ALWAYS called with entities.root (scene root) regardless of where the user actually tapped — useless for picking objects. Now wired via SpatialTapGesture().targetedToAnyEntity() so the callback receives the real entity at the tap location. Soft BREAKING: apps that relied on the broken behavior are unaffected (no useful logic could be built on a constant root reference).
Fixed — NodeGesture.dispatch* actually fires (#1024, #928)¶
The NodeGesture system had full registration + dispatch API surface (onTap / onDrag / onScale / onRotate / onLongPress + corresponding dispatch*) but the dispatch entry points were never CALLED from anywhere — handlers registered via entity.onTap { … } silently never fired. Wired five new .simultaneousGesture(...).targetedToAnyEntity() in SceneViewRepresentation that route to the matching NodeGesture.dispatch*. Empty-space gestures still drive the camera (existing dragGesture + pinchGesture for orbit/zoom).
Added — AR AnchorNode factories (#1025, #894 partial)¶
AnchorNode.image(group:name:)— anchor content to a detected reference image. Mirrors AndroidAugmentedImageNode.AnchorNode.face()— anchor to detected face (front-camera). Mirrors AndroidAugmentedFaceNode(pose only — no morphing-mesh; for that, drop down to rawARFaceAnchor+ custom mesh entity).AnchorNode.body()— anchor to detected human body root joint (rear-camera, iOS 13+). RealityKit-exclusive, no Android equivalent.
Fixed — AR session interruption preserves full tracking config (#1013, #928)¶
ARSceneView.Coordinator.sessionInterruptionEnded(_:) previously rebuilt ARWorldTrackingConfiguration from a single stored property (planeDetection). Image-tracking database, mesh reconstruction flag, environment-texturing setting were silently lost on every background→foreground cycle. Now the Coordinator stores + re-applies all of them.
Fixed — LightNode.spot(innerAngle:) cone-angle invariant (#1013, #928)¶
Clamps safeInner = max(0, min(innerAngle, safeOuter)) and safeOuter = max(0, min(outerAngle, π/2)). RealityKit silently produces undefined results when innerAngle > outerAngle. #if DEBUG print(...) diagnostic surfaces clamping events.
Fixed — iOS demo deep-link routing for model-viewer + multi-model (#1020, closes #1015)¶
Both ids were in DemoDeepLinkRegistry.allowedIds but had no destination(for:) cases — fell to the "Coming soon" placeholder, even though model-viewer is the App Store listing's hero screenshot. Now route to SceneGalleryDemo (the closest iOS analog to Android's tabletop multi-model scene).
Documented — CameraNode.exposure(_:) stays a deprecated no-op (#1019, negative result)¶
Investigation note: PerspectiveCameraComponent.exposureCompensation does NOT exist on RealityKit / Xcode 26.x despite an audit suggestion otherwise. Verified via direct compile failure. The deprecation now points users at the working alternatives: ARSceneView(cameraExposure:) for AR, SceneView.renderQuality(_:) to tune IBL, per-light LightNode.directional(intensity:) for the key/fill ratio.
Sample-app review¶
This release was visually validated by an Opus reviewer agent on the iPhone 16e simulator across 5 demos (lighting, geometry, animation, model-viewer, multi-model). All passed without regression. Side-finding (off-center camera framing across all iOS demos — pre-existing, not regression introduced by this release) filed as #1026.
Library API¶
| Surface | Change |
|---|---|
LightNode |
now @MainActor (was Sendable); added .fill(color:intensity:castsShadow:) factory + spot innerAngle clamp |
SceneView |
added .mainLight(_:) / .fillLight(_:) / .renderQuality(_:) modifiers; .onEntityTapped(_:) semantics fixed |
AnchorNode |
added .image(group:name:) / .face() / .body() factories |
RenderQuality |
new public enum |
LightSlot |
new public enum |
CameraNode.exposure(_:) |
improved deprecation message (still no-op on iOS — verified RealityKit-impossible) |
ARSceneView.Coordinator |
stores full tracking config across interruption |
NodeGesture |
dispatch API surface (existed already) now actually fires |
Cross-platform release set¶
sceneview / arsceneview / sceneview-core (Maven Central) + sceneview-web (npm) + @sceneview-sdk/react-native (npm) + SPM tag — all bumped to 4.2.0. sceneview-mcp continues on its independent 4.0.x patch track.
v4.1.2 — Demo app recovery: Filament .filamat mismatch fixed + AR tab no longer crashes + Samples tab redesign (2026-05-13)¶
The v4.1.0 Play Store release shipped a demo app the author summarised as "très très nul":
the AR View tab crashed the whole process on tab tap, the Samples tab was a plain 2018-era
text list, and 10 of the 24 non-AR demos consistently crashed with a libfilament-jni.so
TPanic<PostconditionPanic> SIGABRT. This release fixes all three.
Fixed — libfilament TPanic<PostconditionPanic> cascade (closes the v4.1.0 crash wave)¶
The bundled .filamat material binaries in sceneview/src/main/assets/materials/ had been
recompiled with matc 1.71 (commit efd296f1), but the Filament runtime was pinned back
to 1.70.2 (commit 4a31b579, PR #961) without recompiling the blobs. Filament 1.70.2
silently loaded the 1.71 blobs and then panicked the moment a demo bound a sampler or
uniform descriptor against the new layout — taking the whole process with it.
- Reverted the 10 sampler-bearing
.filamatto the pre-efd296f1snapshot (git checkout efd296f1~1 -- sceneview/src/main/assets/materials/). - Recompiled the two newer
opaque_unlit_colored.filamat+transparent_unlit_colored.filamatwithmatc 1.70.2from the upstreamv1.70.2release tarball so they match the runtime. - Verified on a Pixel_7a
-gpu hostemulator: 25 / 25 non-AR demos now pass (was 14 / 25 in the v4.1.0 audit). Previously crashing:lighting,movable-light,fog,environment,text,lines-paths,image,billboard,view-node,debug-overlay— all now render.
Fixed — AR View tab no longer kills the app¶
Tapping the AR View tab on v4.1.0 unconditionally instantiated a live ARSceneView. On
devices without ARCore Services installed (and on emulators) the ARCore session creation
crashed Filament with the same TPanic signature.
- New launcher screen gates the live
ARSceneViewbehind an explicit "Start AR Camera" CTA, with anArCoreApk.checkAvailability()status pill and a 2×3 grid of the six headline AR demos visible immediately. runCatchingaroundcheckAvailabilityso it can't silently die on OEMs without Play Services. CTA is hard-disabled onUNSUPPORTED_DEVICE_NOT_CAPABLE/UNKNOWN_*so the user never re-enters the panic path.- Top-right exit button on the live AR view detaches every anchor and flips back to the launcher — no more no-affordance dead end.
sessionStartedis nowrememberSaveableso process death doesn't dump users back to the launcher needlessly.
Changed — Samples tab redesign (Material 3 Expressive grid)¶
Replaces the plain ListItem text list with a 2-column M3 Expressive grid. Each card has
a compact accent-tinted icon tile (36% of card height — title and subtitle remain the
visual anchors) plus a semantic Material icon picked per demo. Categories carry distinct
accent hues (3D Basics purple, Lighting amber, Content blue, Interaction pink, Advanced
teal, AR green) so users can scan the grid by colour at a glance. Visual reference:
Sketchfab mobile + Polycam + Reality Composer launchers.
DemoEntrynow carriesicon: ImageVectorandstatus: DemoStatus(Working/KnownIssue/ComingSoon). Non-Working demos surface an outlined "Preview" / "Soon" chip with an info icon — a calm honest signal, not a red alarm.- Dark-mode accent palette (
#6446CD→#B39DDB, etc.) keeps the tinted icon tiles legible on M3 darksurfaceContainerinstead of burning at >9:1 contrast. LargeTopAppBarscroll behaviour wrapsrememberTopAppBarState()so the collapse offset survives recomposition + rotation.- Grid item keys namespaced
"demo-${id}"to guard against id collisions.
Changed — Explore tab polish¶
- Dropped the dev-flavored "Set SKETCHFAB_API_KEY (env or local.properties)" placeholder that leaked to end-user Play Store builds when the API key was missing. The Sketchfab carousels now silently fall through to the "Try a sample" carousel + categories.
SampleCardrebuilt with the same accent-tinted icon-tile layout as the Samples grid so both tabs feel like one product.FeedSectionself-hides when its Sketchfab feed is empty and not loading — no more three "Nothing here yet." headers stacked under each other in the offline path.- Dropped the red "Couldn't reach Sketchfab" banner. The empty self-hide already conveys the offline state without dev-flavored copy.
Other¶
feedback_stitch_mandatory.mdmemory rule rewritten to drop Google Stitch as the mandated UI source — reference-driven (Sketchfab mobile / Polycam / Reality Composer)DESIGN.mdtokens is the new SceneView demo workflow.- Local Sketchfab API key support in
local.propertiesfor developer builds (CI is unchanged; release builds still source the key from the GitHub Secret).
v4.1.1 — Filament 1.71.0 / .filamat ABI realignment hotfix (2026-05-12)¶
Status: stable. Critical bug fix release. All v4.1.0 consumers should upgrade.
Fixed — SIGABRT on MaterialLoader.createColorInstance (every demo using bundled materials)¶
A multi-agent post-ship audit caught a hard crash regression introduced in v4.1.0 — Lighting, Geometry, Animation, MovableLight, and MultiModel demos (and any consumer app touching MaterialLoader.createColorInstance or any default Filament post-process material) SIGABRT'd on launch with Filament: could not parse the material package for material Opaque Colored.
Root cause — Filament binary version mismatch:
- Commit
efd296f1(Apr 11) bumped Filament 1.70.2 → 1.71.0 and recompiled all 21.filamatfiles viamatc 1.71.0to material-binary version 71. - Commit
4a31b579(May 11, #961) reverted ONLYgradle/libs.versions.toml'sfilamentto1.70.2thinking the.filamatfiles were still v70 — they had been at v71 for a month. Filament 1.70.2 runtime cannot parse v71 packages →SIGABRTinlibfilament-jni.so. - v4.0.8, v4.0.9, and v4.1.0 all shipped this broken pair, but only v4.1.0 was caught (Lighting / Geometry / Animation / MovableLight / MultiModel were all new or refactored demos in the v4.1.0 sprint, exposing the regression).
The fix ([<commit-sha>]) reverts 4a31b579 — restores filament = "1.71.0" to match the v71 .filamat files. Future Filament downgrades MUST first run matc <version> against sceneview/src/main/materials/*.mat and commit the regenerated .filamats.
Tested — visual regression on Pixel_7a emulator¶
All 6 demos validated post-fix on Pixel_7a (Apple M3 host GPU, OpenGL ES 3.0):
- ✅ Lighting (was CRASH) — directional light + helmet renders correctly
- ✅ Geometry (was CRASH) — primitives render with PBR material
- ✅ Animation (was CRASH) — soldier walks in cinematic studio HDR with shadows
- ✅ MovableLight (was CRASH) — F40 model with marker sphere + intensity slider
- ✅ MultiModel (was CRASH) — 4-model tabletop tableau with studio HDR
- ✅ ModelViewer (was alive) — helmet still renders
./gradlew :sceneview:compileReleaseKotlin :arsceneview:compileReleaseKotlin :samples:android-demo:compileDebugKotlin :sceneview:test :arsceneview:testDebugUnitTest all green at Filament 1.71.0.
No public API changes¶
Library API is identical to v4.1.0. Maven Central publishes the bumped triplet (sceneview / arsceneview / sceneview-core 4.1.1) and the npm packages bump for version-tracking and to keep the cross-platform release set coherent.
v4.1.0 — iOS V1 honest + Android rendering uplift + Sketchfab streaming + Claude Code plugin marketplace (2026-05-11)¶
⚠️ BREAKING — Android render defaults change visual look out-of-the-box¶
The SceneView composable now ships with RealityKit-equivalent defaults to close the
"iOS looks better than Android" gap reviewers consistently flagged in 2026-05-10 QA:
- Main directional light intensity:
100_000→10_000lux (×10 drop). Existing apps will render noticeably darker unless they overridemainLightNode.intensityexplicitly or load a brighter IBL. Combined with shadows-now-on and a new fill light at 30% intensity, the overall scene exposure is much closer to RealityKit's defaults. - Shadows: now on by default (
setShadowingEnabled(true)). Existing apps that don't use casters will see no change; apps with floor planes will now display contact shadows. - Fill light: new
fillLightNode: LightNode?param onSceneView, defaulted torememberFillLightNode(engine). Passnullto disable for a single-light setup. - SSAO + bloom + Filmic tone mapper: now on by default on
View. SSAO has no visible cost on models without crevices; bloom strength is 0.10 (subtle, no "cheap mobile game" look). Override viaview.ambientOcclusionOptions.enabled = falseif needed. - Exposure:
setExposure(16, 1/125, 100)(sunny-16, EV~15) →(12, 1/200, 200)(neutral, EV~11.6). The previous defaults required cranking IBL intensity to see anything; the new defaults look right out of the box.
Migration: bump consumers to v4.1.0+ and review the visual delta. To restore v4.0.x
look exactly, set mainLightNode = rememberMainLightNode(engine) { intensity = 100_000f },
fillLightNode = null, and view.ambientOcclusionOptions.enabled = false.
Fixed — Android demo polish (QA pass 2026-05-11)¶
A QA agent walked the demo screens and reported user-visible papercut issues.
Five low-effort high-impact fixes shipped (65f6d8db, ea4c513e, 15c8d254, 15bcaf8c):
- ModelViewerDemo: helmet was pinned to the lower half of the viewport with a big
empty band at the top.
rememberHeroOrbitCameraManipulator(yHeight = 0.2f → 0f). - CameraControlsDemo: helmet rendered at ~10% of the viewport at the default home
camera distance.
homePosition = Position(0, 0, 4) → (0, 0, 1.5). - PhysicsDemo: first frame showed a single ball on an empty floor — the demo's hook
("colourful rain on the floor") was invisible until the user pressed Drop. Initial
sphereCount = 1 → 5so the first frame is the actual demo content. - ARStreetscapeDemo: the permission gate showed only a "Denied" error message with
no escape — Back was the only way out. Now offers
Retry(re-launches the system prompt) andOpen Settings(deep-links into the app's permission page) buttons. - DynamicSkyDemo: rendered as "fully black at noon" because
DynamicSkyNodepositions a directional sun but doesn't paint a sky dome, and the default neutral IBL had no skybox. Mitigation in the demo (not the library): swap the IBL based on the time-of-day slider —rooftop_night_2k/sunset_2k/outdoor_cloudy_2k. Three buckets is coarse but covers the obvious user expectations; a proper procedural-atmosphere skybox is library-level work for a later sprint.
Added — MovableLightDemo + OrbitalARDemo (samples)¶
Two new sample demos shipped on both iOS and Android (commits c345404b, 54233d56).
MovableLightDemo— drag-anywhere-on-the-scene → spherical-orbit math (azimuth / elevation, fixed radius 1.5 m) → light position updates live → specular highlights track the cursor on a PBR model (Damaged Helmet on Android, Ferrari F40 on iOS). Camera is locked so the only thing moving is the light; a yellow unlit marker sphere shows where the light source is. Intensity slider 1k → 100k, "Show light source" toggle hides/shows the marker.OrbitalARDemo— solar-system-style AR scene: eight distinct bundled models orbit around the user at radius 1.5 m, each with its own orbital speed (0.05 → 0.30 rad/s, 21 s to 125 s for a full lap) and a slow local spin. Heights are equipartitioned across ±0.5 m so the formation reads as varied elevations as the user turns. Plane detection is disabled — the formation lives in world space, anchored at the user's starting position.
Added — Sketchfab model viewer cross-fade (iOS + Android parity)¶
- Wow-factor hero state on the Sketchfab download screen (
1e0f86ba) — the previous bare-spinner loading state read as "loading something somewhere". Now both platforms show: (1) a Ken-Burns thumbnail (highest-res Sketchfab preview, slow 1.0→1.18 zoom, soft blur) while the GLB downloads — the screen always shows the model itself, never an empty container; (2) a ~500 ms cross-fade from thumbnail to liveSceneViewonce the model loads — the "come to life" transition that reads as proof of native rendering; (3) premiumstudio_2k.hdrIBL by default (much more flattering on PBR thanneutral_ibl, skybox kept off); (4) a 20 s hero auto-orbit so every angle is visible without touching the screen; (5) a cinematic radial vignette for the "Apple Store hero" framing. iOS uses SwiftUI.onChange(of:)+withAnimation; Android usesCrossfadefromandroidx.compose.animationkeyed on the existing Stage state machine.
Fixed — LoadingScrim on CameraControls + Animation demos (Android)¶
- First-paint black screen (
5cae550a) — QA pass on 2026-05-11 flagged "Demos noires sur first paint (Camera Controls, Lighting, Animation, Multi Model) — ~5-10s pendant lesquels l'écran est noir, user pense que l'app crash".LightingDemo+MultiModelDemoalready hadLoadingScrim; this completes the four-demo set by adding the same translucent spinner overlay toCameraControlsDemoandAnimationDemo(both load non-trivial GLBs —khronos_damaged_helmet.glb/threejs_soldier.glb— with a multi-second empty-black first-frame window).GeometryDemodeliberately skipped (procedural primitives, no model load).
Branch claude/magical-lovelace-7176b1 — staged for the next minor cut.
Added — RenderQuality preset (Android)¶
io.github.sceneview.RenderQuality(2b04c667) — one-lineCinematic/Default/Performanceswitch onSceneView. Wraps shadows, SSAO, bloom, MSAA, HDR color buffer, and dynamic resolution into three coherent presets so AI assistants generating SceneView code (or devs who don't want to learn whatambientOcclusionOptionsis) can pick one preset and ship. Individualview.*settings still win when set after the preset.rememberFillLightNode(engine)(ad81c52a) — composable factory for a secondary "fill" directional light, mirroring iOS RealityKit's default two-light setup. NewfillLightNode: LightNode?parameter onSceneViewdefaults to this; passnullto keep the single-main-light look.
Added — Sketchfab streaming scaffold¶
- iOS (
918faacd) —actor SketchfabServiceundersamples/ios-demo/.../Services/. URLSession + Codable models, on-disk LRU cache (500 MB cap), env-var-based API key (SKETCHFAB_API_KEY). - Android (
72cff080) — mirror insamples/android-demo/.../sketchfab/. OkHttp + kotlinx-serialization, same 500 MB LRU cache,BuildConfig.SKETCHFAB_API_KEYpopulated from env orlocal.properties(gitignored). - CI (
7858051f) —build-apks.ymlforwardssecrets.SKETCHFAB_API_KEYnext to the existingARCORE_API_KEYpattern. Forks / PRs from forks with an unset secret build cleanly — the gallery falls back to bundled featured models and disables Sketchfab search at runtime viaSketchfabError.MissingApiKey. - Security note — V1 scaffold bakes the key into the APK / IPA at build time. V1.1 will route through the mcp-gateway Cloudflare Worker so the master key isn't shipped; demo apps would carry only a short-lived per-user token.
TODO V1.1markers are in place inSketchfabConfig.{swift,kt}and the Gradle build script.
Changed — Android rendering defaults match iOS RealityKit¶
Closes the visible quality gap between Android (Filament) and iOS (RealityKit) out of the box. Side-by-side comparison on a Metal-backed Pixel_7a (Apple M3, -gpu host) on 5 hero models showed Android looking "blown-out / harsh" because of single-light + shadows-off + sunny-16 exposure defaults.
- Shadows on by default (
ad81c52a) —setShadowingEnabled(false → true)inSceneFactories.createView(). - Main light intensity 100 000 → 10 000 (
ad81c52a) —DEFAULT_MAIN_LIGHT_COLOR_INTENSITY. Brings it in line with RealityKit's 1 000-unit directional + IBL contribution. Crank IBL or push intensity back up explicitly when you need outdoor noon punch. - Fill light added (
ad81c52a) — secondary directional at 30% main intensity from(0.5, -0.5, 0.5), no shadows. Softens contrast on the shadow side of models. - Exposure neutralised (
ad81c52a) —setExposure(16, 1/125, 100) → (12, 1/200, 200)(~EV 15 sunny-16 → ~EV 11.6 neutral). - SSAO + bloom on (
7858051f) —view.ambientOcclusionOptions.enabled = trueandview.bloomOptions.enabled = true; strength = 0.1f. Visible grounding gain under metallic / cloth assets, invisible on plain diffuse models. Validated on toy_car / dragon / helmet / lantern / shiba. - Filmic tone mapper kept (
7858051f) — ACES was tested and produces a "cool Hollywood" grade that shifts PBR hero shots away from ground truth. SDK doesn't impose tone preferences — users opt into ACES viaview.colorGrading. (An earlier SwiftShader-based test had flagged ACES as a "PBR helmet crush" — that turned out to be a software-renderer artifact; the loss disappears on real GPU.)
ARScene.createARView() was deliberately left untouched: AR sessions have their own real-world lighting estimation, and layering SSAO / bloom on top of a camera feed is a separate sprint.
Changed — iOS V1 honest: purge the 4 silent Pareto stubs¶
Closes #928 (the 4 stubs in the Pareto-15 minimal API surface).
ModelNode.playAnimation(speed:)(141eda05) — the threeplayAnimation(...)overloads accepted aspeed: Floatparameter but never wired it through. Fixed by capturing the returnedAnimationPlaybackControllerand setting.speed = speed.CameraNode.depthOfField(focusDistance:aperture:)(141eda05) — annotated@available(*, deprecated, message: "..."). RealityKit'sPerspectiveCameraComponentdoes not expose DOF; the method is kept for Android API parity but Xcode now surfaces a clear warning.CameraNode.exposure(_:)(141eda05) — same treatment. The deprecation message redirects users toARSceneView(cameraExposure:)for AR or to scene lighting intensity for 3D.LightNode.shadowColor(_:)(141eda05) —DirectionalLightComponent.Shadowhas nocolorproperty; the parameter is ignored. Deprecation message points users atcastsShadow(_:)/shadowMaximumDistance(_:).
Added — iOS demo: "Coming soon" badges for non-ported demos¶
DemoStatusenum +ComingSoonScreen(567d6476) — Android has 37 sample demos, iOS has 16. The other 21 used to be invisible on iOS. Now they appear in theScenestab list with a "Coming v1.1" badge; tapping routes to an elegant placeholder (sablier icon, version target, links to GitHub issues + the Android demo on Play Store).- 21 placeholder items added to
SamplesTab.allScenes()covering Interaction (Camera Controls / Gesture Editing / Collision / ViewNode), Advanced extras (Post Processing / 2D Shape Extrude / Reflection Probes), Animated Model, Video Texture, and the 12 AR demos that aren't yet on iOS.
Stitch design assets (UI refonte pending)¶
Project 15993476369356042112 on Stitch contains the 8 mockup screens for the V1 UI refresh (4 iOS Liquid Glass + 4 Android M3 Expressive). Pending: actual SwiftUI / Compose implementation in samples/{ios,android}-demo based on those mockups.
Added — sceneview/claude-marketplace Claude Code plugin¶
- New marketplace repo:
github.com/sceneview/claude-marketplace(Apache-2.0). Single plugin (sceneviewv4.0.11) bundling thesceneview-mcpserver, 11 namespaced contributor commands (/sceneview:contribute,/release,/review,/test,/document,/quality-gate,/publish-check,/sync-check,/version-bump,/evaluate,/maintain), and 5 cross-platform reminder hooks that fire on edits to nudge Android ↔ iOS ↔ Web ↔ Flutter ↔ RN API parity. - Install (Claude Code):
- Marketplace clone ~256 KB (vs 1.4 GB if it had lived in the SDK monorepo — split-to-dedicated-repo decision after a multi-agent review flagged the monorepo clone as a ship-blocker).
- Plugin manifest references its npm-published MCP via
npx— no code vendoring,sceneview-mcpstays independently versioned on npm. - Discovery surfaces wired (
01114229): plugin-install instructions added toREADME.md,llms.txt,mcp/README.md,docs/docs/ai-development.md,docs/docs/index.md. GitHub topics on the marketplace repo coverclaude-code,claude-plugin,mcp,3d,ar,android,ios,web,jetpack-compose,swiftui.
Added — .claude/scripts/sync-plugin-versions.sh¶
Verifies the sceneview plugin's manifest version matches npm view sceneview-mcp version. Lives in the marketplace repo (also). Decoupled from sync-versions.sh because the plugin tracks the wrapped npm MCP, not gradle.properties VERSION_NAME.
Security — sceneview/sceneview HEAD scrub¶
Removed off-topic personal-portfolio code from the public SDK repo that had nothing to do with SceneView: hub-gateway/, hub-mcp/, mcp-gaming/, mcp-interior/, plus the strategy/registry-submission docs that listed unrelated MCPs. Also dropped tracked CDI-sensitive session artefacts (.claude/handoff*.md, .claude/plans/, .claude/marketplace-submissions/, RERUN-CHECK.md, hardcoded user paths in samples). The standard employer/portfolio identifier greps return 0 hits in HEAD. Past commits still contain the historical strings — a git filter-repo session is the planned followup.
v4.0.9 — Web unlit parity + Android demo APK -38% + Play Store race fix (2026-05-07)¶
Status: stable. No new library API surface vs v4.0.8 — instead this release bundles cross-platform unlit parity (web + Flutter + RN bridges), big Android sample-app size cuts, and a fix for the Play Store deploy workflow's recurring internal-track race.
Added — KHR_materials_unlit parity on sceneview-web¶
GeometryConfig.unlit()builder +GeometryConfig.unlit: Booleanfield on the webgeometry { … }DSL. When set, the GLB material gets the standard glTF 2.0KHR_materials_unlitextension — Filament.js supports it natively and skips PBR / IBL evaluation entirely. Closes the cross-platform unlit gap (Android already hadcreateUnlitColorInstancein v4.0.8, Apple hadCustomMaterial.unlit, RN/Flutter bridges shippedunlit: boolin v4.0.9 too).- Web demo showcase — per-shape "Unlit" checkbox in
samples/web-demoso users can A/B compare lit-PBR vs unlit on every primitive.
Added — Cross-platform unlit on bridges¶
- React Native (
react-native/) —<GeometryNode unlit={true} />exposed through the JS Fabric bridge with type-safeReadableType.Booleanparsing on the Android side (anti-crash for JS callers without strict TS). Material cache key bumped from(color)to(color, unlit)so toggling returns a fresh instance. - Flutter (
flutter/sceneview_flutter) —GeometryNode(..., unlit: true)constructor +toMap()field. API-ready for when the Android platform-view bridge gains geometry rendering (currently no-opsaddGeometry).
Performance — Android demo APK 161 MB → 100 MB (-38%)¶
- 9 orphan assets dropped (
7a466736) — 5 models (robo_bun.glb,coffee_cart.glb,koi_fish.glb,trumpet.glb,casio_keyboard.glb) + 4 environments (artist_workshop_2k.hdr,comfy_cafe_2k.hdr,pav_studio_2k.hdr,autumn_field_2k.hdr) verified unused by every sample app. Phone APK 161 → 131 MB. - TV-only assets split (
9877918e, closes #879) — moved 6 TV-exclusive models (nike_air_jordan.glb30 MB,khronos_iridescent_dish.glb,khronos_sheen_chair.glb,khronos_glam_velvet_sofa.glb,toon_cat.glb,khronos_duck.glb) from the sharedandroid-demo/assets/symlink target to a TV-demo-private folder. TV demo picks up shared assets viasourceSets.main.assets.srcDirs += '../android-demo/src/main/assets'. Phone APK 131 → 100 MB. - Disabled asset-pack module dropped (
c2fe9010) — 186 MB on-disk repo cleanup. Thesamples/android-demo-assets/com.android.asset-packmodule was disabled (assetPacks = […]commented in the demo's build.gradle) but still tracked in git. None of its 25 GLBs were referenced by code.
Fixed¶
- Play Store deploy workflow race (
f2829214) — addedmax-parallel: 1to the publish job's matrix so theinternalandproductiontracks upload sequentially. Before this, both jobs would grab the same Google Play Edit ID, one would finish first, and the other would fail with "This Edit has been deleted". Recurred on every tag push since v4.0.5; v4.0.9 deploy uses the new sequential path. - iOS demo
MARKETING_VERSIONblind spot (04e75ad5) —samples/ios-demo/SceneViewDemo.xcodeproj/project.pbxprojwas missed for 8+ releases.sync-versions.shnow covers it (29 checks, was 28).
Tested¶
NoTangentsGlbContractTest(04e75ad5) — substring"TANGENT"assertion replaced with regex anchored to theattributesblock, so a future contributor adding"comment": "no TANGENT"to the manifest cannot false-positive. Added 6th test pinning BIN chunk byte length math.TvModelListTest(9877918e) — updated to search both asset folders (TV-only + shared via sourceSets) so missing-asset regressions still fail fast.
Library API¶
No public Kotlin / Swift / Filament API changes vs v4.0.8. Maven Central artifacts are bumped for version-tracking and to keep the cross-platform release set coherent (sceneview, arsceneview, sceneview-core, sceneview-web@4.0.9, sceneview-mcp@4.0.11, SwiftPM v4.0.9, Flutter / npm bridges).
Sample-app review¶
This release was vetted by 5 parallel Opus reviewers (commit 04e75ad5) — 13 findings triaged in 4 buckets (BLOCKING / MAJOR / MINOR / NIT), all BLOCKING + MAJOR + MINOR fixed. Notable: ARFaceDemo overlay had been migrated to opaque blue in v4.0.8, hiding the user's face under a solid mask; switched back to translucent SceneViewColors.PrimaryOverlay (alpha 0.4) so the fitted face mesh actually overlays the visible face — which is the entire point of the demo.
v4.0.8 — Unlit material + 3 demo refresh + AR feature coverage (2026-05-07)¶
Status: stable. Bundles the createUnlitColorInstance material API, the AR feature coverage sprint (6 demos + ARRecorder + EIS), three demo refactors driven by on-device QA, and a regression test for the silent-closed #836 GLB-without-TANGENTS bug.
Added — Unlit colour material¶
MaterialLoader.createUnlitColorInstance(color)— flat-colour material that bypasses lighting entirely. Three overloads: FilamentColor, ComposeColor, andInt. Use for HUD overlays, gizmos, axes, lines, sprites, AR face/body meshes — anywhere PBR shading would fight the use case. Closes #871.- iOS parity:
CustomMaterial.unlit(color:)(was.debug(color:), now deprecated as alias). - Sample app migrations:
Axes3DNode,CollisionDemo,LinesPathsDemo, andARFaceDemo— the front-camera face-mesh overlay no longer needs an explicit fill light to compensate for the front-camera disablingENVIRONMENTAL_HDR. Removes a long-standing visibility-regression risk.
Changed — 3D demo refresh¶
AnimationDemo— IBL intensity slider (0–10 000 lux) replaces the hard-coded 5 000 lux baseline so users can dial atmospheric ↔ neutral. HERO orbit lifted fromyHeight = 0.15 m(low-angle monument) to0.55 m(eyes-level) so head + feet stay in frame on portrait viewports.GeometryDemo— chip row is now horizontally scrollable, all primitives spin continuously on Y, and Metallic / Roughness sliders cover the full PBR range from chalky matte (M=0, R=1) to polished mirror (M=1, R=0).MultiModelDemo— refonte from a generic spread-slider carousel to a tabletop living-room display lit bystudio_warm_2k.hdr. Front row at z=-1.3, back row at z=-1.7. Spread slider removed (the new layout is hand-tuned for the dusk-lit display).LightingDemo— 3×2.4 m backdrop wall + small coloured marker sphere at the light source so directional / point / spot read distinctly. Light pinned at (0, 1.4, 1.0) with tightened spot cone and 4 m falloff.
Fixed¶
Scene.ktcameraManipulator swap reactivity —cameraManipulatoris now wrapped inrememberUpdatedStateso the frame loop reads through a state ref. Callers that swap manipulators at runtime (e.g.AnimationDemo's scripted → Free hand-off, custom mode pickers) now seegetTransform()route to the new manipulator on the next frame instead of staying stuck on the launch-time value.
Tested¶
NoTangentsGlbContractTest(5 JVM tests) — pins the canonical "minimal lit primitive without TANGENTS" GLB binary fixture so futuregltfiobumps cannot silently break the auto-tangent synthesis path that fixes #836. Closes #863.
Added — AR feature coverage (arsceneview + samples/android-demo)¶
Five ARCore capabilities that were already wired in the library but had no demo are now showcased, plus one brand-new library feature.
ARRecorder+ARSceneView(playbackDataset = ...)— first-class ARCore Recording / Playback in SceneView.rememberARRecorder()captures the full session (camera frames, IMU, planes, depth, anchors) into an MP4;playbackDataset: File?onARSceneViewreplays that file 1:1 without a phone. Pair with the existing Rerun bridge for record-replay-inspect debugging. Library:arsceneview/src/main/java/io/github/sceneview/ar/recording/ARRecorder.kt. Demo:samples/android-demo/.../ARRecordPlaybackDemo.ktwith LIVE / RECORD / PLAYBACK modes. Recording usessetAutoStopOnPause(true)so backgrounding the app produces a clean MP4; optionalrecordingRotationkeeps replay upright across orientations.ARDepthOcclusionDemo— togglesConfig.DepthMode.AUTOMATICso real-world objects correctly hide virtual ones. Falls back to a clear "device not supported" banner whenisDepthModeSupportedreturns false. Library plumbing inARCameraStreamwas already wired.ARInstantPlacementDemo—Frame.hitTestInstantPlacement(x, y, 1.0f)places models the moment the user taps, before plane detection converges. Tracking-method badges flip from "Approximating" to "Tracked" once the trackable promotes toFULL_TRACKING.ARTerrainAnchorDemo— geospatial anchor that snaps a model to Google's terrain altitude at any lat/lng. Drop-here button gated onEarth.EarthState.ENABLEDto avoid silently swallowedIllegalStateExceptions.ARRooftopAnchorDemo— geospatial anchor that snaps to building rooftops. Same Earth-state gate as Terrain.ARImageStabilizationDemo— togglesConfig.ImageStabilizationMode.EIS. Smooths the camera background image without affecting virtual content. Gates onSession.isImageStabilizationModeSupported. Back-camera only.
llms.txt gains a new "AR Recording & Playback" section with full record + replay recipes plus a sibling "AR Image Stabilization (EIS)" section; playbackDataset appears in the ARSceneView reference signature.
Tested¶
ARRecorderTest: 21 JVM unit tests pin the Recorder state machine, error paths, andRecordingConfigbuilder calls. Surprising current behaviours pinned:stop()does not internally guard the IDLE state, andattach(newSession)mid-RECORDING is a pure pointer swap (the original session never receivesstopRecording()— see warning in the AR Recording & Playback docs).
Documented¶
docs/docs/ar-recording.md— new mkdocs page for library consumers (record + replay recipes, caveats, Rerun pairing).samples/android-demo/RECORDING_PLAYBACK.md— sample-app feature guide for demo users.README.md— new "Record & Replay AR sessions" sub-section under Developer tools.
Changed¶
ARSceneView: new optionalplaybackDataset: File? = nullparam. Snapshotted at first composition; switch playback files viakey(playbackDataset) { ARSceneView(...) }.PlaybackFailedExceptionis routed toonSessionFailed.
v4.0.7 — ARCore Cloud API key documentation everywhere + npm sceneview-mcp@4.0.9 (2026-05-06)¶
Status: stable. Documentation + MCP-server release.
Documented¶
The ARCore Cloud API key requirement (for Config.CloudAnchorMode.ENABLED,
Config.GeospatialMode.ENABLED, Config.StreetscapeGeometryMode.ENABLED) is
now surfaced everywhere a SceneView consumer might look:
arsceneview/Module.md— dedicated "ARCore Cloud API key" section in the Dokka-published lib reference (with manifest snippet + build.gradle injection- link to the setup guide).
llms.txt(root) +mcp/llms.txt+docs/docs/llms.txt— warning block under the ARSceneScope intro so AI assistants generating Cloud-using code emit the manifest/build.gradle wiring automatically.docs/docs/integrations.md— full setup section in the doc-site Cloud Anchor + Room example.mcp/src/guides.ts(returned by theget_setup_guideMCP tool): added the API_KEY meta-data + ACCESS_FINE_LOCATION permission + Cloud setup block.mcp/src/explain-api.ts(returned byexplain_api): added the missing key/permission gotcha to the "common mistakes" list.mcp/src/debug-issue.ts(returned bydebug_issue): added Cloud manifest snippet to the AR troubleshooting flow.mcp/src/samples.ts: prepended the setup comment block to the Cloud Anchor sample so generated code includes the prereq inline.samples/android-demo/STREETSCAPE_SETUP.mdshipped earlier in v4.0.6 stays the canonical step-by-step guide.
Improved — sample app demos¶
ARStreetscapeDemoandARCloudAnchorDemonow readcom.google.android.ar.API_KEYfrom the manifest at runtime (viaPackageManager.GET_META_DATA) and surface a precise "ARCore Cloud API key not configured — see STREETSCAPE_SETUP.md" banner instead of letting the user wait on "Looking for streetscape geometry…" forever or seeing a crypticERROR_NOT_AUTHORIZEDafter a tap. No-op for production builds (Play Store / App Store ship the key); helpful for forks.
Internal¶
npm sceneview-mcp4.0.8 → 4.0.9 — picks up the regeneratedmcp/src/generated/llms-txt.tssonpx sceneview-mcpusers see the new ARCore Cloud key section insceneview://api.- 8 Dependabot ip-address moderate alerts cleared via
npm audit fixacross 8 lockfiles (commita155966b). - iOS bundle 362 → 363,
MARKETING_VERSION4.0.6 → 4.0.7.
What's still in flight from v4.0.6 (unchanged)¶
- Apple TestFlight processing v4.0.6 build 362 (auto-submit pending Apple review).
- Play Store production track for v4.0.6 (Google review pending).
v4.0.6 — Streetscape Geometry / Geospatial enabled in production (2026-05-06)¶
Status: stable. Activates the AR Streetscape Geometry, Geospatial, and Cloud Anchors demos for Play Store and App Store builds. The library artefacts on Maven Central are unchanged from v4.0.5 — this release only re-builds the sample apps with the now-wired ARCore Cloud API key.
Fixed¶
The v4.0.5 sample apps shipped with com.google.android.ar.API_KEY empty in the manifest, which left the Streetscape / Geospatial / Cloud Anchors demos disabled at runtime. The wiring landed on main after v4.0.5 was tagged (commit b280b6d9 — samples/android-demo/build.gradle reads ARCORE_API_KEY from env or local.properties, injects it via manifestPlaceholders).
v4.0.6 re-cuts the sample-app AAB / iOS archive with the env var supplied by CI (secrets.ARCORE_API_KEY, restricted to package io.github.sceneview.demo + the debug, upload, and Play App Signing SHA-1s). End users of the published demos can now exercise Streetscape Geometry and Geospatial on the production builds.
Internal¶
- iOS
MARKETING_VERSION4.0.5 → 4.0.6,CURRENT_PROJECT_VERSION361 → 362 (TestFlight cumulative bundle counter). - Documentation:
samples/android-demo/STREETSCAPE_SETUP.mdshipped in v4.0.5 stays valid — provisioning a new key follows the same flow.
v4.0.5 — hotfix: android-demo compile + iOS bundle bump (2026-05-06)¶
Status: stable. Hotfix on top of v4.0.4 — that release's tag triggered Maven Central publication successfully, but the store-bound builds (Play Store APK, App Store iOS archive) failed in CI:
Fixed¶
samples/android-demo/MainActivity.kt:Unresolved reference 'initialDemo'— leftover reference to the old launch-time deep-link param after the v4.0.4 conflict resolution. Replaced with aremember { activity?.pendingDemoIdFlow?.value }capture so the NavHost picks the right start destination on first composition without re-introducing the param.samples/android-demo/demos/PhysicsDemo.kt:Assignment type mismatch: actual type is 'Node', but 'SphereNode?' was expected.— the conflict resolution wrapped the falling spheres in aNode()to attach a position via the wrapper, breakingapply = { nodeRef = this }becausethiswas the wrapper Node, not the inner SphereNode. Collapsed back toSphereNode(position = …, apply = { nodeRef = this })since SphereNode supports both.samples/ios-demo/SceneViewDemo.xcodeproj:CURRENT_PROJECT_VERSION359 → 361 — App Store Connect rejected the v4.0.4 archive (bundle version must be higher than the previously uploaded version: '360').
The v4.0.4 library artefacts on Maven Central are unchanged and still valid. v4.0.5 is intentionally minimal — only the android-demo sample app and the iOS sample app are affected.
v4.0.4 — Pixel 9 review fixes + library hardening (2026-05-06)¶
Status: stable. Brings PR #851 (87 sample-app fixes + 20 library fixes from the Pixel 9 live-review session that diverged on 2026-04-22 and never made it into v4.0.3) plus the multi-agent-review hardening of its public API surface.
Fixed — Android demo app (87 commits)¶
The store-published v4.0.3 APK shipped without the live-review fixes. v4.0.4 brings them all:
- AR demos: Face Mesh now visible (proper TANGENTS quaternion encoding via PR #852), Pose has matte materials + Blender-style axes gizmo, Streetscape falls back to plain AR when geospatial unavailable + links Google Fused Location Provider, Placement multi-model spawn + editable + Clear All, Rerun v2 UX (intro screen, live stream stats card, help dialog).
- 3D demos: Animation default Reveal+Walk + cinematic shots + dragon centred, Geometry plane no longer twisted into a wall, Physics 5×N grid spread + Drop-10 + horizontal floor + diagnostic static sphere, Lighting reactive props, DynamicSky time slider drives illumination, BillboardNode mirror, ViewNode reactive props (closes #856), Custom mesh auto-pause, MultiModel redesign, Lines/Paths 3D helix, Gesture-editing axes gizmo + sliders + live transform readout, Video Big Buck Bunny streaming + cinematic camera + creative surfaces, PostProcessing camera-orbit + SideEffect writes, Debug-overlay interactive node spawner + auto-fit + perf graph + stress test.
- Branding: launcher icons regenerated, palette sweep across collision/AR demos + Text + Billboard, gradient video, Surface base palette adoption.
- QA: deep-link --es demo <id> ingress for instrumented tests (coexists with the public scan-to-open URL routing).
Fixed — sceneview / arsceneview library (20 commits)¶
LightNode(SceneScope) now drives intensity / colour / direction reactively on recomposition (was applying only at first creation).ViewNode: reactiveposition/rotation/scale/isVisibleprops on the composable; lifecycle race on post-destroy fixed.Node/ModelNodedefaultScale(1f)regression — was(1, 0, 0)singular transform that cascaded NaN through every downstream matrix op (Physics, animations, children).MaterialInstancereassignment now propagates to all geometry nodes (Sphere/Cube/Plane).onFramecallback no longer captured stale (was ignoring recomposition).- AR camera: editable-node gestures isolated from camera gestures.
- AR
AugmentedFaceNode: tracking state callback always fires (PR #789 follow-up via PR #852). - New:
FovZoomCameraManipulator— pinch-to-FOV zoom for orthographic-style framing. - New:
DefaultCameraManipulator(pinchZoomSpeed, pinchZoomDamping)— non-linear damping curve, default tuned for dense screens (was abrupt on Pixel 9).
New — testability surface¶
- Pure-Kotlin
pinchZoomDeltaandnextFovhelpers extracted from the gesture detectors so the math curves can be regression-tested on the JVM (no Filament Engine needed). 14 new tests in:sceneview:testcover sub-pixel linearity, sign preservation, speed scaling, damping softening, FOV clamps, and default constants.
API surface — non-breaking by design¶
LightNode(color = …)parameter placed AFTERposition(not in slot 3) to preserve positional source-compat for existing 4.0.x callers passingdirectionpositionally. Documented inSceneScope.kt:354.Engine.ktsafeDestroy*helpers retainrunCatchingwrapping (the rebase-rescue PR initially stripped it; restored to avoid ABI break for v4.0.x consumers — see commit messagefd1d820e).ImageNode.destroy()deliberateTextureretention now documented in a public KDoc with the recommendedbitmap = newBitmaprecycling pattern. Tracked: #874.
Internal¶
- 14 new JVM tests (
CameraGestureMathTest). - Roborazzi screenshot tests stay
@Ignore'd (DemoListScreen renderer change tracked separately). - gradle test deps bumped: robolectric 4.14.1 → 4.16.1, roborazzi 1.43.0 → 1.60.0; new androidxTestExtJunit + androidxTestUiAutomator for instrumented coverage.
Follow-up issues filed during the rebase rescue¶
-
873: cache
SurfaceOrientationinAugmentedFaceNode.computeTangents(~30 Hz JNI alloc on hot path).¶ -
874: frame-deferred destroy queue for
ImageNode/ViewNodeGPU textures.¶
v4.0.3 — Save & Share Rerun + scan-to-open deep-links (2026-05-06)¶
Status: stable. Maven Central, Swift Package Manager, npm, and Play Store artifacts are published from this tag.
New — Rerun.io self-serve hosted viewer¶
sceneview.github.io/rerun/page added — drop a.rrdrecording on it (or paste a URL) and SceneView opens the embedded Rerun Web Viewer with the right defaults. Removes the need to install the Rerun desktop app for quick AR-debug shares (afe1cc94).RerunBridge.recordToFile(...)+share(...)(Tier-Sevents) ship on Android and iOS with full parity. iOS uses the native share sheet; Android usesMediaStore. Wire-format goldens updated (4b8993dd, fa1f8bc1).- One-command review guide
.claude/scripts/check-rerun.shfor the Save & Share MVP (58c74d3f).
New — scan-to-open deep-links¶
https://sceneview.github.io/open/?demo=<id>resolves to the published Play Store / App Store apps with the right demo pre-selected. README, website, docs all expose QR codes that route from web → installed app → specific demo (e49d4062, c95ed0d6).- Android App Links:
.well-known/assetlinks.jsonnow ships both Play App Signing and upload-key SHA-256 fingerprints — the production-signed APK is now correctly verified by Android (133df8ff). - iOS Universal Links:
SceneViewDemo.entitlementsnow declaresapplinks:sceneview.github.io(Associated Domains capability). Pairs with the existingapple-app-site-associationpublished on the website (932ac8dc).
Improved — Play Store CI (canary pattern)¶
- Push to
main→ AAB uploaded to the Play Store internal track only (snapshot for dogfooding) (12f3a5ab). - Tag
v[0-9]+.[0-9]+.[0-9]+(this release) → AAB uploaded to internal + production in parallel (canary pattern). Thev4.0.3tag triggers both jobs concurrently (1e247180). - A real release no longer requires a manual Play Console step — once green CI on the tag, the production review is auto-submitted.
Fixed — android-demo About version¶
AboutTabwas hard-coding"v4.0.0-rc.1"; now readsBuildConfig.VERSION_NAMEso the published build always shows the truthful version (f516387f).
Internal¶
- 11 commits in this release, all on
main. Tagv4.0.3is the GA cut.
v4.0.2 — Crash hardening & reactive ViewNode props (2026-05-06)¶
Status: stable. Maven Central and Swift Package Manager artifacts are published from this tag.
Fixed — Filament destroy-order crashes¶
RenderableNode.destroy()now destroys the renderable component before the entity, fixing theMaterialInstance "view" still in use by RenderableSIGABRT seen on screen navigation (#849, closes #837, #847).PlaneRenderer.destroy()routes throughMaterialLoader.destroyMaterial()to prevent double-free on AR scene teardown (#850).ViewNode.destroy()andrememberViewNodeManagerhardened against the post-destroy race that left a leakedWindowManagerview ifresume()anddestroy()interleaved within a single frame (#820, #853).
Fixed — BillboardNode mirrored texture¶
BillboardNode(andTextNodevia inheritance) no longer renders the back face of the plane quad. Switched fromlookAt(camPos)tolookTowards(worldPosition - camPos)so local +Z (front face, correct UVs) faces the viewer. Hardened guard rejects NaN inputs in addition to the zero vector (#838, #854). A 9-test JVM regression suite inBillboardNodeMathTestpins the math convention (#858).
Fixed — ViewNode reactive props¶
ViewNodecomposable restores the full reactive prop set (position,rotation,scale,isVisible) and switches fromSideEffecttoDisposableEffectkeyed on scalar components — Compose state changes now propagate without redundant per-recomposition writes (#856, #857). Closes the regression of the original7d82701cimplementation reintroduced by #842.
Security¶
honobumped to 4.12.17 acrossmcp-gateway,telemetry-workerand the bundled MCP packages — resolves thehono/jsxSSR XSS via JSX attribute names (9 alerts) (#862).postcssbumped to 8.5.14 in the same set — resolves XSS via unescaped</style>in CSS Stringify Output (4 alerts).- 0 open Dependabot alerts at the time of this entry.
Improved — Tooling¶
roborazzi1.43.0 → 1.60.0 (#830).dev.romainguy:kotlin-mathreference inllms.txtsynced to 1.8.0 across all 4 copies (root, website, well-known, bundled MCP) — AI consumers no longer suggest the outdated 1.6.0 dependency (#788 follow-up, #859).- Marketplace submission packet (OpenAI App Store + MCPize manifest) committed under
.claude/marketplace-submissions/for cross-session reuse (#855).
Internal¶
- Render tests on SwiftShader CI remain
@Ignore'd —Filament.capturePixels()still crashes the emulator. Coverage by iOS simulator, Web Playwright, and Android demo screenshot jobs. Pure-JVM math regressions can land in:sceneview:test(see #858 for the pattern).
v4.0.1 — Swift Geometry Primitives, Filament 1.71.0, Hub MCP v0.3.0¶
Status: stable. Maven Central and Swift Package Manager artifacts are published from this tag.
New — Swift Geometry Primitives¶
torus()andcapsule()added toSceneViewSwiftgeometry API, matching the Android/KMP surfaceConeNode,TorusNode,CapsuleNodedocumented in docs/nodes.md
Fixed — Filament 1.71.0 Materials¶
- Recompiled 6
.filamatmaterials for Filament 1.71.0 (closes #818) - All material binaries updated in
arsceneview/src/main/assets/
Improved — Hub MCP v0.3.0 (78 tools)¶
- 78 tools across 11 bridge-API MCPs (up from 52)
gaming-3d-mcpandinterior-design-3d-mcpfiles[]glob fix — tarball no longer ships incomplete- FREE_TOOLS count corrected (14 → 23)
Improved — Android Samples¶
- Layout and
scaleToUnitstuned across all 24 Android demo scenes for better camera framing - PhysicsDemo layout refined for Pixel 9 QA
v4.0.0 — Declarative Compose DSL, Rerun.io AR Debug, MCP Gateway & Cross-Platform Bridges¶
Status: stable. Maven Central and Swift Package Manager artifacts are published from this tag.
Backward compatible with 3.6.x. Existing code compiles and runs unchanged against 4.0.0.
New — Declarative Compose DSL (breaking rename, additive)¶
Renamed the top-level composables from Scene/ARScene to SceneView { } / ARSceneView { } across all public surfaces (KDocs, MCP packages, sample apps, docs, llms.txt, README, website). The old names are still accepted via deprecated aliases — no callers break.
- Nodes are now declared as composables inside the trailing content lambda; imperative node management is no longer the primary API.
LightNode'sapplyis a named parameter (apply = { intensity(…) }), not a trailing lambda — matches the Compose convention for layout-affecting side effects.rememberModelInstance(modelLoader, "models/file.glb")returnsnullwhile loading; all samples handle the null case explicitly.
New — AR Debug via Rerun.io¶
Stream an ARCore (Android) or ARKit (iOS) session to the Rerun viewer for scrub-and-replay debugging. Same JSON-lines wire format on both platforms, single Python sidecar handles both.
- Android: new
io.github.sceneview.ar.rerun.RerunBridge+rememberRerunBridgecomposable helper. Non-blockingDispatchers.IOscope,Channel.CONFLATEDdrop-on-backpressure, rate-limited 10 Hz by default, runtimesetEnabled()kill switch. Zero new Gradle dependencies. - iOS: new
SceneViewSwift.RerunBridge(@ObservableObjectwith@Published eventCount),Network.frameworkNWConnectionon a dedicated utility queue. NewARSceneView.onFrame { frame, arView in … }modifier — usable independently of the bridge for any per-frame custom logic. - Wire format: 5 event types (
camera_pose,plane,point_cloud,anchor,hit_result), byte-identical output from Kotlin and Swift, enforced by 24 golden-string tests (12 per platform). - Python sidecar:
tools/rerun-bridge.py— reads the TCP stream and re-logs each event as the matching Rerun archetype (Transform3D,LineStrips3D,Points3D). Spawns the Rerun viewer automatically viarr.init(spawn=True). - Playground: new "AR Debug (Rerun)" example in the
ar-spatialcategory with per-platform code tabs. - Sample apps: new
RerunDebugDemotile insamples/android-demo(Samples tab) andsamples/ios-demo(Scenes → AR category).
New — rerun-3d-mcp@1.0.0 on npm¶
New dedicated MCP server (npx rerun-3d-mcp) generating Rerun integration boilerplate from natural-language prompts. 5 tools, 73 vitest tests, Apache-2.0. Tarball 13.6 kB.
New — MCP Gateway (Cloudflare Workers + Stripe)¶
Production-grade monetization layer for sceneview-mcp:
- Cloudflare Worker (
gateway/) with Hono router, D1 database, KV namespace. - Stripe-first anonymous checkout: no login wall — user clicks CTA, pays, receives API key by email via Stripe webhook + KV single-use handoff.
- 4 plans: Free / Pro (€19) / Team (€49) / Enterprise — with tier gating and per-plan rate limiting.
POST /mcpproxy withX-Api-Keyauth, lite mode detection, and upstream routing.- Dashboard-less by design: billing managed entirely through the Stripe Customer Portal.
- 168 tests passing across gateway + hub packages.
- Live in production at
https://sceneview-mcp.mcp-tools-lab.workers.dev.
New — Anonymous telemetry worker¶
sceneview-mcp now sends lightweight anonymous usage telemetry (tool name, tier, timestamp — no personal data) to a Cloudflare Worker via batched HTTP. Sponsor CTA fires every 10 tool calls.
New — sceneview-mcp on @latest npm tag (4.0.0)¶
sceneview-mcp@4.0.0 is promoted to the @latest dist-tag. Previous @latest was 3.6.5; @next pointed to 4.0.0-rc.5. The publishConfig: { tag: "next" } guard in package.json has been removed now that the gateway go-live pipeline has verified a real paying customer.
New — Cross-platform bridges¶
- Flutter:
flutter/sceneview_flutter— PlatformView bridge to SceneView on Android + SceneViewSwift on iOS; Kotlin 2.0 + Compose Compiler plugin compatibility fixed. - React Native:
react-native/react-native-sceneview— Fabric/Turbo bridge with nativeandroid/andios/modules scaffolded. - Web:
sceneview-webKotlin/JS package (npm view sceneview-web) — Filament.js (WASM) + WebXR, webpack 5 polyfills unblocked.
New — Empire Analytics dashboard¶
website-static/ now includes a GA4-backed analytics dashboard (/analytics) for tracking playground interactions, MCP install events, and Stripe checkout funnels.
Fixes¶
- NodeAnimator (#388):
NodeAnimatornow writes animated values back to the targetNode's transform fields on every frame, fixing silent no-op animations that computed but discarded results. - Render tests (#803): Fixed intermittent SwiftShader JVM crashes in CI by sharing a single
Engineinstance per test class. The class-level@Ignoreworkarounds have been removed. - AR camera exposure (#792): Added
cameraExposureparameter toARSceneViewcomposable. - customer_creation bug:
stripe-client.tsnow guardsform.customer_creation = "always"withif (mode === "payment"), preventing a Stripe 400 error on subscription checkouts.
Tests¶
- 16 new JVM tests in
arsceneview(Rerun wire format + socket integration). - 12 new Swift tests in
SceneViewSwiftTests(cross-platform wire-format parity). - 73 new vitest tests in
mcp/packages/rerun. - 90+ new unit tests across
sceneviewandarsceneview(#814). - 168 gateway/hub tests.
Dependencies¶
- AGP bumped
8.11.1 → 8.13.2,maven-publish 0.35.0 → 0.36.0. activesupportbumped>= 7.2.3.1(CVE-2026-33176/33170/33169).
Demo apps¶
samples/android-demo: Sprint 1 refactor — 4-tab nav replaced with categorized list, 20 demos (including RerunDebugDemo).samples/android-tv-demo+samples/web-demo: broken asset refs fixed; all 8 previously-404 GLB/USDZ/HDR paths resolved.samples/ios-demo: AR Debug demo added in Scenes → AR category.
Version sweep¶
gradle.properties VERSION_NAME, all gradle.properties submodule files, npm packages, Flutter pubspec.yaml + podspec, llms.txt, docs, website, samples — synced to 4.0.0 via .claude/scripts/sync-versions.sh --fix.
v4.0.0-rc.1 — SceneView ↔ Rerun.io integration (Release Candidate)¶
Status: release candidate. Maven Central and Swift Package Manager artifacts are not published from this tag — pin to 4.0.0-rc.1 manually to test, or wait for the v4.0.0 stable tag.
Strictly additive to 3.6.2. Existing 3.6.x code compiles and runs unchanged.
New — AR Debug via Rerun.io¶
Stream an ARCore (Android) or ARKit (iOS) session to the Rerun viewer for scrub-and-replay debugging. Same JSON-lines wire format on both platforms, single Python sidecar handles both.
- Android: new
io.github.sceneview.ar.rerun.RerunBridge+rememberRerunBridgecomposable helper. Non-blockingDispatchers.IOscope,Channel.CONFLATEDdrop-on-backpressure, rate-limited 10 Hz by default, runtimesetEnabled()kill switch. Zero new Gradle dependencies. - iOS: new
SceneViewSwift.RerunBridge(@ObservableObjectwith@Published eventCount),Network.frameworkNWConnectionon a dedicated utility queue. NewARSceneView.onFrame { frame, arView in … }modifier wired to the existingARSessionDelegate.session(_:didUpdate:)— usable independently of the bridge for any per-frame custom logic. - Wire format: 5 event types (
camera_pose,plane,point_cloud,anchor,hit_result), byte-identical output from Kotlin and Swift (enforced by 24 golden-string tests, 12 per platform). - Python sidecar:
samples/android-demo/tools/rerun-bridge.py— reads the TCP stream and re-logs each event as the matching Rerun archetype (Transform3D,LineStrips3D,Points3D). Spawns the Rerun viewer automatically viarr.init(spawn=True). - Playground: new "AR Debug (Rerun)" example in the
ar-spatialcategory — embeds the official Rerun Web Viewer fromapp.rerun.ionext to the SceneView canvas with per-platform code tabs for Android / iOS / Web / Flutter / React Native / Desktop / Claude. - Sample apps: new
RerunDebugDemotile in bothsamples/android-demo(Samples tab) andsamples/ios-demo(Scenes → AR category).
New — rerun-3d-mcp@1.0.0 on npm¶
New dedicated MCP server — npx rerun-3d-mcp — that generates the Rerun integration boilerplate from natural-language prompts in any MCP client (Claude, Cursor, etc.). 5 tools:
setup_rerun_project— Gradle / SPM / Web / Python scaffolding with boilerplategenerate_ar_logger— Kotlin or Swift AR streaming helper, parameterized by data types and rategenerate_python_sidecar— TCP →rerun-sdkPython bridgeembed_web_viewer— HTML + module-script snippets for@rerun-io/web-viewerexplain_concept— focused docs forrrd,timelines,entities,archetypes,transforms
Published Apache-2.0. 73 vitest tests. Tarball size 13.6 kB (9 files).
New — sceneview-mcp@4.0.0-rc.1 on @next npm tag¶
sceneview-mcp gains the Rerun integration docs via the regenerated sceneview://api resource (82.5 kB, +5.4 kB vs 3.6.4). Stays on the @next dist-tag — @latest is intentionally pinned to 3.6.4 until the gateway go-live pipeline has a first real paying customer (see NOTICE-2026-04-11-mcp-gateway-live.md). Install the RC with npx sceneview-mcp@next.
Adds publishConfig: { tag: "next" } to mcp/package.json so future sessions can't accidentally promote the RC to @latest by running a bare npm publish.
New — AR camera exposure control (#792)¶
- Added
cameraExposureparameter toARSceneViewcomposable, allowing developers to programmatically control the camera exposure applied to the AR scene.
Fixes¶
- Render tests (#803): Fixed intermittent SwiftShader JVM crashes in CI by sharing a single
Engineinstance per test class instead of creating and tearing down one per test method. Affected classes (GeometryRenderTest,VisualVerificationTest,LightingRenderTest,RenderSmokeTest) are now stable; the class-level@Ignoreguards added as a temporary workaround have been removed. - MCP tiers test: Removed stale Polar URL from
tiers.test.tsthat was causing a test failure after the Polar → Stripe migration.
Tests¶
- 16 new JVM tests in
arsceneview(12 golden-JSON forRerunWireFormat, 4 socket integration forRerunBridgewith a mockServerSocket) - 12 new Swift tests in
SceneViewSwiftTests— identical golden strings, enforcing cross-platform wire-format parity at build time - 73 new vitest tests in
mcp/packages/rerun— 100% tool coverage - 90+ new unit tests across
sceneviewandarsceneviewmodules (#814) - Full suite validation:
./gradlew :arsceneview:compileDebugKotlin :arsceneview:testDebugUnitTest✓./gradlew :samples:android-demo:assembleDebug✓swift build --package-path SceneViewSwift✓swift test --package-path SceneViewSwift --filter Rerun*✓xcodebuild -project samples/ios-demo/SceneViewDemo.xcodeproj -scheme SceneViewDemo -destination 'generic/platform=iOS Simulator'✓
Version bump — 3.6.2 → 4.0.0-rc.1¶
Propagated to 28 files via .claude/scripts/sync-versions.sh --fix + manual touches on docs/website/samples. The 4.0.0 major bump reflects two new capabilities (Rerun integration + the 4.0.0-beta.1 gateway lite proxy shipped earlier this day by a parallel session), not breaking API changes — 3.6.x code compiles unchanged against 4.0.0-rc.1.
Release workflow¶
Git tag v4.0.0-rc.1 + GitHub pre-release created. release.yml only matches strict semver v[0-9]+.[0-9]+.[0-9]+, so this RC tag does not trigger Maven Central / SPM publish. Promote to stable by bumping to v4.0.0 and tagging again.
v3.6.2 — Cross-Platform Parity + Render Testing¶
Architecture¶
- Extract
SceneRenderer— shared render loop between SceneView and ARSceneView - Decompose
Nodegod class intoNodeGestureDelegate,NodeAnimationDelegate,NodeState - Extract
ARPermissionHandlerinterface (testable without Activity) - Fix
ModelLoader.releaseSourceData()memory leak - Clean legacy Java collision code
Quality¶
- Add 175 JVM unit tests for sceneview module
- Add 15 JVM unit tests for arsceneview module
- Add 63 KMP tests for sceneview-core
- Add 18 Swift tests for SceneViewSwift (ShapeNode)
- Fix 8 MCP test regressions
- Add pre-push quality gate script
- Stability audit: all platforms PASS
Demo Apps¶
- Rebrand to "3D & AR Explorer" (iOS + Android)
- iOS: Add model gallery, favorites, share, categorized browsing
- Android: Material 3 Expressive rewrite, 4 tabs, 40 models
- Fix Play Store build (duplicate assets in asset pack)
- Fix App Store build (private init access level)
- Fix AR camera tone mapper (rememberView → rememberARView)
Website¶
- Redesign 8 sections on homepage
- Rewrite Showcase page from scratch
- Playground: 7 platform tabs, camera manipulator, Open in Claude
- Playground: geometry primitives preview, AR placeholders
- Fix Docs 404 (redirect page)
- Auto-deploy GitHub Pages workflow
Cross-Platform¶
- iOS: Add ShapeNode (23/24 Android parity)
- iOS: Fix GeometryMaterial.custom(), ViewNode platform guard
- Web: Fix SCENEVIEW_VERSION (1.3.0 → 3.6.0)
- TV: Fix missing assets (would crash at runtime)
- MCP: Align version 3.5.5 → 3.6.0
- Flutter + React Native: Prepare for publication
- CI: Web builds now blocking, Gradle verification added
3.6.0 — Comprehensive quality audit, SwiftUI fixes, website migration (2026-03-31)¶
SceneViewSwift¶
- Fixed SceneSnapshot visionOS compilation (ARView unavailable)
- Fixed VideoNode memory leak (NotificationCenter observer never removed)
- Fixed CameraNode macOS support (removed unnecessary platform guards)
- Removed unreachable dead code in GeometryNode
Website¶
- Migrated ALL pages from model-viewer/Three.js to sceneview.js
- Removed Three.js (53K LOC) and model-viewer.min.js
- Rewrote sceneview-demo.html to use SceneView.modelViewer() API
- Fixed 3 demo pages crashing from non-existent API calls
- Fixed model paths in claude-3d.html
- Deleted 5 dead demo pages + fixed sitemap.xml
- Added 404.html page for GitHub Pages
- Fixed og:image/twitter:image meta tags (SVG → PNG) across all 8 pages
- Fixed sceneview.js version mismatch (runtime 1.5.0 → 3.6.0)
- Fixed IBL path (relative → absolute) for embed/preview subdirectory pages
- Improved synthetic IBL fallback lighting for Claude Artifacts
Branding¶
- Generated 22 PNG exports from SVG sources (logo, app icon, favicon, social, npm, store)
- Created favicon.ico (multi-resolution)
- Updated Open Collective: logo, cover, tiers (Backer $10, Sponsor $50, Gold $200), 10 tags
AI Integration¶
- Added Claude Artifacts section to llms.txt (HTML template, CDN URLs, 26 models)
- Updated MCP tool count: 22 → 26 tools, 2360 tests across 98 suites
Dependencies¶
- Bumped Filament 1.70.0 → 1.70.1
CI/CD¶
- Fixed maintenance.yml (Filament version grep, graceful fallback)
- Fixed docs.yml (download-artifact version, deploy retry)
- All 10 workflows verified green
Version alignment¶
- Updated 100+ files from 3.5.0/3.5.1 to 3.6.0
- All satellite MCPs (automotive, gaming, healthcare, interior) aligned
3.5.1 — macOS support, environment picker, MCP 3.5.3 (2026-03-29)¶
Apple platforms¶
- Native macOS support in SceneViewSwift (all source files + demo app)
- macOS App Store submission (build 357, pending review)
- iOS App Store submission (build 355, pending review)
- Environment picker UI with 6 HDR presets (Studio, Outdoor, Sunset, Night, Warm, Autumn)
- Proper macOS app icon sizes (16px to 1024px)
- Swift 6 strict concurrency fix (
@MainActoron HapticManager)
MCP Server v3.5.3¶
- Updated all dependency references from 3.4.7 to 3.5.0
- Published to npm as sceneview-mcp@3.5.3
- 1204 tests passing
CI/CD¶
- Extended app-store.yml with macOS deploy job (parallel iOS + macOS)
- Fixed TestFlight deploy failure (Swift 6 concurrency)
Documentation¶
- Added ViewNode, SceneSnapshot, SceneEnvironment.allPresets to llms.txt
- Rebuilt docs site — zero stale version references
- Fixed CDN versions in README (1.2.0 → 3.5.1) and website (1.4.0 → 3.5.1)
Assets¶
- URL-based model loading (Android + iOS)
- 6 iOS HDR environments
- Progressive texture loading (Filament async)
- 25 models migrated to GitHub Releases CDN (Play Store compliance)
3.5.0 — Full coherence audit, version alignment (2026-03-29)¶
Version coherence¶
- Unified all version references across 60+ files to 3.5.0
- Fixed module gradle.properties (sceneview, arsceneview, sceneview-core)
- Updated MCP source + dist files, docs, website, samples, Flutter, React Native
- Fixed Flutter/React Native Android build files (were still on 2.3.0)
Documentation¶
- Updated llms.txt, all docs, codelabs, cheatsheets, quickstarts
- Updated CLAUDE.md code samples and platform table
- Cross-platform version consistency across all READMEs
3.4.7 — MCP 18 tools, orbit fix, geometry demo (2026-03-26)¶
MCP Server v3.4.13¶
- 4 new tools:
get_platform_setup,migrate_code,debug_issue,generate_scene - 834 tests across all tools
Bug fixes¶
- Orbit controls: corrected inverted horizontal/vertical camera drag
- 3 core math/collision bugs fixed
- Removed stale CI job
Website¶
- Geometry demo: mini-city with 4 presets (City, Park, Abstract, Minimal)
- Meta tags, sitemap, favicon, canonical URLs polished
3.4.6 — Procedural 3D geometry in Claude Artifacts (2026-03-26)¶
Highlights¶
create_3d_artifactMCP tool with geometry type: procedural shapes with PBR materials- SceneView.js v1.1.0 published to npm: one-liner web 3D with auto Filament WASM loading
- Filament.js PBR rendering on website (replaced model-viewer)
- 9 MCP servers all at v2.0.0
3.4.5 — SceneView Web with Filament.js WASM (2026-03-26)¶
Features¶
- Real 3D rendering in browser via Google Filament compiled to WebAssembly
- 25 KB bundle (+ Filament.js from CDN)
- Live demo at sceneview.github.io
Other¶
- Website mobile polish, 50+ broken links fixed
- GitHub Sponsors: 3 new tiers; Polar.sh approved with Stripe
- MCP v3.4.9:
create_3d_artifacttool (590 tests)
3.4.4 — Play Store readiness, MCP legal (2026-03-25)¶
Features¶
- Android demo: Play Store readiness (crash prevention, dark mode, store listing)
- MCP Server: Terms of Service, Privacy Policy, disclaimers added
- GitHub Sponsors tier structure
3.4.3 — Embeddable 3D widget (2026-03-25)¶
Features¶
- Embeddable 3D viewer via single
<iframe>snippet - MCP
render_3d_previewaccepts code snippets and direct model URLs - Web demo: branded UI, model selector, loading indicator
3.4.2 — Critical AR fix, MeshNode improvement (2026-03-25)¶
Breaking fix¶
- AR materials regenerated for Filament 1.70.0 — previous materials crashed all AR apps
Features¶
MeshNodenow accepts optionalboundingBoxparameter
Security¶
- 6 Dependabot vulnerabilities fixed, 15 audit issues resolved
- 28 stale repository references updated
3.4.1 — Website, smart links, 3D preview (2026-03-25)¶
Features¶
- Website rebuilt: Kobweb replaced with static HTML/CSS/JS + model-viewer 3D
- Smart links:
/go(platform redirect),/preview(3D preview),/preview/embed(iframe viewer) - MCP
render_3d_previewtool for AI-generated 3D previews
Infrastructure¶
- 21 secrets configured (Apple + Android + Maven + npm)
- README rewritten (622 to 200 lines)
3.4.0 — Multi-platform expansion (2026-03-25)¶
New platforms¶
- Web —
sceneview-webmodule: Filament.js (WASM) rendering + WebXR AR/VR - Desktop —
samples/desktop-demo: Compose Desktop, software 3D renderer - Android TV —
samples/android-tv-demo: D-pad controls, model cycling - Flutter —
samples/flutter-demo: PlatformView bridge (Android + iOS) - React Native —
samples/react-native-demo: Fabric bridge (Android + iOS)
Android showcase¶
- Unified
samples/android-demo— Material 3 Expressive, 4 tabs, 14 demos - Blue branding with isometric cube icon
Infrastructure¶
- MCP Registry — SceneView MCP published at
io.github.sceneview/mcp - 21 GitHub Secrets — Android + iOS + Maven + npm fully configured
- Apple Developer — Distribution certificate, provisioning profile, API key
- CI/CD — Play Store + App Store workflows ready
Samples cleanup¶
- 15 obsolete samples deleted, merged into unified platform demos
{platform}-demonaming convention across all 7 platforms- Code recipes preserved in
samples/recipes/
Fixes¶
- material-icons-extended pinned to 1.7.8 (1.10.5 not published on Google Maven)
- wasmJs target disabled (kotlin-math lacks WASM variant)
- AR emulator script updated for new sample structure
3.3.0 — Unified versioning, cross-platform, website¶
Version unification¶
- All modules aligned to 3.3.0 — sceneview, arsceneview, sceneview-core, MCP server, SceneViewSwift, docs, and all references across the repo are now at a single unified version
SceneViewSwift (Apple)¶
- iOS 17+ / macOS 14+ / visionOS 1+ via RealityKit — alpha
- Node types: ModelNode, AnchorNode, GeometryNode, LightNode, CameraNode, ImageNode, VideoNode, PhysicsNode, AugmentedImageNode
- PBR material system with textures
- Swift Package Manager distribution
SceneViewSwift — new nodes and enhancements¶
- DynamicSkyNode — procedural time-of-day sky with sun position, atmospheric scattering
- FogNode — volumetric fog with density, color, and distance falloff
- ReflectionProbeNode — local cubemap reflections for realistic environment lighting
- ModelNode enhancements — named animation playback, runtime material swapping, collision shapes
- LightNode enhancements — shadow configuration, attenuation radius and falloff
- CameraNode enhancements — field of view, depth of field, exposure control
MCP server — iOS support¶
- 8 Swift sample snippets for iOS code generation
get_ios_setuptool for Swift/iOS project bootstrapping- Swift code validation in
validate_codetool - iOS-specific guides and documentation
Tests¶
- 65+ new tests covering edge cases and platform-specific behavior
- Test coverage for all 15+ SceneViewSwift node types
- Platform tests for iOS-specific RealityKit integration
Website¶
- Platform logo ticker on homepage — infinite-scroll marquee showing all supported platforms and technologies (Android, iOS, macOS, visionOS, Compose, SwiftUI, Filament, RealityKit, ARCore, ARKit, Kotlin, Swift)
- CSS-only animation with fade edges, hover-to-pause, dark mode support
Documentation¶
- Updated ROADMAP.md to reflect current state (SceneViewSwift exists, phased plan revised)
- Updated PLATFORM_STRATEGY.md — native renderer per platform architecture (Filament + RealityKit)
- All codelabs, cheatsheet, migration guide updated to 3.3.0
- iOS quickstart guide — step-by-step setup for SceneViewSwift
- iOS cheatsheet — quick reference for SwiftUI 3D/AR patterns
- 2 SwiftUI codelabs — hands-on tutorials for iOS 3D scenes and AR
3.1.2 — Sample polish, CI fixes, maintenance tooling¶
Fixes¶
autopilot-demo: remove deprecatedengineparameter fromPlaneNode,CubeNode,CylinderNodeconstructors (API aligned with composable node design)- CI: fix AR emulator stability — wait for launcher, dismiss ANR dialogs, kill Pixel Launcher before screenshots
Sample improvements¶
model-viewer: scale up Damaged Helmet 0.25 → 1.0; add Fox model (CC0, KhronosGroup glTF-Sample-Assets) with model picker chip rowcamera-manipulator: scale up model 0.25 → 1.0; add gesture hint bar (Drag·Orbit / Pinch·Zoom / Pan·Move)
Developer tooling¶
/maintainClaude Code skill + daily maintenance GitHub Action for automated SDK upkeep- AR emulator CI job using x86_64 Linux + ARCore emulator APK for screenshot verification
ROADMAP.mdadded covering 3.2–4.0 milestones
3.1.1 — Build compatibility patch¶
- Downgrade AGP from 8.13.2 → 8.11.1 for Android Studio compatibility
- Update AGP classpath in root
build.gradleto match - Refresh
gltf-camerasample: animated BrainStem character + futuristic rooftop night environment
3.1.0 — VideoNode, reactive animation API¶
New features¶
VideoNode— render a video stream (MediaPlayer / ExoPlayer) as a textured 3D surface- Reactive animation API — drive node animations from Compose state
ViewNoderename —ViewNode2unified intoViewNode
Fixes¶
ToneMapper.LinearinARSceneprevents overlit camera backgroundImageNodeSIGABRT: destroyMaterialInstancebefore texture on disposecameraNoderegistered withSceneNodeManagerso HUD-parented nodes render correctly- Entities removed from scene before destroy to prevent SIGABRT
UiHelperAPI corrected for Filament 1.56.0
AI tooling¶
- MCP server:
validate_code,list_samples,get_migration_guidetools + live Issues resource - 89 unit tests for MCP validator, samples, migration guide, and issues modules
3.0.0 — Compose-native rewrite¶
Breaking changes¶
The entire public API has been redesigned around Jetpack Compose. There is no source-compatible upgrade path from 2.x; see the Migration guide for a step-by-step walkthrough.
Scene and ARScene — new DSL-first signature¶
Nodes are no longer passed as a list. They are declared as composable functions inside a trailing content block:
// 2.x
Scene(
childNodes = rememberNodes {
add(ModelNode(modelInstance = loader.createModelInstance("helmet.glb")))
}
)
// 3.0
Scene {
rememberModelInstance(modelLoader, "models/helmet.glb")?.let { instance ->
ModelNode(modelInstance = instance, scaleToUnits = 1.0f)
}
}
SceneScope — new composable DSL¶
All node types (ModelNode, LightNode, CubeNode, SphereNode, CylinderNode, PlaneNode,
ImageNode, ViewNode, MeshNode, Node) are now @Composable functions inside SceneScope.
Child nodes are declared in a NodeScope trailing lambda, matching how Compose UI nesting works.
ARSceneScope — new AR composable DSL¶
All AR node types (AnchorNode, PoseNode, HitResultNode, AugmentedImageNode,
AugmentedFaceNode, CloudAnchorNode, TrackableNode, StreetscapeGeometryNode) are now
@Composable functions inside ARSceneScope.
rememberModelInstance — async, null-while-loading¶
// Returns null while loading; recomposes with the instance when ready
val instance = rememberModelInstance(modelLoader, "models/helmet.glb")
SurfaceType — new enum¶
Replaces the previous boolean flag. Controls whether the 3D surface renders behind Compose layers
(SurfaceType.Surface, SurfaceView) or inline (SurfaceType.TextureSurface, TextureView).
PlaneVisualizer — converted to Kotlin¶
PlaneVisualizer.java has been removed. PlaneVisualizer.kt replaces it.
Removed classes¶
The following legacy Java/Sceneform classes have been removed from the public API:
- All classes under
com.google.ar.sceneform.*— replaced by Kotlin equivalents under the same package path (.ktfiles). - All classes under
io.github.sceneview.collision.*— replaced by Kotlin equivalents. - All classes under
io.github.sceneview.animation.*— replaced by Kotlin equivalents.
Samples restructured¶
All samples are now pure ComponentActivity + setContent { }. Fragment-based layouts have been
removed. The model-viewer-compose, camera-manipulator-compose, and ar-model-viewer-compose
modules have been merged into model-viewer, camera-manipulator, and ar-model-viewer
respectively.
Bug fixes¶
ModelNode.isEditable—SideEffectwas resettingisEditableto the parameter default (false) on every recomposition, silently disabling gestures whenisEditable = truewas set only insideapply { }. PassisEditable = trueas a named parameter to maintain it correctly.- ARCore install dialog — Removed
canBeInstalled()pre-check that threwUnavailableDeviceNotCompatibleExceptionbeforerequestInstall()was called, preventing the ARCore install prompt from ever appearing on fresh devices. - Camera background black —
ARCameraStreamusedRenderableManager.Builder(4)with only 1 geometry primitive defined (invalid in Filament). Fixed toBuilder(1). - Camera stream recreated on every recomposition —
rememberARCameraStreamused a default lambda parameter as arememberkey; lambdas produce a new instance on every call, making the key unstable. Fixed by keying onmaterialLoaderonly. - Render loop stale camera stream — The render-loop coroutine captured
cameraStreamat launch; recomposition could recreate the stream while the loop kept updating the old (destroyed) one. Fixed with anAtomicReferenceupdated viaSideEffect.
New features¶
SceneScope/ARSceneScope— fully declarative, reactive 3D/AR content DSLNodeScope— nested child nodes using Compose's natural trailing lambda patternSceneNodeManager— internal bridge that syncs Compose snapshot state with the Filament scene graph, enabling reactive updates without manualaddChildNode/removeChildNodecallsSurfaceType— explicit surface-type selection (SurfacevsTextureSurface)ViewNode— Compose UI content rendered as a 3D plane surface in the sceneEngine.drainFramePipeline()— consolidated fence-drain extension for surface resize/destroyrememberViewNodeManager()— lifecycle-safe window manager forViewNodecomposables- Autopilot Demo — new sample demonstrating autonomous animation and scene composition
- Camera Manipulator — new dedicated sample for orbit/pan/zoom camera control
Node.scaleGestureSensitivity— newFloatproperty (default0.5) that damps pinch-to-scale gestures. Applied as1f + (rawFactor − 1f) × sensitivityinonScale, making scaling feel progressive without reducing the reachable scale range. Set it per-node in theapplyblock alongsideeditableScaleRange.- AR Model Viewer sample — redesigned with animated scanning reticle (corner brackets +
pulsing ring), model picker (Helmet / Rabbit), auto-dismissing gesture hints,
enableEdgeToEdge(), and a clean Material 3 UI.
2.3.0¶
- AGP 8.9.1
- Filament 1.56.0 / ARCore 1.48.0
- Documentation improvements
- Camera Manipulator sample renamed